Please enable JavaScript to view the comments powered by Disqus.

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)를 보낸다.
  • 예전 프로세스가 새 프로세스가 시작되었다는 신호(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',
      },
    },
  ],
}

PM2 Ecosystem File Reference

서버 앱의 실행 준비가 완료되었다는 신호를 전달

설정에서 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

참고