PM2로 Node.js 앱이 무중단 상태가 되도록 관리하기
PM2와 Node.js 앱 사이의 신호 교환
PM2의 클러스터 모드를 통한 로드 밸런싱
PM2를 클러스터 모드로 사용하면 Node.js 서버 앱을 동시에 여러 개 실행하면서 1개의 포트를 공유할 수 있다. 굳이 똑같은 앱을 많은 메모리를 사용해가며 실행해야 하는 이유는 Node.js가 싱글 스레드여서 1개의 CPU만 사용하기 때문이다. PM2는 Node.js의 클러스터 모듈을 사용해서 원하는 수 만큼 프로세스를 쉽게 늘리고 줄일 수 있도록 도와준다. 그리고 자연히 1개의 프로세스만 실행할 때보다 성능과 안정성을 높일 수 있다.
하지만 PM2는 무중단 재시작(graceful reload)를 무조건 보장하지 않는다. PM2로 관리 중이던 앱을 재시작하면 예전 프로세스를 새 프로세스로 교체하는 과정이 발생하는데, 거기서 이슈가 발생할 수 있다. 예를 들면
-
새 프로세스가 시작은 되었지만 앱이 완전히 준비되지 않은 상태에서(=
server.listen
메소드의 콜백이 실행되지 않은 상태에서)- PM2가 네트워크 요청을 새 프로세스로 보낼 때
-
예전 프로세스가 네트워크 요청을 처리중인데도
- PM2가 무시하고 프로세스를 강제로 죽여버렸을 때
위의 두 가지 모두 서비스가 일시적으로 죽은 것처럼 보이게 만들 수 있다.
PM2는 프로세스를 교체할 때 앱의 구동과 종료에 필요한 시간에 여유를 주고 있다. 다만 그 여유 시간이 지나면 앱이 준비가 되었든 말았든 프로세스가 강제로 교체된다.
PM2는 재시작 명령어(pm2 reload
)를 받아서 새 프로세스를 생성한 후 Node.js 앱이 준비될때까지(listen_timeout
) 8초, 예전 프로세스가 종료될때까지(kill_timeout
) 1.6초 동안 대기한다. 그 설정에 따라
-
새 프로세스를 시작한 후 8초가 지나면
- PM2는 예전 프로세스에 새 프로세스가 준비되었다는 신호(
SIGINT
)를 보낸다.
- PM2는 예전 프로세스에 새 프로세스가 준비되었다는 신호(
-
예전 프로세스가 새 프로세스가 시작되었다는 신호(
SIGINT
)를 받은 후 1.6초가 지나면- PM2는 예전 프로세스를 강제로 종료시키고 새 프로세스로 교체한다.
하지만 문제는 모든 앱이 PM2가 기본 설정한 그 시간 안에 시작되고, 종료되지는 않는다는 점이다. 그렇다고 해서 대기 시간을 어림짐작으로 30초, 1분 이런 식으로 늘려주는 방법은 현명하지 못하다. 예외라는 것은 언제든지 발생 할 수 있기 때문이다. 무중단 재시작 보장을 위해서는 Node.js 앱 코드에서 PM2와 신호를 주고받아야 한다.
PM2와 Node.js 사이의 신호 교환
PM2 설정 파일
module.exports = {
apps: [
{
name: 'app',
script: 'server.js',
instances: 4,
exec_mode: 'cluster', // 실행 모드. cluster로 명시하지 않으면 기본 fork 모드가 된다. wait_ready: true, // Node.js 앱으로부터 앱이 실행되었다는 신호를 직접 받겠다는 의미 listen_timeout: 50000, // 앱 실행 신호까지 기다릴 최대 시간. ms 단위. kill_timeout: 5000, // 새로운 프로세스 실행이 완료된 후 예전 프로세스를 교체하기까지 기다릴 시간 max_memory_restart: '500M',
env: {
NODE_ENV: 'development',
},
env_production: {
NODE_ENV: 'production',
PORT: '8081',
},
},
],
}
서버 앱의 실행 준비가 완료되었다는 신호를 전달
설정에서 wait_ready
옵션을 true
로 설정했으므로 Node.js 앱에서 구동이 완료되었다는 신호를 PM2에게 직접 전달해야 한다. express.js를 서버로 사용한다면 listen
메소드의 콜백에서 시작 신호를 보내면 된다.
const server = express()
const listeningServer = server.listen(port, err => {
console.log(`> Ready on http://localhost:${port}`)
// PM2가 스크립트를 실행하지 않았다면 process.send 메소드가 undefined일 수 있다.
if (process.send) {
// PM2에게 앱 구동이 완료되었음을 전달한다
process.send('ready') }
})
실제로 구현된 앱이 실행되기까지 평균적으로 얼마나 걸리는지 확인을 해보고, 그보다 더 여유있는 시간을 listen_timeout
에 설정하면 된다.
새로운 프로세스가 준비되었다는 신호 수신
PM2는 listen_timeout
에 설정된 시간이 경과하거나 process.send('ready')
호출을 통해 시작 신호를 수신받으면 예전 프로세스에게 새 앱이 준비되었으니 앱을 종료시키라는 의미로 SIGINT
시그널을 보낸다. Node.js 앱은 이 신호를 받아서 앱을 종료시키면 된다.
let isAppGoingToBeClosed = false // HTTP 연결을 종료시킬 미들웨어에서 사용할 변수
process.on('SIGINT', function() { // SIGINT 신호가 수신되었을 때 console.log('> received SIGNIT signal')
isAppGoingToBeClosed = true
// pm2 재시작 신호가 들어오면 서버를 종료시킨다.
// listeningServer: server.listen 메소드가 리턴하는 서버 인스턴스를 할당한 변수
listeningServer.close(function(err) { console.log('server closed')
process.exit(err ? 1 : 0)
})
})
위의 예제 코드에서는 로그를 출력하는 정도의 작업만 했지만 서버 앱이 사용중인 데이터베이스같은 다른 서비스의 종료도 처리할 수 있을 것이다. 그리고 앱의 완전한 종료까지 시간이 많이 걸릴 것 같다면 kill_timeout
을 더 여유 있게 설정해줘야 한다.
그리고 SIGINT
수신 후에 HTTP 연결을 종료시키기 위해 네트워크 응답에 Connection 헤더를 close
로 설정하는 미들웨어를 작성할 수 있다.
server.use(function(req, res, next) {
// 프로세스 종료 예정이라면 연결을 종료한다
if (isAppGoingToBeClosed) {
res.set('Connection', 'close') }
next()
})
이렇게 하면 프로세스가 교체되기 전에 Node.js 서버 앱이 먼저 종료되고, 네트워크 요청도 더이상 처리하지 않을 수 있다.
본문에 있는 코드는 글 작성을 위해 만든 예제 앱에서 부분적으로 발췌한 것이며, 완전한 소스는 아래 링크에서 확인할 수 있다.
https://github.com/rhostem/nextjs-pm2-cd