본문 바로가기
빌드노트 검색

빌드노트

docker stop에서 종료 훅이 안 보이면 PID 1부터 확인한다

docker stop 때 종료 훅이 보이지 않는 원인을 PID 1과 신호 경로로 좁힌다. 같은 SIGTERM 처리 프로그램의 shell-form·exec-form·exec 시작 스크립트에서 PID, 수신 로그, 정지 시간과 종료 코드를 실제 비교하고 자기 앱의 진입점을 확인하는 명령을 설명한다.

Linux 컨테이너를 docker stop으로 멈췄는데 종료 훅 로그가 없고, 정지 제한 시간이 지난 뒤에야 끝난다면 시작 명령을 확인할 필요가 있다. 앱 앞에 남은 셸이 종료 신호를 전달하지 않는 경우다. 이때는 기다리는 시간을 늘리기 전에 컨테이너의 PID 1이 누구인지부터 본다.

다음 두 진입점은 같은 Python 프로그램을 실행하지만 신호가 도착하는 경로가 다르다. 프로그램을 직접 실행하는 두 번째 형태로 바꾸면 중간 셸을 없앨 수 있다. 실행 파일과 인자는 자신의 앱에 맞춘다.

# shell-form
ENTRYPOINT python -u /signal_log.py
# exec-form: 위 줄을 이 줄로 교체
ENTRYPOINT ["python", "-u", "/signal_log.py"]

Docker의 ENTRYPOINT 설명 (새 탭에서 열림)에 따르면 Linux의 기본 shell-form은 /bin/sh -c를 거친다. 그 셸 아래에 앱이 남으면 docker stop의 신호가 앱으로 자동 전달되지 않는다. 대괄호의 유무뿐 아니라 실제로 남아 있는 프로세스를 확인해야 한다.

같은 프로그램인데 한쪽에는 SIGTERM 기록이 없었다

확인을 위해 신호 기록만 하는 작은 프로그램을 따로 실행했다. SIGTERM 처리기를 등록한 다음 READY와 자신의 PID·부모 PID를 출력한다. 신호를 받으면 SIGNAL=SIGTERM을 출력하고, 0.2초짜리 정리를 마친 뒤 HOOK_DONE을 출력하며 종료한다. 각 출력은 즉시 내보냈다. 실제 서비스 장애 로그가 아니라 진입점을 비교한 재현이다.

환경은 Windows 11의 Docker Desktop 4.92.0, Linux 엔진 29.8.0, 컨테이너 안의 Python 3.12.15였다. 같은 python:3.12-slim-bookworm 기반 이미지에서 진입점만 바꿨다. --init과 별도 종료 신호 설정은 사용하지 않았다.

shell-form에서는 PID 1의 이름이 sh였고, 앱의 시작 로그는 다음 한 줄이었다. 정지 후에도 로그는 이 줄에서 끝났다.

READY pid=7 ppid=1

exec-form에서는 PID 1이 python이었다. 정지 후에는 신호 수신과 정리 완료까지 남았다.

READY pid=1 ppid=0
SIGNAL=SIGTERM pid=1
HOOK_DONE

정지 제한을 두 경우 모두 2초로 지정했다. 첫 비교에서 shell-form의 docker stop은 2.547초가 걸렸고 종료 코드는 137이었다. exec-form은 0.438초, 종료 코드 0이었다. 두 컨테이너 모두 OOMKilled=false였다. PowerShell 명령으로 다시 비교했을 때도 각각 2.523초와 0.432초였고 로그 차이는 같았다. 시간은 Docker CLI 호출 전체를 잰 값이며 일반적인 종료 성능을 뜻하지 않는다.

docker stop 문서 (새 탭에서 열림)에서 확인할 경계는 두 가지다. 기본적으로 컨테이너의 주 프로세스에 SIGTERM을 보내고, 지정 시간 안에 끝나지 않으면 SIGKILL로 강제 종료한다. 이미지의 STOPSIGNAL, 컨테이너의 --stop-signal 설정이나 실제 stop 호출의 --signal 옵션이 있으면 첫 신호가 달라질 수 있다.

이 재현에서는 신호 수신 로그조차 없는 셸 경유 실행과, 같은 처리기가 끝까지 실행된 직접 실행이 갈렸다. 따라서 정리 작업이 2초보다 길어서 실패했다고 해석할 근거는 없다. 종료 코드 137 하나만으로 같은 원인을 단정하지 말고 PID·수신 로그·정지 시간·OOM 여부를 함께 본다. SIGKILL은 처리기로 잡을 수 없으므로 (새 탭에서 열림) 강제 종료 단계에서 훅을 실행하도록 보완할 수도 없다.

내 컨테이너에서는 세 가지를 먼저 조회한다

Docker CLI가 Linux 엔진에 연결돼 있고 대상 컨테이너가 실행 중이어야 한다. PowerShell에서 $container에 자신의 진단 대상 이름을 넣은 뒤 다음 명령을 실행한다. 이 부분은 컨테이너를 정지하지 않는다.

docker inspect --format '{{json .Config.Entrypoint}}' $container
docker exec $container cat /proc/1/comm
docker inspect --format '{{if .Config.StopSignal}}{{.Config.StopSignal}}{{else}}SIGTERM (default){{end}}' $container

실습의 shell-form에서는 순서대로 다음 값이 나왔다. 첫 줄은 컨테이너에 설정된 진입점, 두 번째는 현재 PID 1의 실행 이름, 세 번째는 첫 종료 신호다. 조회 형식과 실행 조건은 inspect (새 탭에서 열림)와 exec (새 탭에서 열림) 문서에서도 확인할 수 있다.

["/bin/sh","-c","python -u /signal_log.py"]
sh
SIGTERM (default)

sh가 나오면 해당 시작 명령이나 스크립트에서 앱을 어떻게 실행하는지 읽는다. 앱이 자식으로 남는지, 셸에 신호 전달 처리가 있는지가 확인 대상이다. cat이 없는 최소 이미지에서는 두 번째 명령이 실패할 수 있다. 그 오류는 신호 문제의 증거가 아니므로 앱 시작 로그의 PID와 별도 진단 환경에서 확인한 프로세스 구성을 사용한다. PID 1이 init이나 감독 프로세스라면 그 프로세스가 앱으로 신호를 넘기는 경로를 확인한다.

수정 전후 정지는 같은 설정을 복제한 정지해도 되는 실습 컨테이너에서 비교한다. 앱의 시작 완료 로그를 확인한 뒤, $container를 그 실습 이름으로 바꾸고 아래를 실행한다. 각 Docker 명령이 실패하면 다음 줄로 진행하지 않는다. 2초는 이 짧은 프로그램의 비교값이며 실제 앱의 정리 시간을 정하는 기준은 아니다.

$watch = [System.Diagnostics.Stopwatch]::StartNew()
docker stop --timeout 2 $container
$watch.Stop()
$watch.Elapsed.TotalSeconds
docker logs $container
docker inspect --format 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' $container

실습의 마지막 출력은 shell-form에서 exit=137 oom=false, exec-form에서 exit=0 oom=false였다. 자신의 앱에서는 신호 처리 진입과 정리 완료를 각각 기록한다. 신호 수신은 남지만 완료가 없다면 처리기 내부의 대기와 정리 시간으로 조사 범위를 옮긴다. 앱이 PID 1인데 수신 기록이 없다면 설정된 첫 신호, 처리기 등록 시점과 로그 출력 경로도 확인해야 한다.

시작 스크립트가 필요하면 마지막 실행에 exec를 쓴다

설정 준비 때문에 시작 스크립트를 유지해야 할 수도 있다. 앱 하나를 실행하고 스크립트의 역할이 끝나는 구조라면 마지막 실행을 다음처럼 바꾼다.

#!/bin/sh
set -eu
exec python -u /signal_log.py

여기서 exec는 새 자식을 띄우는 대신 현재 셸을 앱으로 교체한다. 같은 프로그램으로 이 스크립트도 실행해 보니 PID 1은 python이었고, SIGTERM과 HOOK_DONE을 모두 기록한 뒤 0.469초에 종료 코드 0으로 끝났다. 스크립트를 호출하는 ENTRYPOINT가 JSON 배열이어도, 스크립트 안에서 앱을 자식으로 띄우고 기다린다면 그 내부 신호 경로는 따로 확인해야 한다. Dockerfile 문서의 시작 스크립트 예 (새 탭에서 열림)도 마지막 실행에 exec를 사용한다.

앱 종료 뒤 셸이 추가 정리를 해야 하거나 여러 자식 프로세스를 관리한다면 단순히 마지막 줄을 교체하는 것만으로는 충분하지 않다. 이때는 자식에게 신호를 전달하고 종료를 기다리는 설계가 필요하다. 이번 실험은 명시적으로 SIGTERM을 처리하는 단일 Python 프로세스와 셸 경로만 검증했다. JVM·Spring의 종료 훅, 여러 프로세스를 관리하는 스크립트, Kubernetes의 종료 절차는 실행하지 않았다. 자신의 앱에서도 진입점 수정 뒤 신호 수신과 정리 완료가 모두 남는지 확인한다.

← 전체 글 보기