docker stop 之后,那十秒在等什么
改完配置,执行 docker stop api,终端停在那里不动。十秒后命令返回,容器没了,日志里却连一句 “shutting down” 都没有。再一看退出码:
1docker inspect -f '{{.State.ExitCode}}' api
2# 137137 是 128 + 9,意思是进程被 SIGKILL 强杀。问题是,docker stop 明明先发的是 SIGTERM,为什么对方像没听见?
十秒是默认值,不是卡住了
docker stop 的流程只有两步:
- 向容器内的 1 号进程发送
SIGTERM; - 等待 10 秒,还没退出就发送
SIGKILL。
这 10 秒可以用 -t 改:
1docker stop -t 30 apiCompose 里对应 stop_grace_period:
1services:
2 api:
3 stop_grace_period: 30s所以“停十秒”本身不是故障,它说明应用没有在收到 SIGTERM 后退出。真正的疑问是:信号到底去哪了?
1 号进程有特权
在 Linux 里,PID 1 是特殊进程。内核不会对它执行信号的“默认动作”,否则一不小心就会把 init 杀掉。换句话说,如果一个进程没有为 SIGTERM 注册处理函数,而它恰好是 1 号进程,那么这个信号会被直接忽略。
注意,是“忽略默认动作”,不是“信号没送到”。只要进程自己写了 handler,SIGTERM 照样能收到。SIGKILL 和 SIGSTOP 除外,它们无法被捕获,也无法被忽略。
容器里的 1 号进程,就是 PID namespace 里的 init。于是同一份程序,在开发机上收到 SIGTERM 会正常退出,进了容器却纹丝不动。
可以做个最小复现:
1docker run -d --name slow alpine sh -c 'sleep 300; echo done'
2time docker stop slow
3docker inspect -f '{{.State.ExitCode}}' slowtime 会显示大约 10 秒,退出码是 137。这里 sh 是 1 号进程,sleep 是它的子进程;sh 没有为 SIGTERM 写处理逻辑,默认动作又被内核跳过,于是只能等 SIGKILL。
最常见的元凶:shell form
很多人是在 Dockerfile 里无意中制造了这个局面:
1# 反例:shell form
2CMD python app.py这种写法会被展开成 /bin/sh -c "python app.py"。1 号进程是 sh,Python 只是它的子进程。sh 既不一定转发信号,自己又忽略 SIGTERM,结果就是十秒后全家一起被 kill。
改成 exec form:
1# 正解:exec form
2CMD ["python", "app.py"]此时 Python 直接成为 1 号进程,SIGTERM 能送到应用手里。用 docker inspect 可以确认:
1docker inspect -f '{{.Config.Entrypoint}} {{.Config.Cmd}}' api入口脚本也有同样的问题:
1#!/bin/sh
2set -e
3
4python manage.py migrate
5python app.py脚本是 1 号进程,python app.py 是子进程。脚本退出前不会替你把信号传下去。最后一行必须用 exec:
exec 不会创建新进程,而是用目标程序替换当前 shell,PID 保持不变。信号自然就送到了。
让 1 号进程有人管
如果入口确实需要 shell 做预处理,又不想自己处理信号转发,可以交给一个专门的 init:
1docker run --init ...Compose:
1services:
2 api:
3 init: true或者在镜像里装 tini:
1ENTRYPOINT ["/sbin/tini", "--"]
2CMD ["python", "app.py"]tini 作为 1 号进程,主要做三件事:转发信号、回收僵尸进程、把子进程的退出码带出来。此时原来的 shell 不再是 PID 1,默认信号动作恢复,SIGTERM 会正常终止它。
但 --init 不是万能药。它只保证信号能转发,不能替应用做业务上的收尾。如果应用收到 SIGTERM 后仍然埋头跑一个很长的任务,十秒一到,还是 137。
应用自己也得配合
以 Go 的 HTTP 服务为例,收到 SIGTERM 后应该先停止接收新请求,再等待在途请求结束:
1package main
2
3import (
4 "context"
5 "log"
6 "net/http"
7 "os"
8 "os/signal"
9 "syscall"
10 "time"
11)
12
13func main() {
14 srv := &http.Server{Addr: ":8080"}
15
16 go func() {
17 if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
18 log.Fatal(err)
19 }
20 }()
21
22 ch := make(chan os.Signal, 1)
23 signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT)
24 <-ch
25
26 ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
27 defer cancel()
28
29 if err := srv.Shutdown(ctx); err != nil {
30 log.Printf("shutdown: %v", err)
31 }
32}这里的 8 秒要小于 docker stop 的 10 秒,给进程留出退出的余量。Node.js 同理,监听 process.on('SIGTERM', ...),关闭 server 后再 process.exit(0)。
还有一个容易忽略的点:不同程序对信号的理解不一样。Nginx 把 SIGQUIT 当作优雅退出,所以官方镜像里声明了:
1STOPSIGNAL SIGQUIT如果你的应用也有类似约定,记得在 Dockerfile 里写清楚,否则 docker stop 发出去的 SIGTERM 可能并不是它期待的那个信号。
退出码速查
| 退出码 | 计算方式 | 常见原因 |
|---|---|---|
| 0 | — | 正常退出 |
| 1 | — | 应用报错、配置错误 |
| 130 | 128 + 2 | 收到 SIGINT,比如 Ctrl-C |
| 137 | 128 + 9 | docker stop 超时、OOM Killer |
| 139 | 128 + 11 | 段错误 |
| 143 | 128 + 15 | 正常响应 SIGTERM |
看到 137 时,先别急着怀疑内存。用下面这条命令区分:
1docker inspect -f '{{.State.OOMKilled}}' api输出 true 才是被 OOM Killer 杀的;false 基本就是 docker stop 等满十秒后补的 SIGKILL。
一条排查路径
- 用
docker inspect看Entrypoint和Cmd,确认是不是 shell form。 - 用
docker top <容器>看 1 号进程到底是谁。 - 用
docker stop -t 30再试一次,看是“永不退出”还是“只是慢”。 - 在应用里打印收到的信号,确认 handler 是否真的执行。
- 检查
OOMKilled,排除内存问题。 - 检查
STOPSIGNAL是否与应用约定一致。 - 在 Kubernetes 里还要看
terminationGracePeriodSeconds和preStop,它们的超时逻辑和docker stop是一回事。
容器并没有改变信号机制,它只是把“谁是 1 号进程”这件事摆到了你面前。把 SIGTERM 当成一个需要认真实现的接口,那十秒的等待自然就消失了。