Docker 基础体系 · 第 50/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker PID 1 与 Init:信号、子进程回收、Shell Form 和 Tini

在 Linux 容器中,PID 1 不是普通的第一个用户进程。它同时承担两个角色:

  1. 容器的主进程,决定容器何时退出;
  2. 所在 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,如果该进程没有为某个信号注册处理函数,部分信号不会按照普通进程的默认动作执行,而是会被忽略。SIGKILLSIGSTOP 仍然不能被捕获、阻塞或忽略。

这会造成一个非常容易误判的结果:

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,其中 9SIGKILL

这里的 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 中的 RUNCMDENTRYPOINT 都有 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 程序。它通常承担以下职责:

  1. 作为容器内 PID 1;
  2. 接收发送给容器 init 的信号;
  3. 将信号转发给它管理的子进程;
  4. 持续调用等待接口,回收退出的子进程;
  5. 在主子进程退出后,以合适的状态结束。

它可以抽象成:

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 必须同时确认:

  1. PID 1 能接收并转发该信号;
  2. 应用确实实现了该信号的语义;
  3. 应用收到信号后能在宽限期内退出;
  4. 退出前的数据刷新和连接清理不会超过超时时间。

八、停止流程的完整时序

考虑如下容器:

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/'

如果持续产生僵尸进程,应检查:

  1. 哪个进程是 PID 1;
  2. 应用是否创建子进程;
  3. 是否存在 Shell 包装器;
  4. 是否启用了 --init 或 Compose 的 init: true
  5. 应用自身是否正确调用 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、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。