목차
SIGTERM 이후에도 살아 있는 Node.js 컨테이너의 종료 처리 (2026-01)
원문 정보
- 원문: Node.js 컨테이너, 왜 깔끔하게 안 죽을까? (feat. Graceful shutdown)
- 매체 · 게시일: 쏘카 기술 블로그 · 2026-01-20
- 저자: 세바스찬
- 원문 내용을 바탕으로 재구성한 요약입니다.
일부만 반영된 채 끝나는 배치
모두의주차장은 여러 배치 컨슈머 앱을 운영합니다. 배치가 아예 멈춘 것은 아닌데, 일부 작업만 반영되지 않은 채 끝난 듯한 사례가 간헐적으로 나왔습니다. 처음에는 배치 로직과 트랜잭션을 의심했지만 재현이 되지 않았고 증상도 상황마다 달랐습니다.
그래서 배치가 도는 동안 프로세스가 어떻게 종료되는지로 시선을 돌렸습니다. 배포 중에 배치가 실행되고 있다면 어디까지를 정상 종료로 볼 것인지, 쿠버네티스가 말하는 graceful shutdown이 실제로 무엇인지를 묻게 됐습니다. 쿠버네티스는 Pod를 종료할 때 먼저 SIGTERM을 보냅니다. 이 앱은 신호를 받고도 배치를 계속 돌리다가 결국 SIGKILL로 강제 종료되고 있었습니다.
PID 1 보호와 dumb-init
“Node.js는 PID 1 역할을 하도록 설계되지 않아 시그널을 못 받는다"는 설명은 절반만 맞다고 합니다. 리눅스 커널은 PID 1에 한해, 핸들러가 없는 시그널을 무시합니다. 일반 프로세스는 핸들러가 없으면 기본 동작으로 종료되지만 PID 1은 그렇지 않습니다. NestJS에서 app.enableShutdownHooks()로 핸들러를 등록했다면 PID 1이어도 SIGTERM은 받을 수 있습니다.
진짜 문제는 좀비 프로세스 정리와 자식·손자 프로세스로의 시그널 전파를 Node.js가 맡지 않는다는 점이었습니다. 팀은 이 역할을 init 프로세스에 맡기기로 하고 dumb-init을 PID 1로 두었습니다.
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "dist/main"]훅이 끝나도 종료되지 않는 이벤트 루프
시그널은 정상 전달됐지만 새 문제가 보였습니다. onModuleDestroy에서 Promise.race로 최대 2분만 기다리게 했는데, Pod는 배치가 끝나는 약 5분 뒤에야 내려갔습니다.
원인은 이벤트 루프입니다. 타임아웃이 먼저 끝나 훅 함수는 반환되지만, 경주에서 진 배치 Promise는 취소되지 않습니다. 그 안의 await sleep(10000) 같은 비동기 작업이 이벤트 루프에 남아 있으니 Node.js는 처리할 일이 있다고 보고 종료를 미룹니다. 함수가 끝났다고 프로세스가 끝나는 것은 아니었습니다.
원문의 로그는 이 흐름을 그대로 보여 줍니다. SIGTERM 수신 후 2분 뒤 타임아웃으로 훅이 반환됐지만 배치는 계속 돌았고, 5분쯤 지나 배치가 끝나서야 프로세스가 내려갔습니다.
AbortController를 쓰지 않은 이유와 최종 구성
AbortController로 루프를 강제로 멈추는 방안도 검토했습니다. 모든 루프와 await 지점에 중단 확인을 넣어야 해서 코드가 복잡해지고, DB 트랜잭션 같은 외부 라이브러리가 중단을 안전하게 처리하지 못할 수 있으며, 데이터 정합성이 오히려 위험해질 수 있다는 이유로 채택하지 않았습니다.
대신 이미 시작된 배치는 끝까지 보장하고 그 뒤는 쿠버네티스의 종료 정책에 맡겼습니다. 애플리케이션에서는 실행 중인 배치를 Promise.allSettled로 묶고 120초 타임아웃과 경주시킵니다. 인프라에서는 terminationGracePeriodSeconds를 앱 타임아웃보다 긴 180초로 두었습니다. 타임아웃이 나도 훅은 에러 로그만 남기며, 필요하면 process.exit(1)로 강제 종료할 수도 있다고 원문은 적었습니다.
원문이 정리한 핵심은 네 가지입니다.
- dumb-init이나 tini로 시그널 전달과 좀비 정리를 맡긴다.
npm start대신node dist/main을 직접 실행해 시그널이 가로막히지 않게 한다.- 훅이 반환돼도 비동기 작업이 남아 있으면 프로세스는 살아 있다는 점을 기억한다.
- 앱 타임아웃보다 쿠버네티스 유예 시간을 길게 맞춘다.
종료 관련 설정과 타임라인 근거 표
| 항목 | 값 | 비고 |
|---|---|---|
앱 타임아웃(Promise.race) | 2분(120000ms) | 2분에 훅은 반환됐지만 프로세스는 유지됨 |
| 실제 종료까지 걸린 시간(수정 전) | 약 5분 | 배치가 끝날 때까지 이벤트 루프에 작업이 남음 |
terminationGracePeriodSeconds | 180초 | 앱 타임아웃 2분보다 길게 설정 |
| PID 1 | dumb-init | 시그널 전파와 좀비 프로세스 정리 담당 |
| 미채택 대안 | AbortController | 코드 복잡도, 트랜잭션 안전성, 정합성 우려 |