Kubernetes 환경에서 Node.js 배치 컨슈머가 SIGTERM을 받아도 깔끔하게 종료되지 않는 문제를 단계별로 해결한 경험이다. PID 1의 시그널 무시 동작, 이벤트 루프와 프로세스 종료의 차이, dumb-init 도입까지 원인을 하나씩 파악하고 최종 해결책을 찾아간 과정을 기술한다.
핵심 포인트- Linux 커널은 PID 1에 핸들러 없으면 시그널 무시(일반 프로세스는 기본 동작으로 종료) — Node.js가 PID 1이면 SIGTERM 무시
- dumb-init을 ENTRYPOINT로 설정해 시그널 전파·좀비 프로세스 정리를 전용 init 시스템에 위임
- NestJS onModuleDestroy에서 Promise.race로 타임아웃 구현해도 패배한 batchPromise의 비동기 작업이 이벤트 루프에 남아 프로세스 유지
- AbortController 강제 중단 대신 "시작된 배치는 완료 보장" 전략 채택 — DB 트랜잭션 정합성 우선
- K8s terminationGracePeriodSeconds를 앱 타임아웃보다 반드시 길게 설정 (앱 120s < K8s 180s)
상세 정리- 증상: 배치 일부만 반영된 채 종료, 재현 불가, 배포 타이밍+종료 시그널+graceful shutdown 복합 원인 의심
- PID 1 원리: 커널 설계 "Global init gets no signals it doesn't want" — 핸들러 없는 PID 1은 SIGTERM 무시
- NestJS enableShutdownHooks()로 핸들러 등록 시 PID 1도 SIGTERM 수신, 그러나 좀비 프로세스 정리와 자식 프로세스 시그널 전파는 여전히 미해결
- dumb-init 도입: apt-get install dumb-init 후 ENTRYPOINT ["/usr/bin/dumb-init", "--"], CMD ["node", "dist/main"]
- npm start 대신 node dist/main 직접 실행 권장: npm이 PID 1이 되어 시그널 가로채는 문제 방지
- 타임아웃 함정 실제 관찰: 16:07:20 SIGTERM → 16:09:20 2분 타임아웃(훅 return) → 16:11:56 배치 완료 후 실제 종료
- 원인: Promise.race에서 타임아웃이 이겨도 batchPromise 내 await sleep() 작업이 이벤트 루프에 남아 Node.js 프로세스 유지
- AbortController 미채택: 모든 await 지점마다 중단 체크 필요, DB 트랜잭션 라이브러리가 abort 안전 처리 못할 가능성
- 최종 구현: Promise.allSettled(this.runningBatches) + 120s 타임아웃, K8s terminationGracePeriodSeconds=180s
- 핵심 교훈: "return만으로 프로세스가 종료되지 않는다. 비동기 작업이 이벤트 루프에 남아 있으면 프로세스는 살아있다"
왜 읽나K8s Node.js 서비스의 "종료됐다고 생각했지만 실제로 살아있는" 문제를 마주치기 전에 읽으면 좋다. PID 1 원리부터 이벤트 루프 종료 조건, K8s Grace Period 설정까지 인프라·앱 레이어를 아우르는 체크리스트를 제공한다.