여는 글 — 앱은 자기가 죽는 줄도 몰랐습니다
Dockerfile 마지막 줄에서 대괄호 두 개를 뺐더니 종료가 0.2초에서 3.2초로 늘고, 종료 코드가 143에서 137로 바뀌었습니다. 자바 코드는 한 글자도 안 건드렸는데요. 앱은 자기한테 무슨 일이 생겼는지도 몰랐습니다.
종료 로그가 없다
배포를 돌리고 컨테이너 목록을 봅니다.
$ docker ps -a
STATUS
Exited (137) 4 seconds ago- 0이 아니니 실패는 실패인데, 애플리케이션 로그에는 에러가 없습니다. 종료한다는 말도, 정리한다는 말도 없이 로그가 그냥 끊깁니다.
여기서 보통 앱을 의심합니다. 종료 훅을 안 걸었나, Spring Boot 설정이 빠졌나 하고요. 저도 server.shutdown=graceful부터 확인했습니다. 들어가 있었습니다.
앱은 죄가 없었습니다. 앱은 자기가 죽는다는 사실을 통보받은 적이 없었어요.
{/* 삽화 자리: 같은 바닥 위에 테이프로 그은 선(상자도 벽도 아닌 열린 선). 선 안에서 프로세스 하나가 등을 돌린 채 일에 몰두해 있고, 선 밖에 선 정체 모를 누군가가 편지(SIGTERM)를 받아 든 채 전달하지 못하고 멈춰 있다(이름은 본문이 밝힌다). 바닥은 선 안팎이 끊기지 않고 이어진다. 프롬프트: docs/book/illustration-prompts-container.md */}
재현해봅시다
Spring Boot 앱 하나를 컨테이너에 담았습니다. server.shutdown: graceful을 켜뒀습니다. 기본값이 immediate라서 명시하지 않으면 안 켜져요. 켜두면 종료 신호를 받았을 때 처리 중인 요청을 마저 끝내고 나갑니다.
Dockerfile 마지막 줄은 이렇게 생겼습니다.
CMD java -jar /app/app.jar띄우고, 기동이 끝나면 docker stop으로 내립니다. 그리고 걸린 시간과 종료 코드를 재봤습니다.
| Dockerfile 마지막 줄 | 컨테이너의 PID 1 | docker stop 소요 | 종료 코드 | graceful shutdown 로그 |
|---|---|---|---|---|
CMD java -jar /app/app.jar | sh | 3.16 ~ 3.21초 | 137 | 없음 |
CMD ["java", "-jar", "/app/app.jar"] | java | 0.17 ~ 0.27초 | 143 | 있음 |
✅ 실측 (Docker Engine 29.7.2 / Docker Desktop 4.87.0 / Apple M1 / macOS 26.6, 2026-09. Spring Boot 3.3.5 / Temurin 21. 3회 반복, 위 범위 안. 전체 소스: docs/book/code/container/ch00/stop-timing.sh)
대괄호가 있고 없고의 차이입니다. 그 차이로 종료가 10배 넘게 느려지고, 종료 코드가 바뀌고, 로그 두 줄이 사라집니다.
종료 코드부터 읽어봅시다. 리눅스에서 시그널에 맞아 죽은 프로세스에는 사실 종료 코드라는 게 없습니다. 죽인 시그널 번호가 대신 남아요. 그걸 셸과 Docker가 128 + 시그널 번호라는 한 숫자로 바꿔 보여줍니다. 137은 128 + 9, SIGKILL입니다. 143은 128 + 15, SIGTERM이고요.
SIGTERM은 “정리하고 나가라”는 요청입니다. 프로세스가 받아서 처리할 수 있어요. SIGKILL은 요청이 아닙니다. 커널이 프로세스를 그 자리에서 없애버립니다. 잡을 수도, 미룰 수도, 무시할 수도 없습니다.
아래쪽 줄은 요청을 받고 스스로 나갔습니다. 위쪽 줄은 커널이 끌어냈고요.
이 읽는 법이 무너지는 자리도 미리 말해둘게요. 앱이 스스로 exit(137)을 불러도 화면에는 똑같이 137로 찍힙니다. Docker는 둘을 구분해서 보여주지 않아요. 그래서 137을 “시그널에 맞았다”로 읽으려면 확인할 게 하나 더 있는데, 13장에서 다시 만납니다. 거기서는 메모리 제한에 걸린 컨테이너가 같은 137로 죽습니다.
그런데 왜 3초였을까
docker stop은 먼저 SIGTERM을 보내고, 정해진 시간만큼 기다린 다음, 그래도 살아 있으면 SIGKILL을 보냅니다. 위쪽 줄의 3.2초는 그 기다린 시간입니다.
문제는 제가 10초를 예상했다는 겁니다. Docker 공식 문서는 리눅스 컨테이너 기본값을 10초라고 적어둡니다(📄 문서 기반 — docker container stop 레퍼런스 ). 그런데 제 기계는 3초에 끊었어요. docker stop --help에는 기본값이 아예 안 적혀 있어서 확인할 방법도 없었습니다. 그래서 데몬 설정을 직접 열어봤습니다.
"default-stop-timeout": 3,✅ 실측 (Docker Desktop 4.87.0의 /run/config/docker/daemon.json. 확인 명령은 docs/book/code/container/ch00/README.md에 적어뒀습니다. 맥에서는 데몬이 리눅스 VM 안에 있어 우회가 필요합니다)
default-stop-timeout은 Docker Engine 29.7.0이 추가한 데몬 옵션입니다. 컨테이너에 따로 지정하지 않았을 때 쓸 유예를 데몬 차원에서 정해둘 수 있어요. 제 Docker Desktop 4.87.0은 거기에 3을 넣어뒀습니다. 여러분 것도 그런지는 직접 확인하셔야 합니다. 리눅스 서버에서도 같은 키로 바꿀 수 있고요.
그러니까 같은 docker stop인데 제 노트북은 3초, 문서의 리눅스 기본값은 10초입니다. 쿠버네티스는 아예 다른 이름의 다른 값을 쓰고요.
이게 이 책에서 처음 만나는 종류의 함정입니다. 명령은 하나인데 동작을 정하는 값은 다른 데 있고, 그 값을 누가 쥐고 있는지 모르면 같은 명령이 환경마다 다르게 굽니다. 21장에서 쿠버네티스가 이 유예를 terminationGracePeriodSeconds라는 이름으로 다시 들고나옵니다. 그때 그 값이 어디 적혀 있는지부터 찾을 겁니다.
지금은 유예가 3초든 10초든 상관없는 사실 하나만 붙들면 됩니다. 위쪽 줄은 그 유예를 끝까지 쓰고도 살아 있었고, 결국 SIGKILL로 죽었습니다. 아래쪽 줄은 0.2초 만에 스스로 나갔고요. 시간을 다 쓴 쪽은 기다린 게 아니라 못 들은 겁니다.
범인은 대괄호였습니다
컨테이너 안을 들여다보면 무슨 일이 벌어졌는지 바로 보입니다.
$ docker exec ch00-shell ps -eo pid,ppid,args
PID PPID COMMAND
1 0 /bin/sh -c java -jar /app/app.jar
7 1 java -jar /app/app.jar자바가 PID 1이 아닙니다. /bin/sh가 PID 1이고, 자바는 그 자식인 PID 7이에요.
Dockerfile의 CMD를 대괄호 없이 쓰면 Docker가 그 줄을 /bin/sh -c "..."로 감쌉니다. 셸이 먼저 뜨고, 셸이 자바를 실행하는 구조가 됩니다. 대괄호를 씌운 CMD ["java", ...]는 감싸지 않고 자바를 그대로 첫 프로세스로 띄우고요. 앞을 shell form, 뒤를 exec form이라고 부릅니다.
docker stop은 컨테이너의 PID 1에게만 SIGTERM을 보냅니다. 안에 프로세스가 몇 개 있든 상관하지 않아요. 그러니 shell form에서는 신호가 셸한테 갑니다. 셸은 자바에게 전달하지 않습니다. 자바는 아무 일도 없다는 듯 계속 요청을 받다가, 유예가 끝나면 커널에게 끌려 나갑니다.
여기서 이런 의문이 들 겁니다. 셸은 왜 안 죽지. SIGTERM을 받으면 종료하는 게 셸의 기본 동작인데요. PID 1이라는 자리가 그걸 막습니다. 커널은 PID 1에게만은 핸들러 없는 시그널을 대신 무시해줘요. SIGKILL만 예외입니다. 그건 PID 1도 못 피합니다.
그래서 셸은 SIGTERM에 꿈쩍도 안 하다가, 유예가 끝나고 날아온 SIGKILL에 죽습니다. 위쪽 줄의 137이 그 SIGKILL이고요. 핸들러를 안 건 sleep을 PID 1로 띄워 확인해봤습니다. docker stop은 유예 3.16초를 꽉 채우고 죽였고, SIGKILL을 바로 보내는 docker kill은 0.12초에 끝냈습니다(✅ 실측, 같은 환경). 왜 PID 1에 이런 규칙이 있는지는 12장입니다.
여러분 기계에선 자바가 PID 1일 수도 있습니다
셸이 자기 자리에 남느냐는 그 /bin/sh가 누구냐에 달려 있습니다. 그래서 방금 실험을 그대로 따라 했는데 자바가 PID 1로 나올 수도 있어요. 제가 틀린 게 아니고, 여러분이 잘못한 것도 아닙니다.
| 베이스 이미지 | /bin/sh의 정체 | shell form일 때 PID 1 |
|---|---|---|
eclipse-temurin:21-jre (데비안 계열) | dash | /bin/sh -c sleep 300 |
alpine:3.20 | busybox | sleep 300 |
✅ 실측 (같은 환경. 전체 소스: docs/book/code/container/ch00/pid1-shell.sh)
재현을 가볍게 하려고 명령만 sleep으로 바꿨습니다. 셸이 보기엔 자바나 sleep이나 똑같은 명령 하나예요. bash도 busybox와 같은 쪽입니다.
busybox의 ash와 bash는 셸이 마지막으로 할 일이 그 명령 하나뿐이면 새 프로세스를 만들지 않고 자기 자신을 그 명령으로 갈아치웁니다. 셸이 사라지고 그 자리에 자바가 들어앉는 거예요. 데비안 계열의 dash는 안 갈아치우고 자식으로 띄운 뒤 기다립니다.
그래서 shell form은 안전한 게 아니라 복불복입니다. 베이스 이미지를 alpine에서 데비안으로 바꾸는 날, 앱 코드는 그대로인데 종료 동작만 조용히 바뀝니다. exec form에는 복불복이 없고요.
이것도 조금 전 3초와 같은 이야기입니다. 내가 쓴 명령이 아니라 내가 안 쓴 것들이 동작을 정하고 있습니다.
짧은 요청만 있으면 멀쩡해 보입니다
여기까지 읽고 이런 생각이 드실 수 있습니다. 종료 로그 한 줄 안 찍히는 게 그렇게 큰일인가.
그래서 사용자가 실제로 잃는 걸 재봤습니다. 응답에 5초 걸리는 /slow를 호출해두고, 1초 뒤에 docker stop을 겁니다. 처리 중이던 그 요청이 어떻게 되는지 보는 겁니다. 유예는 docker stop -t로 직접 줬습니다. 3초는 제 기계 기본값, 10초는 문서상 리눅스 기본값이에요.
| Dockerfile 마지막 줄 | 유예 | 요청 결과 | 종료 코드 | graceful shutdown 로그 |
|---|---|---|---|---|
| shell form | 3초 | 연결 끊김 | 137 | 없음 |
| shell form | 10초 | 200 OK | 137 | 없음 |
| exec form | 3초 | 연결 끊김 | 137 | 있음 |
| exec form | 10초 | 200 OK | 143 | 있음 |
✅ 실측 (같은 환경, 3회 반복 전부 동일. 전체 소스: docs/book/code/container/ch00/inflight.sh)
두 번째 줄을 보세요. shell form인데 요청이 200으로 성공했습니다. 자바가 SIGTERM을 못 받아 하던 일을 계속했고, 5초짜리 요청이 유예 10초 안에 끝나버렸기 때문입니다. 앱은 여전히 137로 죽었지만, 사용자 쪽에서는 아무 문제가 없어 보입니다.
이게 이 버그가 오래 사는 방식입니다. 요청이 유예보다 짧으면 증상이 안 나타나요. 평소엔 조용하다가, 트래픽이 몰려 응답이 느려지는 날에만 요청이 끊깁니다. 하필 가장 바쁜 날에요.
제대로 나가는 건 마지막 줄 하나뿐입니다. exec form으로 신호를 받을 수 있게 만들고, 유예를 처리 시간보다 길게 잡아야 합니다. 둘 중 하나만 해서는 안 됩니다. 세 번째 줄이 그걸 보여줘요. exec form이라 graceful shutdown 로그는 찍혔는데, 유예 3초가 5초짜리 요청보다 짧아서 결국 똑같이 끊겼습니다.
앱 코드는 네 줄 모두 한 글자도 다르지 않습니다.
제대로 나간 쪽이 남긴 말은 이렇습니다.
09:05:44.771 INFO o.s.b.w.e.t.GracefulShutdown - Commencing graceful shutdown. Waiting for active requests to complete
09:05:44.774 INFO o.s.b.w.e.t.GracefulShutdown - Graceful shutdown complete처리 중인 요청을 기다리겠다고 말하고, 다 됐다고 말하고 나갑니다. shell form 쪽에는 그 자리에 아무것도 없습니다. 이 장의 제목이 거기서 나왔어요.
관통하는 한 문장
컨테이너를 배우기 시작하면 보통 이런 그림을 먼저 받습니다. 앱을 상자에 담는다, 그 상자를 어디든 들고 다닌다.
쓸모 있는 그림이지만, 방금 본 장면은 설명하지 못합니다. 상자 안의 앱이 왜 바깥에서 보낸 신호를 못 받는지, 왜 어떤 상자는 3초 뒤에 부서지고 어떤 상자는 스스로 문을 닫는지 말해주지 않아요.
그래서 이 책은 다른 문장에서 출발합니다.
컨테이너는 가상머신도 상자도 아닙니다. 커널이 한 프로세스 둘레에 그은 선이에요. 선은 벽이 아니어서, 어디에 어떻게 그었는지 모르면 시그널도 메모리도 그 선을 넘어 새어 나갑니다.
방금 본 장면이 그 새어 나감입니다. 신호는 선 바깥에서 들어와 선 안의 첫 번째 프로세스에게 전달됐습니다. 그 자리에 누가 서 있느냐를 Dockerfile 한 줄과 베이스 이미지의 /bin/sh가 정했고요. 선이 거기 그어져 있다는 걸 몰랐던 겁니다. 그래서 대괄호 두 개가 배포를 망가뜨린다는 것도 몰랐고요.
선을 긋는 건 Docker가 아니라 리눅스 커널입니다. Docker는 어디에 그을지 정해서 커널에게 부탁할 뿐이에요. 그래서 이 책은 Docker 명령어 사용법으로 시작하지 않습니다. 선을 어디에 긋는지부터 봅니다.
이 책이 답하려는 것
다섯 가지입니다.
- 격리는 어떻게 지금 모양이 됐는가. 무엇을 못 해서 다음 것이 나왔는가 (1부)
- 커널은 정확히 무엇을 나누는가. namespace와 cgroup은 각각 무엇을 못 보게 하고 무엇을 못 쓰게 하는가 (2부)
- 그 위에서 내 Spring Boot 앱은 어떻게 다르게 구는가. PID 1, 메모리 제한, CPU 제한 (3부)
- 컨테이너 여럿을 어떻게 함께 세우는가 (4부)
- 한 대로 안 될 때 쿠버네티스는 무엇을 대신 해주는가. 그리고 무엇을 여전히 내가 정해야 하는가 (5부)
3부부터는 방금 본 그 Spring Boot 앱이 계속 따라옵니다. 3부에서 컨테이너에 담고, 4부에서 DB와 묶고, 5부에서 클러스터에 올립니다. 1·2부의 원리 실험은 알파인 셸로 합니다.
읽는 법
숫자에는 딱지가 붙어 있습니다.
- ✅ 실측 — 제가 직접 돌려 나온 값입니다. Docker와 JDK 버전, 반복 횟수를 함께 적었습니다.
- 📄 문서 기반 — 공식 문서나 커널 문서를 근거로 썼고 직접 계측하지 않은 것입니다.
방금 본 3초가 그 구분이 왜 필요한지 보여줍니다. 문서는 10초라고 하고, 제 기계는 3초였어요. 둘 다 사실입니다. 어느 쪽인지 밝히지 않으면 독자가 자기 환경에서 틀린 값을 믿게 됩니다.
실험은 전부 docs/book/code/container/에 있습니다. Docker만 있으면 같은 명령을 돌릴 수 있습니다. 시간은 기계마다 다르게 나오겠지만, 저장소의 Dockerfile을 그대로 쓰면 종료 코드와 PID 1의 정체는 같을 겁니다. 방금 본 것처럼 베이스 이미지를 바꾸면 PID 1은 달라지고요.
본문은 리눅스 서버 기준으로 씁니다. 저는 맥에서 썼고, 맥의 Docker는 리눅스 VM 안에서 도니까 호스트를 들여다보려면 우회가 필요합니다. 그런 우회는 각주와 코드 디렉터리에만 두겠습니다. 여러분이 리눅스 서버에서 본문 명령을 그대로 쳤을 때 같은 결과가 나오는 쪽이 중요하니까요.
누구를 위한 책인가
docker run은 쳐봤지만 “컨테이너 안은 어떻게 생겼지”에 자신 있게 답하지 못하는 백엔드 개발자를 위해 썼습니다. 인프라와 데브옵스 쪽으로 발을 옮기려는 분이면 더 그렇고요.
리눅스 프로세스나 시그널을 이미 알고 있다고 가정하지 않습니다. 필요한 자리에서 그때그때 설명합니다. 방금 137과 143을 설명한 것처럼요.
마지막으로 하나 남겨둡니다.
이 글에서 제가 “컨테이너 안”이라는 말을 여러 번 썼습니다. 그런데 자바 프로세스는 정말 어딘가 안에 들어가 있는 걸까요.
호스트에서 프로세스 목록을 뽑으면 그 자바가 그대로 한 줄로 찍혀 있습니다. 숨어 있지도, 감싸여 있지도 않아요. 아직 보여드린 적은 없습니다. 5장에서 같이 확인하죠.
그렇다면 무엇이 달라지길래 컨테이너 안의 자바는 자기가 PID 1이라고 믿을까요. 답은 프로세스에게 거짓말을 하는 기술의 역사에 있습니다. 그 역사는 1979년, 어떤 프로세스에게 가짜 루트 디렉터리를 보여준 시스템 콜에서 시작해요. 디렉터리 하나로 시작한 거짓말이 어떻게 PID와 메모리까지 번졌는지가 1·2부입니다.