Docker 里有一个非常典型的现象。
你执行:
bash
docker stop myapp
然后终端像卡住了一样。
等了好几秒:
text
...
最后 Container 才终于停下来。

甚至你再看:
bash
docker ps -a
发现:
text
Exited (137)
于是很多人的第一反应是:
137?是不是又 OOM 了?
不一定。
我们上一篇刚讲过:
text
Exit 137
=
进程收到 SIGKILL
OOM 只是产生 SIGKILL 的原因之一。
还有一种非常常见的情况:
text
docker stop
↓
发送 SIGTERM
↓
应用根本没有正确收到
↓
一直不退出
↓
Docker 等待超时
↓
发送 SIGKILL
↓
Exit 137
所以一个 Container:
能不能优雅停止
其实和:
text
PID 1
ENTRYPOINT
CMD
Shell Form
Exec Form
SIGTERM
SIGKILL
Zombie Process
Init Process
tini
这些 Linux 进程概念密切相关。
今天继续围绕:
text
https://github.com/Rodert/DockerHub
把 Docker Container 生命周期里很容易被忽略的一块彻底讲清楚。
如果只想先记住一句话:
Docker 容器真正管理的是 PID 1;PID 1 不正确处理信号,或者启动脚本没有把信号传给真正业务进程,就很容易出现 docker stop 等十秒后被强杀的问题。
Docker 官方当前也明确说明:docker stop 会先向容器主进程发送停止信号,默认通常是 SIGTERM;Linux Container 默认等待 10 秒,如果还没有退出,再发送 SIGKILL 强制终止。
一、先看一个最经典的问题
Dockerfile:
Dockerfile
FROM node:24-slim
WORKDIR /app
COPY . .
CMD node server.js
看起来:
text
非常正常。
构建:
bash
docker build \
-t myapp:v1 \
.
启动:
bash
docker run \
-d \
--name myapp \
myapp:v1
业务也完全正常。
但是:
bash
time docker stop myapp
你可能发现:
text
real 0m10.xxxs
每次几乎都:
卡 10 秒左右。
这往往不是巧合。
二、Docker stop 到底做了什么?
很多人理解:
bash
docker stop myapp
就是:
text
立即杀 Container。
实际上不是。
正常流程更接近:
text
docker stop
↓
找到 Container PID 1
↓
发送停止 Signal
↓
等待应用自己退出
↓
如果正常退出
结束
如果一直不退出:
text
等待超时
↓
SIGKILL
↓
强制终止
Linux Container 当前默认停止等待时间通常为:
text
10 秒。
Docker 官方当前文档就是这样定义 docker stop 的。
三、默认先发送什么信号?
通常:
SIGTERM。
Signal Number:
text
15
它的意思不是:
现在立刻给我死。
更接近:
系统准备结束你了,请尽快做清理然后退出。
应用收到:
text
SIGTERM
以后可以:
text
停止接受新请求
等待正在处理的请求完成
关闭数据库连接
Flush Buffer
写最后的日志
释放文件锁
关闭 Worker
然后 Exit 0
这就是:
Graceful Shutdown。
四、为什么不能一上来就 SIGKILL?
因为:
text
SIGKILL
完全不给应用处理机会。
例如 API 正在:
text
写订单
数据库事务:
text
还没完成。
你突然:
text
SIGKILL。
进程:
当场消失。
应用根本没有机会:
text
停止新流量
提交/回滚事务
Flush 日志
释放资源。
所以 Docker:
先礼后兵。
先:
text
SIGTERM。
实在不走:
text
SIGKILL。
五、这就是 Grace Period
比如:
bash
docker stop \
--timeout 30 \
myapp
意味着:
text
SIGTERM
↓
最多等 30 秒
↓
仍然不退出
↓
SIGKILL
Docker 当前支持:
text
-t
--timeout
修改停止等待时间;如果设为 -1,Daemon 会无限等待进程退出。
六、Compose 也可以配置
比如:
yaml
services:
api:
image:
mycompany/api:1.8.3
stop_grace_period:
30s
对于:
text
HTTP 服务
Worker
数据库
消息消费者
需要时间:
text
完成当前工作
时:
很有价值。
但是:
把 10 秒改成 60 秒,不等于修复了信号问题。
如果程序:
text
压根收不到 SIGTERM
你只是:
text
从等 10 秒
变成等 60 秒。
最后:
text
还是 SIGKILL。
七、真正的核心:PID 1
Linux Container 启动以后:
里面一定有一个:
text
PID 1。
你可以:
bash
docker exec \
myapp \
ps -ef
可能看到:
text
PID CMD
1 node server.js
这很理想。
业务程序:
自己就是 PID 1。
八、但也可能看到
text
PID CMD
1 /bin/sh -c node server.js
7 node server.js
现在:
text
PID 1
是谁?
不是:
text
Node。
而是:
text
/bin/sh。
这时候 Docker:
text
docker stop
首先把信号:
发给 PID 1 的 Shell。
而不是:
text
PID 7 的 Node。
问题就开始出现了。
九、为什么会变成 Shell?
核心就在 Dockerfile 两种写法。
第一种:
Shell Form。
Dockerfile
CMD node server.js
Docker 会大致变成:
text
/bin/sh -c "node server.js"
也就是说:
text
PID 1
=
/bin/sh
业务:
text
node
是:
Shell 的子进程。
Docker 官方当前 Build Check 和 Dockerfile Reference 都明确提醒:Shell Form 会把命令作为 /bin/sh -c 的子进程执行,这个 Shell 默认不会把 Unix Signal 正确传递给真正的业务进程。
十、另一种:Exec Form
Dockerfile
CMD ["node", "server.js"]
这是:
JSON / Exec Form。
最终:
text
PID 1
=
node server.js。
Docker:
text
SIGTERM
直接:
text
发给 Node。
中间没有:
text
/bin/sh。
所以生产 Dockerfile:
CMD / ENTRYPOINT 通常优先使用 Exec Form。
十一、这两个写法看起来只差几个字符
不推荐:
Dockerfile
CMD node server.js
推荐:
Dockerfile
CMD ["node", "server.js"]
表面:
text
只是写法不同。
实际上影响:
text
PID 1
Signal Handling
Graceful Shutdown
进程树
Container Stop 行为。
所以 Docker 当前甚至有专门的:
text
JSONArgsRecommended
Build Check 来提醒这个问题。
十二、ENTRYPOINT 也是一样
不推荐:
Dockerfile
ENTRYPOINT node server.js
推荐:
Dockerfile
ENTRYPOINT [
"node",
"server.js"
]
Docker 官方当前明确指出,如果 ENTRYPOINT 使用 Shell Form,真正 executable 不会成为 PID 1,也无法直接收到 docker stop 发送给容器的 Unix Signal。
十三、怎么快速验证自己有没有这个问题?
启动 Container 后:
bash
docker exec \
myapp \
ps -o pid,ppid,comm,args
如果看到:
text
PID PPID COMMAND
1 0 sh
7 1 node
说明:
text
Shell
↓
Node
Node:
不是 PID 1。
如果:
text
PID PPID COMMAND
1 0 node
说明:
Node 本身就是主进程。
第一种:
text
值得重点检查 Signal Forwarding。
十四、可以直接测试 SIGTERM
比如:
bash
docker kill \
--signal=SIGTERM \
myapp
这个命令:
text
不会默认用 SIGKILL。
而是明确:
text
向 Container 主进程发送 SIGTERM。
Docker 当前 docker kill 支持通过 --signal 指定 Signal。
然后看:
bash
docker logs \
-f \
myapp
应用有没有输出:
text
Received SIGTERM
Shutting down...
Server closed
如果:
text
完全没反应。
Signal Handling:
很可能有问题。
十五、应用本身也必须处理 SIGTERM
即使:
text
你的程序已经是 PID 1。
也不代表:
text
一定会优雅退出。
Docker 官方当前还特别提醒:
Linux 中 PID 1 的 Signal 行为具有特殊性,一些默认终止型 Signal 如果应用没有显式处理,PID 1 的行为可能与普通进程不同。
所以真正稳妥的做法:
应用显式处理 SIGTERM。
十六、Go 怎么处理?
Go 可以:
go
ctx, stop := signal.NotifyContext(
context.Background(),
syscall.SIGINT,
syscall.SIGTERM,
)
defer stop()
<-ctx.Done()
log.Println("shutting down")
HTTP Server:
go
shutdownCtx, cancel := context.WithTimeout(
context.Background(),
10*time.Second,
)
defer cancel()
server.Shutdown(shutdownCtx)
流程:
text
SIGTERM
↓
停止接新请求
↓
等待已有请求结束
↓
退出。
这就是:
真正的 Graceful Shutdown。
十七、Node.js 同样要处理
例如:
javascript
process.on("SIGTERM", async () => {
console.log("SIGTERM received");
server.close(async () => {
await database.close();
process.exit(0);
});
});
Docker:
text
SIGTERM
到了:
text
Node PID 1。
Node:
text
执行清理。
然后:
text
主动退出。
这才是完整链路。
十八、Java 也一样
Spring Boot 等现代 Framework:
text
通常已经具备一定 Shutdown Lifecycle。
但是你的:
text
自定义线程
Executor
Kafka Consumer
异步任务
如果设计不正确:
text
JVM 仍然可能长时间不退出。
所以看到:
text
Java Container 每次 stop 都等满 10 秒
不能简单说:
Java 就是慢。
应该看:
text
SIGTERM 是否收到
Spring Context 是否关闭
线程池是否 Shutdown
非 Daemon Thread 是否卡住。
十九、Python 也需要注意
比如:
python
signal.signal(
signal.SIGTERM,
handle_shutdown,
)
Web Framework:
text
Gunicorn
Uvicorn
Celery
各自也有自己的:
text
Signal / Worker Lifecycle。
所以 Docker:
只是负责发 Signal。
应用怎么响应:
是 Application Responsibility。
二十、为什么使用 npm start 有时也会让信号更复杂?
Dockerfile:
Dockerfile
CMD ["npm", "start"]
进程可能变成:
text
PID 1
npm
↓
node server.js
Docker:
text
SIGTERM
先:
text
发给 npm。
npm 是否正确把:
text
Signal
传给:
text
node
取决于具体进程链和版本行为。
如果你的目标:
text
就是运行 server.js
生产 Image 中通常更直接:
Dockerfile
CMD ["node", "server.js"]
减少:
中间进程层级。
二十一、真正的问题其实是"谁才是 Container 的主进程?"
Container 生命周期:
text
不是看所有进程。
关键:
PID 1。
PID 1 退出:
text
Container 就结束。
哪怕里面:
text
还有某些后台进程。
Container 生命周期仍由:
Main Process
决定。
这也是为什么:
text
一个 Container 通常应该围绕一个主要服务进程设计。
二十二、一个错误启动脚本
例如:
sh
#!/bin/sh
node server.js &
echo "started"
while true
do
sleep 60
done
Container:
text
PID 1
=
这个 Shell Script。
Node:
text
后台子进程。
Docker Stop:
text
SIGTERM → Shell。
如果 Shell 没有:
text
trap
Signal Forwarding
Node:
可能根本不知道容器正在结束。
这就是:
text
Wrapper Script
经常埋的坑。
二十三、正确 Shell Wrapper 最核心的是 exec
比如:
sh
#!/bin/sh
set -e
echo "running migrations..."
./app migrate
echo "starting app..."
exec ./app server
最后:
text
exec
特别重要。
为什么?
因为:
text
exec ./app server
不是:
text
Shell 再创建一个 Child。
而是:
用应用进程直接替换当前 Shell。
最终:
text
PID 1
从:
text
entrypoint.sh
变成:
text
./app server。
Docker Signal:
text
直接进应用。
二十四、没有 exec 会怎样?
sh
#!/bin/sh
./app server
最终进程:
text
PID 1:
sh
PID 7:
app
有 exec:
text
PID 1:
app
所以以后看 Docker EntryPoint Script:
最后一行有没有 exec "$@"
非常值得关注。
二十五、经典 EntryPoint 写法
sh
#!/bin/sh
set -e
echo "prepare..."
mkdir -p /app/data
echo "start..."
exec "$@"
Dockerfile:
Dockerfile
COPY docker-entrypoint.sh \
/usr/local/bin/
ENTRYPOINT [
"docker-entrypoint.sh"
]
CMD [
"./app",
"server"
]
最终:
text
entrypoint
↓
做初始化
↓
exec ./app server
↓
App 成为 PID 1。
这就是:
非常标准的模式。
二十六、什么时候 Shell Form 还是有价值?
比如你确实需要:
text
环境变量扩展
管道
&&
||
重定向。
例如:
Dockerfile
CMD echo "$APP_NAME" \
&& ./app
Shell Form:
text
写起来方便。
但 Docker 官方当前建议,如果需要 Shell 能力,可以显式使用 Wrapper Script,同时清楚处理 PID 1 和 Signal Forwarding。
也就是说:
Shell 不是不能用。
而是:
用了 Shell,就要知道多出来了一层进程管理责任。
二十七、接下来是第二个问题:僵尸进程
很多开发者第一次看到:
text
Zombie Process
会觉得:
进程都死了,怎么还会有僵尸?
Linux Process:
text
结束
以后,
Kernel 仍然会保留少量:
text
Exit Status
Process Metadata
让父进程调用:
text
wait()
waitpid()
获取。
父进程完成:
text
wait
以后:
text
Process Entry
才真正被清理。
二十八、如果父进程不 wait 呢?
子进程已经:
text
退出。
但父进程:
text
从来不回收。
Process Table 中:
text
留下一个 Zombie。
例如:
text
STAT
Z
或者:
text
<defunct>
这就是:
Zombie Process。
二十九、为什么 Container 特别容易碰到这个问题?
传统 Linux:
text
PID 1
通常是:
text
systemd
init
它承担一个非常重要的职责:
回收孤儿和僵尸进程。
但 Container:
text
PID 1
可能直接是:
text
node
python
java
你的 Go 程序。
这些应用:
不一定是为充当完整 Linux init 设计的。
如果它:
text
fork Child Process
但:
text
没有正确 wait
Zombie:
text
可能逐渐积累。
三十、为什么 Zombie 多了会出问题?
一个 Zombie:
text
几乎不占什么 RAM。
但它仍然:
text
占 Process Table Entry。
如果你的应用:
text
不断创建 Child
↓
不断产生 Zombie
最终:
text
PID 数
越来越多。
可能导致:
text
fork failed
resource temporarily unavailable
无法创建新进程。
所以:
Zombie 不是内存问题,而是进程生命周期问题。
三十一、怎么检查 Zombie?
进入 Container:
bash
docker exec \
myapp \
ps -eo \
pid,ppid,state,comm
查:
text
STATE = Z
或者:
bash
docker exec \
myapp \
ps aux
看到:
text
<defunct>
就值得关注。
三十二、这就是 --init 存在的意义之一
Docker 当前支持:
bash
docker run \
--init \
myimage
官方当前定义非常清楚:
text
--init
会在 Container 中运行一个轻量 Init Process,
负责:
text
Signal Forwarding
Reaping Zombie Processes。
Docker 默认安装中的 docker-init 当前由 tini 支撑。
三十三、加 --init 后进程树会发生什么?
原来:
text
PID 1
App
加:
text
--init
以后:
text
PID 1
tini / docker-init
│
↓
PID 7
App
现在:
text
Docker
↓
Signal
↓
tini
↓
App
同时子进程结束:
text
tini
负责:
Reap。
这就是小型 init 的核心价值。
三十四、Compose 也支持 init
例如:
yaml
services:
api:
image:
mycompany/api:1.8.3
init:
true
这个配置很适合:
text
应用会启动 Child Process
运行 Shell / Worker
浏览器自动化
FFmpeg
ImageMagick
各种外部 Command
的服务。
因为这些应用:
比纯单进程 HTTP Server 更容易涉及 Child Lifecycle。
三十五、是不是所有 Container 都必须 init: true?
不一定。
如果你的:
text
Go API
就是单一进程,
自己:
text
正确处理 SIGTERM
几乎不 spawn Child Process。
那:
text
直接作为 PID 1
完全可以。
--init:
是工具,不是强制仪式。
三十六、什么时候特别值得使用 init?
比如:
text
Node 应用会运行 ffmpeg
或者:
text
Python 服务不断 subprocess.Popen()
或者:
text
浏览器自动化启动 Chrome Child Processes
或者:
text
Worker 会 fork 大量外部命令。
这时候:
text
Zombie Reaping
就非常值得关注。
我会更倾向:
yaml
init:
true
三十七、为什么不是直接在 Container 里跑 systemd?
因为绝大多数应用 Container:
text
不需要完整 Init System。
为了:
text
转发 Signal
回收 Zombie
装一整套:
text
systemd
过于重。
tini:
text
就是非常轻量地完成
Container 最需要的 init 职责。
所以:
Small Init > Full OS Init
在普通 Application Container 里通常更合理。
三十八、Tini 不是进程管理器的替代品
这点也要讲清楚。
tini:
主要:
text
Signal Forwarding
+
Child Reaping。
它不是:
text
完整 Supervisor
不会帮你:
text
自动启动 20 个业务服务
做复杂依赖管理
进程崩了按高级策略恢复
服务发现。
所以:
不要因为有 tini,就把一个 Container 当虚拟机使用。
三十九、为什么"一容器一个进程"不是绝对规则?
大家经常听:
一个 Container 只能跑一个进程。
严格来说:
这并不是技术限制。
一个 Container 完全可以:
text
有很多 Process。
例如:
text
Web Server
↓
Worker Child
↓
Helper
很常见。
更准确的原则是:
一个 Container 通常围绕一个主要职责/主服务生命周期设计。
而不是:
text
PID 数必须永远等于 1。
四十、例如 Nginx 自己就不只一个进程
你可能看到:
text
PID 1 nginx master
PID 8 nginx worker
PID 9 nginx worker
这完全正常。
因为:
text
Master
负责:
text
管理 Worker。
重要的是:
主进程生命周期清晰,并且正确 Reap/Handle Signal。
四十一、为什么 PID 1 特别特殊?
传统 Linux 中:
text
PID 1
不是普通应用进程。
Kernel 对它:
text
有特殊处理。
Docker 当前 docker run 文档明确提醒,运行在 Container PID 1 的进程对默认终止类 Signal 的处理具有特殊性,如果程序没有显式处理 SIGINT / SIGTERM,不一定会像普通进程那样退出。
所以最安全:
明确写 Signal Handler。
不要:
text
只赌 Runtime 默认行为。
四十二、STOPSIGNAL 又是什么?
Dockerfile 可以:
Dockerfile
STOPSIGNAL SIGTERM
或者:
Dockerfile
STOPSIGNAL SIGQUIT
它告诉 Docker:
docker stop时优先给这个 Image 使用什么停止 Signal。
如果 Image 没配置:
text
默认通常 SIGTERM。
Docker 当前 docker stop 和 docker run --stop-signal 都支持覆盖这一行为。
四十三、什么时候需要自定义 Stop Signal?
某些程序:
text
对特定 Signal
有自己的优雅关闭约定。
比如某些 Server:
text
SIGQUIT
可能代表:
text
Graceful Shutdown。
而:
text
SIGTERM
是另一种处理方式。
这时候:
Dockerfile
STOPSIGNAL SIGQUIT
可以让:
text
docker stop
更符合该程序原生约定。
但不要:
凭感觉乱改。
先看应用自己的:
text
Signal Documentation。
四十四、Compose 也可以配置 stop_signal
例如:
yaml
services:
api:
image:
myapi
stop_signal:
SIGTERM
stop_grace_period:
30s
这两个参数:
text
一个决定发什么
一个决定等多久。
可以简单记成:
text
stop_signal
=
礼貌地怎么通知你
text
stop_grace_period
=
最多给你多少时间收拾东西
四十五、千万不要把 stop_signal 配成 SIGKILL
比如:
yaml
stop_signal:
SIGKILL
技术上:
text
很干脆。
但这意味着:
直接失去 Graceful Shutdown。
你配置:
text
stop_grace_period: 60s
也没有意义。
因为:
text
SIGKILL
应用:
根本不能处理。
四十六、怎么判断 Stop 卡住在哪?
最简单:
bash
time \
docker stop \
myapp
如果:
text
几乎稳定 10 秒。
这就是一个非常明显的:
Signal / Graceful Shutdown Warning。
Docker Compose 官方 FAQ 当前也直接说明:如果 docker compose stop 总是在默认 10 秒超时后才结束,说明 Container 没有在收到 SIGTERM 后正常 Shutdown;官方第一条建议就是检查 CMD/ENTRYPOINT 是否使用 Exec Form。
四十七、第二步:看 PID 1
bash
docker exec \
myapp \
ps -o \
pid,ppid,state,args
确认:
text
PID 1
是谁。
如果:
text
/bin/sh -c ...
重点检查:
text
Dockerfile Shell Form。
四十八、第三步:主动发 SIGTERM
bash
docker kill \
--signal=TERM \
myapp
看:
bash
docker logs \
-f \
myapp
如果应用:
text
完全无日志
说明:
text
Signal
很可能:
没到业务进程或者业务没处理。
四十九、第四步:看真正业务进程
例如:
bash
docker top \
myapp
可以从 Host 查看:
text
Container 内进程。
如果你看到:
text
sh -c java -jar app.jar
java -jar app.jar
说明:
text
Shell Parent
确实存在。
Dockerfile:
text
值得马上检查。
五十、第五步:查看 Image 配置
bash
docker inspect \
myapp \
--format \
'Path={{.Path}} Args={{json .Args}}'
还可以:
bash
docker inspect \
myapp \
--format \
'{{json .Config.Entrypoint}} {{json .Config.Cmd}}'
看看:
text
最终 Entrypoint
最终 Cmd
到底是什么。
因为实际 Container 配置:
text
可能还被 Compose 覆盖。
不要只看:
Dockerfile 源码。
五十一、Compose command 也可能影响进程结构
例如 Dockerfile:
Dockerfile
ENTRYPOINT ["/app"]
Compose:
yaml
command:
- server
这通常:
text
仍然比较清晰。
但如果你:
yaml
entrypoint:
- /bin/sh
- -c
然后:
yaml
command:
- ./app server
你重新:
引入了一层 Shell。
所以 Runtime 最终配置:
text
也要看。
五十二、Shell Script 里有多个后台程序怎么办?
例如:
sh
worker &
scheduler &
web
如果 Shell:
text
作为 PID 1
你要处理:
text
Signal Forwarding
wait
Child Exit
Zombie Reaping
很快就开始:
自己写一个简陋 init/supervisor。
所以这类场景:
要么:
text
拆成多个 Container
要么:
text
明确使用成熟 Supervisor / init。
不要:
靠一个十行 Shell 脚本硬顶复杂进程管理。
五十三、为什么多个服务通常应该拆 Container?
例如:
text
Nginx
+
API
+
Worker
+
Cron
全塞一个 Container。
任何一个组件:
text
崩溃
主 Container:
text
要不要退出?
Restart Policy:
text
应该重启谁?
Health Check:
text
检查谁?
日志:
text
怎么区分?
Signal:
text
怎么转发?
复杂度迅速上升。
拆成:
text
nginx
api
worker
scheduler
生命周期:
清晰太多。
五十四、但是拆 Container 后 Shutdown 顺序也要考虑
例如:
text
API
↓
MySQL
正常发布:
最好:
text
先停止接流量
↓
让 API 完成请求
↓
API 退出
↓
再处理后端依赖。
如果:
text
数据库先断
API Graceful Shutdown:
text
还没执行完事务
可能:
text
大量错误。
所以 Container Signal:
最终还是整个应用生命周期设计的一部分。
五十五、反向代理环境尤其需要 Graceful Shutdown
假设:
text
Nginx
↓
API Container A
API Container B
更新 A:
如果:
text
SIGKILL
A 中正在处理:
text
2 秒请求
用户:
text
Connection Reset。
如果 A:
text
收到 SIGTERM
↓
停止接新请求
↓
完成已有请求
↓
退出
体验:
完全不同。
所以 Graceful Shutdown:
不是"优雅不优雅"的代码洁癖。
它直接影响:
text
发布期间用户是否掉请求。
五十六、消息消费者更需要
比如 Worker:
text
拿到一个订单任务
↓
处理中
突然:
text
SIGKILL。
消息系统:
text
可能重新投递。
如果任务:
text
不是幂等的
甚至:
text
可能产生重复扣款
重复发送。
而收到:
text
SIGTERM
以后:
text
停止拉新消息
完成当前任务
Commit Offset / Ack
退出
才是:
正确生命周期。
五十七、数据库也非常依赖停止语义
数据库通常:
text
自己就有成熟 Signal Handling。
不要:
text
为了"停得快"
每次:
bash
docker kill database
强制 SIGKILL。
这相当于:
text
不断模拟断电。
现代数据库有:
text
WAL
Redo
Crash Recovery
能尽量恢复,
但:
正常运维不应该把 Crash Recovery 当 Shutdown Strategy。
五十八、为什么有时候 docker stop 很快,但应用还是不优雅?
因为:
text
快速退出
不等于:
text
优雅退出。
例如程序收到:
text
SIGTERM
直接:
text
os.Exit(0)
当然:
text
0.01 秒停掉。
但:
text
正在处理请求
消息
缓冲数据
全部放弃。
真正的指标:
不只是 Stop Duration。
还应该看:
text
请求有没有完成
连接有没有 Drain
任务有没有 Ack
数据有没有 Flush。
五十九、所以需要设置一个合理的 Grace Period
比如普通 API:
text
15s
30s
可能够。
长任务 Worker:
text
可能需要 60s
甚至几分钟。
但:
text
Grace Period
也不能无限长。
否则部署:
text
一个卡死任务
可能:
text
永远停不下来。
所以最终:
text
SIGTERM
↓
给合理时间
↓
超时 SIGKILL
这套机制:
本身就是正确兜底。
六十、137 和 OOM 应该如何区分?
再复习一次。
看到:
text
Exited (137)
先:
bash
docker inspect \
myapp \
--format \
'Exit={{.State.ExitCode}} OOM={{.State.OOMKilled}}'
如果:
text
OOM=true
偏向:
内存。
如果:
text
OOM=false
而且:
text
每次 docker stop
都等十秒
↓
然后 137
非常值得怀疑:
Graceful Shutdown / Signal 问题。
这就是为什么:
Exit Code 不能脱离上下文看。
六十一、Docker Events 也能帮助建立时间线
例如:
bash
docker events \
--filter container=myapp
你可能看到:
text
kill signal=15
过一会:
text
kill signal=9
然后:
text
die exitCode=137
这条链:
text
SIGTERM
↓
没有退出
↓
SIGKILL
↓
137
就很明显。
六十二、一个最简单的 Signal 测试镜像
可以写一个 Bash:
sh
#!/bin/sh
trap '
echo "SIGTERM received";
exit 0
' TERM
echo "started"
while true
do
sleep 1
done
Dockerfile:
Dockerfile
FROM alpine
COPY app.sh /app.sh
RUN chmod +x /app.sh
CMD ["/app.sh"]
执行:
bash
docker run \
--rm \
--name signal-demo \
signal-demo
另一个终端:
bash
docker stop signal-demo
你会看到:
text
SIGTERM received
然后:
正常退出。
非常适合用来理解 Container Signal。
六十三、再做一个错误版
Dockerfile:
Dockerfile
CMD /app.sh
变成 Shell Form。
再:
bash
docker stop signal-demo
比较:
text
Signal
行为。
这两个实验:
特别适合拿来做教程视频。
用户能非常直观地理解:
text
Exec Form
vs
Shell Form。
六十四、如何查看 Container PID 1 在 Host 上是谁?
可以:
bash
docker inspect \
myapp \
--format '{{.State.Pid}}'
比如:
text
32541
Host:
bash
ps \
-fp \
32541
就能看到:
text
Container PID 1
在 Host PID Namespace 中:
text
对应哪个进程。
这对:
text
Signal Debug
Process Tree Debug
非常有帮助。
六十五、为什么 Container 内是 PID 1,Host 上却可能是 32541?
因为:
PID Namespace。
Container:
text
自己的 Process Namespace。
里面:
text
应用 PID:
1
Host:
text
看整个系统
它可能是:
text
32541。
所以:
text
PID 1
是:
Namespace-relative。
这也是 Container Isolation 的基础之一。
六十六、可以看进程树
Host:
bash
pstree \
-p \
"$(docker inspect -f '{{.State.Pid}}' myapp)"
或者 Container:
bash
docker exec \
myapp \
ps -ef
如果看到:
text
sh
└── npm
└── node
这种三层:
你已经应该问:
Docker SIGTERM 最终到底怎么传到 Node?
这就是进程树分析的价值。
六十七、为什么最理想的生产进程树往往很简单?
比如:
text
docker-init
└── app
或者:
text
app
而不是:
text
sh
└── npm
└── bash
└── app
进程链越多:
text
Signal Forwarding
Exit Code Propagation
Zombie Reaping
越容易复杂。
所以 Container:
越简单越好。
六十八、Exit Code 也可能被 Wrapper Script 吃掉
比如:
sh
#!/bin/sh
./app
echo "app exited"
exit 0
应用:
text
崩溃
Exit 1。
Shell Script:
text
最后 Exit 0。
Docker:
text
Container Exit Code
=
0。
Restart Policy:
yaml
restart:
on-failure
看到:
text
0
于是:
不重启。
你明明:
text
配置了 on-failure
结果:
程序崩了为什么没恢复?
因为:
Wrapper 把真正 Exit Code 吞了。
六十九、exec "$@" 还能解决 Exit Code Propagation
sh
exec "$@"
以后:
text
最终业务进程
直接成为:
text
Container Main Process。
它:
text
Exit 1
Container:
text
Exit 1。
Restart Policy:
text
能正确看到。
所以 exec:
不只是信号问题。
还影响:
text
Exit Code
Lifecycle。
七十、这和 Restart Policy 又串起来了
正确链:
text
App Crash
↓
App Exit 1
↓
Container Exit 1
↓
restart: on-failure
↓
Docker Restart
错误 Wrapper:
text
App Crash
↓
App Exit 1
↓
Shell 忽略
↓
Shell Exit 0
↓
Container Exit 0
↓
Docker 不 Restart
所以一个:
text
看起来很简单的 startup.sh
也可以把:
Restart 语义彻底改掉。
七十一、--init 能解决业务不处理 SIGTERM 吗?
不能完全解决。
tini:
text
可以正确把 Signal
转发给 Child。
但如果 Child:
text
收到 SIGTERM
以后完全不处理
最终:
text
还是可能无法按你的业务要求优雅退出。
所以:
text
--init
解决:
text
Signal Delivery
Zombie Reaping。
应用本身:
仍然应该实现 Graceful Shutdown。
七十二、可以简单理解责任分工
Docker:
text
负责发 Signal。
tini:
text
负责传 Signal
+
回收 Child。
应用:
text
负责收到 Signal 后
正确关闭业务。
三层:
各有职责。
七十三、一个比较推荐的 Compose
比如 API:
yaml
services:
api:
image:
mycompany/api:1.8.3
init:
true
restart:
unless-stopped
stop_signal:
SIGTERM
stop_grace_period:
30s
healthcheck:
test:
[
"CMD",
"/app",
"healthcheck"
]
interval:
10s
timeout:
3s
retries:
3
再确保 Image:
Dockerfile
ENTRYPOINT ["/app"]
CMD ["server"]
程序:
text
自己处理 SIGTERM。
这套 Runtime Lifecycle:
已经比较完整。
七十四、Health 和 Shutdown 又是什么关系?
Health Check:
text
服务运行过程中
还能不能正常工作。
Graceful Shutdown:
text
服务准备结束时
怎么安全退出。
两者:
一个管生,一个管死。
真正成熟的 Container:
text
启动
↓
Ready
↓
Healthy
↓
收到 SIGTERM
↓
Draining
↓
Exit
应该有:
完整生命周期。
七十五、生产发布其实就是在反复走这条生命周期
比如:
text
Old Container
Healthy
发布:
text
New Container
Start
↓
Healthy
然后旧:
text
SIGTERM
↓
停止接流量
↓
完成旧请求
↓
退出。
如果旧 Container:
text
完全不处理 SIGTERM
发布:
text
每次都等 10 秒
↓
SIGKILL。
不仅:
text
发布慢。
还可能:
text
掉请求。
所以 Stop Behavior:
其实是 Deployment Quality 的一部分。
七十六、这对反向代理尤其重要
比如 Nginx:
text
API A
API B
如果:
text
API A
准备下线,
最理想:
text
先从流量池摘掉
↓
SIGTERM
↓
完成已有连接
↓
Exit。
而不是:
text
SIGKILL
↓
正在连接的用户一起断。
Docker 自身:
text
不会替你的 Load Balancer
完成所有 Drain 逻辑。
这需要:
部署平台和应用配合。
七十七、Rodert/DockerHub 可以增加一个 PID 1 Doctor
现在这个项目已经一路覆盖:
text
Pull
Build
Storage
Volume
Network
Port
Health
OOM
Permission
Env。
很适合继续增加:
Process Doctor。
例如:
text
docker-doctor process api
输出:
text
Container:
api
PID 1:
sh
Command:
/bin/sh -c node server.js
Child:
node server.js
Init:
NO
Stop Signal:
SIGTERM
Stop Timeout:
10s
然后:
text
🔴 Signal Forwarding Risk
提示:
text
The application is not PID 1.
Dockerfile likely uses
Shell Form CMD/ENTRYPOINT.
Recommended:
CMD ["node", "server.js"]
非常实用。
七十八、如果已经是 PID 1
例如:
text
PID 1:
java
Init:
NO
工具可以:
text
🟢 Direct PID 1
然后进一步:
text
SIGTERM Test:
No exit after 10s
提示:
text
Application receives or owns PID 1
but does not shut down
within the configured grace period.
Check application-level
SIGTERM handling.
这样:
不会把所有 Stop 问题都怪 Dockerfile。
七十九、如果 Zombie 很多
工具:
text
Zombie Processes:
137
提示:
text
🔴 Zombie Process Accumulation
The application is PID 1
and appears not to reap child processes.
Consider:
- fix child wait logic
- run with init: true
这类功能:
很适合长期运行 Worker。
八十、如果检测到 npm 是 PID 1
例如:
text
PID 1:
npm
Child:
node
可以提示:
text
🟡 Extra Process Layer
If npm is only used to launch the
production Node.js application,
consider running Node directly:
CMD ["node", "server.js"]
This simplifies signal delivery
and process lifecycle.
这种建议:
很贴近真实项目。
八十一、Dockerfile Analyzer 也可以直接检测 Shell Form
例如:
Dockerfile
CMD java -jar app.jar
提示:
text
🔴 Shell-form CMD
Docker will run this command
through /bin/sh -c.
Your Java process may not be PID 1
and may not receive SIGTERM directly.
Prefer:
CMD ["java", "-jar", "app.jar"]
Docker 当前官方 Build Check 本身也会给出类似 JSONArgsRecommended 警告。
八十二、再检测 EntryPoint Script
例如:
Dockerfile
ENTRYPOINT [
"/entrypoint.sh"
]
脚本:
sh
#!/bin/sh
./app "$@"
工具可以提示:
text
🟠 Missing exec
The wrapper script starts
the application as a child process.
Consider:
exec ./app "$@"
这个:
自动分析价值非常高。
八十三、如果脚本用后台进程
例如:
sh
./worker &
./api
工具:
text
🟠 Multiple Process Lifecycle
提示:
text
The entrypoint starts background
child processes.
Verify:
- signal forwarding
- wait/reaping
- exit-code propagation
Consider init: true
or splitting responsibilities.
这比:
text
"一个容器不能多个进程"
这种过度简化的说法:
更准确。
八十四、还可以检测 Stop Duration
比如 Doctor:
text
Graceful Stop Test
SIGTERM sent:
10:22:01
Container exited:
10:22:11
Duration:
10.04s
Exit:
137
结论:
text
🔴 Forced Shutdown Detected
Container reached the default
stop timeout and was killed
with SIGKILL.
这几乎:
直接把根因摆在用户面前。
八十五、生产监控甚至可以记录强杀次数
比如:
text
Graceful Exit:
98.7%
Forced SIGKILL:
1.3%
如果发布:
text
每次都有大量 SIGKILL。
这是:
一个非常值得优化的稳定性指标。
很多团队只监控:
text
CPU
Memory
HTTP 500。
却完全不关心:
text
服务到底是怎么被关闭的。
实际上:
Shutdown Quality 同样影响可靠性。
八十六、容器生命周期可以最终整理成一张图
text
docker start
│
↓
PID 1
│
↓
Application Running
│
↓
docker stop
│
↓
SIGTERM / STOPSIGNAL
│
├── App handles signal
│ ↓
│ Graceful cleanup
│ ↓
│ Exit
│
└── App ignores / can't receive
↓
Grace period expires
↓
SIGKILL
↓
Exit 137
如果还有 init:
text
Docker
↓
tini PID 1
↓
Application
tini:
text
Forward Signal
+
Reap Children。
这就是:
Docker Process Lifecycle 的核心模型。
八十七、为什么理解这一套很重要?
因为它同时解释很多问题:
text
docker stop 为什么总要 10 秒?
text
为什么 Container 最后 Exit 137?
text
为什么 restart 偶尔会强杀?
text
为什么 shell script 启动的程序收不到 SIGTERM?
text
为什么 Container 里有一堆 defunct?
text
为什么 on-failure 没有按预期重启?
text
为什么更新服务时用户请求被突然中断?
这些看起来是:
text
七八个问题。
根源很多时候都在:
PID 1 与进程生命周期。
总结
Docker Container 并不是:
"里面所有进程一起被 Docker 管。"
真正决定 Container 生命周期的:
是它的主进程,也就是 Container Namespace 中的 PID 1。
当你执行:
bash
docker stop myapp
Docker 当前默认会先给主进程发送:
text
SIGTERM
Linux Container 通常等待:
text
10 秒。
如果进程没有正常退出:
text
再发送 SIGKILL
强制终止。
所以如果:
bash
time docker stop myapp
每次都稳定接近:
text
10 秒
甚至最后:
text
Exit 137
第一件事不应该只怀疑:
text
OOM。
而应该检查:
text
PID 1 到底是谁?
SIGTERM 有没有到达真正应用?
应用有没有实现 Graceful Shutdown?
Dockerfile:
Dockerfile
CMD node server.js
这种 Shell Form 会通过:
text
/bin/sh -c
启动应用,真正 executable 不会直接成为 PID 1,而且 Shell 默认不会替你正确转发 Signal。Docker 官方当前明确建议需要正常 Signal Handling 的 CMD/ENTRYPOINT 优先使用 Exec/JSON Form。
因此更推荐:
Dockerfile
CMD [
"node",
"server.js"
]
如果必须使用启动脚本:
sh
#!/bin/sh
prepare_something
exec "$@"
最关键的:
是最后的 exec。
让真正应用:
text
替换 Shell
成为主进程
从而正确接收:
text
SIGTERM
并把真正 Exit Code 传给 Docker。
如果应用还会:
text
创建大量 Child Process
则可以考虑:
bash
docker run \
--init \
...
或者 Compose:
yaml
init:
true
Docker 当前 --init 会运行一个轻量 Init Process,负责 Signal Forwarding 和 Zombie Reaping,默认 Docker Init 实现由 tini 支撑。
但要记住:
text
tini
只能帮你:
text
把 Signal 送到应用
+
回收 Child。
它不能替你的应用决定:
text
收到 SIGTERM 后
如何停止 HTTP
如何完成任务
如何关闭数据库
如何提交消息
如何 Flush 数据。
这仍然是:
Application Graceful Shutdown Design。
如果只记住一句话:
Docker stop 卡满 10 秒,往往不是 Docker "停得慢",而是 PID 1 没有在 SIGTERM 后正常退出;先把 CMD/ENTRYPOINT、exec、Signal Handler 和 Zombie Reaping 设计正确,再去调整 stop_grace_period。
项目地址:
text
https://github.com/Rodert/DockerHub
这个项目下一步很适合继续增加:
text
PID 1 Doctor
Signal Forwarding Checker
Shell Form Detector
Missing exec Analyzer
Zombie Process Detector
Graceful Stop Benchmark
这样用户碰到:
"为什么我的 Container 每次 stop 都卡十秒?"
就可以直接判断,到底是:
text
Dockerfile 写法
Wrapper Script
应用 Signal Handler
还是 Child Process 管理
出了问题。