LabHub

도커 기초 · 실행과 수명주기 · 이론

컨테이너는 어떻게 죽는가

LabHub 에서 이어서 보기

한 줄 요약

docker stop 은 SIGTERM 을 보내고 기본 10초를 기다린 뒤 SIGKILL 합니다.
종료코드 143 은 SIGTERM(128+15), 137 은 SIGKILL(128+9) 입니다.
이 두 숫자를 구분할 수 있으면 장애 원인의 절반이 좁혀집니다.

왜 이게 필요했나

배포할 때마다 종료가 10초씩 걸리고 종료코드가 137 로 찍히는 서비스가
있었습니다. 같은 앱을 명령 형식만 바꿔 다시 재면 이렇게 달라집니다.

CMD npm start            ->  real 0m10.412s,  ExitCode 137CMD ["node","server.js"] ->  real 0m0.284s,   ExitCode 143

10초는 도커의 기본 유예 시간이고, 137 은 강제 종료입니다. 정상 종료였다면
즉시 끝나고 143 이 나왔어야 합니다.

어떻게 동작하나

원인은 두 겹입니다.

첫째, PID 1 은 커널에서 특별 취급을 받습니다. 핸들러를 명시적으로
등록하지 않은 신호는 기본 동작이 적용되지 않습니다. 셸 형식 CMD
실제로 /bin/sh -c "npm start" 로 실행되어 PID 1 이 셸이 되고, 셸은
SIGTERM 핸들러가 없으므로 신호가 그냥 무시됩니다.

둘째, "셸이 신호를 전달하지 않는다"는 설명은 절반만 맞습니다.
dash 나 busybox ash 는 sh -c 에 단순 명령 하나만 있으면 fork 없이
exec 으로 자기를 대체하는 최적화를 합니다. 그래서 셸이 사라지고 대상이
PID 1 이 되기도 합니다. 문제는 그 최적화가 조건부라는 점입니다.
파이프, &&, 변수 확장, 리다이렉션이 하나만 끼어도 셸이 남습니다.
셸에 기대는 동작은 언젠가 조용히 깨집니다.

정답은 exec 형식 JSON 배열이고, 엔트리포인트 스크립트를 쓴다면 마지막
줄은 반드시 exec "$@" 입니다.

현장에서 만나는 모습

PID 1 에는 두 번째 책임이 있습니다. 부모를 잃은 프로세스는 PID 1 에게
입양되고 PID 1 이 종료 상태를 거둬야 합니다. init 시스템은 이 일을 하지만
Node 나 파이썬 프로세스는 하지 않습니다. 실제로 좀비를 세어 보면
1847 개까지 쌓인 사례가 있고, 프로세스 테이블이 차면 애플리케이션
로그에 fork: Resource temporarily unavailable 이 찍힙니다.

그리고 137 은 OOM 킬러가 낸 것일 수도 있습니다. 둘을 가르는 것은
State.OOMKilled 필드와 커널 로그입니다. 애플리케이션은 SIGKILL 에
대해 어떤 예외도 받지 못하므로, 컨테이너가 조용히 재시작을 반복하면
애플리케이션 로그가 아니라 커널 로그를 봐야 합니다.

다음 실습에서 할 것

핸들러를 붙인 컨테이너와 붙이지 않은 컨테이너를 각각 세워 143 과 137 을
직접 만들어 보고, 애플리케이션 종료코드와 재시작 정책까지 확인합니다.