Node 컨테이너 배포 때 에러가 쌓일 때, SIGTERM 종료 처리 고치는 법

Docker 로 배포할 때마다 같은 에러가 한 건씩 쌓인다면 SIGTERM 종료 처리를 의심해 보세요. 원인을 찾고 NestJS 에서 안전하게 끄도록 고치는 순서를 알려 드려요.

셀루모2026.10.14 · 10분 읽기
목차

새 버전을 올릴 때마다 에러 추적 도구에 같은 에러가 한두 건씩 찍히는데, 서비스는 멀쩡하고 바로 복구되는 경우가 있어요. 처음에는 무시하게 되지만, 이런 에러가 쌓이면 진짜 장애가 그 사이에 묻혀요. 이 글은 그런 에러를 어떻게 읽고, 정말 원인인 "컨테이너가 끄라는 신호를 못 받는 문제"를 어떻게 찾아 고치는지 순서대로 정리해요.

혹시 이런 고민 있으신가요?

  • 배포할 때마다 에러가 한 건씩 올라오는데, 확인해 보면 이미 복구되어 있어서 조치할 게 없어요.
  • 에러 알림이 계속 울려서 이제는 알림을 보지 않게 됐어요. 진짜 장애가 나도 놓칠까 걱정돼요.
  • 컨테이너를 내리는 데 항상 10초쯤 걸리는 것 같은데, 이유를 모르겠어요.

이 글을 읽으면

  • 배포 때만 생기는 에러인지, 평소에도 생기는 에러인지 에러 시각과 배포 시각을 맞춰 가려낼 수 있어요.
  • 컨테이너가 종료 신호를 무시하는지 로컬에서 직접 재현하고 시간을 잴 수 있어요.
  • Node.js 와 NestJS 앱이 종료 신호를 받으면 하던 일을 마무리하고 끝나게 고칠 수 있어요.

1단계. 에러 시각을 배포 시각과 나란히 놓기

먼저 해야 할 일은 고치는 것이 아니라 에러가 언제 생기는지 보는 거예요. 에러 추적 도구에서 같은 에러의 발생 시각을 쭉 뽑고, 배포 기록의 시각을 옆에 놓아요. 둘이 겹치면 "배포가 원인"이라는 단서가 생겨요.

저희 경우에는 이벤트를 모아 두었다가 1초마다 데이터베이스에 옮기는 기능에서 "Redis 명령이 중단됐다"는 에러가 올라왔어요. Redis 는 서버가 잠깐 쓰는 임시 저장소예요. 에러는 컨테이너마다 딱 한 번씩 났고 바로 복구됐는데, 발생 시각이 배포 기록보다 몇 초 앞에 몰려 있었어요. 일정한 간격 없이 배포 때마다 한 번씩만 나는 에러는 보통 "내려가는 쪽 컨테이너"가 내는 소리예요.

에러가 나는 모양의심할 곳
배포 직전이나 직후에만 한 번씩종료와 시작 과정
일정한 간격으로 계속외부 서비스 장애나 설정 오류
특정 요청에서만코드의 버그
트래픽이 몰릴 때자원 한계

TIP

에러 건수가 아니라 에러가 난 컨테이너 수를 세어 보세요. "배포 한 번에 컨테이너 한 개당 에러 한 건"이라는 비율이 나오면 종료 과정을 의심해도 좋아요.

2단계. 먼저 소음부터 줄이기: 보고 조건 바꾸기

원인을 찾는 데는 시간이 걸려요. 그동안 알림이 계속 울리면 사람이 알림에 둔해져요. 그래서 저희는 원인을 찾기 전에 보고 조건부터 바꿨어요. 에러를 끄는 게 아니라 "한 번 실패"가 아니라 "계속 실패"일 때 보고하게 한 거예요.

핵심은 이 이벤트 옮기기가 실패해도 이벤트를 잃지 않는다는 점이었어요. 실패한 묶음은 대기열 맨 앞으로 되돌아가서 1초 뒤에 다시 시도돼요. 한 번 실패하는 건 조치할 게 없는 일이에요. 그래서 이렇게 바꿨어요.

TS
const REPORT_AFTER_FAILURES = 5 // 1초 간격이니 5초 이상 이어진 실패만 보고 try {  await flushOnce()  if (failures > 0) logger.log(`복구됨, 그 사이 실패 ${failures}회`)  failures = 0} catch (error) {  failures += 1  logger.warn("플러시 실패, 다음 주기에 다시 시도해요")  if (failures === REPORT_AFTER_FAILURES) {    Sentry.captureException(error) // 5회째에 한 번만 올려요  }}

세 가지를 지켰어요. 첫 실패는 경고 로그로만 남겨요. 보고는 임계치에 닿았을 때 한 번만 해요. 복구되면 그때까지의 실패 횟수를 로그에 남겨서, 나중에 "얼마나 자주 흔들렸나"를 볼 수 있게 해요.

여러분이 바꿀 곳은 임계치예요. 재시도 간격이 1초면 5회가 5초지만, 간격이 1분인 작업이라면 2~3회로도 충분해요. 기준은 "사람이 개입해야 할 만큼 길게 이어졌는가"예요.

참고

이 단계는 원인을 고친 게 아니에요. 소음을 줄인 것뿐이라서, 다음 단계에서 왜 배포 때마다 실패하는지 계속 파고들어야 해요. 임계치를 올려서 에러를 숨기고 끝내면 같은 문제가 다른 모양으로 돌아와요.

3단계. 컨테이너가 종료 신호를 받는지 재현하기

임계치를 바꾼 뒤에도 가끔 에러가 올라왔어요. 그것도 이미 보고 조건을 바꾼 이미지가 돌고 있을 때였어요. 한 번 실패가 아니라 5초 넘게 이어지는 실패가 배포 때마다 생긴다는 뜻이에요. 그렇다면 새 컨테이너가 뜨는 동안 옛 컨테이너가 5초 넘게 "반쯤 죽은 채로" 살아 있다는 이야기예요.

여기서 배경 지식이 하나 필요해요. 컨테이너를 내릴 때 Docker 는 먼저 SIGTERM 이라는 신호를 보내요. "이제 끝내 주세요"라는 정중한 요청이에요. 앱이 이 신호를 받으면 하던 일을 정리하고 스스로 끝나요. 기본 유예는 10초예요. 10초가 지나도 안 끝나면 SIGKILL 이라는 강제 종료를 보내요. 정중한 요청이 먼저 가고, 안 통하면 전원을 뽑는 순서예요.

문제는 이 신호가 앱에 닿지 않는 경우가 있다는 거예요. 컨테이너 안에서 맨 처음 뜨는 프로세스는 번호가 1번이라 PID 1 이라고 불러요. 리눅스는 PID 1 에게만 특별한 규칙을 적용해서, 스스로 신호를 처리하는 코드를 달아 두지 않았다면 SIGTERM 을 전해 주지 않아요. Node.js 앱을 이렇게 시작하면 node 가 곧 PID 1 이 돼요.

DOCKERFILE
CMD ["node", "dist/main.js"]

여기에 NestJS 는 기본값으로 종료 신호를 듣지 않아요. 종료 훅을 켜는 한 줄을 넣어야 신호 처리 코드가 달려요. 그래서 앱은 신호를 받아도 모르는 척하고, 정리 코드는 한 번도 실행되지 않은 채 10초 뒤 강제 종료돼요. 이 10초 동안 이벤트 옮기기는 계속 돌면서 이미 내려가는 중인 Redis 연결에 명령을 보내다 실패하는 거예요. 에러 발생 시각이 배포 기록 몇 초 전에 몰렸던 이유가 이거예요.

종료 신호가 앱에 닿는 경우와 닿지 않는 경우신호를 무시하면 10초 뒤 강제 종료돼요

직접 확인하는 방법은 간단해요. 빌드한 앱을 PID 1 로 띄우고 신호를 보내서 얼마나 걸려 끝나는지 재요.

BASH
# 새 PID 네임스페이스 안에서 앱을 PID 1 로 띄우고 SIGTERM 을 보내요unshare --pid --fork --mount-proc \  timeout -s TERM 15 node dist/main.jsecho "종료 코드: $?"

저희 앱은 수정 전에 15초가 지나도 살아 있었어요. 종료 코드 124 는 timeout 이 시간 초과로 끝냈다는 뜻이에요. 같은 도구로 수정 후에는 0.03초 만에 종료 코드 0 으로 끝났어요.

TIP

로컬에서 docker stop 으로 컨테이너를 내리면서 시간을 재 보세요. 항상 딱 10초쯤 걸린다면 앱이 신호를 못 받고 강제 종료까지 가는 거예요. 이 "정확히 10초"는 가장 쉽게 찾는 단서예요.

4단계. 종료 신호를 받아 정리하고 끝내기

고칠 곳은 앱 코드 한 군데예요. NestJS 라면 종료 훅을 켜요.

TS
const app = await NestFactory.create(AppModule)app.enableShutdownHooks([], { useProcessExit: true })await app.listen(port)

이 한 줄이 하는 일은 이래요. SIGTERM 을 받으면 NestJS 가 각 모듈의 onModuleDestroy 를 부르는데, 다른 모듈에 의존하는 쪽부터 불러요. 저희 앱에서는 이벤트 옮기기 타이머를 멈추고 진행 중인 작업이 끝나길 기다린 뒤에야 Redis 연결이 닫혀요. 그 뒤 프로세스를 정상 종료 코드 0 으로 끝내요. 그래서 10초 동안 살아서 실패를 반복하던 시간이 사라져요.

여기서 useProcessExit: true 가 중요해요. 기본 동작은 정리가 끝난 뒤 프로세스가 자기 자신에게 같은 신호를 다시 보내는 건데, PID 1 은 그 신호도 무시해요. 그러면 정리까지 하고도 끝나지 못하고 10초를 채워요. 정리 뒤에 process.exit 로 직접 끝내게 하면 이 함정을 피해요.

Nest 를 쓰지 않는다면 같은 생각을 직접 쓰면 돼요.

TS
process.on("SIGTERM", async () => {  server.close() // 새 요청은 받지 않아요  await flushPending() // 하던 일은 마무리해요  await redis.quit() // 연결은 맨 마지막에 닫아요  process.exit(0)})

그리고 정리 코드에서 정할 것이 두 가지 있어요.

  • 순서: 새 일을 받는 입구를 먼저 닫고, 하던 일을 끝내고, 연결을 닫아요. 연결을 먼저 닫으면 하던 일이 그 연결에 쓰다가 실패해요.
  • 제한 시간: 정리가 10초 안에 끝나야 해요. 오래 걸릴 일을 정리 코드에 넣지 마세요.

대안도 있어요. 컨테이너 시작 명령에 작은 초기화 프로세스를 PID 1 로 두고 신호를 앱에 전달하게 하는 방법이에요. Docker 는 --init 옵션으로 이 방식을 지원해요. 앱 코드를 못 고치거나 여러 언어가 섞여 있다면 좋은 선택이에요. 다만 앱이 신호를 받아도 정리 코드가 없으면 받은 신호로 그냥 죽을 뿐이라서, 하던 일을 마무리하려면 결국 앱에 정리 코드가 필요해요.

TIP

시작 명령은 가능하면 CMD ["node", ...] 처럼 대괄호 형태로 쓰세요. CMD node dist/main.js 처럼 쓰면 셸이 PID 1 이 되어 신호를 앱에 전달하지 않는 일이 생겨요. 대괄호 형태는 앱이 직접 PID 1 이 된다는 점만 알고 있으면 돼요.

5단계. 고친 뒤 확인하고 다시 막기

고쳤으면 두 가지로 확인해요. 저희는 로컬 재현으로 확인을 마쳤고, 배포 뒤 결과는 지켜보는 중이에요. 하나는 위의 재현 명령을 다시 돌려 종료 시간이 짧아졌는지 보는 거예요. 다른 하나는 다음 배포 뒤 에러 추적 도구에서 그 에러가 다시 열리는지 보는 거예요. 한 번의 배포로는 결론을 내리지 말고, 배포 두세 번 동안 지켜보세요.

그리고 같은 문제가 새 서비스에서 반복되지 않게 틀로 막아 둬요.

  • 새 서비스의 시작 템플릿에 종료 훅과 대괄호 형태 시작 명령을 넣어요.
  • 배포 점검 항목에 "컨테이너 내리는 데 걸리는 시간"을 넣어요. 10초에 가까우면 의심해요.
  • 에러 보고는 "한 번 실패"가 아니라 "이어진 실패"를 기준으로 삼는다는 규칙을 문서에 적어요.

참고

배포 도구가 컨테이너를 내릴 때 주는 유예 시간은 설정으로 늘릴 수 있어요. 하지만 늘리는 건 증상을 늦출 뿐이에요. 앱이 신호를 못 받으면 유예를 30초로 늘려도 30초를 다 기다린 뒤 강제 종료돼요.

자주 묻는 질문

Q1. 배포 때 에러 한 건쯤은 무시해도 되지 않나요?

데이터를 잃지 않는다면 서비스에는 영향이 없을 수 있어요. 하지만 에러 목록이 소음으로 가득 차면 진짜 장애를 알아채기 어려워요. 또 종료 신호를 못 받는 앱은 하던 요청을 중간에 끊을 수도 있어요. 저희는 이벤트가 대기열에 남아 있어서 잃지 않았지만, 그렇지 않은 앱이라면 요청이 끊기는 일이 생겨요.

Q2. Node 가 아닌 다른 언어도 같은가요?

PID 1 이 신호를 받지 못하는 건 리눅스 컨테이너의 규칙이라서 언어와 상관이 없어요. 파이썬이나 자바도 신호 처리 코드를 달지 않으면 비슷하게 10초를 채우고 강제 종료돼요. 언어마다 신호를 다루는 방법만 달라요.

Q3. 컨테이너를 내릴 때 정리할 게 없는 앱도 신경 써야 하나요?

앱이 신호를 못 받으면 배포가 항상 10초씩 느려져요. 정리할 일이 없어도 신호를 받아 바로 끝나게 해 두면 배포 시간이 줄고, 나중에 정리할 일이 생겼을 때도 안전해요.

정리

  • 배포 때마다 한 건씩 나는 에러는 에러 시각과 배포 시각을 나란히 놓는 데서 시작해요.
  • 알림이 둔해지기 전에 "한 번 실패"가 아니라 "이어진 실패"를 보고하도록 조건을 바꿔요.
  • 컨테이너의 첫 프로세스는 SIGTERM 을 받지 못하는 규칙이 있어서, 앱에 종료 처리 코드를 달아야 해요.
  • 정리는 입구 닫기, 하던 일 마무리, 연결 닫기 순서로 하고 마지막은 직접 끝내요.
  • 로컬에서 시간을 재 보면 고치기 전과 후가 숫자로 비교돼요.

배포 때 컨테이너가 조용히 10초를 채우고 끝나는 문제는 한 줄로 고칠 수 있지만, 찾는 데는 이렇게 순서가 필요해요.

이어서 읽기 좋은 글