LabHub
배우기 러닝패스 코스

Docker Fundamentals

Why docker stop Takes a Whole Ten Seconds

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

컨테이너의 첫 프로세스는 PID 1 이고, 리눅스 커널은 PID 1 을 다르게 취급합니다. docker stop 이 10초를 기다리는 것도, Ctrl+C 가 안 먹히는 것도, 좀비 프로세스가 쌓이는 것도 전부 여기서 나옵니다.

Concept map: PID 1 · SIGTERM · SIGKILL · SIGTERM 을 받고도 안 죽었다

왜 이게 필요했나

배포 스크립트에 docker stop 을 넣었더니 컨테이너 하나당 정확히 10초씩 걸립니다. 20개면 200초. 로그를 봐도 에러는 없고, 그냥 느립니다.

원인은 이렇습니다. docker stop 은 먼저 SIGTERM 을 보내고, 기다렸다가, 안 죽으면 SIGKILL 을 보냅니다. 그 "기다리는" 시간의 기본값이 10초입니다. 즉 10초가 걸렸다는 건 SIGTERM 을 받고도 안 죽었다는 뜻입니다.

docker stop 의 10초를 네 가지 PID 1 에 대해 비교한 시간표 — 셸 폼과 핸들러 없는 exec 폼은 SIGTERM 을 아무도 못 받아 10초를 다 쓰고 SIGKILL 로 죽고, 핸들러가 있는 exec 폼과 --init 은 즉시 정상 종료된다

왜 안 죽었을까요. 일반 프로세스는 시그널 핸들러를 등록하지 않으면 커널이 기본 동작(종료)을 대신 해 줍니다. 그런데 PID 1 에는 그 기본 동작이 없습니다. 커널은 PID 1 이 init 이라고 가정하고, 핸들러가 등록되지 않은 시그널은 그냥 버립니다. 시스템의 마지막 프로세스가 실수로 죽으면 커널 패닉이니, 보호 장치인 셈입니다.

그래서 CMD ["python", "app.py"] 로 띄운 파이썬이 SIGTERM 핸들러를 등록하지 않았다면, 그 컨테이너는 SIGTERM 을 무시합니다. 10초 뒤 SIGKILL 로 강제 종료됩니다. 열려 있던 커넥션도, 쓰다 만 파일도 그대로 잘립니다.

어떻게 동작하나

두 번째 함정은 셸 폼입니다.

CMD python app.py          # 셸 폼 → /bin/sh -c "python app.py"
CMD ["python", "app.py"]   # exec 폼 → python 이 직접 PID 1

셸 폼으로 쓰면 PID 1 은 파이썬이 아니라 /bin/sh 입니다. sh 는 SIGTERM 을 받아도 자식에게 전달하지 않습니다. 애플리케이션은 시그널을 구경도 못 합니다.

상황 PID 1 SIGTERM 결과
셸 폼 CMD python app.py /bin/sh 앱에 전달 안 됨 → 10초 후 KILL
exec 폼, 핸들러 없음 python 커널이 버림 → 10초 후 KILL
exec 폼, 핸들러 있음 python 즉시 정상 종료 ✅
--init 사용 docker-init 전달됨 + 좀비 수거 ✅

세 번째는 좀비입니다. 자식 프로세스가 죽으면 부모가 wait() 로 종료 상태를 거둬야 프로세스 테이블에서 사라집니다. 부모가 안 거두면 좀비로 남습니다. 원래 이 뒷정리는 init 의 일인데, 컨테이너의 PID 1 이 애플리케이션 이라면 그 앱은 좀비 수거를 하지 않습니다. 셸 스크립트를 계속 실행하는 컨테이너에서 프로세스 수가 며칠에 걸쳐 늘어나는 건 대개 이것입니다.

--init 은 이 두 문제를 한 번에 해결하는 아주 작은 init 프로세스를 PID 1 에 끼워 넣습니다.

exec 와 attach 는 다르다

docker exec docker attach
하는 일 새 프로세스를 컨테이너 안에서 실행 PID 1 의 표준입출력에 연결
나갈 때 그 프로세스만 종료 Ctrl+C 하면 컨테이너가 죽는다
쓰임새 디버깅, 파일 확인 원래 프로세스의 출력을 직접 보기

운영 중인 컨테이너에 attach 로 붙었다가 습관적으로 Ctrl+C 를 눌러 서비스를 내려 본 사람이 아주 많습니다. 붙을 때는 exec 를 쓰세요.

현장에서 만나는 모습

들어가지 못하는 컨테이너 안을 보는 법

docker exec -it ... sh 는 셸이 있을 때만 통한다. distroless 나 scratch 로 만든 이미지에는 셸도 ps 도 없다. 그래도 안을 볼 수 있다 — 네임스페이스는 밖에서도 들여다볼 수 있기 때문이다.

호스트에서 그 프로세스를 그냥 본다. 컨테이너의 PID 1 은 호스트에서도 하나의 프로세스다. 이름공간만 다를 뿐이다.

pid=$(docker inspect -f '{{.State.Pid}}' myapp)
ps -p $pid -o pid,ppid,rss,etime,args
ls -l /proc/$pid/fd            # 열린 파일과 소켓
cat /proc/$pid/limits          # ulimit
cat /proc/$pid/cgroup          # 어느 cgroup 에 매였는지

파일 시스템은 마운트하지 않고도 읽는다. /proc/<pid>/root 는 그 프로세스가 보는 루트다. 호스트의 도구로 컨테이너 안의 파일을 읽을 수 있다.

cat /proc/$pid/root/etc/app/config.yml

도구는 밖에서 들고 들어간다. nsenter 는 지정한 이름공간에 붙어 명령을 실행한다. 이미지 안에 그 도구가 없어도 된다.

sudo nsenter -t $pid -n ss -tlnp      # 그 컨테이너의 네트워크에서 포트를 본다
sudo nsenter -t $pid -m ls /tmp       # 그 컨테이너의 마운트에서 파일을 본다

쿠버네티스에서는 같은 일을 임시 컨테이너가 해 준다. 대상 파드에 도구가 든 컨테이너를 하나 붙이고, 프로세스 이름공간을 공유하게 한다.

kubectl debug -it pod/myapp --image=nicolaka/netshoot --target=app

--target 을 빼면 프로세스가 안 보인다. 그 경우 새 컨테이너는 네트워크만 공유하고 PID 이름공간은 따로다. 프로세스를 보려면 반드시 대상을 지정한다.

로그가 안 보이는 이유는 대개 버퍼다. 애플리케이션이 stdout 을 파이프로 쓰면 libc 가 블록 버퍼링으로 바꾸므로, 4KB 가 차기 전에는 아무것도 안 나온다. 파이썬은 PYTHONUNBUFFERED=1, 일반 명령은 stdbuf -oL 로 줄 단위로 돌린다. "로그가 안 찍힌다" 가 사실은 "아직 안 나왔다" 인 경우가 많다.

다음 확인에서 볼 것

이어지는 퀴즈에서는 앞 실습의 관찰을 바탕으로 PID 1의 시그널 전달과 좀비 수거, docker stop의 강제 종료 조건을 구분합니다. exec 폼과 셸 폼의 차이가 종료 시간과 프로세스 트리에 어떻게 나타났는지를 근거로 답해 보세요.