LabHub
学习 学习路径 课程

Docker 基础

容器是怎么死的

在 LabHub 中继续学习

一句话总结

docker stop 会发送 SIGTERM,默认等待 10 秒后再发送 SIGKILL。退出码 143 表示 SIGTERM(128+15)137 表示 SIGKILL(128+9)。只要能区分这两个数字,就能把故障原因缩小一半。

概念图: 143 表示 SIGTERM(128+15) · 137 表示 SIGKILL(128+9) · 第一,内核会特殊对待 PID 1。 · 不会应用默认行为。

为什么需要了解这一点

曾经有一个服务,每次部署时停止过程都要花费 10 秒,退出码也总是 137。对同一个应用只改变命令形式后重新测量,结果如下。

CMD npm start            ->  real 0m10.412s,  ExitCode 137
CMD ["node","server.js"] ->  real 0m0.284s,   ExitCode 143

这 10 秒是 Docker 的默认宽限时间,137 表示强制终止。如果是正常终止,进程应该立即结束,并返回 143。

工作原理

原因分为两层。

第一,内核会特殊对待 PID 1。 对于没有显式注册处理器的信号,不会应用默认行为。 Shell 形式的 CMD 实际会以 /bin/sh -c "npm start" 运行,使 Shell 成为 PID 1。Shell 没有 SIGTERM 处理器,因此信号就这样被忽略。

第二,“Shell 不会转发信号”这个解释只说对了一半。 dash 或 busybox ash 在 sh -c 只包含一条简单命令时,可能进行不 fork、直接以 exec 替换自身的优化。这样 Shell 会消失,目标程序有时会成为 PID 1。问题在于,这种优化是有条件的。只要加入管道、&&、变量展开或重定向中的任意一项,Shell 就会保留下来。依赖 Shell 的这种行为,迟早会在不知不觉中失效。

正确答案是使用 JSON 数组形式的 exec 格式;如果使用入口脚本,最后一行必须是 exec "$@"

实际工作中的表现

PID 1 还有第二项责任。失去父进程的进程会被 PID 1 收养,而 PID 1 必须回收它们的退出状态。init 系统会做这件事,但 Node 或 Python 进程不会。实际案例中,僵尸进程曾累积到 1847 个;进程表填满后,应用日志会出现 fork: Resource temporarily unavailable

此外,137 也可能是 OOM Killer 造成的。区分两者需要查看 State.OOMKilled 字段和内核日志。应用不会收到任何关于 SIGKILL 的异常,因此,如果容器毫无提示地反复重启,应查看内核日志,而不是应用日志。

信号无法到达 PID 1 的两种情况

停止容器时,运行时会向 PID 1 发送 SIGTERM;如果经过 --stop-timeout(默认 10 秒)后仍存活,就发送 SIGKILL。但有两种情况下,信号无法到达应用

Shell 形式 CMD。 使用 CMD npm start 时,/bin/sh -c "npm start" 会成为 PID 1,而 Shell 不会把信号转发给子进程。

CMD npm start                    # ❌ sh 가 PID 1. SIGTERM 을 삼킨다
CMD ["npm", "start"]             # ✅ exec 형식 — 애플리케이션이 PID 1

包装脚本。 如果入口脚本最后调用程序时没有使用 exec,脚本会继续作为 PID 1 留下来。

#!/bin/sh
설정_준비
exec "$@"        # ← exec 이 있어야 프로세스가 교체되어 PID 1 이 된다

两种情况的症状相同:进程不响应停止请求,并在 10 秒后被强制终止。 日志中不会留下任何内容,正在处理的请求和缓冲区数据都会消失。

PID 1 的另一项义务

在 Linux 中,PID 1 还负责回收孤儿进程。如果应用不做这件事,就会累积僵尸进程。对于会创建子进程的容器(Shell 脚本、构建工具),这一问题尤其常见。

docker run --init ...        # tini 를 PID 1 로 넣어 준다

在 Kubernetes 中,使用 shareProcessNamespace: true 时,pause 容器会承担这一职责;否则应在镜像中加入 tini。如果 ps 中不断出现 Z 状态,就是这个问题。

设计优雅终止

1. SIGTERM 수신
2. 새 요청 받기를 멈춘다 (헬스 체크를 실패로 바꾼다)
3. 진행 중 요청이 끝나기를 기다린다
4. 연결·파일을 닫고 종료

第二步尤其重要。Kubernetes 删除 Pod 时,会同时移除端点并发送 SIGTERM,但端点变更传播需要数秒。在这段时间内到达的请求仍可能被发送到正在终止的 Pod,因而失败。在 preStop 钩子中加入短暂的 sleep,可以关闭这个时间窗口。

lifecycle:
  preStop:
    exec: {command: ["sh", "-c", "sleep 5"]}
terminationGracePeriodSeconds: 30

下个练习将做什么

分别启动带有处理器和不带处理器的容器,亲自制造退出码 143 与 137,并检查应用退出码和重启策略。