블로그의 과거 버전에서 진행한 내용입니다.
아래 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의 프로세스 재시작 과정 살펴보기
- PM2의 클러스터 옵션을 통해 프로세스 10개가 실행되고 있는 상황에서
pm2 reload
실행 - PM2는 기존의
0
번 프로세스를‘_old_0’
프로세스로 만들고(1)
, 새로운 0번 프로세스를 만든다(2)
- 새로운 0번 프로세스는 요청을 처리할 준비가 완료되면, 마스터 프로세스에게
‘ready’
이벤트를 보낸다(3)
- 마스터 프로세스는 더 이상 필요 없어진
‘_old_0’
프로세스에게SIGINT
시그널을 보내고 프로세스가 종료되기를 기다린다.(4)
- 일정 시간(1600ms)가 지났는데도 종료되지 않았따면,
SIGKILL
시그널을 보내 프로세스를 강제로 종료시킨다.(5)
- 이 과정을 총 프로세스
(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 이벤트를 기다리는 시간
module.exports = {
apps: [
{
name: "byjuun.com-server-production",
script: "build/index.js",
wait_ready: true,
listen_timeout: 50000,
env: {
NODE_ENV: "production",
},
},
],
};
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 까지의 시간)
에 설정하고 사용자의 요청이 모두 끝나면, 프로세스가 종료되도록 처리합니다.
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",
},
},
],
};
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
로 실행할 경우, 내부적으로 NodeJS
의 Cluster
모듈을 이용해 프로세스를 생성합니다.
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 component
, nextjs
기타 feature
들을 이용해서 Caching
이 잘되어있어, 백엔드 서버 인스턴스를 늘릴 필요가 없다고 판단했습니다.