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

Docker 健康检查与优雅关闭:PID 1、Probe、Stop Signal 和超时

在 Linux 容器中,“应用还活着”“应用可以接收流量”“容器正在关闭”是三个不同问题:

  • **健康检查(health check)**回答:容器内的探测命令最近是否成功。
  • **优雅关闭(graceful shutdown)**回答:应用收到停止信号后,能否停止接收新请求、完成必要工作并退出。
  • PID 1决定:容器收到信号时,哪个进程首先处理信号,以及子进程是否被正确回收。
  • Stop Signal 和超时决定:Docker 如何通知容器停止,以及容器有多长时间完成退出。

这些机制相互关联,但不会自动替代彼此。健康检查失败通常不会直接触发应用退出;应用能优雅关闭,也不意味着健康检查一定能够准确表达其状态。


一、先建立容器生命周期模型

一个 Linux 容器可以抽象为一个隔离的进程环境。容器启动时,Docker 创建容器的命名空间、挂载和网络,然后启动一个容器内进程。这个进程在容器内部的进程号通常是 PID 1

容器是否继续存在,首先取决于这个 PID 1 进程是否继续运行:

容器创建
   ↓
容器启动
   ↓
PID 1 运行
   ├── 健康检查周期性执行
   ├── 应用接收请求
   └── Docker 发送停止信号
           ↓
      PID 1 退出
           ↓
      容器停止

需要区分两个概念:

  1. 容器状态:例如 createdrunningpausedexited
  2. 健康状态:例如 startinghealthyunhealthy

健康状态是运行中容器的附加观测信息,不是容器生命周期本身。一个容器可以处于:

running + unhealthy

也可以因为 PID 1 退出而变成:

exited

此时健康检查不会继续执行,健康状态也不再是决定容器是否运行的主要因素。

Docker 的基本状态转换可以表示为:

stateDiagram-v2
    [*] --> Created
    Created --> Running: start
    Running --> Running: health probe
    Running --> Stopping: stop signal
    Stopping --> Exited: PID 1 正常退出
    Stopping --> Exited: 超时后 SIGKILL
    Running --> Exited: PID 1 自行退出
    Exited --> Running: restart policy 满足
    Exited --> [*]: rm

这里的 health probe 不会把 unhealthy 自动转换为 Stopping。除非外部控制器或运维脚本根据健康状态执行停止、替换或重启操作,否则容器通常仍会继续运行。


二、PID 1:容器中的第一个进程

2.1 PID 1 不一定是你以为的应用进程

以下 Dockerfile 使用了 shell 形式的 CMD

FROM alpine:3.20

CMD /usr/local/bin/my-server

它通常会被解释为:

/bin/sh -c /usr/local/bin/my-server

因此进程关系可能是:

PID 1  /bin/sh -c /usr/local/bin/my-server
  └── my-server

而以下写法使用 exec 形式:

FROM alpine:3.20

CMD ["/usr/local/bin/my-server"]

进程关系通常是:

PID 1  /usr/local/bin/my-server

exec 形式不会额外引入一个 shell,通常更适合作为容器主进程。

ENTRYPOINT 也有同样的区别:

# shell 形式
ENTRYPOINT /usr/local/bin/my-server

# exec 形式
ENTRYPOINT ["/usr/local/bin/my-server"]

如果还需要传递默认参数,可以写成:

ENTRYPOINT ["/usr/local/bin/my-server"]
CMD ["--config", "/etc/my-server/config.yaml"]

最终进程相当于:

/usr/local/bin/my-server --config /etc/my-server/config.yaml

2.2 PID 1 的信号语义具有特殊性

在 Linux 中,PID 1 是一个特殊进程。对于普通进程,如果没有安装信号处理器,某些信号会采用默认动作,例如 SIGTERM 会导致进程终止;但 PID 1 对默认信号行为有特殊约束。实际效果是:一个没有显式信号处理逻辑的 PID 1,可能不会像普通进程一样响应默认终止行为。

因此,下面这个程序如果作为 PID 1 运行,却没有处理 SIGTERM,就不能简单假设它会按照普通进程的方式完成优雅退出。

更常见的问题是:真正的应用在 PID 1 下面,而 PID 1 是 shell。Docker 发送的信号首先到达 PID 1,不一定会自动转发给子进程。

这会导致:

Docker stop
   ↓ SIGTERM
/bin/sh                ← 收到信号
   └── application     ← 可能没有收到
   ↓
超时
   ↓ SIGKILL

最终表现通常是:

  • 应用没有执行关闭回调;
  • HTTP 连接被中断;
  • 消息消费任务没有提交或确认;
  • 日志没有完整刷出;
  • Docker 等待超时后强制杀死整个容器。

2.3 exec 为什么重要

如果必须使用启动脚本,应在启动应用时使用 exec

#!/bin/sh
set -eu

echo "initializing..."

# exec 会用应用进程替换当前 shell
exec /usr/local/bin/my-server "$@"

Dockerfile:

FROM alpine:3.20

COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["--port", "8080"]

启动后的进程关系应接近:

PID 1  /usr/local/bin/my-server --port 8080

exec 的关键不是“让 shell 更快结束”,而是让应用替换 shell 成为 PID 1。这样 Docker 发送给容器 PID 1 的停止信号,才会直接交给应用。

2.4 PID 1 还负责孤儿进程回收

当容器中的子进程退出时,父进程需要调用 wait 回收其退出状态。否则会产生僵尸进程。普通宿主机通常由系统初始化进程协助处理孤儿进程,但容器的 PID 1 需要承担容器内部的这部分责任。

应用本身通常不是为“容器 init”设计的。可以让 Docker 使用一个轻量 init:

docker run --init my-server:1.0

Compose 中可以写:

services:
  app:
    image: my-server:1.0
    init: true

--initinit: true 的作用主要是提供信号转发和子进程回收能力。它不能替应用实现业务级优雅关闭,也不能自动知道哪些连接、任务或事务必须先完成。

如果应用自己是可靠的 PID 1,并且能够正确处理信号和回收子进程,则不一定需要额外 init;如果应用会派生子进程,或启动脚本复杂,使用 init 往往更稳妥。


三、健康检查 Probe 到底检查什么

3.1 Probe 是容器内执行的命令

Docker 健康检查是周期性执行的探测命令。它通常在容器的文件系统、网络命名空间和环境中执行,而不是由 Docker 宿主机直接访问应用端口。

Dockerfile 示例:

FROM nginx:1.27-alpine

HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
  CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1

这个 Probe 的含义是:

  1. 每隔约 10 秒执行一次命令;
  2. 命令最长运行 3 秒;
  3. wget 成功并返回退出码 0,表示本次检查成功;
  4. wget 失败或返回非 0,表示本次检查失败;
  5. 连续达到失败次数后,健康状态变为 unhealthy

CMD 使用 exec 形式执行;也可以显式使用 shell:

HEALTHCHECK CMD-SHELL \
  wget -q -O /dev/null http://127.0.0.1/ || exit 1

两者的区别与 CMDENTRYPOINT 类似:

HEALTHCHECK CMD ["curl", "-f", "http://127.0.0.1:8080/health"]

相当于直接执行程序。

HEALTHCHECK CMD-SHELL \
  curl -f http://127.0.0.1:8080/health || exit 1

相当于通过 shell 执行字符串,适合使用管道、条件和重定向,但也会引入 shell 解析、转义和变量展开问题。

3.2 健康检查的状态变化

健康检查通常经历以下状态:

starting
   ├── Probe 成功 → healthy
   └── 失败次数达到 retries → unhealthy

healthy
   ├── Probe 成功 → healthy
   └── 失败次数达到 retries → unhealthy

unhealthy
   └── Probe 成功 → healthy

可以通过命令观察:

docker inspect --format '{{json .State.Health}}' my-app

典型输出结构类似:

{
  "Status": "healthy",
  "FailingStreak": 0,
  "Log": [
    {
      "ExitCode": 0,
      "Output": "..."
    }
  ]
}

Log 中会保留最近若干次探测结果,具体保留数量和输出截断行为由 Docker 实现控制,因此不应把它当作长期日志系统。

3.3 各参数的准确含义

一个完整的 Dockerfile 健康检查可以写成:

HEALTHCHECK \
  --interval=10s \
  --timeout=3s \
  --start-period=30s \
  --start-interval=2s \
  --retries=5 \
  CMD ["curl", "-fsS", "http://127.0.0.1:8080/health"]

参数含义如下:

  • interval:相邻探测开始之间的基础间隔。
  • timeout:单次探测允许运行的最长时间。
  • start-period:应用启动宽限期。
  • start-interval:启动宽限期内的探测间隔;这是较新的能力,使用前应确认目标 Docker Engine 版本支持。
  • retries:连续失败达到该次数后变为 unhealthy

timeout 只限制 Probe 命令本身,不会自动限制被探测 HTTP 服务内部所有工作。例如:

Probe timeout = 3 秒
HTTP handler 内部执行数据库查询 = 30 秒

如果 curl 在 3 秒内没有收到满足条件的响应,本次 Probe 失败;它不会替你终止服务端已经启动的数据库查询。

start-period 的目的,是避免应用初始化阶段的临时失败立即累计到失败次数中。启动期失败通常不会计入 FailingStreak;如果启动期内出现一次成功检查,后续失败则按正常规则处理。它不是“保证应用在这段时间内一定健康”的承诺。

retries 也不是重试总次数,而是达到 unhealthy 所需的连续失败次数。粗略地说,若应用已经进入正常探测阶段,连续失败时间约为:

Tunhealthy(r1)×I+τT_{\text{unhealthy}} \approx (r-1) \times I + \tau

其中:

  • rrretries
  • IIinterval
  • τ\tau 是最后一次 Probe 的实际执行时间,最多为 timeout

这是近似估算,因为调度、执行和 Docker Engine 实现会带来额外偏差。

例如:

interval = 10s
timeout  = 3s
retries  = 3

如果每次 Probe 都立即失败,通常需要经历三次检查,约在 20~23 秒后进入 unhealthy,而不是启动后固定 30 秒精确变更。

3.4 健康检查的退出码

Probe 的退出码约定是:

  • 0:成功;
  • 1:失败;
  • 2:保留给“仍在启动”语义,不应作为普通业务失败码随意使用。

如果不需要区分启动中状态,通常只返回 0 或 1:

#!/bin/sh

if curl -fsS --max-time 2 http://127.0.0.1:8080/health >/dev/null; then
    exit 0
fi

exit 1

3.5 Probe 应检查什么

健康检查不应只测试“端口是否监听”。端口监听只能说明某个进程绑定了端口,不能说明:

  • 请求处理线程是否可用;
  • 关键依赖是否已经初始化;
  • 应用是否已经停止接收新流量;
  • 数据库连接池是否全部失效;
  • 消息消费者是否卡死。

但也不应把所有外部依赖都塞进同一个健康检查,否则一个非关键依赖短暂故障就可能让整个实例变为 unhealthy

常见做法是区分:

/liveness   进程是否还能工作
/readiness  当前是否适合接收流量

Docker 原生只提供一个健康状态,并不强制定义这两个 HTTP 端点的语义。应用如何设计端点,取决于外部负载均衡器、编排器或发布系统如何消费这个状态。


四、健康检查不会自动重启容器

这是生产环境中最容易混淆的事实之一。

以下两个事件不同:

健康检查失败

和:

PID 1 退出

Docker 的普通 restart policy 主要依据容器是否退出。例如:

docker run -d \
  --name my-app \
  --restart=unless-stopped \
  my-app:1.0

如果应用进程退出,Docker 可以根据重启策略重新启动容器;但仅仅因为健康状态变为 unhealthy,Docker Engine 通常不会自动重启容器。

因此这条因果链并不成立:

Probe 失败
   → Docker 自动发送 SIGTERM
   → 容器重启

更准确的链路是:

Probe 失败
   → 健康状态累计失败
   → healthy 变为 unhealthy
   → 外部系统决定摘流、替换或重启

在 Compose 中,depends_on 的健康条件主要影响依赖服务的启动顺序:

services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 5s
      timeout: 3s
      retries: 10

  api:
    image: my-api:1.0
    depends_on:
      db:
        condition: service_healthy

这表示 api 启动前等待 db 进入健康状态。它不表示:

  • db 之后变得不健康时,Compose 必然停止 api
  • api 会自动根据 db 的健康变化重启;
  • 健康检查可以替代应用内部的连接重试和故障处理。

五、Stop Signal:Docker 如何通知容器停止

5.1 默认停止流程

在 Linux 容器中,典型的 docker stop 流程是:

t0: Docker 向容器 PID 1 发送停止信号
    ↓
    等待宽限期
    ↓
t0 + T: 若仍未退出,发送 SIGKILL

默认停止信号通常是 SIGTERM,默认等待时间通常是 10 秒,但具体默认值和调用场景应以目标 Docker Engine 或上层工具配置为准。生产脚本不应完全依赖隐含默认值。

可以显式设置停止信号:

FROM alpine:3.20

STOPSIGNAL SIGTERM

CMD ["/usr/local/bin/my-server"]

Compose 中:

services:
  api:
    image: my-api:1.0
    stop_signal: SIGTERM

STOPSIGNALstop_signal 只是改变 Docker 发送的信号,不会自动产生优雅关闭逻辑。例如把信号改成 SIGQUIT,只有在应用确实把 SIGQUIT 定义为优雅退出时才有意义。

也可以在命令行覆盖:

docker run --stop-signal=SIGQUIT my-app:1.0

5.2 停止超时和强制终止

手动停止时可以设置等待时间:

docker stop --time 30 my-app

这个命令的含义是:

  1. 发送配置的停止信号;
  2. 最多等待约 30 秒;
  3. 如果容器仍未退出,发送 SIGKILL
  4. SIGKILL 无法被应用捕获、延迟或转换为业务级清理逻辑。

无限等待可以通过:

docker stop --time=-1 my-app

但这意味着停止操作可能永久卡住,通常不适合自动化发布和节点维护。更合理的做法是根据应用实际关闭时间设置有限的宽限期,并在超时后保留明确的强制终止路径。

Compose 中可以设置:

services:
  api:
    image: my-api:1.0
    stop_signal: SIGTERM
    stop_grace_period: 30s

stop_grace_period 是 Compose 对停止宽限期的配置。它和 Dockerfile 中的 HEALTHCHECK --timeout 完全不同:

配置 作用对象 超时后果
Probe timeout 单次健康检查命令 本次检查失败
stop_grace_period 容器停止过程 仍未退出则强制终止
docker stop --time 某次 CLI 停止操作 覆盖或指定本次等待时间

5.3 Stop Signal 只发给谁

停止信号首先发给容器的 PID 1。Docker 不会自动保证每个应用子进程都收到同一个信号。

例如:

PID 1  shell
 ├── worker-1
 ├── worker-2
 └── server

如果 shell 没有转发信号,子进程可能继续运行;但在容器最终被 SIGKILL 时,所有进程都会被强制终止。

因此,应用启动方式、PID 1 处理逻辑和子进程回收责任共同决定优雅关闭是否可靠。不能只修改 stop_signal 就解决进程树问题。


六、应用必须如何实现优雅关闭

一个可用的优雅关闭流程通常具有以下顺序:

收到 SIGTERM
   ↓
标记 shutting_down = true
   ↓
健康端点变为不可接收新流量
   ↓
停止接受新的连接或任务
   ↓
等待已有请求、事务、消息处理完成
   ↓
关闭连接池、消费者、线程和子进程
   ↓
退出进程

关键点是“停止接收新流量”应尽早发生,否则应用一边排空旧请求,一边继续接收新请求,可能永远无法达到零活动连接。

一个简化的 Python HTTP 服务示例:

# server.py
import signal
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

shutting_down = False
shutdown_event = threading.Event()


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        global shutting_down

        if self.path == "/health":
            if shutting_down:
                self.send_response(503)
                self.end_headers()
                self.wfile.write(b"shutting down\n")
                return

            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"ok\n")
            return

        if self.path == "/work":
            time.sleep(2)
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"done\n")
            return

        self.send_response(404)
        self.end_headers()

    def log_message(self, fmt, *args):
        pass


server = ThreadingHTTPServer(("0.0.0.0", 8080), Handler)


def handle_term(signum, frame):
    global shutting_down
    shutting_down = True
    shutdown_event.set()


signal.signal(signal.SIGTERM, handle_term)
signal.signal(signal.SIGINT, handle_term)

server.serve_forever()

# serve_forever 返回后,关闭监听 socket 并退出
server.server_close()

这个例子展示了基本顺序:

  1. 收到 SIGTERM
  2. shutting_down 变为 True
  3. /health 返回 503,告诉依赖方不应继续把新流量发来;
  4. 服务停止接受新请求的能力仍取决于实际服务器实现;
  5. 现有请求需要有明确的排空上限;
  6. 最终进程退出。

生产应用还需要考虑关闭期间的请求截止时间。若当前存在最长请求时间 RR,清理时间为 CC,则停止宽限期 TT 至少需要满足:

TR+C+ΔT \ge R + C + \Delta

其中:

  • RR:允许的最长在途请求时间;
  • CC:关闭连接池、消费者和子进程所需时间;
  • Δ\Delta:调度、日志刷盘和信号传递余量。

例如:

最长请求时间 R = 20s
资源清理 C    = 3s
安全余量 Δ    = 2s

则最低估算为:

T >= 25s

如果配置:

stop_grace_period: 10s

则约 10 秒时 Docker 就可能发送 SIGKILL,应用设计出的 20 秒排空逻辑没有机会完成。

这不是把 stop_grace_period 无限制调大就能解决的问题。超时时间过长会拖慢滚动发布、节点排空和故障恢复;超时时间过短则会增加请求中断和数据未完成提交的风险。


七、优雅关闭与健康检查之间的协作

健康检查本身不负责关闭应用,但应用可以在收到停止信号后改变健康结果,以协助外部流量系统摘除实例。

例如:

正常状态:
GET /health → 200

收到 SIGTERM 后:
GET /health → 503

完整过程可能是:

sequenceDiagram
    participant D as Docker
    participant P as PID 1/应用
    participant H as Healthcheck
    participant L as 负载均衡器

    D->>P: SIGTERM
    P->>P: 设置 shutting_down=true
    H->>P: 执行 /health
    P-->>H: 503
    H-->>D: Probe failure
    D-->>L: 由外部系统消费 unhealthy 状态
    P->>P: 停止接收新请求
    P->>P: 等待在途请求
    P-->>D: 进程退出

但这里有两个重要边界。

7.1 Probe 不是即时摘流通知

健康检查是周期性机制。即使应用收到 SIGTERM 后立刻返回 503,Docker 也要等下一次 Probe 执行;负载均衡器还需要自己的发现、轮询或摘除延迟。

因此从 SIGTERM 到真正没有新流量进入之间存在窗口:

Ddrain=Dprobe+Dobserver+DroutingD_{\text{drain}} = D_{\text{probe}} + D_{\text{observer}} + D_{\text{routing}}

其中:

  • DprobeD_{\text{probe}}:等待下一次探测的时间;
  • DobserverD_{\text{observer}}:外部系统读取健康状态的延迟;
  • DroutingD_{\text{routing}}:负载均衡器停止发送新连接所需时间。

如果应用只保留 5 秒停止宽限期,但外部系统可能需要 10 秒摘流,那么优雅关闭仍可能被新请求打断。

7.2 应用应区分“活着”和“可接收流量”

如果应用正在优雅关闭,返回 503 可以表达“不要再给我流量”;但这不一定表示进程已经失效。把所有失败都称为“进程死亡”会导致运维判断错误。

因此,健康端点的语义应和消费方约定清楚:

  • 进程无响应:可能是故障;
  • 依赖不可用:可能是不健康;
  • 正在关闭:通常是主动摘流;
  • 尚未初始化:通常是启动中。

Docker 只记录 Probe 成功或失败,不会替应用区分这些业务语义。需要通过 Probe 输出、应用日志、容器事件和外部编排状态共同诊断。


八、一个可运行的端到端示例

目录结构:

demo/
├── Dockerfile
├── server.py
└── compose.yaml

server.py

import signal
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

stopping = False
server = None


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/health":
            if stopping:
                self.send_response(503)
                self.end_headers()
                self.wfile.write(b"stopping\n")
            else:
                self.send_response(200)
                self.end_headers()
                self.wfile.write(b"ok\n")
            return

        if self.path == "/slow":
            time.sleep(5)
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"slow done\n")
            return

        self.send_response(404)
        self.end_headers()

    def log_message(self, fmt, *args):
        print(fmt % args, flush=True)


def stop(signum, frame):
    global stopping
    stopping = True

    # 停止接收新的 HTTP 请求;已有处理线程不一定立即结束
    threading.Thread(target=server.shutdown, daemon=True).start()


signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)

server = ThreadingHTTPServer(("0.0.0.0", 8080), Handler)
server.serve_forever()
server.server_close()

Dockerfile

FROM python:3.12-slim

WORKDIR /app
COPY server.py .

STOPSIGNAL SIGTERM

HEALTHCHECK --interval=3s --timeout=2s --start-period=2s --retries=2 \
  CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=1)"]

CMD ["python", "server.py"]

这里的 CMD 是 exec 形式。Python 进程会成为容器 PID 1,并且代码安装了 SIGTERM 处理器。

compose.yaml

services:
  app:
    build:
      context: .
    ports:
      - "8080:8080"
    stop_signal: SIGTERM
    stop_grace_period: 15s

启动:

docker compose up -d --build

预期可以访问:

curl -i http://127.0.0.1:8080/health

正常结果类似:

HTTP/1.0 200 OK

ok

查看健康状态:

docker compose ps
docker inspect --format '{{.State.Health.Status}}' "$(docker compose ps -q app)"

通常最终会看到:

healthy

测试关闭期间的状态:

curl http://127.0.0.1:8080/slow &
docker compose stop app
wait

/slow 会等待约 5 秒。由于 Compose 给了 15 秒停止宽限期,应用有机会完成这个请求并正常退出。

也可以在另一个终端观察进程:

docker exec "$(docker compose ps -q app)" ps -ef

不过需要注意:docker exec 只能在容器仍运行时执行;停止过程很快时,命令可能返回容器已停止,这不是应用错误,而是生命周期竞争的正常结果。

停止后检查:

docker compose ps -a
docker inspect --format '{{.State.Status}} {{.State.ExitCode}}' "$(docker compose ps -aq app)"

正常退出通常表现为:

exited 0

如果宽限期耗尽而被强制终止,退出码和日志表现可能不同,且应用的 Python 信号处理器没有机会完成剩余逻辑。


九、常见失败模式与诊断路径

9.1 docker stop 总是等满超时时间

可能原因:

  • PID 1 没有处理配置的停止信号;
  • shell PID 1 没有把信号传递给应用;
  • 应用进入死锁或阻塞系统调用;
  • 应用等待一个永远不会完成的子进程;
  • 关闭逻辑依赖网络,但网络依赖本身已经不可用。

先查看 PID 1:

docker top my-app

再查看容器内进程树:

docker exec my-app ps -o pid,ppid,stat,cmd

检查 Docker 配置的停止信号:

docker inspect --format '{{.Config.StopSignal}}' my-app

查看容器事件:

docker events --filter container=my-app

如果发现 PID 1 是 /bin/sh -c ...,优先改用 exec 形式或在脚本中使用 exec。如果发现大量子进程和僵尸进程,考虑应用自身的 wait 逻辑或 --init

9.2 容器 unhealthy,但没有自动重启

这是符合 Docker 基本语义的结果。需要确认:

docker inspect --format '{{.State.Status}}' my-app
docker inspect --format '{{.State.Health.Status}}' my-app
docker inspect --format '{{json .HostConfig.RestartPolicy}}' my-app

如果第一个结果是 running,第二个是 unhealthy,说明容器仍在运行,只是 Probe 失败。此时应检查:

docker inspect --format '{{json .State.Health.Log}}' my-app
docker logs --tail=200 my-app

常见原因包括:

  • Probe 命令不存在,例如镜像中没有 curl
  • 探测地址写成 localhost,但应用只监听了其他地址;
  • 应用监听端口与 Probe 端口不一致;
  • 健康端点依赖了不必要的外部服务;
  • timeout 设置小于正常启动或响应时间;
  • 使用 CMD-SHELL 时引号、变量或管道逻辑错误。

9.3 Probe 一直是 starting

可能原因:

  • start-period 过长;
  • Probe 从未成功;
  • 命令本身一直超时;
  • 应用实际没有启动成功;
  • 健康检查只在容器网络命名空间内成立,但测试时使用了错误地址。

应同时查看容器日志和健康检查日志,而不能只看 docker ps

docker logs my-app
docker inspect --format '{{json .State.Health}}' my-app

9.4 应用收到 SIGTERM 后仍然没有优雅关闭

检查顺序通常是:

  1. 应用是否确实是 PID 1;
  2. STOPSIGNAL 是否是应用处理的信号;
  3. 启动脚本是否使用 exec
  4. 应用是否设置了信号处理器;
  5. 处理器是否只设置关闭标志,却没有停止监听;
  6. 是否存在请求、线程、协程或子进程无限等待;
  7. 停止宽限期是否小于实际排空时间。

可以用一个简单的日志确认信号是否到达:

def stop(signum, frame):
    print("received signal", signum, flush=True)

但日志出现并不代表关闭完成。必须继续验证:

收到信号
→ 停止接收新请求
→ 在途请求完成
→ 资源释放
→ PID 1 退出

只打印“收到 SIGTERM”而不退出,仍然会在超时后被 SIGKILL


十、配置时需要明确区分的几个边界

10.1 HEALTHCHECKdocker healthcheck

Dockerfile 中可以定义:

HEALTHCHECK CMD ["..."]

也可以在运行容器时覆盖或禁用镜像自带检查:

docker run --no-healthcheck my-app:1.0

Compose 也可以定义服务级健康检查:

services:
  app:
    image: my-app:1.0
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/health"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 20s

Compose 的字段会受到 Compose CLI 和目标 Engine 版本影响。尤其是较新的字段如 start_interval,应在 CI 和生产环境中验证版本兼容性,而不是仅依据本地 Docker 版本。

10.2 SIGTERMSIGKILL 的差异

SIGTERM 是请求进程自行退出的信号,应用可以捕获并执行清理逻辑。

SIGKILL 是强制终止信号,应用不能捕获,也不能执行清理函数。因此:

stop_grace_period 太短

会直接降低优雅关闭的可靠性,而不是让应用“更快地优雅退出”。

10.3 健康检查不是监控系统

Probe 输出适合表达当前容器是否满足一个简单条件,不适合承担:

  • 长期指标存储;
  • 请求级错误统计;
  • 慢查询分析;
  • 业务告警聚合;
  • 发布期间的完整审计。

生产环境应把健康状态用于流量和生命周期决策,把应用日志、指标和链路追踪用于原因分析。

10.4 Linux 容器边界

本文讨论的是 Linux 容器中的 Unix 信号、PID 1、SIGTERMSIGKILL。Windows 容器的进程模型、停止机制和信号支持并不等价,不能直接套用这些结论。


十一、将四个参数放在同一条因果链上

一个稳定的容器停止设计,需要同时满足以下条件:

应用是 PID 1,或有可靠 init
        ↓
Stop Signal 能被 PID 1 接收并转发/处理
        ↓
应用先摘除流量,再排空请求和任务
        ↓
Probe 能表达“可接收流量”状态
        ↓
外部系统有时间观察 unhealthy 并停止送流量
        ↓
stop_grace_period 覆盖排空与清理时间
        ↓
超时后才使用 SIGKILL 作为兜底

可以把停止过程的可行条件写成:

Tgrace>Dprobe+Dobserver+Drouting+Rmax+CcleanupT_{\text{grace}} > D_{\text{probe}} + D_{\text{observer}} + D_{\text{routing}} + R_{\text{max}} + C_{\text{cleanup}}

其中:

  • TgraceT_{\text{grace}}:容器停止宽限期;
  • DprobeD_{\text{probe}}:健康检查发现状态变化的延迟;
  • DobserverD_{\text{observer}}:外部系统读取状态的延迟;
  • DroutingD_{\text{routing}}:流量系统完成摘除的延迟;
  • RmaxR_{\text{max}}:最长在途请求或任务的排空时间;
  • CcleanupC_{\text{cleanup}}:连接池、消费者、子进程等清理时间。

这个不等式不是 Docker 的规范公式,而是用于设计和校验的工程模型。若不满足它,应用即使正确处理了 SIGTERM,也可能在流量尚未完全停止或请求尚未排空时被 SIGKILL

最终应分别验证三件事:

健康检查失败时:
    容器是否仍按预期运行?
    外部系统是否会摘流或替换?

执行 docker stop 时:
    PID 1 是否收到正确的 Stop Signal?
    应用是否停止接收新请求?
    在途请求是否能在宽限期内完成?

超时发生时:
    是否能接受 SIGKILL 带来的数据和连接中断风险?
    是否有日志、指标和恢复流程定位原因?

当 PID 1、Probe、Stop Signal 和停止超时各自的责任边界清楚时,Docker 容器的“健康”“停止”和“重启”才不会被混为一个模糊的状态。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。