docker stop이 매번 10초 걸릴 때 확인할 PID 1 문제

docker stop이 매번

docker stop을 실행할 때마다 왜 매번 똑같이 10초가 걸릴까요? 컨테이너의 메인 프로세스가 SIGTERM을 받고도 반응하지 않아서, 도커가 정해진 대기시간을 다 채운 뒤에야 SIGKILL로 강제 종료하기 때문입니다. 대부분의 경우 원인은 Dockerfile의 CMD 작성 방식과 컨테이너 안에서 PID 1이 신호를 다루는 방식에 있습니다.

docker stop이 매번 멈추는 순서: SIGTERM 다음에 오는 10초 대기

docker stop 명령을 내리면 도커 엔진은 컨테이너 안에서 가장 먼저 실행된 프로세스, 즉 PID 1에게 SIGTERM을 보냅니다. 이 신호는 “곧 종료되니 정리할 시간을 주겠다”는 뜻이고, 애플리케이션이 이 신호를 받아 커넥션을 닫고 로그를 플러시한 뒤 스스로 종료하는 것이 정상적인 흐름입니다.

문제는 PID 1이 SIGTERM을 받고도 아무 반응을 하지 않는 경우입니다. 도커는 정해진 타임아웃이 끝날 때까지 기다리고, 그래도 프로세스가 살아 있으면 SIGKILL을 보내 강제로 종료시킵니다. 이 타임아웃 값이 바로 docker stop이 매번 10초씩 걸리는 구간입니다.

왜 하필 10초일까요? 기본 타임아웃의 출처

도커 CLI의 docker stop 명령은 --time(단축형 -t) 옵션의 기본값으로 10초를 사용합니다. 이 값은 도커 엔진 자체의 기본 설정이고, 별도로 지정하지 않으면 모든 컨테이너에 동일하게 적용됩니다. 따라서 Dockerfile이나 애플리케이션 코드를 하나도 건드리지 않았다면, 신호가 제대로 전달되지 않는 컨테이너는 항상 똑같이 10초 뒤에야 멈춥니다.

이 타임아웃은 명령어 실행 시점에 바꿀 수 있습니다.

$ docker stop -t 30 my-app   # 최대 30초까지 기다렸다가 SIGKILL
$ docker stop -t 0 my-app    # 대기 없이 즉시 SIGKILL (주의)

docker compose를 쓴다면 서비스별로 stop_grace_period를 지정해 같은 효과를 낼 수 있습니다.

services:
  app:
    image: my-image
    stop_grace_period: 30s

터미널에서 도커 명령어를 실행하는 개발자 화면

이 값을 조정해도 신호가 전달되지 않는 근본 원인은 그대로 남아 있다는 점이 이 글의 핵심입니다. 타임아웃을 늘리거나 줄이는 것은 증상을 가리는 것이고, 실제 해결은 다음 단계에서 다루는 PID 1 쪽 문제를 고치는 데 있습니다. 관련 옵션의 정확한 동작은 도커 공식 문서의 stop 명령 설명에서 확인할 수 있는데, 거기서도 기본 타임아웃을 분명히 명시하고 있습니다.

SIGTERM이 조용히 무시되는 이유 — 셸 형식 CMD와 PID 1

Dockerfile에서 CMD를 어떻게 쓰느냐에 따라 PID 1이 되는 프로세스가 달라집니다. 아래처럼 문자열 그대로 쓰는 셸 형식은 내부적으로 /bin/sh -c를 거쳐서 실행됩니다.

# 셸 형식 CMD - 셸이 PID 1이 됩니다
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm install
CMD npm start

이 경우 컨테이너 안에서 가장 먼저 뜨는 프로세스는 npm이나 node가 아니라 /bin/sh입니다. SIGTERM은 이 셸 프로세스에 도착하지만, 셸이 이 신호를 자식 프로세스로 그대로 넘겨주지 않는 경우가 많습니다. 결과적으로 실제 애플리케이션은 종료 신호를 받은 적도 없이 계속 돌아가다가, 타임아웃이 끝나는 순간 SIGKILL로 프로세스 트리 전체가 통째로 날아갑니다.

배열 형태로 쓰는 exec 형식을 쓰면 셸을 거치지 않고 애플리케이션 프로세스가 직접 PID 1이 됩니다.

# exec 형식 CMD - 애플리케이션이 직접 PID 1이 됩니다
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]

그런데 여기서 한 가지 더 알아둘 부분이 있습니다. 리눅스에서 컨테이너 안의 첫 프로세스가 PID 1이 되면, 일반 프로세스와 달리 신호에 대한 기본 처리 동작이 적용되지 않습니다. 커널 입장에서 PID 1은 특별한 취급을 받는 프로세스라서, 애플리케이션이 SIGTERM 핸들러를 직접 코드로 등록해 두지 않으면 신호를 받아도 그냥 무시되는 경우가 생깁니다. exec 형식으로 바꿔도 코드에 핸들러가 없다면 여전히 매번 타임아웃을 다 기다리게 되는 이유가 여기에 있습니다.

구성 PID 1이 되는 프로세스 SIGTERM 전달·처리 docker stop 소요
CMD npm start (셸 형식) /bin/sh 자식 프로세스까지 전달 안 될 수 있음 타임아웃까지 대기
CMD ["node","server.js"], 핸들러 없음 node 신호는 도달하지만 처리 코드 없음 타임아웃까지 대기
CMD ["node","server.js"] + SIGTERM 핸들러 node 전달·처리됨 수백 ms 내 종료
docker run --init 사용 tini tini가 자식에게 전달 자식이 처리하면 즉시 종료

PID 1 문제, 이렇게 해결해 보세요

리눅스 서버 프로세스 관리 화면

가장 직접적인 방법은 애플리케이션 코드에 SIGTERM 핸들러를 명시적으로 추가하는 것입니다. Node.js 기준으로는 아래처럼 작성하면 됩니다.

// server.js
const server = app.listen(3000);

process.on('SIGTERM', () => {
  console.log('SIGTERM 수신, 연결을 정리하고 종료합니다.');
  server.close(() => {
    process.exit(0);
  });
});

이렇게 핸들러를 달아도 셸 형식 CMD를 그대로 쓰고 있다면 신호 자체가 애플리케이션까지 도달하지 못하므로, exec 형식 CMD로 바꾸는 작업이 먼저입니다. 두 가지를 같이 적용해야 효과가 납니다.

CMD를 직접 고치기 어려운 상황, 예를 들어 베이스 이미지의 엔트리포인트 스크립트를 그대로 써야 하는 경우라면 docker run --init 옵션이 유용합니다. 이 옵션은 tini라는 가벼운 init 프로세스를 PID 1로 주입해서, 이 프로세스가 신호를 받아 실제 애플리케이션 프로세스로 전달해 주고 좀비 프로세스도 정리해 줍니다.

$ docker run --init -d --name my-app my-image
$ docker stop my-app

셸 스크립트를 엔트리포인트로 쓰는 경우에는 스크립트 마지막 줄에 exec를 붙이는 것만으로도 효과를 볼 수 있습니다. exec는 새 프로세스를 자식으로 띄우는 대신 현재 셸 프로세스를 애플리케이션 프로세스로 그대로 바꿔치기하기 때문에, 불필요한 셸 계층 없이 애플리케이션이 직접 컨테이너 안에서 가장 먼저 실행된 프로세스 자리를 차지합니다.

타임아웃을 줄이기 전에 확인해야 할 트레이드오프

docker stop -t 2처럼 타임아웃 값을 줄이면 배포 속도는 빨라지지만, 애플리케이션이 커넥션을 닫거나 쓰던 데이터를 디스크에 반영할 시간이 부족해질 수 있습니다. 데이터베이스 컨테이너나 메시지 큐처럼 종료 시점에 정리 작업이 필요한 경우라면, 타임아웃을 줄이는 대신 신호 전달 경로를 고치는 쪽이 안전합니다.

또 하나 헷갈리기 쉬운 지점은, 신호가 정상적으로 PID 1까지 도달해도 애플리케이션이 기대하는 종료 신호가 SIGTERM이 아닌 경우입니다. 일부 서버 소프트웨어는 SIGTERM을 빠른 강제 종료로, 다른 신호를 정상적인 정리 종료로 구분해서 처리합니다. 이런 경우에는 Dockerfile에 STOPSIGNAL 지시어로 애플리케이션이 실제로 기대하는 신호를 지정해 줘야, 신호 전달 자체는 멈추지 않습니다.

지금 당장 확인해 볼 순서는 이렇습니다.

  • Dockerfile의 CMD가 셸 형식인지 exec 형식인지 확인합니다.
  • 컨테이너 안에서 ps -ef로 PID 1이 애플리케이션 프로세스인지, 셸이나 래퍼 스크립트인지 확인합니다.
  • 애플리케이션 코드에 SIGTERM 핸들러가 있는지 확인합니다.
  • 위 세 가지를 고치기 어렵다면 --init 옵션으로 tini를 PID 1로 끼워 넣습니다.
  • 그래도 안 되면 -t 값이나 stop_grace_period를 늘려 임시로 완화한 뒤, 근본 원인을 따로 고칩니다.

LLM 요청에 캐시가 안 걸릴 때, 프롬프트 순서 점검법

참고: docker stop이 매번 — 위키백과

Leave a Comment