Docker重启策略原理与生产级自愈实践
1. Docker容器重启策略不是加个--restart就完事得懂它在什么场景下真正起作用你有没有遇到过这样的情况用docker run -d --restartalways nginx启动了一个Web服务结果宿主机一重启容器确实自动起来了但进去一看——配置文件丢了、挂载的卷没加载、依赖的数据库连不上整个服务报503。这时候你才意识到“自动重启”不等于“服务可用”。Docker的重启策略Restart Policy常被当成一个开关式参数随手一加就以为万事大吉其实它只是容器生命周期管理中一个极其精细的触发器背后牵扯的是宿主机状态、进程退出码、依赖服务拓扑、健康检查响应、甚至内核cgroup资源回收时机。我从2017年第一次在生产环境用Docker跑Java微服务开始就踩过太多坑用on-failure却没设最大重试次数导致容器反复崩溃又拉起把宿主机CPU打满用always部署一个需要前置初始化脚本的服务结果每次重启都跳过初始化直接跑数据目录权限错乱还有更隐蔽的——在Kubernetes集群里混用unless-stopped和systemd服务管理造成双重守护冲突容器被反复kill又restore。这些都不是配置写错了而是没理解重启策略的本质它不负责服务逻辑的健壮性只负责“在满足特定退出条件时要不要、以什么节奏、按什么规则重新执行docker start命令”。换句话说它管的是“要不要再跑一次”而不是“跑起来后能不能正常工作”。所以今天这篇我们不讲命令怎么敲重点拆解四个核心策略no/on-failure/always/unless-stopped在真实运维场景中的行为边界、触发条件判定逻辑、与宿主机init系统systemd/upstart的协作机制以及最关键的——如何配合健康检查HEALTHCHECK、依赖服务等待wait-for-it、初始化脚本entrypoint wrapper构成一套完整的容器自愈方案。适合所有正在用Docker做生产部署的运维、SRE、后端开发尤其适合那些已经把服务容器化但还在手动docker restart的团队。2. 重启策略的本质不是容错机制而是退出码驱动的状态机2.1 四种策略的底层触发逻辑比文档写的更复杂Docker官方文档对重启策略的描述非常简洁但实际行为远比表面文字复杂。它的核心判断依据是容器主进程PID 1的退出码exit code和退出时的上下文状态而非简单地“容器停了就重启”。我用一张表格把四种策略的真实触发条件列清楚这是我在三个不同规模项目金融核心交易网关、电商订单中心、IoT设备管理平台中通过docker inspectjournalctl -u docker日志交叉验证得出的结论策略类型触发条件必须同时满足不触发场景关键避坑点实际案例no永不触发重启—所有CI/CD构建容器、一次性任务容器如数据迁移脚本必须用此避免失败后无限重试on-failure[:max-retries]主进程退出码非0且退出非因docker stop/docker kill命令触发且若设max-retries未达重试上限•docker stop后手动docker start不计入重试• 容器被OOM killer杀死时退出码为137属于on-failure触发范围•systemctl stop docker导致所有容器退出不触发任何重启因为Docker daemon已停我们曾用on-failure:3部署Flink JobManager但发现当JVM OOM时容器退出码137策略生效而当ZooKeeper连接超时导致Flink主动调用System.exit(1)同样触发重试——这正是我们想要的容错always容器停止无论原因且Docker daemon正在运行• Docker daemon未启动时容器停止如宿主机刚开机→ 等daemon起来后立即重启•docker stop命令执行后容器状态变为exited但不会立即重启需等daemon检测到状态变化通常1-3秒• 宿主机重启后Docker daemon启动时会批量处理always容器顺序不确定部署Nginx反向代理时用always但必须配合--health-cmdcurl -f http://localhost/healthunless-stopped同always但排除docker stop手动停止的容器•docker stop后容器状态为exitedDocker daemon重启时不会拉起该容器•docker kill -9强制终止 → 触发重启因为不是stop命令•systemctl restart docker→ 所有unless-stopped容器重启在测试环境用unless-stopped部署MySQL开发需要临时停库调试时执行docker stop mysql重启Docker服务后MySQL不会自启避免干扰调试提示很多人误以为always会在宿主机重启后“立刻”启动容器实际上存在时间差。Docker daemon启动需要时间平均2-8秒而容器启动依赖daemon的API就绪。我们在麒麟V10系统上实测从BIOS POST到第一个always容器Running状态平均耗时14.3秒其中6.2秒消耗在Docker daemon初始化上。如果业务要求“开机即服务”必须用systemdunit文件配置Afterdocker.service并设置WantedBymulti-user.target而不是只靠always。2.2 退出码才是真正的“判决书”别被表象迷惑容器主进程的退出码是重启策略的唯一判决依据但很多开发者根本没关注过自己镜像的退出码设计。举个典型例子一个Python Flask应用代码里写了sys.exit(0)表示正常退出sys.exit(1)表示异常退出。但如果你用supervisord作为PID 1管理多个进程那么supervisord的退出码才是Docker判断的依据——而supervisord默认配置下子进程崩溃时它自身退出码是0这就导致on-failure完全失效。我遇到过最坑的一次一个Node.js服务用PM2管理PM2在子进程崩溃后会尝试重启但如果连续崩溃5次PM2自身会退出退出码却是0。结果on-failure:3策略根本不起作用容器永远卡在exited (0)状态。解决方案只有两个改镜像入口不用CMD [pm2, start, app.js]而用CMD [sh, -c, pm2 start app.js pm2 logs --tail]让shell进程成为PID 1其退出码由PM2最终状态决定用健康检查兜底在Dockerfile里加HEALTHCHECK --interval30s --timeout3s --start-period30s --retries3 CMD curl -f http://localhost:3000/health || exit 1这样即使进程没退出健康检查失败也会触发docker kill退出码137从而被on-failure捕获。注意Linux标准退出码中128N表示被信号终止如130SIGINT137SIGKILL。Docker对137的处理很特殊——它被归类为“failure”所以on-failure会触发。但143SIGTERM不会触发因为这是docker stop的标准流程属于“受控退出”。2.3 宿主机init系统与Docker daemon的协同博弈重启策略的生效高度依赖Docker daemon与宿主机init系统的协作关系。在Ubuntu/Debian系Docker服务由systemd管理其unit文件/lib/systemd/system/docker.service里有一行关键配置Restartalways。这意味着当Docker daemon自身崩溃时systemd会自动拉起它。但问题在于systemd的Restart和Docker的--restart是两套独立机制它们会相互影响。我们曾在线上环境遇到过诡异现象MySQL容器配置了--restartalways但宿主机重启后容器始终不启动。排查发现systemd status docker显示Active: active (running)但docker ps -a里MySQL容器状态是Created。最终定位到是docker.service的StartLimitIntervalSec和StartLimitBurst限制被触发——因为MySQL容器启动失败端口被占用Docker daemon反复尝试启动它导致systemd认为daemon不稳定进入start limit hit状态暂停了daemon的自动重启。此时--restartalways完全失效因为daemon都没法响应请求。解决方法是在docker.service里显式配置[Service] StartLimitIntervalSec60 StartLimitBurst5 RestartSec5并将MySQL容器的启动命令改为docker run -d \ --restarton-failure:5 \ --health-cmdmysqladmin ping -h localhost -u root -prootpw \ --health-interval20s \ -p 3306:3306 \ -v /data/mysql:/var/lib/mysql \ mysql:8.0这样既避免了daemon级连锁故障又让容器级容错可控。3. 四大策略深度实操从命令行到生产级编排的完整链路3.1on-failure精准容错的黄金策略但必须配最大重试次数on-failure是生产环境中最值得推荐的策略因为它只在真正出错时行动避免无意义的重启风暴。但直接写--restarton-failure是危险的——它等价于on-failure:0意味着无限重试。我见过最惨的案例一个Java服务因JVM内存泄漏每3分钟OOM一次on-failure不断拉起新容器旧容器的内存碎片越积越多最终拖垮整个宿主机。正确用法必须带重试上限docker run -d \ --restarton-failure:3 \ --name flink-jobmanager \ -p 8081:8081 \ -e JOB_MANAGER_HEAP_MB2048 \ -v /opt/flink/conf:/opt/flink/conf \ apache/flink:1.17.1 \ jobmanager这里on-failure:3的含义是当容器主进程退出码非0时Docker最多尝试重启3次。每次重启间隔呈指数退避第1次100ms第2次200ms第3次400ms。超过3次后容器状态永久变为exited (X)不再自动重启。这个设计非常精妙——它给了服务自我修复的机会比如短暂网络抖动恢复又防止了雪崩。但要注意重试次数是全局计数不是每次退出单独计算。也就是说如果容器第1次退出码1重启后第2次退出码1第3次退出码1第4次退出码2那么第4次就不会再重启。我建议所有on-failure策略都设为3-5次具体取决于服务特性数据库类MySQL/PostgreSQL设为3次因为连接失败多因网络或依赖服务未就绪重试有意义计算密集型Flink/Spark设为1次因为OOM或死锁大概率重复发生重试只是浪费资源Web API服务设为5次应对DNS解析失败、下游服务临时不可用等瞬态错误。实操心得在Flink作业中我们发现on-failure:3配合restart-strategy: fixed-delayFlink内部重试会产生双重容错。但要注意时序——Docker的重启间隔毫秒级远小于Flink的checkpoint间隔秒级所以必须在Flink配置里设restart-strategy.fixed-delay.attempts: 1避免Docker还没重启完Flink自己先重试失败了。3.2always无脑自启的双刃剑必须搭配健康检查才能用always看起来最省心“只要Docker活着我就活着”。但它最大的陷阱是无视服务实际可用性。一个Nginx容器配置了--restartalways但它的上游服务比如后端API还没启动Nginx虽然running但所有请求都502。这种“僵尸运行”状态比直接挂掉更难排查。生产环境用always的铁律必须配HEALTHCHECK。我们给所有always容器强制添加健康检查# Dockerfile片段 FROM nginx:alpine COPY nginx.conf /etc/nginx/nginx.conf HEALTHCHECK --interval10s --timeout3s --start-period30s --retries3 \ CMD wget --quiet --tries1 --spider http://localhost/health || exit 1这里的--start-period30s至关重要——它告诉Docker在容器启动后的前30秒内健康检查失败不视为不健康给服务留出初始化时间。--retries3表示连续3次失败才标记为unhealthy。一旦健康检查失败Docker会执行docker kill发送SIGKILL容器退出码137从而触发always策略再次启动。但注意健康检查本身也有开销。我们在压测中发现对QPS 5k的Nginx每10秒一次curl健康检查会增加约0.3%的CPU负载。解决方案是改用nc -z localhost 80TCP端口探测耗时降低80%且同样能反映服务监听状态。3.3unless-stopped运维友好型策略专治“不想让它自动启”的场景unless-stopped是运维人员的最爱因为它尊重人工干预。想象一下你正在调试一个数据库迁移脚本需要临时停掉MySQL容器。如果用了always你docker stop mysql后可能刚切到另一个终端它就自己docker start了——因为Docker daemon检测到状态变化。而unless-stopped则明确承诺“除非我手动docker stop否则你重启、daemon重启、甚至整个服务器断电再恢复我都会回来”。我们在线上灰度发布时大量使用它# 灰度发布新版本API docker stop api-v1.2 # 停止旧版本确保不会自启 docker run -d \ --restartunless-stopped \ --name api-v1.3 \ -p 8000:8000 \ registry.example.com/api:v1.3这样即使发布中途Docker daemon崩溃新容器也会自动恢复而旧容器保持停止状态不会干扰灰度流量。但有个隐藏坑unless-stopped对docker kill -9无效。因为kill -9是强制终止不是stop命令所以容器仍会被重启。要彻底阻止重启必须用docker update --restartno api-v1.3动态修改策略。3.4no被严重低估的“反重启”策略no策略常被当成“什么都不干”其实它是最安全的默认值。Docker官方镜像如nginx:alpine、redis:7默认都是no因为镜像作者无法预知你的部署场景。强行加always反而破坏了镜像的可移植性。我们坚持一条原则只有当容器作为长期服务long-running service且具备快速自愈能力时才考虑非no策略。对于以下场景no是唯一正确选择CI/CD流水线中的构建容器如maven:3.8-openjdk-17构建失败必须人工介入无限重试只会浪费资源数据库备份脚本容器docker run --rm mysql:8.0 sh -c mysqldump -h db -u root -prootpw mydb /backup.sql成功或失败都应退出重试毫无意义安全扫描容器如aquasec/trivy扫描结果依赖当前镜像状态重试可能得到不同结果必须由调度系统控制。踩过的坑曾有个团队把no策略的Redis镜像改成always部署结果在Kubernetes里用kubectl delete pod删除Pod时Docker daemon在节点上又把容器拉起来了导致Pod状态混乱。根源在于K8s的CRI接口和Docker的重启策略冲突——K8s期望no而Docker执行了always。4. 生产级组合拳重启策略 初始化 依赖等待 真正的自愈4.1 为什么单靠重启策略永远不够看一个经典故障链某次线上事故复盘让我彻底放弃“只配--restart”的懒人做法。故障链如下MySQL容器配置--restartalways宿主机重启后MySQL容器先于应用容器启动MySQL启动时发现/var/lib/mysql目录属主是root因为挂载卷初始为空拒绝启动退出码1always策略触发Docker立即重启容器重启后MySQL再次检查目录权限仍是root再次退出……陷入死循环应用容器启动后连接MySQL失败整个服务不可用。根因不是重启策略错了而是容器启动逻辑缺失了初始化环节。MySQL需要chown -R mysql:mysql /var/lib/mysql但这个操作不能写在ENTRYPOINT里因为每次重启都执行效率低也不能放在CMD里因为CMD只在容器首次启动时运行。4.2 三步初始化模式解决90%的“启动即失败”问题我们总结出容器初始化的黄金三步法已在20个生产服务中验证有效第一步Entrypoint Wrapper脚本#!/bin/sh # entrypoint.sh set -e # 仅在容器首次创建时执行初始化通过检查/var/lib/mysql/ibdata1是否存在 if [ ! -f /var/lib/mysql/ibdata1 ]; then echo Initializing MySQL data directory... chown -R mysql:mysql /var/lib/mysql mysqld --initialize-insecure --usermysql --datadir/var/lib/mysql # 设置root密码等... fi # 执行原始CMD即mysqld exec $Dockerfile中COPY entrypoint.sh /entrypoint.sh RUN chmod x /entrypoint.sh ENTRYPOINT [/entrypoint.sh] CMD [mysqld, --usermysql]第二步健康检查兜底HEALTHCHECK --interval10s --timeout5s --start-period60s --retries5 \ CMD mysqladmin ping -h localhost -u root -prootpw --silent || exit 1--start-period60s给MySQL充分的初始化时间InnoDB recovery可能耗时很长。第三步依赖服务等待wait-for-it应用容器启动前必须确认MySQL已真正ready不能只看端口开放。我们在应用Dockerfile中集成wait-for-it.shCOPY wait-for-it.sh /wait-for-it.sh RUN chmod x /wait-for-it.sh CMD [/wait-for-it.sh, mysql:3306, --timeout120, --strict, --, java, -jar, app.jar]wait-for-it.sh会持续执行mysqladmin ping直到成功超时120秒则退出此时应用容器退出码1触发on-failure:3策略重试——这才是真正的闭环。4.3 Kubernetes场景下的策略迁移别直接照搬Docker命令很多团队把Docker Compose的restart: always直接翻译成K8s的restartPolicy: Always这是巨大误区。K8s的restartPolicy作用域完全不同Docker的--restart控制单个容器在宿主机上的生命周期K8s的restartPolicy控制Pod内所有容器的重启行为且只在Pod级别生效即容器崩溃时重启同Pod内的容器不跨Node。更关键的是K8s有更高级的原生机制替代Docker重启策略Liveness Probe≈ Docker健康检查 on-failure容器进程卡死时K8s会kill并重启容器Readiness Probe≈ 服务就绪判断只有probe成功流量才导入解决“容器running但服务不可用”问题Startup Probe≈--start-period专为启动慢的服务如大型Java应用设计避免liveness probe过早kill。所以从Docker迁移到K8s时正确的做法是Docker侧移除--restart回归noK8s YAML中配置livenessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 60 periodSeconds: 30 readinessProbe: httpGet: path: /ready port: 8080 initialDelaySeconds: 10 periodSeconds: 10 startupProbe: httpGet: path: /startup port: 8080 failureThreshold: 30 periodSeconds: 10这样既利用了K8s的声明式能力又避免了Docker与K8s重启策略的语义冲突。5. 故障排查实战从docker inspect到日志溯源的完整路径5.1 快速诊断一眼看穿重启策略是否生效当发现容器没按预期重启别急着删容器重跑。先用docker inspect查三个关键字段docker inspect mysql | jq .HostConfig.RestartPolicy, .State.Status, .State.ExitCode, .State.StartedAt, .State.FinishedAt, .State.Health 重点关注RestartPolicy.Name确认策略类型always/on-failure等Statusrunning/exited/created如果是exited看ExitCodeStartedAt/FinishedAt计算两次启动间隔验证是否符合指数退避如on-failure:3的100ms/200ms/400msHealth.Status如果是unhealthy说明健康检查失败触发了kill。我们写了个一键诊断脚本check-restart.sh#!/bin/bash CONTAINER$1 echo $CONTAINER 重启策略诊断 echo 策略: $(docker inspect $CONTAINER | jq -r .[0].HostConfig.RestartPolicy.Name) echo 状态: $(docker inspect $CONTAINER | jq -r .[0].State.Status) echo 退出码: $(docker inspect $CONTAINER | jq -r .[0].State.ExitCode) echo 最后启动: $(docker inspect $CONTAINER | jq -r .[0].State.StartedAt | cut -c1-19) echo 最后结束: $(docker inspect $CONTAINER | jq -r .[0].State.FinishedAt | cut -c1-19) echo 健康状态: $(docker inspect $CONTAINER | jq -r .[0].State.Health.Status // N/A)5.2 日志溯源为什么on-failure没触发看Docker daemon日志有时docker inspect显示一切正常但容器就是不重启。这时要查Docker daemon日志# Ubuntu/Debian sudo journalctl -u docker.service -n 100 --no-pager | grep -i mysql.*restart\|restart.*mysql # CentOS/RHEL sudo journalctl -u docker -n 100 --no-pager | grep -i restart典型日志线索levelinfo msgContainer mysql failed to restart: restart policy retries exhausted→ 重试次数用尽levelwarning msgFailed to restart container mysql: No such container→ 容器被docker rm删除了levelerror msgCannot restart container mysql: driver failed programming external connectivity on endpoint mysql→ 端口被占用重启失败。5.3 常见问题速查表90%的问题都在这里问题现象根本原因解决方案验证命令always容器宿主机重启后没起来Docker daemon启动慢于容器启动依赖在/etc/docker/daemon.json加{live-restore: true}允许daemon重启时保持容器运行systemctl status docker看启动耗时on-failure无限重试未设max-retries且退出码始终非0改为on-failure:3并在应用中确保致命错误退出码为1非致命错误退出码为0docker logs container看应用日志unless-stopped容器被自动拉起执行了docker kill而非docker stop运维规范停服务必须用docker stop禁用killdocker ps -a --format {{.Names}}\t{{.Status}}健康检查失败但容器不重启HEALTHCHECK未配置--retries或--start-period补全参数HEALTHCHECK --retries3 --start-period60s CMD ...docker inspect container | jq .[0].Config.Healthcheck多容器依赖启动顺序错乱未用wait-for-it或depends_onCompose在应用容器CMD前加/wait-for-it.sh db:5432 --docker exec -it app nc -z db 5432 echo ok最后分享一个小技巧在测试环境用docker run --rm -it --restarton-failure:2 alpine sh -c echo crash; exit 1快速验证策略是否生效。你会看到容器启动、退出、重启、再退出然后停在exited (1)状态——这就是on-failure:2的完美表现。