Docker 容器优雅停止失败:docker stop 卡满 10s 超时被 SIGKILL,SIGTERM 被 PID1(shell) 吞掉不转发,状态文件未保存
日期:2026-09-04 · 环境:Linux + Docker / Docker Compose v2 · Python 服务入口信号处理 技术栈:Docker、docker stop、docker compose、SIGTERM/SIGKILL、PID 1、init 进程、exec、entrypoint、信号转发、优雅退出 graceful shutdown
一、问题背景
把一个带"有状态"的 Python 服务 app.py(收到 SIGTERM 会把处理条数写入 /app/state.txt 后优雅退出)打包进 Docker 镜像,入口(CMD)指向一个 shell 启动脚本:
#!/bin/sh
python3 /app/app.py # 前台运行服务,但没有 exec
每次 docker stop 都卡满默认 10 秒超时,随后容器被强制杀死(SIGKILL,exit 137),/app/state.txt 根本没被写出来——数据状态全丢,无法优雅关闭。
现象关键词:`docker stop 卡住 / docker stop timeout / docker stop SIGKILL / exit code 137 / 优雅退出没生效 / state 没保存 / SIGTERM 收不到 / PID 1 shell 不转发信号**
二、复现步骤
cd scenario/2026-09-04-0800
docker compose up -d --build # 默认入口 entrypoint_problem.sh,PID1=shell
确认进程结构(PID1 = shell,app.py 是子进程):
docker compose exec app cat /proc/1/comm # → entrypoint_prob(shell)
docker compose exec app sh -c 'echo PID1=$(cat /proc/1/comm) child_pid=$(cat /app/started.flag)'
# → PID1=entrypoint_prob child_pid=7
触发优雅停止(会卡约 10 秒):
bash -c 'time docker stop 3day-sigterm'
# real 0m10.159s ← 卡满 10s
验证状态没保存:
docker inspect 3day-sigterm --format 'State={{.State.Status}} ExitCode={{.State.ExitCode}}'
# State=exited ExitCode=137 ← SIGKILL
docker logs 3day-sigterm # 只有启动日志,无"收到信号 15"输出
# /app/state.txt 不存在
三、根本原因
docker stop只向容器的 PID 1 进程发SIGTERM,默认等 10 秒优雅退出,超时后发SIGKILL强杀。- 镜像的 PID 1 是那个 shell 启动脚本(
/bin/sh entrypoint_problem.sh),真正的服务python3 app.py只是它的子进程。 /bin/sh收到 SIGTERM 后不会把信号转发给子进程,也没有用exec让 app 取代自己成为 PID1。- 于是真正要处理信号、要保存状态的
app.py永远收不到 SIGTERM → 无法优雅退出 →docker stop等满 10s → SIGKILL 强杀 → 状态丢失。
一句话根因:容器的 PID 1 不是"真正需要处理信号的业务进程",而是一个不做信号转发的 shell,导致 SIGTERM 被吞掉、无法优雅关闭。
四、解决办法
核心思路:让真正需要处理 SIGTERM 的业务进程成为容器 PID 1。 推荐 exec 写法(见 entrypoint_fix.sh):
#!/bin/sh
exec python3 /app/app.py
exec 用 python 替换 shell 进程本身,python 即 PID 1,docker stop 的 SIGTERM 直接送达 app.py。
其它等价方案(任选其一):
- 去掉 shell 包装,Dockerfile
CMD直接写["python3","/app/app.py"]; - 用
tini作为 PID 1 负责转发信号; - 在 shell 加
trap 'kill -TERM $child' TERM手动转发(较繁琐)。
五、验证
用同一镜像、以 /entrypoint_fix.sh 为入口跑一个容器(不动问题版 compose):
docker run -d --name 3day-sigterm-fix --entrypoint /entrypoint_fix.sh 3day-sigterm:latest
sleep 3
docker exec 3day-sigterm-fix sh -c 'echo PID1=$(cat /proc/1/comm) child_pid=$(cat /app/started.flag)'
# → PID1=python3 child_pid=1 ← app.py 自己就是 PID1
优雅停止:
bash -c 'time docker stop 3day-sigterm-fix'
# real 0m0.171s ← 很快返回,不再卡满 10s
docker inspect 3day-sigterm-fix --format 'State={{.State.Status}} ExitCode={{.State.ExitCode}}'
# State=exited ExitCode=0 ← 干净退出
状态文件已写出 + 日志可见优雅退出:
docker logs 3day-sigterm-fix
# [app] pid=1 收到信号 15,正在优雅保存状态 ...
# [app] 状态已保存到 state.txt,优雅退出(exit 0)
docker cp 3day-sigterm-fix:/app/state.txt /tmp/state_fix.txt && cat /tmp/state_fix.txt
# gracefully_saved, processed=6, time=...
对照:问题版 10.159s / exit 137 / state 未保存;修复版 0.171s / exit 0 / state 已保存。
六、经验与避坑
- Docker 容器里的 PID 1 决定了能否优雅停机。 任何作为 PID1 的启动脚本必须
exec真实业务进程,或用tini等 init,或显式做信号转发,否则docker stop会 SIGKILL 强杀。 exec是优雅停机的关键写法:exec <命令>会用该命令替换当前 shell 进程,使业务进程成为 PID 1,直接接收 SIGTERM。docker stop的 10s 是默认-t超时,可用docker stop -t <秒>调整,但只调超时治标不治本——根因是 PID1 收不到/不处理信号。- exit code 137(128+9=SIGKILL)意味着被强杀;0 才是优雅退出。看到 137 就要警惕"没有优雅停机"。
- shell 里执行
time docker stop若报time: not found,用bash -c 'time docker stop ...'。 - 有状态服务务必把"收到终止信号→落盘→清理→退出"写在业务进程里(如
signal.signal(SIGTERM, handler)),并确保该进程就是 PID 1。 - 可复现命令(checkout 本轮分支后):
docker compose -f scenario/2026-09-04-0800/docker-compose.yml up -d --build docker compose -f scenario/2026-09-04-0800/docker-compose.yml stop # 复现卡 10s
