오늘의 주제는 Github와 Nginx를 통해 만들어진 무중단 배포 시스템이 정말로 무중단일까?이다.
보통 Nginx로 프로그램을 배포시키고, 반영할 개선사항이 존재하면 Github를 통해 반영시킨 후, pm2를 통해 reload를 하는 방식을 사용한다. 하지만 반영하는 과정에서 서비스가 짧게라도 중단되지 않을까? 진짜로 무중단이 맞을까? 하는 의문이 들었다.
해당 질문은 이직 과정에서 있었던 실무면접에서 들었던 질문으로 면접 당시에는 정답을 모르고 면접 이후에 찾아 알게된 것을 바탕으로 글을 써보려고 한다. ( 그런데 붙었다^_^ 이유가 뭘까.. )
먼저 pm2를 이용한 무중단 배포관리에 대해 이야기해보자.
NodeJS의 프로세스 매니저 PM2
NodeJS는 기본적으로 싱글스레드이다. 멀티코어 시스템을 이용할 수 없기에 클러스터 모듈을 통해 단일 프로세스를 멀티 프로세스로 늘리는 방법을 제공한다.
애플리케이션을 실행하면 처음엔 마스터 프로세스만 생성이 되는데, 이 때 CPU 개수 만큼 프로세스를 생성하고 마스터 프로세스와 워커 프로세스가 각각의 업무를 수행한다.
예를 들면, 애플리케이션의 변경을 반영하기 위한 재시작을 할 때 어떻게 처리할지(이 글을 쓰게 된 이유), 예상치 못한 이유로 워커 프로세스가 종료됐을 때 종료 이벤트를 어떻게 처리할지에 대한 고민이 생긴다.
이런 고민들을 편하게 해결시켜 주는 것이 PM2이다.
서비스 운영하기
우리는 NodeJS로 개발한 어플리케이션을 서비스 서버로 배포하고 PM2로 실행하여 사용자가 사용할 수 있게 한다.
서비스는 오픈했다고 다가 아니고 계속해서 수정을 하게 된다.
수정사항이 생기면 우리는 다시 배포를 해야한다. 배포를 완료 시킨 후에는 변경을 반영하기 위해 프로세스를 재시작해야 한다. 이 때 reload명령어를 사용해 재시작이 가능하다.
하지만 reload만 믿고 재배포를 하는 과정에서 'ERR_CONNECTION_REFISED'와 같은 에러 메시지와 함께 서비스가 중단되었다. 이유를 알기 위해서 PM2가 여러 개의 프로세스를 재시작하는 방식과 과정을 알아보자.
PM2의 프로세스 재시작 과정
프로세스가 10개가 실행되고 있을 때, reload를 실행하면 PM2는 기존 0번 프로세스를 old_0번 프로세스로 옮기고 새로운 0번 프로세스를 생성한다. 새로운 0번 프로세스는 요청을 처리할 준비가 완료되면 마스터 프로세스에게 'ready' 신호를 보낸다. 마스터 프로세스는 필요없어진 old_0번 프로세스에게 'SIGINT' 신호를 보내고 종료되기를 기다린다. 만약 신호를 받고도 old_0번 프로세스가 종료되지 않는다면 'SIGKILL' 신호를 보내어 강제로 종료시킨다.
재시작 과정에서 서비스 중단이 발생하는 이유
1. 새 프로세스가 구동되지 않았는데 READY 신호를 보낸 경우
위에서 새프로세스가 생성된 후 READY 신호를 마스터에게 보낸다고 했는데, 앱이 구동되기도 전에 마스터에게 READY 신호를 보낸다면 기존의 old_0번 프로세스가 필요없다고 생각하고 종료시켜버린다. 또한 SIGINT 신호 이후에도 기존 프로세스가 살아있다면 SIGKILL 신호로 강제 종료를 시키게 된다. 짧은 시간안에 새로운 앱으로 초기화되고 클라이언트로부터 요청받을 준비가 완료되다면 문제가 없겠지만, 앱 구동 시간이 지나치게 길어져(1600MS 이상) 구동이 완료되지도 않았는데 READY신호를 보내게 되면 old 프로세스는 SIGINT신호를 받아 종료되고 새로운 프로세스는 준비되지 않은 상태로 사용자를 맞이하게 된다. 이는 서비스 중단을 의미한다..( 진행중인 서비스가 없음..)
해결방법
이를 해결하기 위해서는 새로은 앱이 요청받을 준비가 됐을 때 READY시점을 보내도록 설정해주면 된다.
또한 마스터 프로세스가 READY 신호를 언제까지 기다릴지도 설정 파일에 명시해야 한다.
ecosystem.config.js에서 wait_ready 옵션을 True로 설정하면 마스터에게 READY 이벤트를 기다려! 라는 의미다.
listen_timeout은 READY를 기다리는 시간값이다. app.listen이 완료가 될 경우에 실행되는 콜백함수를 만들어 마스터 프로세스로 READY 신호를 보내자.
//ecosystem.config.js
module.exports = {
apps: [{
name: 'app',
script: './app.js',
instances: 0,
exec_mode: ‘cluster’,
wait_ready: true,
listen_timeout: 50000
}]
}
//APP
app.listen(port, function () {
process.send(‘ready’)
console.log(`application is listening on port ${port}...`)
})
2. 클라이언트의 요청 처리 과정에서 프로세스가 죽을 경우
reload 명령어 실행시, old_0번 프로세스는 프로세스가 종료되기 전까지(새로운 앱이 구동되기 전까지) 사용자의 요청을 처리한다. 근데 SIGINT 신호가 전달된 후 사용자 요청을 받고, 그 요청을 처리하는데 1600MS(강제종료까지 기본적으로 제공되는 시간)이 넘는다면 요청이 끝나지도 않았는데 강제종료된다. 요청받는 도중, 일정시간이 지나면 SIGKILL 신호를 받으면 응답을 하지 못한채로 강제종료가 되기 때문에 클라이언트와의 연결이 끊긴다. 이런 경우 서비스가 중단된다.
해결방법
SIGINT 신호를 리스닝하다가 해당 시그널을 받으면 app.close로 프로세스가 새로운 클라이언트 요청을 받는 것을 거절하고 기존 연결을 유지하도록 한다. 사용자 처리하기에 충분한 시간을 kill_timeout에 설정하고, 기존 연결이 종료되면 프로세스가 종료되도록 처리한다.
ecosystem.config.js에서 kill_timeout을 이용해 종료되기까지의 시간을 설정해주자.
//ecosystem.config.js
module.exports = {
apps: [{
name: 'app',
script: './app.js',
instances: 0,
exec_mode: ‘cluster’,
wait_ready: true,
listen_timeout: 50000,
kill_timeout: 5000
}]
}
//app.js
process.on(‘SIGINT’, function () {
app.close(function () {
console.log(‘server closed’)
process.exit(0)
})
})
참고
'Computer Engineering' 카테고리의 다른 글
MSA에 대해 알아보자.. vs 모노로틱 (디스커버리, API gateway) (0) | 2023.02.21 |
---|---|
Sentry Slack에 붙여서 에러 로깅하기 (0) | 2022.10.31 |