LINE Corporation이 2023년 10월 1일부로 LY Corporation이 되었습니다. LY Corporation의 새로운 기술 블로그를 소개합니다. LY Corporation Tech Blog

Blog


PM2를 활용한 Node.js 무중단 서비스하기

이 글은 마이크로소프트웨어 393호에 기고된 글입니다.

자바스크립트는 가장 널리 사용되는 클라이언트 측 프로그래밍 언어이자 프론트엔드 웹 개발 언어 중 하나입니다. 그리고 Node.js는 Chrome의 V8 자바스크립트 엔진으로 빌드된 자바스크립트 런타임(runtime)으로 'Event Driven', 'Non-Blocking I/O' 모델을 사용해 가볍고 성능이 뛰어나 높은 평가를 받고 있습니다. 실제로 여러 글로벌 기업에선 웹 애플리케이션을 개발할 때 Node.js를 많이 선택하고 있습니다. LINE 역시 웹 테크 파트에서 진행하는 여러 프로젝트에서 SSR(Server Side Rendering)과 BFF(Backend for Frontend)등의 역할을 Node.js로 개발해 서비스하고 있습니다. 이번 글에선 이와 같이 최근에 많은 관심을 받고 있는 Node.js를 활용해서 실제 서비스를 무중단으로 운영해 본 경험을 공유하고자 합니다.

Node.js의 프로세스 매니저 PM2

먼저 알아야 할 사실은 Node.js는 기본적으로 싱글 스레드(thread)라는 점입니다. Node.js 애플리케이션은 단일 CPU 코어에서 실행되기 때문에 CPU의 멀티코어 시스템은 사용할 수 없습니다. 만약 보유하고 있는 서버의 사양이 8코어이며 하이퍼스레딩을 지원한다면 최대 16개 코어를 사용 할 수 있는데요. 모든 코어를 사용해 최대 성능을 내지 못하고 오직 한 개의 코어만 사용해야 한다면 주어진 자원을 제대로 활용하지 못하는 꼴이 됩니다. Node.js는 이런 문제를 해결하기 위해 클러스터(Cluster) 모듈을 통해 단일 프로세스를 멀티 프로세스(Worker)로 늘릴 수 있는 방법을 제공합니다. 그렇다면 우리는 클러스터 모듈을 사용해서 마스터 프로세스에서 CPU 코어 수만큼 워커 프로세스를 생성해서 모든 코어를 사용하게끔 개발하면 됩니다.

애플리케이션을 실행하면 처음에는 마스터 프로세스만 생성되는데요. 이때 CPU 개수만큼 워커 프로세스를 생성하고 마스터 프로세스와 워커 프로세스가 각각 수행해야 할 일들을 정리해서 구현하면 됩니다. 예를 들어 워커 프로세스가 생성됐을 때 온라인 이벤트가 마스터 프로세스로 전달되면 어떻게 처리할지, 워커 프로세스가 메모리 제한선에 도달하거나 예상치 못한 오류로 종료되면서 종료(exit) 이벤트를 전달할 땐 어떻게 처리할지, 그리고 애플리케이션의 변경을 반영하기 위해 재시작해야 할 때 어떤 식으로 재시작을 처리할 지 등등 고민할 게 많습니다. 이런 것들은 직접 개발하기에 번거로운 작업입니다. 따라서 이런 문제를 간편하게 해결할 수 있는 무언가가 있으면 좋겠다고 생각할 수 있는데요. 다행히 이런 고민이 녹아있는 PM2라는 Node.js의 프로세스 매니저가 존재합니다. 이 PM2를 간단히 살펴보고, 서비스에 PM2를 적용해 어떻게 Node.js 애플리케이션을 무중단으로 운영할 수 있는 지에 대해서 알아보겠습니다.

PM2 설치 방법

PM2는 아래와 같이 Node.js의 패키지 매니저(Package Manager)인 NPM으로 쉽게 설치할 수 있습니다.

//코드1. PM2 설치
$ npm install -g pm2@latest

PM2 기본 사용 방법

먼저 아래 '코드2'를 완성된 애플리케이션이라고 가정해보겠습니다.

//코드2. 예시 애플리케이션 app.js
//app.js
const express = require('express')
const app = express()
const port = 3000
app.get('/', function (req, res) { 
  res.send('Hello World!')
})
app.listen(port, function () {
  console.log(`application is listening on port ${port}...`)
})

위 '코드2'의 애플리케이션은 아래 '코드3'의 명령어로 데몬화(daemonize)하고 모니터링할 수 있습니다.

//코드3. 명령어
$ pm2 start app.js

[PM2] Spawning PM2 daemon with pm2_home=/Users/gentlejo/.pm2
[PM2] PM2 Successfully daemonized
[PM2] Starting /Users/gentlejo/Projects/maso/app.js in fork_mode (1 instance)
[PM2] Done.

위 '코드3'처럼 아무런 옵션없이 애플리케이션을 실행하면 PM2는 기본 모드인 포크(fork)모드로 애플리케이션을 실행합니다. 앞서 언급한 것처럼 모든 CPU를 사용하기 위해서는 애플리케이션을 클러스터 모드로 실행해야 합니다. 아래 '코드4'와 같이 간단하게 설정파일을 만들어보겠습니다.

//코드4. 설정파일 ecosystem.config.js
//ecosystem.config.js
module.exports = {
  apps: [{
  name: 'app',
  script: './app.js',
  instances: 0,
  exec_mode: ‘cluster’
  }]
}

완성된 설정파일을 활용해 애플리케이션을 클러스터 모드로 실행할 수 있습니다. exec_mode 항목값을 'cluster'로 설정하면 애플리케이션을 클러스터 모드로 실행하겠다는 의미이고, instance 항목값을 '0'으로 설정하면 CPU 코어 수 만큼 프로세스를 생성하겠다는 뜻입니다.

//코드5. 설정파일 실행
$ pm2 start ecosystem.config.js
[PM2][WARN] Applications app not running, starting...
[PM2] App [app] launched (4 instances)

위 '코드5'와 같이 실행하면 pm2 start app.js로 실행할 때와 다르게 클러스터 모드로 실행되고 CPU 코어 수 만큼 프로세스가 생성되며 최대 코어 수 만큼 요청을 처리할 수 있게 됩니다. 이를 통해 Node.js가 싱글 스레드라서 주어진 자원을 최대한 활용하지 못하고 하나의 CPU만 사용하는 문제를 해결할 수 있습니다.

만약 프로세스 개수를 늘리거나(scale up) 줄여야(scale down) 한다면 pm2 scale 명령어를 사용해서 실시간으로 프로세스 수를 증가시키거나 감소시킬 수 있습니다. 예를 들어, 현재 프로세스 4개가 실행 중인데 4개를 더 실행시켜 총 프로세스 8개를 실행하고 싶다면 아래 '코드6'과 같이 명령을 실행하면 됩니다.

//코드6. 프로세스 늘리기
$ pm2 scale app +4
[PM2] Scaling up application
[PM2] Scaling up application
[PM2] Scaling up application
[PM2] Scaling up application

혹은 실행 중인 8개의 프로세스가 너무 많다고 판단돼 다시 4개로 줄이고 싶다면 아래 '코드7'과 같이 명령을 실행하면 됩니다.

//코드7. 프로세스 줄이기
$ pm2 scale app 4
[PM2] Applying action deleteProcessId on app [0](ids: 0)
[PM2] [app](0) ✓
[PM2] Applying action deleteProcessId on app [1](ids: 1)
[PM2] [app](1) ✓
[PM2] Applying action deleteProcessId on app [2](ids: 2)
[PM2] [app](2) ✓
[PM2] Applying action deleteProcessId on app [3](ids: 3)
[PM2] [app](3) ✓

app.js의 내용을 수정한 뒤 수정한 사항을 프로세스에 반영하고 싶다면 프로세스를 재시작해야 합니다. 아래 '코드8'과 같이 pm2 reload 명령어를 사용하면 실행 중인 프로세스를 재시작할 수 있습니다.

//코드8. 프로세스 재시작
$ pm2 reload app
[PM2] Applying action reloadProcessId on app [app](ids: 4,5,6,7)
[PM2] [app](5) ✓
[PM2] [app](4) ✓
[PM2] [app](6) ✓
[PM2] [app](7) ✓

현재 프로세스 상태를 간략하게 표시해주는 pm2 list 명령어를 실행하면 재시작이 잘 되었는지 확인할 수 있습니다. 아래 '코드9'를 보면 재시작 전과 다르게 restart 필드의 숫자가 '0'에서 '1'로 변경된 것을 확인할 수 있습니다.

//코드9. 프로세스 상태 간략 표시
$ pm2 list

추가적인 명령어 사용 방법이나 로그를 보는 방법, 그리고 설정 파일에 설정할 수 있는 추가 옵션은 PM2 공식 사이트에서 자세하게 확인할 수 있습니다.

서비스 운영하기

이제 PM2를 활용해서 Node.js 애플리케이션을 운영할 수 있는 몇 가지 기본 지식을 갖췄습니다. Node.js로 개발한 애플리케이션을 서비스 서버로 배포하고 PM2로 애플리케이션을 실행하면 우리가 만든 애플리케이션을 사용자에게 서비스할 수 있습니다.

서비스는 오픈 이후에도 여러 상황 변화에 따라 지속적으로 변경해야 합니다. 만약 애플리케이션에 새로운 기능을 추가했거나 발견된 버그를 수정했다면, 이를 실제 서비스에 반영하기 위해선 다시 배포해야 합니다. 또한 배포가 완료된 후에는 애플리케이션의 변경을 반영하기 위해서 기존 프로세스를 재시작해야 합니다. 이때 reload 명령어를 활용하면 프로세스를 재시작할 수 있는데요. 이때 무중단 서비스를 유지하려면 몇 가지 주의해야 할 사항이 있습니다.

애플리케이션이 '코드2'의 예제 app.js 수준이라면 기본적으로 reload 명령어만 수행해도 PM2가 별다른 문제없이 알아서 프로세스를 재시작할 테고, 따라서 서비스에 영향을 주지 않고 무중단 서비스를 운영할 수 있습니다. 하지만 우리가 만드는 실제 서비스 애플리케이션들은 그렇게 가벼운 애플리케이션이 아닐 겁니다. 그저 reload 명령어만 신뢰하고 수행한다면, 배포과정에서 사용자에게 종종 에러 메시지를 보여주게 될 수도 있습니다(그렇게 당신은 사용자를 한 명 잃게 될 수도 있겠죠).

LINE 타임라인 웹(Timeline Web) 프로젝트에서도 이와 같은 문제가 발생했습니다. LINE 타임라인 웹은 SPA(Single Page Application) 구조의 CSR(Client Side Rendering) 방식으로 개발된 서비스였습니다. 여기에 Node.js를 도입해 리액트(React) SSR(Server Side Rendering)을 적용하고, 백엔드와의 통신을 Node.js에서 하도록 전환하고 있었습니다. 대부분의 개발이 완료되고 RC(Release Candidate) 환경에서 한창 QA를 진행하던 중이었습니다. 그런데 QA를 통해 발견된 버그를 수정하고 이를 확인하려고 하면, 배포 직후에만 가끔씩 브라우저에 'Service Unavailable', 'ERR_CONNECTION_REFUSED' 같은 에러 메세지가 출력됐습니다. 로컬(Local)이나 베타(Beta) 환경에서는 문제가 없었습니다. 릴리스를 위한 준비 과정에서 문제가 발생한 것입니다. 아마도 대부분의 개발자들이 한 번씩 겪어봤을 문제라고 생각합니다. 이 현상은 나중에 서비스를 릴리스하고 나서 새로운 버전이나 핫픽스 등을 배포할 때 재현될 것이라고 판단했고, 원인을 찾아서 꼭 해결해야만 했습니다.

왜 이런 문제가 발생하는 걸까요? 이 문제를 해결하기 위해서 PM2가 여러 개의 프로세스를 재시작하는 방식과 과정을 알아보고, 그 과정에서 서비스 중단이 발생할 수 있는 문제를 발견하고 어떻게 대처할 수 있는지 알아보겠습니다. 우리의 목적은 '서비스 무중단을 어떻게 실현할 것인가!'입니다.

프로세스 재시작 과정

그림1. 프로세스 재시작 과정

프로세스 10개가 실행되고 있다고 가정해보겠습니다. 이런 상태에서 pm2 reload를 실행하면 PM2는 기존 '0'번 프로세스를 '_old_0' 프로세스로 옮겨두고 새로운 0번 프로세스를 만듭니다. 새로운 0번 프로세스는 요청을 처리할 준비가 되면 마스터 프로세스에게 'ready' 이벤트를 보내고, 마스터 프로세스는 더 이상 필요없어진 _old_0 프로세스(기존 0번 프로세스)에게 'SIGINT' 시그널을 보내고 프로세스가 종료되기를 기다립니다. 만약 SIGINT 시그널을 보내고 난 후 일정 시간(1600ms)이 지났는데도 종료되지 않는다면, 'SIGKILL' 시그널을 보내 프로세스를 강제로 종료합니다. 0번 프로세스의 재시작은 이런 과정을 거쳐 완료되는데요. 이 과정을 총 프로세스 개수만큼 반복하면 모든 프로세스의 재시작이 완료됩니다. 

재시작 과정에서 서비스 중단이 발생하는 경우

새로 만들어진 프로세스가 실제로는 아직 요청을 받을 준비가 되지 않았는데 ready 이벤트를 보내는 경우

아래 '그림2'와 같이 과정(2)(Spawn new app)에서 프로세스를 생성한 뒤 과정(3)에서 앱 구동이 완료되기도 전에 마스터 프로세스에게 ready 이벤트를 보낸다면, 마스터 프로세스는 새로운 프로세스가 요청받을 준비가 완료됐다고 판단해 버립니다. 이에 기존 프로세스는 더 이상 필요없다고 판단하고 SIGINT 시그널을 보내 프로세스에게 곧 종료될 것을 알립니다. 여기서 만약 SIGINT 시그널을 보내고 일정시간(1600ms)이 지났는데도 프로세스가 살아있다면 이번엔 SIGKILL 시그널을 보내 프로세스를 강제 종료합니다. 만약 'App'을 실행했을 때, 매우 짧은 시간에 초기화 작업이 진행되고 요청을 받을 수 있는 준비가 완료됐다면 크게 문제되지 않을 수 있습니다. 하지만 (2)번 과정에서 새로운 프로세스를 생성하고 요청받을 준비를 하는데까지 일정시간(1600ms) 이상 걸리게 된다면 기존 프로세스는 이미 종료된 상태에서 새로운 프로세스는 사용자 요청이 유입돼도 처리할 수 없는 상황이 되어 버립니다. 즉 서비스 중단이 발생하게 되는 것입니다.

그림2. ready 이벤트 전송 시점부터 서비스 중단 가능성 발생

이 문제를 해결하기 위해서는 프로세스가 수행될 때 바로 ready 이벤트를 보내지 말고, 애플리케이션 로직에서 요청 받을 준비가 완료된 시점에 ready 이벤트를 보내도록 처리해야 합니다. 그리고 마스터 프로세스가 ready 이벤트를 언제까지 기다리게 할 것인지도 설정 파일에 명시해야 합니다. 아래 '코드10'은 '코드2'의 app.js와 '코드4'의 ecosystem.config.js에 관련 코드를 추가한 코드입니다. ecosystem.config.js에서 wait_ready 옵션을 'true'로 설정하면 마스터 프로세스에게 ready 이벤트를 기다리라는 의미입니다. listen_timeout 옵션은 ready 이벤트를 기다릴 시간값(ms)을 의미합니다. app.js에는 app.listen이 완료되면 실행되는 콜백(Callback) 함수에서 마스터 프로세스로 ready 이벤트를 보내도록 합니다.

//코드10. ready 이벤트 설정 변경
//ecosystem.config.js
module.exports = {
  apps: [{
  name: 'app',
  script: './app.js',
  instances: 0,
  exec_mode: ‘cluster’,
  wait_ready: true,
  listen_timeout: 50000
  }]
}
//app.js
const express = require('express')
const app = express()
const port = 3000
app.get('/', function (req, res) { 
  res.send('Hello World!')
})
app.listen(port, function () {
  process.send(‘ready’)
  console.log(`application is listening on port ${port}...`)
})

아래 '그림3'은 ready 이벤트 설정 변경 후의 모습입니다.

그림3. ready 이벤트 설정 변경 후

클라이언트 요청을 처리하는 도중에 프로세스가 죽어버리는 경우

reload 명령어를 실행할 때, 기존 0번 프로세스인 _old_0 프로세스는 프로세스가 종료되기 전까진 계속해서 사용자 요청을 받습니다. 그런데 만약 SIGINT 시그널이 전달된 상태에서 사용자 요청을 받았고, 그 요청을 처리하는 데 5000ms가 걸린다고 가정해보겠습니다. 앞서 말씀드렸듯, SIGINT 시그널을 받은 뒤 1600ms 이후에도 종료하지 않는다면 SIGKILL 시그널을 받고 강제 종료됩니다. 따라서 5000ms가 걸리는 사용자 요청을 처리하는 도중 SIGKILL 시그널을 받고 사용자에게 응답을 보내주지 못한 채 종료될 테고, 프로세스가 강제 종료되었기 때문에 클라이언트와의 연결은 끊어지게 됩니다. 이런 경우에도 서비스가 중단됩니다.

그림4. 클라이언트 요청 처리 도중 프로세스 중단

이 문제를 해결하기 위해서는 SIGINT 시그널을 리스닝(listening)하다가 해당 시그널이 전달되면 app.close명령어로 프로세스가 새로운 요청을 받는 것을 거절하고 기존 연결은 유지하게 처리합니다. 그리고 사용자 요청을 처리하기에 충분한 시간을 kill_timeout에 설정하고, 기존에 유지되고 있던 연결이 종료되면 프로세스가 종료되도록 처리합니다. 아래 '코드11'처럼 ecosystem.config.js에서 kill_timeout 옵션을 '5000'으로 설정하면 SIGINT 시그널을 보낸 후 프로세스가 종료되지 않았을 때 SIGKILL 시그널을 보내기까지의 대기 시간을 디폴트 값 1600ms에서 5000ms로 변경할 수 있습니다. app.js에 새로 추가된 코드는 해당 프로세스에 SIGINT 시그널이 전달되면, 새로운 요청을 더 이상 받지 않고 연결되어 있는 요청이 완료된 후 해당 프로세스를 강제로 종료하도록 처리하는 코드입니다.

//코드11. 클라이언트 요청 처리 설정
//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
const express = require('express')
const app = express()
const port = 3000
app.get('/', function (req, res) { 
  res.send('Hello World!')
})
app.listen(port, function () {
  process.send(‘ready’)
  console.log(`application is listening on port ${port}...`)
})
process.on(‘SIGINT’, function () {
  app.close(function () {
  console.log(‘server closed’)
  process.exit(0)
  })
})

app.close를 이용해 새로운 요청을 거절하고 이미 연결되어 있는 건 유지할 때, 만약 아래 '그림5'와 같이 'HTTP 1.1 Keep-Alive'를 사용하고 있었다면, 요청이 처리된 후에도 기존 연결이 계속 유지되기 때문에 앞의 방법만으로는 해결되지 않습니다.

그림5. HTTP 1.1 Keep-Alive를 사용하는 경우

이때는 아래 '코드12'와 같이 SIGINT 시그널을 받았을 때 특정 전역 플래그값에 따라 응답 헤더에 'Connection: close'를 설정해 클라이언트 요청을 종료하는 방법을 활용, 타임아웃으로 서비스가 중단되는 문제를 해결할 수 있습니다.

//코드12. 특정 전역 플래그값에 따른 응답 헤더 설정
//app.js
const express = require('express')
const app = express()
const port = 3000
let isDisableKeepAlive = false
app.use(function(req, res, next) {
  if (isDisableKeepAlive) {
    res.set(‘Connection’, ‘close’)
  }
  next()
})
app.get('/', function(req, res) { 
  res.send('Hello World!')
})
app.listen(port, function() {
  process.send(‘ready’)
  console.log(`application is listening on port ${port}...`)
})
process.on(‘SIGINT’, function () {
  isDisableKeepAlive = true
  app.close(function () {
  console.log(‘server closed’)
  process.exit(0)
  })
})

마치며

지금까지 Node.js의 프로세스 매니저인 PM2를 서비스에 적용해 Node.js 애플리케이션을 무중단으로 운영하고자 할 때 고려해야 할 사항에 대해서 알아보았습니다(물론 여기에 언급한 내용이 서비스를 무중단으로 운영하는데 필요한 전부는 아니고 이외에도 고려할 사항이 많습니다). 이 글이 PM2를 사용하여 Node.js 서비스를 운영하면서 마주칠 수 있는 이슈를 미리 파악하고 미연에 방지하는데 조금이나마 도움이 됐으면 합니다.