朕喜技术(ZhenxiJS)
2026.09.04
1 分钟阅读

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 已保存。

六、经验与避坑

  1. Docker 容器里的 PID 1 决定了能否优雅停机。 任何作为 PID1 的启动脚本必须 exec 真实业务进程,或用 tini 等 init,或显式做信号转发,否则 docker stop 会 SIGKILL 强杀。
  2. exec 是优雅停机的关键写法:exec <命令> 会用该命令替换当前 shell 进程,使业务进程成为 PID 1,直接接收 SIGTERM。
  3. docker stop 的 10s 是默认 -t 超时,可用 docker stop -t <秒> 调整,但只调超时治标不治本——根因是 PID1 收不到/不处理信号。
  4. exit code 137(128+9=SIGKILL)意味着被强杀;0 才是优雅退出。看到 137 就要警惕"没有优雅停机"。
  5. shell 里执行 time docker stop 若报 time: not found,用 bash -c 'time docker stop ...'。
  6. 有状态服务务必把"收到终止信号→落盘→清理→退出"写在业务进程里(如 signal.signal(SIGTERM, handler)),并确保该进程就是 PID 1。
  7. 可复现命令(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
    

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-04-0809-docker-stop-sigterm-pid1-shell-not-forward

分享这篇文章