Docker 基础体系 · 第 50/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。
Docker PID 1 与 Init:信号、子进程回收、Shell Form 和 Tini
在 Linux 容器中,PID 1 不是普通的第一个用户进程。它同时承担两个角色:
- 容器的主进程,决定容器何时退出;
- 所在 PID namespace 的 init 进程,负责接收特定的内核事件并回收孤儿进程。
Docker 负责创建容器、设置 namespace,并向容器中的目标进程发送停止信号;但应用进程是否正确处理信号、是否回收子进程,通常取决于容器内的 PID 1。这也是 Dockerfile 的 Shell Form、Exec Form 以及 Tini 等工具会影响容器行为的根本原因。
本文讨论 Linux 容器边界内的行为。Windows 容器的进程模型和信号机制不同,不能直接套用本文结论。
一、先明确三个“进程身份”
理解 PID 1 前,需要区分三个概念:
- 宿主机中的进程 ID;
- 容器内部 PID namespace 中的进程 ID;
- Docker 发送信号时选择的目标进程。
1. PID namespace 会重新编号进程
Linux PID namespace 为进程提供独立的进程编号空间。同一个进程可以同时拥有多个 PID:
宿主机 PID namespace 容器 PID namespace
宿主机 PID 42100 ──────── 容器 PID 1
宿主机 PID 42101 ──────── 容器 PID 7
宿主机 PID 42102 ──────── 容器 PID 8
容器内执行:
ps
看到的 1,是该进程在容器 PID namespace 中的 PID,不一定是宿主机上的 PID 1。
Docker 容器的主进程通常作为该 namespace 中的 PID 1 启动。可以用下面的例子观察这一点:
docker run --rm alpine:3.20 sh -c '
echo "container PID: $$"
echo "process list:"
ps -o pid,ppid,stat,comm,args
'
典型输出类似:
container PID: 1
process list:
PID PPID STAT COMMAND COMMAND
1 0 S sh sh -c ...
具体 COMMAND 和列宽可能随镜像中的 BusyBox 版本变化,但 sh 通常是容器内的 PID 1。
2. Docker 发送信号给谁
对于常规容器停止流程,Docker Engine 会向容器的 init 进程,也就是容器内的 PID 1,发送配置的停止信号。默认停止信号通常是 SIGTERM,随后等待超时,仍未退出则发送 SIGKILL。
这不是“把信号广播给容器内所有进程”。因此:
docker stop
│
├── 向容器 PID 1 发送 SIGTERM
│
├── 等待停止超时
│
└── 仍未退出时向容器 PID 1 发送 SIGKILL
如果 PID 1 是一个不会转发信号的 Shell,而真正的应用是它的子进程,那么应用可能收不到 SIGTERM。
3. 容器的生命周期由 PID 1 决定
在一个正常的 Linux PID namespace 中,namespace init 进程退出后,该 namespace 中剩余的进程会被内核终止。因此,对 Docker 容器而言,容器主进程退出通常意味着容器退出,即使它此前启动过其他子进程。
可以把容器的基本退出条件写成:
容器运行状态 = PID 1 仍然存在
容器退出状态 = PID 1 已退出
这不等价于“所有应用工作都完成”。例如,PID 1 错误地退出时,子进程不会让容器继续保持正常运行;它们最终也会被清理。
二、Linux 中 PID 1 的特殊语义
1. PID 1 不只是编号为 1 的普通进程
Linux 内核对 PID 1 有特殊处理。对于 PID 1,如果该进程没有为某个信号注册处理函数,部分信号不会按照普通进程的默认动作执行,而是会被忽略。SIGKILL 和 SIGSTOP 仍然不能被捕获、阻塞或忽略。
这会造成一个非常容易误判的结果:
FROM alpine:3.20
CMD ["sleep", "infinity"]
如果 sleep 没有以适合 PID 1 的方式处理 SIGTERM,执行:
docker run --name demo demo-image
docker stop demo
可能表现为:
docker stop等待完整停止超时;- 最后 Docker 使用
SIGKILL; - 容器退出码常见为
137,因为128 + 9 = 137,其中9是SIGKILL。
这里的 137 是常见结果,不是所有停止路径都必然得到的唯一值。应用也可能自行退出、使用不同停止信号,或者 Docker 使用其他运行时行为。
2. PID 1 需要显式处理应用信号
常规应用通常应至少考虑:
SIGTERM:请求优雅退出,Docker 默认停止信号;SIGINT:交互式前台运行时常见;SIGQUIT:某些程序用于优雅退出或生成诊断信息;SIGCHLD:子进程状态变化通知,涉及回收;SIGKILL:无法被处理,最终强制终止。
一个简单的 Go、Python 或 Node.js 应用如果直接作为 PID 1,是否能正确响应 SIGTERM,取决于该语言运行时和应用代码。不能仅因为“进程收到了信号”就假设它会自动完成连接排空、数据刷新和子进程清理。
3. 退出码的计算
Unix 约定中,进程被信号 N 终止时,Shell 常把退出状态表示为:
128 + N
例如:
SIGTERM = 15 → 143
SIGKILL = 9 → 137
SIGINT = 2 → 130
这是 Shell 和工具常用的表示方式,不是说内核把进程的真实退出码直接设置成这些数字。Docker 展示的容器退出码通常会反映这类结果,但具体行为仍应结合进程是否自行退出、运行时和外层 Shell 判断。
三、子进程、孤儿进程与僵尸进程
PID 1 的第二项关键职责是处理进程树变化。
1. 子进程退出后不会立即消失
Linux 进程退出时,内核会保留一小部分进程信息,例如退出状态和资源统计,供父进程调用 wait()、waitpid() 或相关接口读取。
在父进程完成等待前,这个已退出的子进程处于 zombie 状态:
父进程仍在运行
│
└── 子进程已退出,但尚未 wait()
└── zombie
僵尸进程不再执行用户代码,也不继续占用原有地址空间,但会占用进程表中的条目。大量僵尸进程最终可能耗尽 PID 或进程表资源。
下面的 C 程序可以构造一个僵尸子进程:
#include <unistd.h>
int main(void) {
if (fork() == 0) {
_exit(0);
}
sleep(300);
return 0;
}
编译并运行后,父进程在 300 秒内不调用 wait(),子进程会短暂处于僵尸状态。可以用:
ps -o pid,ppid,stat,comm,args
观察到子进程的 STAT 中包含 Z,例如:
PID PPID STAT COMMAND
100 1 S parent
101 100 Z child
2. 父进程退出后,子进程会成为孤儿
如果父进程先退出,仍在运行的子进程会成为孤儿。内核需要为它们重新指定父进程,才能保证这些子进程未来退出时仍有进程负责 wait()。
在一个 PID namespace 中,孤儿进程通常会被重新托管给该 namespace 的 init 进程,也就是 PID 1;如果进程树中存在通过 PR_SET_CHILD_SUBREAPER 设置的子收割者,则可能先被该 subreaper 接管。
所以,PID 1 需要具备这样的能力:
发现被托管的子进程退出
↓
调用 wait()/waitpid()
↓
读取退出状态
↓
释放进程表条目
3. “子进程回收”不是杀死子进程
回收(reaping)和终止(terminating)是不同动作:
- 终止:让进程停止执行;
- 回收:父进程通过
wait*()读取已退出子进程的状态,并让内核释放其僵尸条目。
一个进程可以已经被终止,但还没有被回收。Tini 的核心职责之一就是执行后者,而不是替应用管理所有业务子进程。
4. PID 1 不一定天然能正确回收子进程
Linux 内核提供 PID 1 的孤儿托管语义,但不会自动替 PID 1 调用 wait()。如果 PID 1 是一个没有处理 SIGCHLD、也没有循环调用 waitpid(-1, ...) 的普通程序,就可能积累僵尸进程。
因此,以下两件事不能混为一谈:
内核:把孤儿进程重新托管给 PID 1
PID 1:调用 wait() 回收这些已退出进程
前者是内核的进程归属行为,后者是 PID 1 程序必须实现的逻辑。
四、Dockerfile 的 Exec Form 与 Shell Form
Dockerfile 中的 RUN、CMD 和 ENTRYPOINT 都有 Shell Form 与 Exec Form 的区别,但它们在构建阶段和运行阶段的含义不同。
1. Exec Form:直接执行指定程序
Exec Form 使用 JSON 数组:
FROM alpine:3.20
ENTRYPOINT ["/usr/local/bin/myapp"]
CMD ["--config", "/etc/myapp/config.yaml"]
Docker 不会为这组参数自动插入 /bin/sh -c。容器启动后,目标程序可以直接成为 PID 1:
PID 1 /usr/local/bin/myapp --config /etc/myapp/config.yaml
Exec Form 的另一个重要性质是 CMD 可以作为 ENTRYPOINT 的默认参数:
docker run image --config /tmp/test.yaml
实际执行逻辑相当于:
/usr/local/bin/myapp --config /tmp/test.yaml
而不是启动一个额外 Shell。
2. Shell Form:先启动 Shell
Shell Form 使用普通字符串:
FROM alpine:3.20
CMD /usr/local/bin/myapp --config /etc/myapp/config.yaml
在 Linux 镜像中,它通常等价于:
/bin/sh -c "/usr/local/bin/myapp --config /etc/myapp/config.yaml"
如果没有其他入口层,容器内的进程树可能是:
PID 1 /bin/sh -c /usr/local/bin/myapp ...
└── PID 7 /usr/local/bin/myapp ...
此时 Docker 的 SIGTERM 首先发给 PID 1,也就是 /bin/sh,不一定发给 PID 7 的应用。
3. ENTRYPOINT 的 Shell Form 更容易隐藏参数
例如:
ENTRYPOINT /usr/local/bin/myapp
CMD ["--port", "8080"]
Shell Form 的 ENTRYPOINT 会通过 Shell 执行。Dockerfile 中的 CMD 不会像 Exec Form 那样自然地作为应用参数拼接到 Shell 命令后形成可靠的参数传递模型。需要参数组合时,通常使用:
ENTRYPOINT ["/usr/local/bin/myapp"]
CMD ["--port", "8080"]
这让“程序路径”和“默认参数”保持了明确的数组语义。
4. Shell 不一定转发信号
Shell 的默认行为不是“把收到的每个信号复制给所有子进程”。不同 /bin/sh 实现、前台/后台作业状态以及命令形式都会影响表现,不能依赖 Shell 自己完成信号转发。
下面的 Dockerfile 可用于观察进程树:
FROM alpine:3.20
RUN apk add --no-cache procps
CMD /bin/sh -c 'sleep 300'
构建并运行:
docker build -t shell-form-demo .
docker run -d --name shell-form-demo shell-form-demo
docker exec shell-form-demo ps -o pid,ppid,stat,comm,args
可能看到类似:
PID PPID STAT COMMAND COMMAND
1 0 S sh /bin/sh -c /bin/sh -c sleep 300
7 1 S sh /bin/sh -c sleep 300
8 7 S sleep sleep 300
精确的层数取决于 Dockerfile 内容和 Shell 优化,但关键事实是:Shell Form 可能产生一个或多个 Shell 层,应用不一定是 PID 1。
5. 在 Shell 包装器中使用 exec
如果必须使用 Shell 做环境变量处理、条件判断或参数拼接,应在启动最终应用时使用 exec:
FROM alpine:3.20
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
#!/bin/sh
set -eu
: "${PORT:=8080}"
echo "starting application on port ${PORT}"
exec /usr/local/bin/myapp --port "$PORT"
执行 exec 后,Shell 进程会被应用替换:
执行前:
PID 1 /entrypoint.sh
└── PID 7 myapp
执行 exec 后:
PID 1 myapp
因此 Docker 发给 PID 1 的信号会直接到达应用。exec 只解决“最终应用成为 PID 1”问题,不自动解决:
- 应用是否正确处理
SIGTERM; - 应用是否回收自己启动的子进程;
- 包装脚本是否需要同时管理多个长期运行进程;
- 应用启动失败时是否正确返回退出码。
6. exec "$@" 是通用入口脚本的关键
常见入口脚本:
#!/bin/sh
set -eu
if [ "$#" -eq 0 ]; then
set -- /usr/local/bin/myapp --config /etc/myapp/config.yaml
fi
exec "$@"
其中:
"$@"保留每个参数的边界,避免空格和通配符被错误拆分;exec替换当前 Shell;set -e不能替代错误设计,复杂条件下仍应明确检查返回值;- 使用 Shell 脚本时要确认镜像中存在脚本所需的
/bin/sh、工具和动态库。
五、Shell 包装器中的信号转发
如果入口程序必须保留为 Shell,例如它需要同时管理一个应用和一个辅助子进程,就不能只写:
#!/bin/sh
/usr/local/bin/myapp &
wait
此时 Shell 是 PID 1,应用是子进程。应明确记录子进程 PID,并转发停止信号:
#!/bin/sh
set -eu
/usr/local/bin/myapp &
child=$!
term_handler() {
kill -TERM "$child" 2>/dev/null || true
}
trap term_handler TERM INT
wait "$child"
status=$?
# 可选:在应用退出后等待或清理其他子进程
exit "$status"
这个例子说明了三个阶段:
启动应用
↓
Shell 保存 child PID
↓
Shell 收到 SIGTERM
↓
Shell 向 child 发送 SIGTERM
↓
wait child
↓
以 child 的状态退出
但该脚本仍不等同于完整的 init。若应用还会创建孙进程,或者同时运行多个子进程,脚本需要:
- 记录和转发每个相关进程;
- 等待所有子进程;
- 处理某个子进程提前失败的情况;
- 处理重复信号和超时;
- 避免子进程成为僵尸。
这类逻辑一旦变复杂,使用专门的轻量 init 往往比继续扩展 Shell 脚本更可靠。
六、Tini 是什么,以及它解决什么问题
Tini 是一个面向容器的轻量 init 程序。它通常承担以下职责:
- 作为容器内 PID 1;
- 接收发送给容器 init 的信号;
- 将信号转发给它管理的子进程;
- 持续调用等待接口,回收退出的子进程;
- 在主子进程退出后,以合适的状态结束。
它可以抽象成:
Docker
│ SIGTERM
▼
Tini(PID 1)
│ 转发信号
▼
应用(子进程)
│ 创建子进程
▼
工作进程
任意子进程退出
│
▼
Tini wait()/waitpid() 回收
Tini 不是完整的进程管理平台。它通常不会替应用完成:
- 服务发现;
- 日志轮转;
- 多服务编排;
- 自动重启任意失败进程;
- 应用级连接排空;
- 数据库事务恢复。
它解决的是容器中最底层的 init 问题:信号转发和孤儿/僵尸进程回收。
1. 使用 Docker Engine 的 --init
现代 Docker Engine 支持:
docker run --init --name tini-demo myapp:latest
--init 会让 Docker 在容器中使用一个轻量 init 进程。Docker 使用的具体 init 实现和版本由 Docker Engine 分发,不能把宿主机上手工安装的 Tini 版本与 Engine 内置实现假定为完全相同。
验证进程树:
docker exec tini-demo ps -o pid,ppid,stat,comm,args
常见结构类似:
PID PPID STAT COMMAND COMMAND
1 0 S docker-init /dev/init -- ...
7 1 S myapp /usr/local/bin/myapp
这里 docker-init 是 Docker Engine 放入容器的 init 实现名称之一,具体显示名称可能因版本和发行方式不同而变化。重点不是名称,而是它位于容器 PID 1,并把应用作为子进程启动。
2. 在镜像中显式安装 Tini
也可以把 Tini 放入镜像并作为入口:
FROM alpine:3.20
RUN apk add --no-cache tini
COPY myapp /usr/local/bin/myapp
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/myapp"]
其中:
/sbin/tini
└── -- 结束 Tini 自身选项
└── /usr/local/bin/myapp
如果发行版路径或包名不同,应以目标基础镜像的软件包和文件路径为准。生产构建还必须验证 Tini 二进制来源、版本、架构和完整性,不能仅复制一份未经验证的宿主机文件。
3. Tini 的退出状态传播
一般情况下,Tini 会让主子进程的退出状态影响容器退出状态。这使下面的诊断仍然有意义:
docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' tini-demo
但如果 Tini 自身启动失败、配置了特殊信号映射,或外层还有其他包装器,退出状态可能来自不同层。诊断时应同时查看:
docker logs tini-demo
docker inspect tini-demo
docker top tini-demo
七、Docker Engine 的 Init 与 Compose 的 init
1. docker run --init
单次运行容器时:
docker run --init image:tag
作用是让容器具有一个轻量 init。它不改变应用代码,也不意味着应用已经具备正确的优雅关闭逻辑;它只是改善 PID 1 的信号和子进程管理。
2. Compose 中的 init: true
Compose 服务可以写成:
services:
app:
image: myapp:latest
init: true
stop_signal: SIGTERM
stop_grace_period: 30s
这些配置的含义是:
init: true:为服务容器启用平台提供的 init 进程;stop_signal:指定停止时发送给容器 init 的信号;stop_grace_period:指定发送强制终止前的等待时间。
启动和停止:
docker compose up -d
docker compose ps
docker compose stop
init: true 的具体实现由 Compose 所使用的平台和容器运行时提供。不要把它理解成 Compose 自己在容器内运行了一个完整 supervisor。
3. stop_signal 不会绕过 PID 1
例如:
services:
app:
image: myapp:latest
stop_signal: SIGQUIT
停止时,Docker 仍然把 SIGQUIT 发给容器的 init,也就是 PID 1。若 PID 1 是 Tini,Tini 再向应用转发;若 PID 1 是不转发信号的 Shell,应用仍可能收不到信号。
因此修改 stop_signal 必须同时确认:
- PID 1 能接收并转发该信号;
- 应用确实实现了该信号的语义;
- 应用收到信号后能在宽限期内退出;
- 退出前的数据刷新和连接清理不会超过超时时间。
八、停止流程的完整时序
考虑如下容器:
PID 1 tini
└── PID 7 myapp
├── PID 8 worker-a
└── PID 9 worker-b
执行:
docker stop --time 30 mycontainer
典型时序如下:
sequenceDiagram
participant D as Docker Engine
participant T as PID 1: Tini
participant A as 应用
participant W as 工作子进程
D->>T: SIGTERM
T->>A: 转发 SIGTERM
A->>W: 停止或等待工作进程
W-->>A: 退出
A-->>T: 应用退出
T->>T: wait()/waitpid() 回收子进程
T-->>D: PID 1 退出
D-->>D: 容器变为 stopped
如果应用没有在 30 秒内退出:
sequenceDiagram
participant D as Docker Engine
participant T as PID 1
participant A as 应用
D->>T: SIGTERM
T->>A: 转发 SIGTERM
Note over D,A: 等待宽限期结束
D->>T: SIGKILL
Note over D,A: namespace 内进程被强制终止
关键边界是:Tini 可以转发信号和回收进程,但不能强迫应用在业务上“优雅完成”。如果应用忽略信号、卡在不可中断睡眠,或者关闭流程本身超过超时时间,最终仍会被 SIGKILL 终止。
九、一个可运行的对比实验
下面用一个简单脚本模拟“收到信号后退出”的应用。
1. 应用脚本
#!/bin/sh
set -eu
term() {
echo "app: received TERM"
echo "app: cleaning up"
sleep 2
echo "app: exiting"
exit 0
}
trap term TERM INT
echo "app: started, pid=$$"
while :; do
sleep 1
done
保存为 app.sh 并赋予执行权限:
chmod +x app.sh
2. 直接使用 Exec Form
FROM alpine:3.20
COPY app.sh /app.sh
RUN chmod +x /app.sh
ENTRYPOINT ["/app.sh"]
构建和运行:
docker build -t signal-exec-demo .
docker run -d --name signal-exec-demo signal-exec-demo
docker logs signal-exec-demo
docker stop --time 10 signal-exec-demo
docker inspect -f 'status={{.State.Status}} exit={{.State.ExitCode}}' signal-exec-demo
预期日志类似:
app: started, pid=1
app: received TERM
app: cleaning up
app: exiting
这里应用本身是 PID 1,因此 Docker 发送的 SIGTERM 直接到达应用。
3. 使用 Shell Form
把 Dockerfile 改为:
FROM alpine:3.20
COPY app.sh /app.sh
RUN chmod +x /app.sh
CMD /app.sh
此时典型结构是:
PID 1 /bin/sh -c /app.sh
└── PID 7 /bin/sh /app.sh
具体进程树可能因 Shell 是否执行优化而变化,但应用不应被假定为 PID 1。执行:
docker build -t signal-shell-demo .
docker run -d --name signal-shell-demo signal-shell-demo
docker stop --time 3 signal-shell-demo
docker inspect -f 'status={{.State.Status}} exit={{.State.ExitCode}}' signal-shell-demo
可能出现:
- 日志中没有
app: received TERM; docker stop等待 3 秒;- 容器最终被强制终止;
- 退出码常见为
137。
这不是因为 trap 写错,而是 SIGTERM 可能只到达了 Shell PID 1,未到达应用。
4. 加入 Tini
不改变 Shell Form 的前提下,也可以:
docker run --init -d --name signal-init-demo signal-shell-demo
docker stop --time 10 signal-init-demo
Tini 可以改善 init 层的信号转发和子进程回收,但更可靠的根本修复仍是使用 Exec Form,或者在包装脚本中使用 exec。如果应用被 Shell 包装后,Shell 对信号的处理方式仍不符合预期,不能把 --init 当作所有信号问题的自动修复器。
十、常见误解与失败路径
误解一:CMD ["sh", "-c", "..."] 就是 Exec Form,所以信号一定正确
CMD ["sh", "-c", "/usr/local/bin/myapp"]
这确实是 Dockerfile 语法上的 Exec Form,但真正成为 PID 1 的程序是 sh,因为数组第一个元素就是 sh:
PID 1 sh -c /usr/local/bin/myapp
└── myapp
Exec Form 只表示 Docker 不额外插入 Shell,并不保证数组中的程序不是 Shell。
如果确实需要 Shell 语法,应在脚本最后:
exec /usr/local/bin/myapp
误解二:PID 1 会自动回收所有子进程
内核会重新托管孤儿进程,但不会自动替 PID 1 调用 wait()。没有回收逻辑的 PID 1 仍可能造成僵尸进程。
误解三:有 Tini 就可以启动多个服务
Tini 不是通用 supervisor:
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/app"]
它适合围绕一个主应用提供 init 能力。若容器中需要可靠管理多个长期服务,应重新评估进程模型;简单地把多个服务放进一个 Shell 命令、再加 Tini,并不会自动获得依赖管理、独立健康状态和故障重启能力。
误解四:发送了 SIGTERM 就等于应用完成优雅关闭
SIGTERM 只是请求。应用可能:
- 没有注册处理函数;
- 注册了处理函数但没有停止监听;
- 仍等待永不结束的后台任务;
- 关闭顺序错误;
- 在处理信号时再次崩溃。
因此应在日志和进程状态中验证实际路径,而不是只观察 docker stop 是否返回。
误解五:Shell Form 永远错误
Shell Form 并非语法错误。它在需要 Shell 展开、管道、条件判断时有实际用途:
CMD echo "$GREETING" | tr '[:lower:]' '[:upper:]'
问题在于:如果 Shell 是容器 PID 1,就必须明确处理信号转发和子进程回收。对单一长期运行应用,Exec Form 通常能减少一个不必要的进程层;对确实需要 Shell 的场景,应使用经过验证的入口脚本和 exec。
十一、如何诊断 PID 1、信号和僵尸问题
1. 查看容器内 PID 1
docker exec <container> ps -o pid,ppid,stat,comm,args
重点确认:
PID 1 的 COMMAND 是什么?
真正的应用是不是 PID 1?
应用的 PPID 是否为 1 或 Tini?
如果镜像没有 ps,可以使用:
docker top <container>
或在调试镜像中安装进程查看工具。
2. 查看容器配置的停止信号
docker inspect -f '{{.Config.StopSignal}}' <container>
空值通常意味着使用默认停止信号。也可以查看完整配置:
docker inspect <container>
关注:
Config.StopSignal
State.Pid
State.Status
State.ExitCode
State.FinishedAt
3. 观察停止是否超时
time docker stop --time 10 <container>
如果命令几乎总是等待完整的 10 秒,常见原因包括:
- PID 1 没有处理
SIGTERM; - PID 1 没有把信号转发给应用;
- 应用收到信号但关闭流程超时;
- 应用处于无法响应信号的内核状态。
之后查看退出码:
docker inspect -f 'status={{.State.Status}} exit={{.State.ExitCode}}' <container>
137 强烈提示最终使用了 SIGKILL,但仍应结合事件、日志和实际配置确认。
4. 检查僵尸进程
在容器内执行:
ps -eo pid,ppid,stat,comm,args
筛选 STAT 中包含 Z 的进程:
ps -eo pid,ppid,stat,comm,args | awk '$3 ~ /Z/'
如果持续产生僵尸进程,应检查:
- 哪个进程是 PID 1;
- 应用是否创建子进程;
- 是否存在 Shell 包装器;
- 是否启用了
--init或 Compose 的init: true; - 应用自身是否正确调用
wait()。
5. 不要只在开发环境用 docker kill 判断优雅关闭
docker kill <container>
默认直接发送 SIGKILL,用于强制终止和故障恢复测试,不适合验证 SIGTERM 的优雅关闭路径。应分别测试:
docker kill --signal=SIGTERM <container>
docker stop --time 30 <container>
docker kill --signal=SIGKILL <container>
三者验证的是不同层次:
SIGTERM:应用能否按请求退出;docker stop:完整的 Engine 停止时序;SIGKILL:强制终止后的恢复能力。
十二、生产中的取舍
对于一个直接启动单个长期运行应用的镜像,通常采用:
ENTRYPOINT ["/usr/local/bin/myapp"]
如果应用自身实现了可靠的信号处理和子进程回收,直接作为 PID 1 可以减少组件。
如果应用可能创建子进程,但自身不是完整 init,或者无法保证孤儿进程回收,则可以采用:
docker run --init image:tag
或:
services:
app:
image: image:tag
init: true
如果需要 Shell 逻辑,应使用:
ENTRYPOINT ["/entrypoint.sh"]
并在脚本末尾:
exec "$@"
只有在 Shell 必须长期作为进程管理者时,才应保留它作为 PID 1,并明确实现信号转发、子进程等待、错误传播和清理逻辑。
最终应把容器启动结构理解为一条可验证的进程链:
Docker 停止信号
↓
容器 PID 1
↓
信号是否转发
↓
应用是否处理
↓
子进程是否结束
↓
PID 1 是否 wait() 回收
↓
应用和容器是否在宽限期内退出
只要其中任一环节依赖“Shell 大概会处理”或“应用应该会收到信号”的假设,容器就可能出现停止超时、退出码异常、连接未排空或僵尸进程累积。Exec Form、正确的 exec 包装、适当的 Tini,以及经过实际验证的停止流程,分别解决的是这条链路中的不同问题,不能相互替代。
系列导航与关联阅读
- 系列入口:Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:Docker GPU 容器:NVIDIA Runtime、设备、驱动、资源和可观测
- 下一篇:Docker 时区、Locale 与 CA 证书:最小镜像中的运行时基础
- 延伸:Docker 容器生命周期:创建、启动、信号、退出、重启与清理
- 延伸:Docker 健康检查与优雅关闭:PID 1、Probe、Stop Signal 和超时
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论