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 退出
↓
容器停止
需要区分两个概念:
- 容器状态:例如
created、running、paused、exited。 - 健康状态:例如
starting、healthy、unhealthy。
健康状态是运行中容器的附加观测信息,不是容器生命周期本身。一个容器可以处于:
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
--init 或 init: 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 的含义是:
- 每隔约 10 秒执行一次命令;
- 命令最长运行 3 秒;
wget成功并返回退出码 0,表示本次检查成功;wget失败或返回非 0,表示本次检查失败;- 连续达到失败次数后,健康状态变为
unhealthy。
CMD 使用 exec 形式执行;也可以显式使用 shell:
HEALTHCHECK CMD-SHELL \
wget -q -O /dev/null http://127.0.0.1/ || exit 1
两者的区别与 CMD、ENTRYPOINT 类似:
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 所需的连续失败次数。粗略地说,若应用已经进入正常探测阶段,连续失败时间约为:
其中:
- 是
retries; - 是
interval; - 是最后一次 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
STOPSIGNAL 或 stop_signal 只是改变 Docker 发送的信号,不会自动产生优雅关闭逻辑。例如把信号改成 SIGQUIT,只有在应用确实把 SIGQUIT 定义为优雅退出时才有意义。
也可以在命令行覆盖:
docker run --stop-signal=SIGQUIT my-app:1.0
5.2 停止超时和强制终止
手动停止时可以设置等待时间:
docker stop --time 30 my-app
这个命令的含义是:
- 发送配置的停止信号;
- 最多等待约 30 秒;
- 如果容器仍未退出,发送
SIGKILL; 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()
这个例子展示了基本顺序:
- 收到
SIGTERM; shutting_down变为True;/health返回 503,告诉依赖方不应继续把新流量发来;- 服务停止接受新请求的能力仍取决于实际服务器实现;
- 现有请求需要有明确的排空上限;
- 最终进程退出。
生产应用还需要考虑关闭期间的请求截止时间。若当前存在最长请求时间 ,清理时间为 ,则停止宽限期 至少需要满足:
其中:
- :允许的最长在途请求时间;
- :关闭连接池、消费者和子进程所需时间;
- :调度、日志刷盘和信号传递余量。
例如:
最长请求时间 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 到真正没有新流量进入之间存在窗口:
其中:
- :等待下一次探测的时间;
- :外部系统读取健康状态的延迟;
- :负载均衡器停止发送新连接所需时间。
如果应用只保留 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 后仍然没有优雅关闭
检查顺序通常是:
- 应用是否确实是 PID 1;
STOPSIGNAL是否是应用处理的信号;- 启动脚本是否使用
exec; - 应用是否设置了信号处理器;
- 处理器是否只设置关闭标志,却没有停止监听;
- 是否存在请求、线程、协程或子进程无限等待;
- 停止宽限期是否小于实际排空时间。
可以用一个简单的日志确认信号是否到达:
def stop(signum, frame):
print("received signal", signum, flush=True)
但日志出现并不代表关闭完成。必须继续验证:
收到信号
→ 停止接收新请求
→ 在途请求完成
→ 资源释放
→ PID 1 退出
只打印“收到 SIGTERM”而不退出,仍然会在超时后被 SIGKILL。
十、配置时需要明确区分的几个边界
10.1 HEALTHCHECK 与 docker 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 SIGTERM 与 SIGKILL 的差异
SIGTERM 是请求进程自行退出的信号,应用可以捕获并执行清理逻辑。
SIGKILL 是强制终止信号,应用不能捕获,也不能执行清理函数。因此:
stop_grace_period 太短
会直接降低优雅关闭的可靠性,而不是让应用“更快地优雅退出”。
10.3 健康检查不是监控系统
Probe 输出适合表达当前容器是否满足一个简单条件,不适合承担:
- 长期指标存储;
- 请求级错误统计;
- 慢查询分析;
- 业务告警聚合;
- 发布期间的完整审计。
生产环境应把健康状态用于流量和生命周期决策,把应用日志、指标和链路追踪用于原因分析。
10.4 Linux 容器边界
本文讨论的是 Linux 容器中的 Unix 信号、PID 1、SIGTERM 和 SIGKILL。Windows 容器的进程模型、停止机制和信号支持并不等价,不能直接套用这些结论。
十一、将四个参数放在同一条因果链上
一个稳定的容器停止设计,需要同时满足以下条件:
应用是 PID 1,或有可靠 init
↓
Stop Signal 能被 PID 1 接收并转发/处理
↓
应用先摘除流量,再排空请求和任务
↓
Probe 能表达“可接收流量”状态
↓
外部系统有时间观察 unhealthy 并停止送流量
↓
stop_grace_period 覆盖排空与清理时间
↓
超时后才使用 SIGKILL 作为兜底
可以把停止过程的可行条件写成:
其中:
- :容器停止宽限期;
- :健康检查发现状态变化的延迟;
- :外部系统读取状态的延迟;
- :流量系统完成摘除的延迟;
- :最长在途请求或任务的排空时间;
- :连接池、消费者、子进程等清理时间。
这个不等式不是 Docker 的规范公式,而是用于设计和校验的工程模型。若不满足它,应用即使正确处理了 SIGTERM,也可能在流量尚未完全停止或请求尚未排空时被 SIGKILL。
最终应分别验证三件事:
健康检查失败时:
容器是否仍按预期运行?
外部系统是否会摘流或替换?
执行 docker stop 时:
PID 1 是否收到正确的 Stop Signal?
应用是否停止接收新请求?
在途请求是否能在宽限期内完成?
超时发生时:
是否能接受 SIGKILL 带来的数据和连接中断风险?
是否有日志、指标和恢复流程定位原因?
当 PID 1、Probe、Stop Signal 和停止超时各自的责任边界清楚时,Docker 容器的“健康”“停止”和“重启”才不会被混为一个模糊的状态。
系列导航与关联阅读
- 系列入口:Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:Docker 日志与监控:Logging Driver、Metrics、事件和容量告警
- 下一篇:Docker 数据备份与主机迁移:卷、数据库、一致性和恢复演练
- 延伸:Docker 容器生命周期:创建、启动、信号、退出、重启与清理
- 延伸:Docker 生产交付体系:CI、灰度、回滚、容量和运行手册
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论