12장. PID 1의 저주 — SIGTERM이 도착하지 않는 이유
같은 sleep을 두 자리에서 돌렸습니다. 1번이 아닐 때는 SIGTERM에 죽고, 1번일 때는 안 죽었어요. 커널이 PID 1만 다르게 대하기 때문이고, 여는 글의 그 3.2초가 여기서 나옵니다.
{/* 삽화 자리: 문 앞에 선 사람이 바깥에서 온 편지를 받아 들고 있다. 편지에는 “정리하고 나가시오”라고 적혀 있지만 그는 읽지 않고 그냥 들고 서 있다. 안쪽 방에서는 다른 사람이 등을 돌린 채 계속 일한다. 문 앞 사람의 발밑에는 “1”이라고 적힌 표식이 있고, 그 표식이 편지를 튕겨내는 것처럼 보인다. 프롬프트: docs/book/illustration-prompts-container.md */}
배경
여는 글에서 시작한 사고를 이제 풀 차례입니다.
배포할 때마다 docker stop이 유예를 다 쓰고 exit 137로 죽었죠. Spring Boot의 graceful shutdown 설정은 켜져 있었는데 로그가 한 줄도 안 찍혔고요. 범인은 Dockerfile 마지막 줄, 대괄호 두 개였습니다.
그때는 현상만 보여드렸습니다. docker stop이 PID 1에게만 SIGTERM을 보내고, shell form에서는 그 자리에 /bin/sh가 서 있어서 자바까지 안 간다고요.
이 장에서는 그 아래를 팝니다. 왜 셸은 신호를 받고도 안 죽었을까요. 보통 프로세스는 SIGTERM을 받으면 종료하는데요.
2부에서 배운 게 여기서 쓰입니다. 5장에서 /proc/<pid>/ns/의 소속 목록을 봤고, 6장에서 PID namespace를 손으로 만들어 그 안의 첫 프로세스가 1번이 되는 걸 확인했죠. 그 1번이라는 자리에 커널이 특별한 규칙을 걸어둡니다.
스토리
규칙부터 확인해봅시다. 똑같은 sleep 300을 두 자리에서 돌리고, 둘 다 바깥에서 SIGTERM을 보냈습니다. 6장처럼 --privileged 알파인 셸 안에서요.
# 1번이 아닌 자리
sleep 300 & P=$!
kill -TERM $P
# 1번 자리 (6장에서 만든 그 namespace)
unshare --pid --fork --mount-proc sleep 300 &
kill -TERM $(pgrep -P $! sleep)=== 1. PID 1 이라는 자리의 규칙 ===
1번이 아닌 sleep 에 SIGTERM: 죽었다
PID 1 인 sleep 에 SIGTERM: 살아 있다✅ 실측 (Docker Engine 29.8.0 / Docker Desktop 4.91.0 / alpine 3.20 / Apple M1 / macOS 26.6, 2026-09. 전체 소스: docs/book/code/container/ch12/pid1.sh)
같은 신호, 다른 결과입니다.
리눅스는 대부분의 시그널에 기본 동작을 정해둡니다. SIGTERM의 기본 동작은 종료예요. 프로그램이 아무것도 안 해도 커널이 알아서 죽입니다.
그런데 PID 1에게는 그 기본 동작을 적용하지 않습니다. 핸들러를 직접 등록하지 않은 시그널은 그냥 무시돼요.
왜 그런 규칙이 있냐면, PID 1은 원래 시스템의 init이기 때문입니다. 실수로 날아온 시그널에 init이 죽으면 기계 전체가 멈춥니다. 그래서 커널이 보호막을 씌워둔 거예요. man 2 kill이 그 동기까지 적어둡니다 — 1번에게는 핸들러를 등록한 시그널만 보낼 수 있고, 이는 시스템이 사고로 내려가지 않게 하려는 것이라고요.
한 가지 짚어둘 게 있습니다. 이 규칙은 기계의 진짜 init 하나에만 걸리는 게 아닙니다. PID namespace마다의 1번 전부에 걸려요. 방금 실험도 unshare로 만든 namespace의 1번이었죠.
📄 문서 기반 (kill(2) , 커널 kernel/signal.c의 sig_task_ignored())
문제는 컨테이너가 그 자리를 빌려 쓴다는 겁니다. 내 자바 앱이, 또는 /bin/sh가 그 자리에 앉는 순간 보호막도 같이 씌워집니다.
보호막에 구멍이 하나 있습니다
여는 글에서 docker kill은 0.12초에 끝났습니다. 보호막이 있다면서 그건 어떻게 통했을까요.
SIGKILL과 SIGSTOP은 못 막기 때문입니다. 단, 조건이 붙어요. 바깥에서 보냈을 때만 통합니다.
=== 1-2. 보호막의 구멍 ===
컨테이너 안에서 kill -9 1 : running
호스트에서 docker kill : exited exit 137✅ 실측 (같은 환경, 같은 소스)
같은 SIGKILL인데 안에서 보내면 무시되고 밖에서 보내면 통합니다. 커널이 1번을 보호하는 건 “같은 namespace 안의 시그널”이고, 상위 namespace에서 내려오는 SIGKILL·SIGSTOP은 통과시킵니다.
SIGTERM은 어느 쪽에서 보내든 안 통합니다. docker stop의 첫 신호가 막히는 이유가 그거예요.
핵심
두 형식이 만드는 트리
이제 여는 글의 그 두 Dockerfile을 다시 봅시다. 실제 프로세스 트리는 이렇습니다.
--- shell form ---
PID PPID COMMAND
1 0 /bin/sh -c java -jar /app/app.jar
7 1 java -jar /app/app.jar
--- exec form ---
PID PPID COMMAND
1 0 java -jar /app/app.jar✅ 실측 (같은 환경, 같은 소스)
CMD를 대괄호 없이 쓰면 Docker가 그 줄을 /bin/sh -c "..."로 감쌉니다. 셸이 1번 자리에 앉고 자바는 그 자식이 돼요. 대괄호를 씌우면 자바가 바로 1번입니다.
이 트리는 베이스 이미지의 /bin/sh가 누구냐에 달려 있습니다. 여기 이미지는 데비안 계열이라 dash고, dash는 자기가 남아서 자식을 기다립니다. alpine의 busybox ash는 마지막 명령으로 자기를 갈아치우는 경우가 있어요 — 여는 글에서 본 그 복불복입니다.
차이가 왜 치명적인지는 방금 본 규칙과 합치면 나옵니다.
docker stop은 1번에게만 SIGTERM을 보냅니다. shell form에서 그걸 받는 건 /bin/sh고, 이 /bin/sh는 — 데비안 계열 이미지라 dash죠 — SIGTERM 핸들러를 등록하지 않습니다. PID 1이니 기본 동작도 안 걸리고요. 그래서 아무 일도 안 일어납니다. 자식에게 전달하지도 않고요.
직접 보내 봤습니다
두 컨테이너의 PID 1에 SIGTERM을 직접 보내고 3초 기다렸습니다.
=== 3. PID 1 에 SIGTERM 을 직접 보내면 ===
shell form: 살아 있다, graceful 로그 0개
exec form: 죽었다 (exit 143), graceful 로그 1개✅ 실측 (같은 환경, 같은 소스)
shell form은 3초 뒤에도 멀쩡합니다. 여는 글에서 docker stop이 유예를 다 쓰고 SIGKILL까지 간 이유가 이거예요. 여는 글의 3.2초는 데몬 기본 유예 3초를 꽉 채운 시간입니다. 그 3초 동안 셸은 아무것도 안 하고 서 있었어요.
정확히 말하면 못 들은 게 아닙니다. 시그널은 문 앞까지 도착했고, 커널이 1번에게는 배달하지 않고 버린 겁니다.
exec form은 exit 143으로 끝났고 graceful shutdown 로그도 찍혔습니다. 자바가 1번 자리에 있었는데 왜 보호막에 안 걸렸을까요.
보호막은 “핸들러를 등록하지 않은 시그널”에만 걸리기 때문입니다. JVM은 SIGTERM 핸들러를 직접 등록해요. 그래서 커널이 무시하지 않고 JVM에게 전달하고, JVM이 셧다운 훅을 돌리고, Spring Boot의 graceful shutdown이 그 위에서 동작합니다.
그런데 이 143은 여는 글에서 배운 128 + 시그널 번호와 출처가 다릅니다. 자바는 시그널에 맞아 죽은 게 아닙니다. 훅을 다 돌리고 스스로 143을 골라 나간 거예요.
확인해봤습니다. 셧다운 훅에서 종료 코드를 직접 정하게 해봤어요.
=== 6. exec form 의 143 은 누가 정한 값인가 ===
셧다운 훅에서 halt(7) 호출 → 마지막 로그 "HOOK RAN", exit 7✅ 실측 (같은 환경, 같은 소스)
7이 나옵니다. 시그널에 맞아 죽은 거라면 나올 수 없는 값이에요. 여는 글에서 “앱이 스스로 exit(137)을 불러도 화면에는 똑같이 137로 찍힌다”고 했던 그 경우가 바로 이겁니다. 같은 숫자라도 누가 정했는지는 다릅니다.
정리하면 이렇습니다.
| PID 1이 누구 | 핸들러가 있나 | SIGTERM을 받으면 | |
|---|---|---|---|
| shell form | /bin/sh | 없음 | 무시됨. 유예 뒤 SIGKILL |
| exec form | java | JVM이 등록 | 셧다운 훅 실행, exit 143 |
그러니까 “대괄호를 씌워라”는 규칙의 정체는 이겁니다. 핸들러를 가진 프로세스를 1번 자리에 앉혀라.
--init을 붙이면 되는 거 아닌가요
여기서 많이 나오는 해법이 있습니다. Docker에 --init 플래그를 붙이면 docker-init(tini)이 1번 자리에 들어가 시그널을 대신 받아준다고요.
붙여봤습니다.
=== 4. --init 을 붙이면 어디까지 해결되나 ===
PID PPID COMMAND
1 0 /sbin/docker-init -- /__cacert_entrypoint.sh /bin/sh -c java -jar /app/app.jar
7 1 /bin/sh -c java -jar /app/app.jar
8 7 java -jar /app/app.jar
stop 0.13초, exit 143, graceful 로그 0개
마지막 로그:
01:17:57.308 INFO d.wiki.container.App - Started App in 1.49 seconds✅ 실측 (같은 환경, 같은 소스)
절반만 해결됩니다.
좋아진 건 있습니다. 여는 글에서 3.2초 걸리던 종료가 0.13초로 끝나고, exit 코드도 137에서 143으로 바뀌었어요. docker-init이 1번 자리에 앉아 시그널을 제대로 받았으니까요.
그런데 마지막 줄을 보세요. graceful 로그가 0개이고, 마지막 로그가 여전히 기동 메시지입니다. 자바는 정리할 기회를 못 받았어요.
트리를 보면 이유가 보입니다. docker-init의 자식은 /bin/sh(7번)이고 자바는 그 손자입니다. tini는 자기 자식에게 신호를 넘기는데, 그 자식이 여전히 안 넘기는 셸이에요.
정말 그런지 대조군으로 확인했습니다. 같은 --init에 exec form을 물려봤어요.
=== 5. --init 에 exec form 을 물리면 (대조군) ===
PID PPID COMMAND
1 0 /sbin/docker-init -- /__cacert_entrypoint.sh java -jar /app/app.jar
7 1 java -jar /app/app.jar
stop 0.14초, exit 143, graceful 로그 1개✅ 실측 (같은 환경, 같은 소스)
같은 --init인데 graceful 로그가 1개로 바뀝니다. 중간의 셸이 없어졌을 뿐이에요.
| tini의 직계 자식 | graceful 로그 | |
|---|---|---|
--init + shell form | /bin/sh | 0개 |
--init + exec form | java | 1개 |
그러니까 tini가 신호를 안 넘기는 게 아닙니다. 직계 자식까지는 넘기고 손자에게는 못 넘기는 거예요.
그럼 shell form 쪽에서 컨테이너는 왜 0.13초 만에 끝났을까요. 셸이 1번이 아니라서 SIGTERM 기본 동작에 걸려 죽었고, 직계 자식이 죽으면 tini도 따라 나갑니다. 1번이 나가면 컨테이너가 접히고, 아직 살아 있던 자바는 런타임이 SIGKILL로 걷어냅니다. 자바는 SIGTERM을 구경도 못 했어요.
빨라졌지만 앱은 여전히 갑자기 끊긴 거죠. 처리 중이던 요청이 있었다면 여는 글의 2×2 표에서 본 그 “연결 끊김”입니다.
그러니까 --init은 중간의 셸을 없애주지 않습니다. 고쳐야 할 건 Dockerfile 쪽이에요.
참, 트리 1번 줄의 /__cacert_entrypoint.sh는 temurin 이미지가 들고 있는 ENTRYPOINT입니다. 인증서를 손보고 마지막 줄에서 exec "$@"으로 자기 자신을 다음 명령으로 갈아치워요. exec form 트리(1 java -jar)에 이 스크립트가 안 보이는 이유가 그겁니다 — 이미 자바가 돼버렸거든요. 바로 이게 다음 절에서 쓸 기술입니다.
그럼 --init은 언제 쓰나
쓸모가 없다는 뜻은 아닙니다. PID 1에는 시그널 말고 다른 임무가 하나 더 있거든요.
부모가 먼저 죽은 프로세스는 PID 1에게 입양됩니다. 그리고 그 자식이 끝나면 PID 1이 종료 상태를 거둬가야 프로세스 테이블에서 사라져요. 이걸 안 하면 좀비 프로세스가 쌓입니다.
시스템의 init은 이 일을 합니다. 그런데 컨테이너의 1번은 보통 그냥 내 앱이에요. 자바도, nginx도, 남의 자식을 거둬가도록 만들어지지 않았습니다.
앱이 자식 프로세스를 만들지 않는다면 문제될 일이 없습니다. 자식을 만들고 그 자식이 또 자식을 만드는 구조라면 좀비가 쌓일 수 있고, 그때 --init이 답입니다.
순수 자바 앱이라면 거의 해당 없습니다. JVM이 ProcessBuilder로 띄운 자식은 자기가 거두거든요. 위험한 건 셸 스크립트나 supervisor를 1번에 앉히는 구조입니다.
정리하면 --init은 좀비를 거두고 시그널도 직계 자식까지는 넘겨줍니다. 못 넘기는 건 손자예요. 그러니 시그널 전달은 Dockerfile을 고치는 게 맞습니다. 둘 다 필요하면 둘 다 하면 되고요.
그래서 무엇을 해야 하나
세 가지를 다 해야 합니다. 하나만 해서는 안 돼요.
| 해야 할 일 | 왜 |
|---|---|
CMD를 exec form으로 쓰기 | 핸들러를 가진 프로세스가 1번 자리에 앉는다 |
| 앱이 SIGTERM을 처리하게 하기 | JVM은 기본으로 하지만, 셧다운 훅에서 할 일은 내가 정한다 |
| 유예를 처리 시간보다 길게 잡기 | 여는 글의 2×2 표 마지막 줄 |
entrypoint 스크립트를 쓴다면 마지막 줄에 exec 붙이기 | 셸이 자기 자신을 앱으로 갈아치워 1번 자리를 넘겨준다 |
네 번째가 특히 함정입니다. ENTRYPOINT ["/app/entrypoint.sh"]로 대괄호를 잘 씌워놓고도, 스크립트 마지막 줄이 그냥 java -jar app.jar이면 셸이 1번에 남아 똑같이 망합니다. 답은 exec java -jar app.jar 한 단어예요.
세 번째도 빠지기 쉽습니다. exec form으로 고쳐도 유예가 짧으면 정리하다 말고 죽어요. 여는 글에서 -t 3으로 실험했을 때 exec form도 요청을 잃었던 게 그겁니다.
그 유예를 쿠버네티스에서는 다른 이름으로 부릅니다. 21장에서 다시 만납니다.
정리
- 리눅스는 각 PID namespace의 1번에게 시그널 기본 동작을 적용하지 않습니다. 핸들러를 등록하지 않은 시그널은 무시돼요. 원래
init을 보호하려는 규칙인데, 컨테이너가 그 자리를 빌려 쓰면서 같이 적용됩니다. - 실측에서 같은
sleep이 1번이 아닐 때는 SIGTERM에 죽었고, 1번일 때는 살아남았습니다. 바깥에서 보내도요. - 보호막에 구멍이 있습니다. SIGKILL·SIGSTOP은 못 막는데, 바깥에서 보냈을 때만 통합니다. 실측에서 컨테이너 안의
kill -9 1은 무시됐고 호스트의docker kill은 exit 137로 끝냈어요. - shell form은
/bin/sh가 1번이 됩니다. 셸은 SIGTERM 핸들러가 없고 PID 1이라 기본 동작도 안 걸려서, 신호를 받고도 아무 일도 안 합니다. - exec form은 자바가 1번입니다. JVM이 SIGTERM 핸들러를 등록하므로 보호막에 안 걸리고 셧다운 훅이 돕니다. 실측에서 exit 143, graceful 로그가 찍혔어요. 그 143은 시그널에 맞은 값이 아니라 JVM이 스스로 고른 값입니다 — 훅에서
halt(7)을 부르면 7이 나옵니다. --init은 절반만 해결합니다. 실측에서 종료는 0.13초·exit 143으로 좋아졌지만 graceful 로그는 0개였어요. 같은--init에 exec form을 물리면 1개가 됩니다. tini는 직계 자식까지만 신호를 넘기고 손자에게는 못 넘깁니다.--init의 진짜 쓸모는 좀비 수거입니다. 앱이 손자 프로세스를 만드는 구조라면 필요하고, 시그널 전달은 Dockerfile을 고치는 게 맞습니다.- exec form으로 고쳐도 유예가 처리 시간보다 짧으면 여전히 잘립니다. 두 가지를 같이 해야 해요.
생각해볼 질문
여는 글의 사고가 닫혔습니다. 137도, 3.2초도, 안 찍히던 로그도 설명됐어요.
그런데 그 137이라는 숫자를 7장에서 한 번 더 봤습니다. 메모리 제한을 건 컨테이너가 OOMKilled=true에 exit 137로 죽었죠.
둘 다 SIGKILL입니다. 137이 같은 이유가 그거예요. 다른 건 보낸 쪽입니다. 하나는 유예가 끝나 Docker가 보냈고, 하나는 한도를 넘겨 커널의 OOM killer가 보냈습니다. 구분할 단서는 7장에서 봤죠, OOMKilled=true요.
그리고 자바에는 특별한 사정이 하나 더 있습니다. JVM은 시작할 때 힙 크기를 정하는데, 그 크기를 무엇을 보고 정할까요. 컨테이너에 512MB를 걸어뒀다면 JVM이 그 512MB를 알까요, 아니면 기계 전체 메모리를 보고 정할까요.