Docker-04 重启策略与健康检查:让服务在正确的时候自动恢复
本篇较长,按需跳转: 四种策略 · 按事件比较 · on-failure 判断 · PID 1 · docker restart 命令 · Compose 三种 restart · 策略与健康检查 · HEALTHCHECK 深入 · depends_on 启动顺序 · 信号与优雅退出 · OOM · 如何选择 · 实验 · 排障清单
1. 重启策略:容器退出后到底会发生什么
Docker restart policy 是 daemon 针对容器主进程退出所执行的生命周期策略。它解决的是单机上"进程退出后是否重新启动",不等于健康检查、故障转移、高可用或滚动发布。
创建容器时设置:
docker run -d --name api \
--restart unless-stopped \
example/api:1.0.0
也可以更新已有容器,不需要重新创建:
docker update --restart always api
docker inspect api --format '{{.HostConfig.RestartPolicy.Name}} {{.HostConfig.RestartPolicy.MaximumRetryCount}}'
1.1 四种 restart policy
| 策略 | 触发条件 | daemon 重启后 | 典型用途 |
|---|---|---|---|
no |
从不自动重启,默认值 | 不启动 | 一次性任务、由外部系统调度的容器 |
on-failure[:N] |
主进程以非 0 状态退出 | 不因为 daemon 重启而启动 | 有限次数重试的批处理、临时任务 |
always |
不论退出码都重启 | 会重新启动;之前手动停止的容器也会启动 | 必须随 daemon 恢复的常驻服务 |
unless-stopped |
不论退出码都重启 | 会启动,但之前被手动停止的容器保持停止 | 最常见的单机常驻服务 |
N 是最大重试次数:
docker run --restart on-failure:5 example/job:1.0.0
没有写 N 的 on-failure 会持续重试非 0 退出。它只关心退出状态,不知道业务操作是否安全;会产生重复写入的任务必须自己保证幂等。
2. 按事件比较策略
下面的"自动重启"指 restart policy 的正常行为;显式执行 docker start 永远可以启动未删除的容器。
| 事件 | no |
on-failure |
always |
unless-stopped |
|---|---|---|---|---|
主进程 exit 0 |
否 | 否 | 是 | 是 |
主进程 exit 1 |
否 | 是 | 是 | 是 |
| 进程因 OOM/SIGKILL 以非 0 退出 | 否 | 是 | 是 | 是 |
执行 docker stop |
保持停止 | 保持停止 | 当下保持停止 | 保持停止 |
| 手动停止后重启 dockerd | 保持停止 | 保持停止 | 重新启动 | 保持停止 |
| 宿主机重启,关机前容器在运行 | 保持停止 | 通常不因 daemon 启动恢复 | 启动 | 启动 |
healthcheck 变成 unhealthy |
否 | 否 | 否 | 否 |
| 容器被删除 | 不存在,无法重启 | 不存在 | 不存在 | 不存在 |
这里最容易答错的是三点:
always不是"执行docker stop后马上和你对抗"。显式停止会抑制当前 daemon 生命周期中的自动重启;daemon 再启动时,always会恢复它。unless-stopped会记住显式停止状态,所以 daemon 或宿主机重启后仍保持停止。unhealthy只是健康状态,不会触发 Docker Engine restart policy。需要自动替换不健康实例,应由编排平台、外部 watchdog 或应用故障退出策略负责。
不同 Engine 版本、live-restore 和 daemon 关闭路径会影响边缘时序。生产环境应该在目标版本上做故障演练,而不是只依赖表格推断。
3. on-failure 如何判断失败
restart manager 只观察容器主进程的退出码:
docker run --name job-ok --restart on-failure:3 \
alpine:3.20 sh -c 'sleep 11; exit 0'
docker run --name job-fail --restart on-failure:3 \
alpine:3.20 sh -c 'date; sleep 11; exit 1'
检查结果:
docker inspect job-fail --format \
'status={{.State.Status}} exit={{.State.ExitCode}} restarts={{.RestartCount}}'
docker logs --timestamps job-fail
Docker 会对连续崩溃使用逐步增加的重启等待,避免毫无间隔地占满 CPU 和日志;容器稳定运行一段时间后,退避状态会重置。具体等待上限属于 Engine 实现细节,不要让业务逻辑依赖精确毫秒数。
Docker 文档还把"容器已成功启动"定义为至少稳定运行约 10 秒,目的是避免一个从未正常启动的容器被无限快速拉起。实验中使用 sleep 11,可以避免不同版本边缘行为干扰观察。
4. restart policy 只关注 PID 1
如果容器内的 worker 子进程崩溃,但 PID 1 的 shell 或 supervisor 仍然存活,Docker 认为容器仍在运行,不会触发重启:
CMD myserver & tail -f /dev/null
更好的方式是让服务直接成为 PID 1:
ENTRYPOINT ["/usr/local/bin/myserver"]
确实需要多个子进程时,使用能正确转发信号、回收子进程并在关键进程失败时退出的 init/process supervisor,同时明确谁决定容器退出状态。
5. docker restart 命令不是 restart policy
名称相似,但两者完全不同:
docker restart -t 30 api
docker update --restart unless-stopped api
docker restart 对运行中的容器执行 stop/start:先发送容器配置的停止信号,等待 timeout,再在需要时 SIGKILL,然后重新启动。它不会改变 HostConfig.RestartPolicy。
查看停止信号和超时:
docker inspect api --format 'signal={{.Config.StopSignal}} timeout={{.Config.StopTimeout}}'
镜像可用 STOPSIGNAL 指定默认信号;容器创建时也可用 --stop-signal、--stop-timeout 覆盖。应用应在 timeout 内完成摘流、在途请求处理、连接关闭和日志刷新。
6. Compose 中的三种"restart"不要混淆
容器 restart policy
普通 docker compose up 使用服务级 restart:
services:
api:
image: example/api:1.0.0
restart: unless-stopped
worker:
image: example/worker:1.0.0
restart: "on-failure:5"
最终仍写进 Docker 容器的 HostConfig.RestartPolicy。验证:
docker compose up -d
docker compose ps -q api | xargs docker inspect \
--format '{{json .HostConfig.RestartPolicy}}'
depends_on.restart
services:
api:
depends_on:
db:
condition: service_healthy
restart: true
这里的 restart: true 不是容器退出策略。它表示 Compose 显式更新/重启依赖服务 db 时,也重启依赖它的 api,让 API 重新建连接;它不表示数据库崩溃一次,Compose 就永久驻留并替你重启 API。
deploy.restart_policy
services:
worker:
image: example/worker:1.0.0
deploy:
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
这是 Compose Deploy Specification/编排层策略,语义包括 condition、delay、max_attempts、window。它与普通容器级 restart 不是同一个字段;实际是否以及如何生效取决于部署目标和 Compose 实现。使用前必须执行 docker compose config,并在目标平台验证。
7. restart policy 与健康检查
下面的服务即使持续 unhealthy,PID 1 没退出就不会因 restart: always 自动重启:
services:
api:
image: example/api:1.0.0
restart: always
healthcheck:
test: ["CMD", "/app/healthcheck"]
interval: 10s
timeout: 3s
retries: 3
start_period: 30s
健康检查的主要消费者是人、监控和编排平台。常见设计有两种:
- 单机 Docker:应用检测到不可恢复状态后主动退出,由 restart policy 拉起;可恢复故障只报 unhealthy/指标并继续重试。
- 编排平台:应用保持运行并报告 readiness/liveness,由平台摘流、重启或替换实例。
不要让探针因为一个短暂的下游超时就杀掉整个服务,否则故障期间所有副本可能同步重启,形成重启风暴。
8. HEALTHCHECK 深入:Dockerfile vs Compose
8.1 Dockerfile HEALTHCHECK
Dockerfile 中用 HEALTHCHECK 指令为镜像内嵌健康检查:
HEALTHCHECK --interval=15s --timeout=3s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:8080/healthz || exit 1
参数含义:
| 参数 | 默认值 | 作用 |
|---|---|---|
--interval |
30s | 两次探测之间的间隔 |
--timeout |
30s | 单次探测超时,超时视为失败 |
--start-period |
0s | 容器启动后的宽限期,期间失败不计入 retries |
--start-interval |
5s | start_period 内的探测间隔(Engine 25+) |
--retries |
3 | 连续失败多少次后标记为 unhealthy |
探测命令的退出码决定结果:0 表示健康,1 表示不健康,其他值保留。
HEALTHCHECK NONE 可以在子镜像中取消父镜像的健康检查。一个 Dockerfile 只保留最后一条 HEALTHCHECK。
8.2 Compose healthcheck
Compose 中直接在服务级别声明,会覆盖镜像内嵌的 HEALTHCHECK:
services:
api:
image: example/api:1.0.0
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/healthz || exit 1"]
interval: 15s
timeout: 3s
retries: 3
start_period: 30s
test 的三种写法:
| 写法 | 示例 | 说明 |
|---|---|---|
CMD |
["CMD", "curl", "-fsS", "http://localhost:8080/healthz"] |
exec form,直接执行,不经过 shell |
CMD-SHELL |
["CMD-SHELL", "curl -fsS ... || exit 1"] |
经过 /bin/sh -c,支持管道和逻辑运算符 |
| 字符串 | "curl -fsS http://localhost:8080/healthz" |
等同于 CMD-SHELL |
如果需要禁用镜像中内嵌的 HEALTHCHECK:
services:
api:
healthcheck:
disable: true
8.3 健康状态转换
容器启动
↓
starting ──(start_period 内探测失败不计数)──→ starting
│ │
├─ 探测成功 ─→ healthy │
│ │ │
│ ├─ 连续 retries 次失败 ─→ unhealthy
│ │ │
│ └─ 下次成功 ─→ healthy │
│ │
└─ start_period 后连续 retries 次失败 ─→ unhealthy
查看当前健康状态:
docker inspect api --format '{{json .State.Health}}'
docker inspect api --format '{{.State.Health.Status}}'
docker ps --format 'table {{.Names}}\t{{.Status}}'
docker inspect 的 .State.Health.Log 保留最近若干次探测的 stdout/stderr 和退出码,排障时先看这里。
8.4 参数调优建议
start_period 要覆盖应用的冷启动时间。Java 应用可能需要 60-120 秒加载 class 和预热连接池;如果 start_period 只有 5 秒,容器还没就绪就被标记为 unhealthy。
interval 不要太短。每次探针都会 fork 进程并执行命令,如果 interval=1s 且探针涉及 HTTP 请求或数据库查询,可能产生不必要的负载。对大多数服务 10-30 秒够用。
timeout 要留给慢响应,不是网络抖动。如果应用正常响应需要 2 秒,timeout 设成 3 秒只留 1 秒余量,GC pause 或磁盘抖动就会误判。
retries 至少 2-3 次,避免单次偶发超时就翻转状态。
8.5 探针设计原则
探针应该只检查本进程是否可服务,不要级联检查所有下游:
# 好:只检查自己的 HTTP 端口
curl -fsS http://localhost:8080/healthz
# 不好:检查自己 + 数据库 + Redis + 外部 API
curl -fsS http://localhost:8080/deep-health
如果探针包含对 Redis、数据库、外部 API 的连通性检查,任何一个下游故障都会导致所有上游服务报 unhealthy。在编排平台中,这意味着一个 Redis 短暂不可达,会导致所有依赖它的服务同时被摘流或重启。
把下游检查留给专门的监控系统或 readiness probe;liveness/healthcheck 只验证进程自身能接受请求。
8.6 实际示例
一个 Go 服务在 /healthz 返回 200 表示可以处理请求,在 /readyz 额外检查数据库连接(用于监控,不用于 Docker 健康检查):
Dockerfile:
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .
FROM alpine:3.20
RUN apk add --no-cache curl
COPY --from=build /app/server /app/server
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
CMD curl -fsS http://localhost:8080/healthz || exit 1
ENTRYPOINT ["/app/server"]
Compose:
services:
api:
build: .
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/healthz || exit 1"]
interval: 15s
timeout: 3s
retries: 3
start_period: 20s
如果镜像没有 curl,可以使用 wget -qO- ... 或在应用中内置一个 /healthz 的自检客户端避免额外安装工具。
9. depends_on 与 service_healthy:完整启动顺序控制
9.1 三种 condition
Compose 的 depends_on 支持三种启动条件:
| condition | 等待什么 | 适合场景 |
|---|---|---|
service_started |
容器启动(默认值) | 不关心是否真正就绪 |
service_healthy |
健康检查变为 healthy | 需要等数据库/中间件真正可用 |
service_completed_successfully |
容器以 exit 0 结束 | 一次性初始化任务(迁移、seed) |
9.2 典型用法:API 等数据库就绪后再启动
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: dev-only
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 30s
migrate:
image: example/api:1.0.0
command: ["./migrate", "up"]
depends_on:
db:
condition: service_healthy
api:
image: example/api:1.0.0
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
restart: unless-stopped
启动顺序:db 先启动 → db healthy 后 migrate 执行 → migrate exit 0 后 api 启动。
9.3 depends_on.restart: true 的含义
services:
api:
depends_on:
db:
condition: service_healthy
restart: true
restart: true 表示当你执行 docker compose restart db 或 docker compose up -d db(导致 db 重建)时,Compose 也会重启 api。它不是自动故障恢复机制:如果 db 的进程崩溃后被 restart policy 拉起,Compose 不会检测到并级联重启 api。
9.4 depends_on 的边界
depends_on 只控制 docker compose up 和 docker compose stop 时的启动/停止顺序,不提供运行时保证:
- 数据库 healthy 不等于连接永远畅通。网络分区、连接池耗尽、权限变更都可能在运行中断开连接。
- 生产应用必须在代码中实现连接重试、超时、断路器和降级逻辑。
- depends_on 不支持跨 Compose 项目的依赖。
验证启动顺序:
docker compose up -d
docker compose ps
docker compose logs --since 1m
10. 信号传递与优雅退出
10.1 docker stop 的流程
docker stop api
↓
发送 STOPSIGNAL(默认 SIGTERM)给容器 PID 1
↓
等待 stop_timeout(默认 10 秒)
↓
进程仍未退出 → 发送 SIGKILL
如果应用在 10 秒内无法完成清理(大量在途请求、慢 SQL、大文件刷盘),要么优化退出逻辑,要么调整超时。
10.2 Dockerfile STOPSIGNAL
某些进程不使用 SIGTERM 作为优雅退出信号。例如 Nginx 使用 SIGQUIT 做 graceful shutdown:
STOPSIGNAL SIGQUIT
创建容器时也可以覆盖:
docker run --stop-signal SIGQUIT nginx:alpine
10.3 Compose stop_grace_period
services:
api:
image: example/api:1.0.0
stop_grace_period: 30s
这会把 docker stop 等待时间从默认 10 秒改为 30 秒。设置过长会拖慢部署和滚动更新;设置过短会导致 SIGKILL 强杀,丢失在途请求。
10.4 exec form vs shell form:信号能不能到达应用
这是容器信号问题最常见的根因。
exec form — 应用直接成为 PID 1,接收信号:
CMD ["./server"]
ENTRYPOINT ["./server"]
进程树:
PID 1: ./server ← 直接收到 SIGTERM
shell form — 被 /bin/sh -c 包裹,sh 不转发信号:
CMD ./server
ENTRYPOINT ./server
进程树:
PID 1: /bin/sh -c "./server" ← 收到 SIGTERM,但 sh 默认不转发
└── PID 2: ./server ← 收不到 SIGTERM
结果:docker stop 发送 SIGTERM 给 PID 1(sh),sh 不处理也不转发,等待 10 秒后 SIGKILL 强杀整个 cgroup。应用没有机会做优雅退出。
修复方式:
- 使用 exec form(推荐)。
- 如果必须用 shell form,使用
exec替换 shell 进程:CMD exec ./server。 - 使用 init 进程。
10.5 tini / dumb-init:信号转发 + 僵尸回收
容器内的 PID 1 还有一个特殊职责:回收孤儿进程(zombie reaping)。普通应用通常不处理 wait() 系统调用来回收子进程,导致僵尸积累。
tini 和 dumb-init 是轻量 init 进程,解决两个问题:
- 转发信号给子进程。
- 回收孤儿僵尸进程。
使用 --init 注入 Docker 内置的 tini:
docker run --init example/api:1.0.0
或在 Dockerfile 中显式安装:
RUN apk add --no-cache tini
ENTRYPOINT ["tini", "--"]
CMD ["./server"]
Compose:
services:
api:
image: example/api:1.0.0
init: true
进程树变为:
PID 1: tini -- ./server
└── PID 2: ./server ← tini 转发 SIGTERM 到这里
不是所有应用都需要 tini。如果应用直接用 exec form 运行、自己处理信号、不产生子进程,可以不加。但如果不确定,加 --init 几乎没有副作用。
10.6 优雅退出清单
一个生产服务收到终止信号后应该按顺序执行:
1. 从负载均衡器/服务注册中摘除自己
2. 停止接受新请求/新任务
3. 等待在途请求完成(设置截止时间)
4. 关闭数据库连接池、消息队列消费者
5. 刷新日志缓冲区和指标
6. exit 0
整个过程必须在 stop_grace_period 内完成,否则会被 SIGKILL。
10.7 验证实验
测试信号传递和退出时间:
docker run --name signal-test -d \
alpine sh -c 'trap "echo caught SIGTERM; exit 0" TERM; while true; do sleep 1; done'
time docker stop signal-test
docker logs signal-test
预期:容器在 1-2 秒内退出(trap 捕获 SIGTERM 后立即 exit),日志中出现 caught SIGTERM。
对比没有 trap 的情况:
docker run --name signal-no-trap -d \
alpine sh -c 'while true; do sleep 1; done'
time docker stop signal-no-trap
预期:等满 10 秒后被 SIGKILL,因为 sh 不处理 SIGTERM。
对比使用 --init 的情况:
docker run --name signal-init -d --init \
alpine sh -c 'while true; do sleep 1; done'
time docker stop signal-init
预期:tini 收到 SIGTERM 后转发给子进程并退出,不需要等满 10 秒。
清理:
docker rm signal-test signal-no-trap signal-init
11. restart policy 与 OOM
容器超过 cgroup 内存上限时,内核可能选择进程并发送 SIGKILL。主进程退出通常显示 137,符合 on-failure/always/unless-stopped 的重启条件:
docker inspect api --format \
'oom={{.State.OOMKilled}} exit={{.State.ExitCode}} restart={{.RestartCount}}'
docker stats --no-stream api
docker exec api cat /sys/fs/cgroup/memory.events
自动重启只能恢复进程,不能修复内存泄漏或过低 limit。反复 OOM 会变成 crash loop,并可能打爆日志、数据库连接和下游服务。正确做法是限制并发、分析堆和堆外内存、为运行时留余量并设置告警。
12. 如何选择策略
| 工作负载 | 建议起点 | 原因 |
|---|---|---|
| 本地长期运行的 API、代理 | unless-stopped |
异常退出恢复,同时尊重人工停机状态 |
| 必须随 daemon 恢复的基础代理 | always |
即使之前显式停止,daemon 重启后也恢复 |
| 允许有限重试的批处理 | on-failure:N |
正常完成不重跑,失败有上限 |
| 数据迁移、一次性管理命令 | no 或外部 job controller |
避免非幂等操作被自动重复执行 |
| Kubernetes/其他编排管理的容器 | 由编排平台声明 | 避免两层控制器产生冲突和错误假设 |
生产常驻服务通常从 unless-stopped 开始,但必须同时具备健康检查、优雅退出、资源限制、日志轮转和外部监控。
13. 一套可复现的实验
创建 compose.restart.yaml:
name: restart-lab
services:
no-restart:
image: alpine:3.20
command: ["sh", "-c", "echo no-restart; sleep 11; exit 1"]
restart: "no"
retry-three:
image: alpine:3.20
command: ["sh", "-c", "date; sleep 11; exit 1"]
restart: "on-failure:3"
always:
image: alpine:3.20
command: ["sh", "-c", "date; sleep 11; exit 0"]
restart: always
unless-stopped:
image: alpine:3.20
command: ["sh", "-c", "date; sleep 11; exit 0"]
restart: unless-stopped
观察状态和计数:
docker compose -f compose.restart.yaml up -d
watch 'docker compose -f compose.restart.yaml ps -a'
for id in $(docker compose -f compose.restart.yaml ps -aq); do
docker inspect "$id" --format \
'{{.Name}} policy={{.HostConfig.RestartPolicy.Name}} exit={{.State.ExitCode}} restarts={{.RestartCount}}'
done
然后分别执行 docker stop、重启 Docker daemon、重启宿主机,记录四种服务的状态。实验结束:
docker compose -f compose.restart.yaml down
不要在远程生产 Docker context 中做这个实验;执行前用 docker context show 确认目标。
14. restart policy 排障清单
没有重启
├── RestartPolicy 是否真写入容器? docker inspect
├── 是不是 exit 0 + on-failure? State.ExitCode
├── 是否被显式 docker stop? events + 运维记录
├── 容器是否已经被删除? docker ps -a
└── 是否由另一个编排器接管? Compose/Swarm/K8s 配置
持续重启
├── 主进程为何退出? logs + State.Error/ExitCode
├── 是否 OOM? OOMKilled + memory.events
├── entrypoint/配置/权限是否错误? inspect + 镜像验证
├── 下游是否不可达? DNS/TCP/TLS/认证
└── 重试是否造成副作用? 数据幂等、日志、连接风暴
保留现场的命令:
docker inspect api > api.inspect.json
docker logs --timestamps api > api.log 2>&1
docker events --since 30m --until 0s > docker.events.log
15. 本篇小结
restart policy 解决的是单机"进程退出后是否自动拉起",健康检查解决的是"怎么知道服务能不能用",信号与优雅退出解决的是"怎么让服务安全地停下来"。三者配合才能让服务在正确的时候恢复、在需要的时候停止、在异常时暴露状态。生产服务还需要编排平台的摘流/滚动更新、外部监控告警和应用内的重试/断路器,restart policy 只是底线,不是全部。
| 上一篇 | 下一篇 |
|---|---|
| 03-镜像容器与常用命令 | 05-资源管理与 docker update |