Docker 容器反复重启或在数次重试后停在 Exited,不等于镜像损坏,也不等于健康检查主动杀死了进程。排障时最有价值的线索通常是第一次明确报错、PID 1 的退出码、实际生效的重启策略,以及修复前后是否只改变了一个变量。本文给出一套可复核的诊断方法,并用完全隔离的对照实验验证:缺少必需配置文件会让入口脚本以 78 退出;Docker 的 on-failure:3 因非零退出码执行三次重试;只补齐同一个只读配置文件后,同一镜像、同一入口脚本和同一健康检查即可稳定运行。
适用范围、症状与先给结论
本文适用于 Docker Engine 或 Docker Compose 中的这类现象:容器刚启动就消失,docker ps -a 显示已退出,日志里同一错误出现多次,管理面板短暂显示 restarting,或服务最终同时呈现 exited 与 unhealthy。本文不适用于 Kubernetes 的 Pod 重启策略,也不能代替对宿主机、密钥平台或远程配置中心的专项检查。
先给结论:应当先确认“哪个进程以什么退出码结束”,再解释重启。Docker 官方说明,on-failure[:max-retries] 针对的是容器以非零状态码退出;健康检查则独立记录应用健康状态。健康检查失败本身不会因为 on-failure 而自动重启一个仍在运行的容器。若 PID 1 因缺配置主动退出 78,正确修复是恢复经过验证的配置供给,而不是扩大重试次数、删除探针或盲目更换镜像。
在本次隔离实验中,失败实例的最终摘要是 status=exited、running=false、exit_code=78、restart_count=3、oom_killed=false、health=unhealthy。恢复实例只增加了 /config/app.conf 的只读挂载,随后变为 running=true、restart_count=0、health=healthy,稳定窗口前后的 PID 相同,两次 HTTP 请求均返回 ok:configured。这是实验环境的证据,不表示本文对任何真实生产容器做过修改。
建立假设:先按证据成本排序
看到重启循环时,不要把“容器重启”当作根因。它只是 Docker 对进程退出的后续动作。可以把候选原因按验证成本和破坏性排序:
- 配置或启动参数缺失:日志通常有稳定、可重复的 fatal 信息,退出码可能是应用约定的配置错误码。本实验使用
EX_CONFIG对应的 78。 - 镜像或入口链变化:核对声明的镜像摘要、实际镜像 ID、Entrypoint 与 Args,避免把标签漂移或部署模板变化误判成运行时故障。
- OOM 或外部强制终止:检查
OOMKilled、退出码、内存限制、内核日志和平台事件。只凭“进程突然没了”不能推出 OOM。 - 健康检查不正确:读取探针命令与历史输出,但要先确认 PID 1 是否已经自行退出。探针会影响健康标记,不等于它触发了
on-failure。 - 应用启动后才发生的依赖故障:数据库、DNS、证书或权限问题也可能导致非零退出,需要由第一条错误和时间线继续缩小范围。
这个顺序的关键是保留原容器。立即执行 docker compose down、强制重建或清理所有已退出容器,会一起删除 inspect 状态、重启次数和原始日志。应先用容器 ID 固化证据,再进行可逆的最小更改。
隔离复现:保持相同镜像、入口与策略
对照实验使用 Docker 24.0.7、Compose 2.40.0,并将镜像固定为 nginx:1.28-alpine@sha256:a8b39bd9cf0f83869a2162827a0caf6137ddf759d50a171451b335cecc87d236。失败组与恢复组采用相同镜像摘要、相同 /docker-entrypoint.sh、相同参数 /lab/app.sh、相同 on-failure:3、相同健康检查和内部网络。根文件系统为只读,不发布宿主机端口,运行时文件只写入 tmpfs。
唯一对照变量是配置供给:失败组不挂载 /config/app.conf;恢复组把该文件只读挂载到相同路径。入口脚本先检查配置是否存在、内容是否符合实验约束,然后才生成 Nginx 运行配置并通过 exec nginx ... daemon off; 让 Nginx 成为 PID 1。这样的设计避免了“修复时顺便换镜像、改命令、删探针”的混杂因素。
# 先查看 Compose 展开后的真实配置与镜像引用
docker compose config > rendered-compose.yaml
docker compose config --images | sort -u
# 只启动缺配置的隔离服务,保留退出后的容器
docker compose up -d --no-deps missing-config-app
docker compose ps -a
实验并未连接生产网络,也未读取或修改生产配置。它验证的是一种可迁移的诊断方法:用固定制品和单变量对照判断配置供给是否足以解释故障,而不是把实验结果直接套用到未检查的真实系统。
原始证据表:失败、恢复与清理
以下字段来自实验保存的 Compose 展开结果、完整日志和 docker inspect,不是根据页面现象推测。失败和恢复实例的实际镜像 ID 都是 sha256:dc73b49f5124cf2ee538dfbdfbd121f0b4ccdcb20fea30f3a81bd477c02e2bb5。
| 检查项 | 缺配置实例 | 只读补齐配置后 | 可以支持的判断 |
|---|---|---|---|
| 声明镜像 | 同一固定 Nginx digest | 同一固定 Nginx digest | 修复没有更换镜像制品 |
| 入口与参数 | /docker-entrypoint.sh + /lab/app.sh |
完全相同 | 启动链没有改变 |
| 配置文件 | 没有 /config/app.conf |
只读挂载同一路径 | 这是对照实验的唯一业务变量 |
| 首条错误 | fatal: required application config is missing: /config/app.conf |
没有该 fatal | 应用在进入 Nginx 运行阶段前主动拒绝启动 |
| 最终状态 | exited,退出码 78 |
running,退出码字段为 0 |
配置校验失败足以解释进程退出 |
| 重启次数 | 3 | 0 | on-failure:3 对非零退出执行了有界重试 |
| 健康状态 | unhealthy |
healthy |
健康状态随应用可用性变化,但不是本次重启触发器 |
| OOM 标记 | false |
false |
本实验没有 Docker 容器级 OOM 标记 |
| PID 与响应 | PID 为 0,无法服务 | PID 1 为 Nginx;Docker PID 3755591 在稳定窗口内不变;两次响应均为 ok:configured |
恢复不是短暂启动后再次重启 |
| 实验清理 | 最终 containers=0、networks=0、volumes=0、专用网络 absent | 隔离资源已移除 | |
失败日志中同一 fatal 共出现四次:首次启动一次,加上三次重试。RestartCount=3 统计的是重启尝试次数,不应把四条错误误写成“四次重启”。同理,失败实例最终显示 health=unhealthy,只能说明保存状态中的健康标记,不能推导为“健康检查失败导致了三次重启”。
诊断命令:保存状态、策略、日志与配置供给
真实排障时,先定位确切容器 ID,再一次性保存状态。名称可能在重建时复用,容器 ID 更适合把日志和 inspect 绑定到同一个对象。下面的命令只读,不会修改容器:
container_id=$(docker compose ps -aq app)
docker inspect "$container_id" > container-inspect.json
docker logs --timestamps "$container_id" > container.log 2>&1
docker inspect --format '
status={{.State.Status}}
running={{.State.Running}}
exit_code={{.State.ExitCode}}
restart_count={{.RestartCount}}
oom_killed={{.State.OOMKilled}}
health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}
image={{.Config.Image}}
image_id={{.Image}}
entrypoint={{json .Config.Entrypoint}}
args={{json .Args}}
restart={{json .HostConfig.RestartPolicy}}' "$container_id"
接着核对配置“是否存在于当前容器”,不要只看仓库或主机上的同名文件。对于已退出容器,不能依赖 docker exec;应从 inspect 的 Mounts、部署平台生成配置和镜像启动逻辑交叉确认。如果容器仍在运行,可进行只读检查,避免把密钥内容打印到工单或聊天记录:
# 只检查路径和权限,不输出配置正文
docker inspect --format '{{json .Mounts}}' "$container_id"
docker exec app sh -c '
test -f /config/app.conf
rc=$?
ls -ld /config /config/app.conf 2>/dev/null || true
exit "$rc"
'
# 核对 Compose 最终展开值,避免 override 或环境变量覆盖
docker compose config --images
docker compose config | sed -n '/app:/,/^[^ ]/p'
如果日志很多,优先保存首条 fatal 及其前后文,同时保留完整日志。最后一条报错可能只是退出清理或连接关闭的连锁反应,第一条确定性错误更接近因果起点。还要记录采集时间和部署版本,避免将不同一次发布产生的证据拼接在一起。
根因判定:为何不是 OOM、镜像或 healthcheck
根因是必需配置文件没有被供给。入口脚本在检查到 /config/app.conf 不存在时,明确输出 fatal: required application config is missing: /config/app.conf,随后以 78 退出。失败最终状态保留了同一个 78,且错误在初次启动和三次有界重试中完全一致。只读补齐文件后,脚本通过校验并 exec Nginx,服务转为稳定健康。这个“明确错误 + 同退出码 + 单变量修复 + 稳定复核”的闭环,比单看面板上的 restarting 更能支持根因结论。
不是本实验中的 OOM。Docker inspect 明确记录 OOMKilled=false,退出码为 78,而不是常见的 SIGKILL 表现 137;日志也显示应用主动进行配置校验后退出。严格来说,OOMKilled=false 不能在所有事故中独自排除宿主机级内存问题或外部 kill,因此真实事故仍应结合内核日志和平台事件。但在这个受控实验里,明确的配置 fatal 和可重复的单变量恢复已经给出更直接解释。
不是镜像差异。两组声明引用完全相同的 digest,inspect 记录的实际镜像 ID 也完全相同,入口脚本、Entrypoint、Args 和重启策略保持一致。标签漂移、错误架构或镜像更新因此不是两组结果不同的变量。真实系统中若只用了可变标签,应先记录当前镜像 ID,再考虑重新拉取。
不是健康检查触发重启。失败容器还没进入 Nginx 稳定运行阶段就以 78 退出;on-failure 根据这个非零退出码执行重启。健康检查只是独立观察并留下 unhealthy 状态。一个 PID 1 仍运行但探针失败的容器,可以持续处于 running/unhealthy,并不会仅因 on-failure 自动重启。删除探针或增加 healthcheck 重试无法补回缺失配置。
最小修复:恢复经过验证的配置供给
修复前先回答四个问题:配置应该由镜像、bind mount、Compose secret、平台 secret 还是远程配置中心提供;目标路径是否与 APP_CONFIG_PATH 一致;运行用户是否可读;配置格式和必填字段是否适配当前镜像版本。不要从另一台机器随意复制可能过期的配置,更不要把密钥内容写进镜像层、Git 提交或公开日志。
本实验的最小修复是把经过校验的 app.conf 只读挂载到 /config/app.conf,没有修改镜像、入口脚本、重启策略或探针。对应的 Compose 形式如下:
services:
app:
image: nginx:1.28-alpine@sha256:a8b39bd9cf0f83869a2162827a0caf6137ddf759d50a171451b335cecc87d236
command: ["/lab/app.sh"]
restart: "on-failure:3"
environment:
APP_CONFIG_PATH: /config/app.conf
volumes:
- ./config/app.conf:/config/app.conf:ro
在真实变更中,应先在预发布或隔离命名空间验证配置语法和非敏感字段,再通过现有发布系统注入。若配置来自 secret 管理器,还要检查 secret 名称、版本、挂载键名和轮换时间。把 on-failure:3 改成无限重启只会反复制造相同日志、延迟告警并增加依赖压力;它不能把错误配置变成正确配置。
验证、观察窗口与回滚步骤
“容器变成 Up”不足以结束修复。应记录启动后和观察窗口后的两组状态,确认 PID、重启计数和健康结果稳定,并从与业务调用方相同的网络路径至少请求两次。实验在初始健康后等待四秒,再次读取状态;Docker 记录的 PID 均为 3755591,restart_count=0,两次 /health 响应都严格等于 ok:configured,容器内 /proc/1/cmdline 显示 PID 1 是 nginx: master process nginx -c /run/app/nginx.conf -g daemon off;。
docker compose up -d --wait app
docker inspect --format \
'status={{.State.Status}} running={{.State.Running}} restart={{.RestartCount}} pid={{.State.Pid}} health={{.State.Health.Status}}' \
"$(docker compose ps -q app)"
docker compose exec -T app wget -qO- http://127.0.0.1:8080/health
sleep 30
docker compose exec -T app wget -qO- http://127.0.0.1:8080/health
docker inspect --format 'restart={{.RestartCount}} pid={{.State.Pid}}' \
"$(docker compose ps -q app)"
生产级观察窗口应根据冷启动、健康检查周期和业务峰值设定,不能机械照搬实验的四秒。还要核对错误率、延迟、依赖连接、日志中的配置警告和部署平台事件。若配置包含证书或凭据,应验证有效期与最小权限,但不要在验证输出中泄露值。
回滚前预先保存上一版配置的安全引用、校验值和挂载方式。如果补配置后出现新的解析错误、权限异常或业务回归,应停止继续扩大变更,恢复上一版已知可用配置或上一份部署声明,然后重新创建受影响实例并重复同一套状态、PID、重启计数、健康与 HTTP 验证。若上一版同样缺配置,就不能以“回滚”为名恢复已知故障;此时应暂停发布、保持流量在健康实例,并由配置负责人确认正确版本。
证据边界、复核清单与官方资料
这次实验能够证明:在给定 Docker 24.0.7、Compose 2.40.0、固定 Nginx 镜像摘要和入口脚本下,缺少指定配置会产生精确 fatal、退出 78,并由 on-failure:3 记录三次重启;只增加配置挂载后,同一制品可以稳定健康运行。它不能证明所有退出 78 都是缺文件,也不能证明任何线上重启都具有相同根因。应用可以自定义退出码,平台也可能在 Docker 之外追加重启控制。
提交结论前可逐项复核:
- 是否保存了故障容器 ID、首条错误、完整日志和 inspect,而不是只截取面板截图;
- 是否区分初次启动和重启次数,避免把四条日志写成四次重启;
- 是否核对声明镜像 digest 与实际镜像 ID,以及 Entrypoint、Args 和最终 Compose 配置;
- 是否将
on-failure关联到非零退出码,而不是错误关联到unhealthy; - 是否只改变配置供给,并在恢复后检查 PID 稳定、零重启和至少两次端点响应;
- 是否准备了不泄露密钥的回滚材料,并按环境要求保留审计记录;
- 隔离实验是否清理容器、网络和卷。本实验最终记录为 containers=0、networks=0、volumes=0、network=absent。
Docker 官方的 Restart policies 文档说明 on-failure[:max-retries] 在容器以非零状态码退出时重启,并可限制重试次数;docker container inspect 文档说明如何取得容器的底层状态;docker container run 的重启策略章节给出通过 docker inspect 读取 RestartCount 的方法。官方语义与本实验保存的非零退出码、三次重启和恢复后的零重启相互吻合。