AI Briefing

Node.js 컨테이너는 왜 깔끔하게 종료되지 않을까? (Graceful shutdown 편)

·2026.01.20 00:00

PID 1, Node 이벤트 루프, K8s 종료 정책이 겹치면 종료 훅만으로는 부족하다.

배치 컨슈머 운영 중, 종료 시그널을 넣었는데도 작업이 계속 돌다가 SIGKILL로 끊기는 문제가 드러났다. 단순한 시그널 처리 문제가 아니라 Linux PID 1 보호 메커니즘Node.js 이벤트 루프가 함께 얽힌 종료 문제였다.

먼저 의심한 것은 “Node.js가 PID 1이라 시그널을 못 받는다”는 흔한 설명이었지만, 실제 핵심은 그보다 넓었다. PID 1은 핸들러가 없을 때 시그널을 무시할 수 있고, 더 중요한 문제는 자식·손자 프로세스 정리와 시그널 전파를 Node.js가 맡지 않는다는 점이었다. 이를 해결하기 위해 컨테이너 안에서 dumb-init을 PID 1로 두고, 시그널 전달과 좀비 프로세스 reaping을 맡겼다.

  • dumb-init을 엔트리포인트로 사용해 종료 시그널이 애플리케이션까지 전달되도록 구성
  • npm start 대신 node dist/main처럼 직접 실행해 시그널 전달을 방해할 가능성을 줄임
  • PID 1이 맡아야 할 프로세스 관리는 전문 init 시스템에 위임

그다음 문제는 더 미묘했다. onModuleDestroy에서 Promise.race2분 타임아웃을 걸었지만, 훅이 return된 뒤에도 배치는 계속 실행됐고 Pod는 약 5분 뒤에야 종료됐다. 이유는 타임아웃이 끝나도 패배한 batchPromise가 취소되지 않았고, 내부의 await sleep(10000) 같은 비동기 작업이 이벤트 루프에 남아 있어 Node.js가 아직 할 일이 있다고 판단했기 때문이다. 즉, 함수가 끝났다고 프로세스가 끝나는 것은 아니었다.

AbortController로 루프를 중단하는 방법도 검토했지만 채택하지 않았다. 모든 await 지점에 중단 체크를 넣어야 하고, DB 트랜잭션이나 외부 라이브러리가 안전하게 취소를 처리하지 못할 수 있어 오히려 정합성 리스크가 커질 수 있었기 때문이다. 그래서 이미 시작된 배치는 끝까지 보장하고, 그 이후 종료는 Kubernetes 정책에 맡기는 쪽으로 정리했다.

최종적으로는 애플리케이션과 인프라의 책임을 분리했다. 애플리케이션에서는 app.enableShutdownHooks()onModuleDestroy로 종료 훅을 두고, 내부 배치 완료를 최대 120초까지 기다리게 했다. 인프라에서는 terminationGracePeriodSeconds180초로 설정해, 앱 타임아웃보다 더 긴 유예 시간을 확보했다.

핵심은 종료 훅을 넣는 것보다, 종료 시점에 이벤트 루프에 무엇이 남아 있는지를 이해하는 일이었다. Node.js 이벤트 루프, PID 1, Kubernetes 종료 정책은 서로 맞물려 동작하므로, 한쪽만 손보면 "끝난 줄 알았는데 아직 살아 있는" 상태가 쉽게 생긴다. 결국 중요한 것은 오버엔지니어링이 아니라, 문제의 성격을 정확히 보고 애플리케이션과 인프라의 역할을 적절히 나누는 선택이었다.

이 요약은 원문 이해를 돕기 위한 큐레이션입니다. 저작권은 원저작자에게 있으며, 정확한 내용과 맥락은 원문을 확인하세요.

요약 오류, 출처 표기 문제, 삭제 요청은 문의 · 건의로 알려주세요.