Hits

블로그의 과거 버전에서 진행한 내용입니다.



아래 Reference를 참고했습니다.

https://engineering.linecorp.com/ko/blog/pm2-nodejs


⚙️ 문제상황

블로그(byjuun.com)의 백엔드 서버는 EC2 인스턴스에 올라가 있으며, PM2를 이용해 서비스를 하고 있습니다.

새로운 기능 개발 또는 리팩토링 작업 후에는 EC2 인스턴스에 작업한 내용물을 올린 후, pm2 reload app_name 명령어를 이용해 서버를 재시작 할 수 있었습니다.

이 과정에서 아래에서 볼 수 있듯 잠깐이지만 서비스가 중단되는 현상이 있었습니다. (실제 배포 과정에서는 자동화되어있습니다)

사실 사용자가 있는 서비스가 아닌 개인 프로젝트이기 때문에, 이 정도의 중단은 크게 문제가 되지 않았습니다.
하지만 조만간.. 실서비스의 SSR 서버를 배포하고 운영할 일이 생겨 겸사겸사 개인프로젝트에서 먼저 공부해보기로 했습니다.


🛠️ 트러블슈팅

https://engineering.linecorp.com/ko/blog/pm2-nodejs

위 Reference를 기준으로 하나씩 적용해보며, 모르는 개념은 공부하는 방식으로 트러블슈팅을 진행했습니다.

1️⃣ PM2의 프로세스 재시작 과정 살펴보기

  1. PM2의 클러스터 옵션을 통해 프로세스 10개가 실행되고 있는 상황에서 pm2 reload 실행
  2. PM2는 기존의 0번 프로세스를 ‘_old_0’ 프로세스로 만들고(1), 새로운 0번 프로세스를 만든다(2)
  3. 새로운 0번 프로세스는 요청을 처리할 준비가 완료되면, 마스터 프로세스에게 ‘ready’ 이벤트를 보낸다(3)
  4. 마스터 프로세스는 더 이상 필요 없어진 ‘_old_0’ 프로세스에게 SIGINT 시그널을 보내고 프로세스가 종료되기를 기다린다. (4)
  5. 일정 시간(1600ms)가 지났는데도 종료되지 않았따면, SIGKILL 시그널을 보내 프로세스를 강제로 종료시킨다. (5)
  6. 이 과정을 총 프로세스(10)의 개수 만큼 반복한다.

(SIGINT시그널은 프로세스 종료를 지시하는 시그널, SIGKILL은 프로세스를 종료(kill)시키는 시그널)

2️⃣ 아직 새로운 프로세스가 준비되지 않았는데, ready를 보내는 경우

새로 만든 프로세스가 아직 준비가 되지 않았을 때, ready 이벤트를 보내게 되는 경우, 마스터 프로세스는 새로운 프로세스가 요청받을 준비가 완료 되었다고 판단 후, 기존의 프로세스(old)에 SIGINT 시그널을 보내 프로세스 종료를 지시합니다.

애플리케이션을 시작할 때 필요한 초기화 작업이 기존 프로세스가 종료될때까지(5) 완료되지 않는다면, 새로운 프로세스는 사용자 요청이 유입 되어도 처리할 수 없는 상황이 되어버립니다.

이 문제를 해결하기 위해서는 새롭게 생성된 프로세스에서 애플리케이션 초기화 작업이 완료된 이후, 마스터 프로세스(프로세스들을 관리하는 프로세스)에게 ready 이벤트를 보내도록 수정해야합니다.

pm2의 설정을 위한 ecosystem.config.js 파일에 wait_ready옵션과 listen_timeout을 추가해 마스터 프로세스가 ready이벤트를 기다릴 수 있도록 합니다.

  • wait_ready : boolean // 마스터 프로세스가 ready 이벤트를 기다릴지 말지를 결정
  • listen_timeout : number // ready 이벤트를 기다리는 시간
ecosystem.config.js
module.exports = {
  apps: [
    {
      name: "byjuun.com-server-production",
      script: "build/index.js",
      wait_ready: true,
      listen_timeout: 50000,
      env: {
        NODE_ENV: "production",
      },
    },
  ],
};
ecosystem.config.js
module.exports = {
  apps: [
    {
      name: "byjuun.com-server-production",
      script: "build/index.js",
      wait_ready: true,
      listen_timeout: 50000,
      env: {
        NODE_ENV: "production",
      },
    },
  ],
};

express 서버 초기화 작업이 완료된 이후에는 listen 이벤트가 실행되기 때문에, listen 콜백 함수에서 마스터 프로세스로 ready 이벤트를 보내는 코드를 추가합니다.

const app = express();
 
app.listen(3065, () => {
  if (process.send) {
    process.send("ready");
  }
  console.log("서버 실행 중");
});
const app = express();
 
app.listen(3065, () => {
  if (process.send) {
    process.send("ready");
  }
  console.log("서버 실행 중");
});

3️⃣ 클라이언트 요청 처리 도중, 프로세스가 죽는 경우

reload 명령어를 수행할 경우, 기존 프로세스는 프로세스가 종료되기전(SIGKILL 시그널을 통해 강제종료 되기 전)까지 사용자 요청을 받습니다.

만약, SIGINT시그널을 받은 상태에서, 사용자의 요청(GET /api/user.json)을 받게 되었고, 해당 요청이 SIGKILL 시그널을 받기 까지(1600ms)걸리는 시간보다 오래 걸리게 된다면, 사용자는 해당 API 요청에 대한 응답을 받지 못하게 됩니다.

이 문제를 해결하기 위해서는, SIGINT 시그널에 대한 콜백함수를 지정해, SIGINT 시그널을 받을 경우 app.close 명령어를 통해 새로운 요청은 거절하고 기존 연결은 유지하여 처리하게 합니다.

이 후, 기존에 유지되고 있던 사용자 요청을 처리하기에 충분한 시간을 kill_timeout(SIGINT ~ SIGKILL 까지의 시간)에 설정하고 사용자의 요청이 모두 끝나면, 프로세스가 종료되도록 처리합니다.

ecosystem.config.js
module.exports = {
  apps: [
    {
      name: "byjuun.com-server-production",
      script: "build/index.js",
      wait_ready: true,
      listen_timeout: 50000,
      kill_timeout: 5000,
      env: {
        NODE_ENV: "production",
      },
    },
  ],
};
ecosystem.config.js
module.exports = {
  apps: [
    {
      name: "byjuun.com-server-production",
      script: "build/index.js",
      wait_ready: true,
      listen_timeout: 50000,
      kill_timeout: 5000,
      env: {
        NODE_ENV: "production",
      },
    },
  ],
};
const app = express();
 
const server = app.listen(3065, () => {
  if (process.send) {
    process.send("ready");
  }
  console.log("서버 실행 중");
});
 
process.on("SIGINT", () => {
  server.close(() => process.exit(0));
});
const app = express();
 
const server = app.listen(3065, () => {
  if (process.send) {
    process.send("ready");
  }
  console.log("서버 실행 중");
});
 
process.on("SIGINT", () => {
  server.close(() => process.exit(0));
});

Reference에서는 express() 의 호출로 생성된 app 인스턴스의 close 메서드를 호출합니다.

하지만, 이렇게 코드를 작성 후 실행하게 될 경우, close is not a function 오류를 만나게 됩니다.

close 메서드는 app 인스턴스가 아닌 app.listen의 반환값이 http.Server 인스턴스에서 호출해야 합니다.

공식문서에 따르면, server.close 메서드는 새로운 connection을 accept 하는 것을 멈추고, 기존의 connection은 유지한다는 것을 알 수 있습니다.

5️⃣ HTTP 1.1 Keep-Alive를 사용해 connection을 맺은 경우

앞서, server.close 메서드는 새로운 connection을 accept 하는 것을 멈추고, 기존의 connection은 유지한다는 것을 알 수 있었습니다.

이 과정에서 기존의 connection이 HTTP 1.1 Keep-Alive를 통해 맺을 connection이라면, 사용자의 요청이 처리된 이후에도 connection이 유지되기 때문에, 사용자는 다음 요청에 정상적인 응답을 받지 못할 수 있습니다.

이 경우, 응답 헤더에 Connection : close를 삽입해, connection을 종료하도록 만들 수 있습니다.

기존의 프로세스가 마스터 프로세스로 부터 SIGINT 시그널을 받으면, 해당 순간 전역변수 isDisableKeepAlive를 true로 세팅하고, server.close 를 호출합니다.

이후, 사용자가 HTTP 1.1 Keep-Alive를 통해 연결된 connection을 통해 요청하게 될 경우, express의 맨 앞단 미들웨어에서 isDisableKeepAlive의 값이 true인 경우, connection을 닫는 헤더를 세팅하게 됩니다.

const app = express();
let isDisableKeepAlive = false;
 
app.use(function (req, res, next) {
  if (isDisableKeepAlive) {
    res.set("Connection", "close");
  }
  next();
});
 
const server = app.listen(3065, () => {
  if (process.send) {
    process.send("ready");
  }
  console.log("서버 실행 중");
});
 
process.on("SIGINT", () => {
  isDisableKeepAlive = true;
  server.close(() => process.exit(0));
});
const app = express();
let isDisableKeepAlive = false;
 
app.use(function (req, res, next) {
  if (isDisableKeepAlive) {
    res.set("Connection", "close");
  }
  next();
});
 
const server = app.listen(3065, () => {
  if (process.send) {
    process.send("ready");
  }
  console.log("서버 실행 중");
});
 
process.on("SIGINT", () => {
  isDisableKeepAlive = true;
  server.close(() => process.exit(0));
});

HTTP 1.1 Keep Alive

HTTP 1.0은 사용자가 서버에 요청을 전송할때마다, TCP를 이용해 connection을 맺어야 했습니다.

HTTP 1.1에서는 한번 연결한 connection을 유지해, 여러번의 요청이 가능하며 이를 Keep Alive라 부릅니다.


⚙️ Cluster 모드 적용

Reference에 나와있는대로 코드를 수정 후 적용을 완료 했지만, 아직까지 reload 이후 서비스가 중단되었습니다.

제 블로그 서버는 서버 앞단에는 Nginx 웹서버를 붙여 사용하고 있습니다.
(로드 밸런싱이나 정적 파일 서빙을 하지는 않지만, 초기에 Nginx를 배워보기 위해 적용 후, HTTPS만 적용했습니다…)

reload 이후 서비스가 잠시 중단된 경우, nginx 502 bad gateway 에러를 마주하게 되었는데요.

서버에 남은 nginx error log를 확인해보니 아래와 같았습니다.

구글링을 통해 해당 에러에 대해 알아보았을때는, 경우의수가 너무 많았습니다.

경우의 수를 하나하나 살펴보니, reload 후 순간적으로 서버가 열리는 포트(3065)에 어떠한 프로세스도 존재하지 않는다는것을 알게 되었습니다.

(Nginx에서 서버가 실행되는 3065번 포트로 reverse proxy를 하지만, 어떤한 프로세스도 존재하지 않아 502 error 발생)

서버가 실행되는 포트 관련 오류를 마주하니, 기존에 살펴 보았던 fork 모드와 cluster 모드의 차이가 생각났습니다.

cluster 모드는 서버가 실행되는 포트에서 마스터 프로세스가 실행되어, 연결되는 connection을 Round-Robin 방식으로 분산하게 됩니다.

이후, pm2 설정파일(ecosystem.config.js)에서 exec_mode를 cluster로 변경하니, reload를 수행과정에서도 3065번 포트에 프로세스가 존재하는것을 확인할 수 있었고, 무중단으로 배포를 진행할 수 있었습니다.


🧐 Instance는 1로 똑같은데, 왜 Cluster Mode만 되는걸까?

이제 여기서 궁금한 점은 Cluster Mode와 Fork Mode 모두 instance는 1로 실행 했을 때, 왜 Cluster Mode만 중단이 발생하지 않을까 하는 점이었습니다.

Cluster Mode와 Fork Mode의 차이점에 대해서 찾아보았지만, instance가 1일때는 어떠한 차이점이 있는지 정확히 알지 못했습니다. 그래서 이와 관련해 공식 레포에 ISSUE를 남겨놓은 상태입니다.
답변이 돌아오면, 관련 내용을 추가할 예정입니다.

ISSUE에 Comments가 달리지 않아, Stack Overflow와 다른 Reference를 통해 추측한 내용을 작성합니다. → StackOverflow

확실한 내용이 아닐 수 있습니다.

Cluster Mode로 실행할 경우, 내부적으로 NodeJSCluster 모듈을 이용해 프로세스를 생성합니다.
Fork Mode로 실행할 경우, child_process 모듈을 이용해 프로세스를 생성합니다.

Cluster Mode로 실행할 경우 앞단에 마스터 프로세스가 존재하고, 마스터 프로세스가 포트로 들어온 요청을 스케쥴링 알고리즘을 통해 각 프로세스에 분배합니다.
Fork Mode로 실행할 경우, 앞단에 마스터 프로세스가 존재하지 않아, 애초에 위 논의 자체가 의미가 없었던 것으로 보입니다.

Instance를 1로 설정한 이유

express-session을 사용중인데, session을 저장하기 위해 MemoryStore를 사용중입니다. 인스턴스를 여러개 만들 경우, session을 공유하지 못하기 때문에, instance를 1로 설정했습니다.

물론, redis를 사용하거나 session-file-store 와 같이 file based store를 사용 하는 방법도 있긴하지만, 이미 클라이언트쪽에서 사용자정보 관련 API, 방문객 관련 API 말고는 server componentnextjs 기타 feature들을 이용해서 Caching이 잘되어있어, 백엔드 서버 인스턴스를 늘릴 필요가 없다고 판단했습니다.