68 行 Shell 看门狗:把一次生产事故做成 Docker Compose 自动自愈
发布:2026-10-23 | 事故日期:2026-10-18 | 验证:真实事故 + 人工演习双重通过
结论先行:服务器被云厂商重启后,"端口活着、服务已死"的僵尸容器,从发现到恢复全程无人介入,约 3 分钟自愈。
方案 = compose healthcheck(感知)+ 68 行watchdog.sh+ cron(行动)+ 外部拨测(喊人),
零依赖、零配置、零常驻进程;已抽离为通用开源仓库,任何 Compose 项目 4 步接入。
一、事故现场:docker ps 全绿,业务全红
云厂商维护窗口重启了宿主机——这是躲不掉的常态:系统更新、内核补丁、宿主迁移,
哪个都可能带走你的服务器。Docker 守护进程开机自启、按 restart 策略把容器全部拉起,
docker ps 一看:全是 Up,状态完美。
然后你发现业务挂了:外部探测全超时,日志静得像死了一样。
这就是僵尸态:进程活着、端口在监听、请求管道已死。它比崩溃更阴险,因为
崩溃至少会留下 Crash / Restarting 的痕迹,而僵尸态在所有常规视角里都是"正常":
| 常规手段 | 为什么救不了 |
|---|---|
restart: always |
只救"进程崩了",不救"进程活着但不干活"——它根本不会触发重启 |
| Docker 开机自启拉起容器 | 这条路径不经过 compose 的健康检查,没人替你判断"起来了 ≠ 能服务" |
docker ps / 面板看状态 |
显示 Up、端口在——探活层面无异常 |
| 人肉发现 | 重启常发生在深夜/维护窗口,等你看到邮件时已经红了很久 |
缺的不是重启能力,而是感知:没有任何一环在问"它真的还在正常服务吗?"
二、三角设计:感知 → 行动 → 喊人
这次事故后的自愈方案只有三个角色,每个角色只干一件事:
| 角色 | 职责 | 失败时 |
|---|---|---|
| healthcheck(compose 定义) | 感知:定期探活,把僵尸标成 unhealthy |
没有它,看门狗永不触发 |
| watchdog.sh + cron(本方案) | 行动:读到 unhealthy → 按依赖序 force-recreate |
靠人工恢复 |
| 外部拨测告警(如 Uptime Kuma) | 喊人:自愈万一失败,通知人来兜底 | 静默失败 |
前两个组成闭环自愈,第三个是安全网——三层缺一层,这套体系都会在某次事故里塌掉。
三、三个关键决策(每个都对应一次真实踩坑)
1. 为什么是 up -d --force-recreate,而不是 restart
docker compose restart 绕过 depends_on 的健康条件——它会不管数据库是否就绪就把应用
重启起来,造成二次故障(真实事故根因之一)。而 up -d 严格按依赖顺序放行:等依赖服务健康后
才动目标容器,恰好消除了冷启动竞态。
配套红线:生产恢复一律 down + up -d;down 绝不带 -v(会删数据卷)。
2. 零配置:部署信息全部从容器标签自动读取
服务名、compose 文件路径、部署目录不写死在脚本里,而是运行时从 docker compose 写入容器的
标签读取:
com.docker.compose.project.working_dir # 部署目录
com.docker.compose.service # 服务名
com.docker.compose.project.config_files # compose 文件(多文件取第一个)
换服务器、改部署目录、改 compose 文件名——脚本一个字都不用改。标签读不到(比如容器是
手工 docker run 起的)就明确报错 exit 1 并打印现场值,绝不盲目重建。
3. 静默语义:平时安静 = 一切正常
healthy 时零输出零日志;动作与错误全部带时间戳落日志。于是日志有了明确的语义:
- 日志是空的 → 从部署到现在没出过事
- 日志有内容 → 每一条都是自愈或报错的留痕,带时间可回溯
监控系统的最高境界是"没消息就是好消息",而不是每分钟给你写一行"我还活着"。
四、时间线:约 3 分钟是怎么算出来的
healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/8080' || exit 1"]
interval: 15s # 每 15 秒探一次
timeout: 5s
retries: 3 # 连续 3 次失败才判 unhealthy
start_period: 60s # 给应用 60 秒冷启动宽限
T+0 容器进入僵尸态(探活开始连续失败)
T+~105s start_period 60s + 15s×3 次失败 → 状态 = unhealthy
T+≤60s cron 每分钟调用一次脚本 → 读到 unhealthy,开始按依赖序重建
T+~30s up -d 等依赖就绪 → 重建应用 → 探活恢复
T+≈3min 状态回 healthy,脚本恢复静默 ← 全程无人介入
关键在于每一层都只做自己那 20%:healthcheck 不负责重启,watchdog 不负责判断依赖顺序
(交给 compose),cron 不负责逻辑(每分钟空跑一次的成本约等于零)。
五、接入四步(给想抄作业的人)
先决条件(缺一不生效):① 容器必须由
docker compose创建(脚本靠它的标签定位部署信息)
② 目标服务已配 healthcheck(没有它状态恒空、永不触发)③ cron 在跑
(systemctl is-enabled cron,CentOS 系统名crond)④ 使用 compose v2 CLI(docker compose)。
1. 给目标服务加 healthcheck(没有它 = 形同虚设)
api:
# ……既有配置……
healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/8080' || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 60s
镜像需带
bash(Debian/Ubuntu 系均可);Alpine 用nc -z localhost <port>。
⚠️ healthcheck 变更必须up -d --force-recreate <服务>才生效,restart不会应用新定义。
2. 部署脚本
sudo cp watchdog.sh /usr/local/bin/watchdog.sh
sudo chmod +x /usr/local/bin/watchdog.sh
/usr/local/bin/watchdog.sh <你的容器名>; echo "exit=$?" # 正确表现 = 无输出 + exit 0
从 Windows 传文件后报
Permission denied→ 每次重传都要chmod +x;
报^M: bad interpreter→ 文件是 CRLF,sed -i 's/\r$//'去掉(从仓库正常检出无此问题)。
3. 挂 crontab
crontab -l 2>/dev/null > /tmp/crontab.bak # 先备份
crontab -e
* * * * * /usr/local/bin/watchdog.sh <你的容器名> >> /var/log/<你的容器名>-watchdog.log 2>&1
4. 验证四连
crontab -l # 调用行在
/usr/local/bin/watchdog.sh <你的容器名>; echo "exit=$?" # 静默 + 0
docker inspect <你的容器名> --format '{{.State.Health.Status}}' # healthy
ls -l /var/log/<你的容器名>-watchdog.log 2>/dev/null || echo "日志未生成(没触发过,正常)"
退出码约定:0 = 健康/已处理,1 = compose 标签读不到,2 = 缺参数。
六、怎么证明它真的会救:人工演习
没演练过的自愈方案等于没有。低峰期做一次(坏探活期间应用本身照常服务,只有重建那一下几秒中断):
cd <部署目录>
cp <compose文件> /tmp/compose.bak
sed -i "s@echo > /dev/tcp/localhost/8080@exit 1@" <compose文件> # 换成必失败的探活
docker compose -f <compose文件> up -d --force-recreate <服务> # 带坏探活重建
cp /tmp/compose.bak <compose文件> # 【立刻】恢复——不恢复就进重建循环!
watch -n 10 "docker inspect <容器名> --format '{{.State.Health.Status}}'"
tail -f /var/log/<容器名>-watchdog.log
验收:约 105s 变 unhealthy → ≤1 分钟看门狗自动救 → 回到 healthy + 日志新增两条时间戳记录
- compose 文件是原版(无残留)。
两个必须知道的坑:
- 网上流传的
docker update --health-cmd ...不存在该参数——健康检查创建后不可变,
compose 换坏探活法是唯一可靠的演习手段。 - 演习时"恢复 compose 文件"必须紧跟"坏探活重建",否则进入重建循环;
真陷进去了:手动cp /tmp/compose.bak回文件再up -d --force-recreate即脱困。
万一自愈没生效,人工应急三步(先恢复业务,再查原因):
- 先手动
docker compose -f <compose文件> up -d --force-recreate <服务>,把业务拉回来 - 看 watchdog 日志里的
exit=码,或手动跑一次脚本看 stderr 报什么错 - 对照仓库 README 的「排障表」逐条排除,修完重跑第 五 节的「验证四连」
七、生态位:为什么不用现成方案
| 方案 | 问题 |
|---|---|
autoheal 类容器(监控 unhealthy 后 docker restart) |
走的是 restart 路线,绕过 compose 的 depends_on 健康条件——恰好踩中本方案的红线;且对"依赖没就绪"的场景是二次故障源 |
| 1Panel 社区版/专业版 | 官方版本对比里都没有"unhealthy → 按依赖序自动重建"这种功能;专业版多的是告警能力(企微/钉钉/短信)。但社区版的计划任务模块(cron 表达式脚本任务)恰好是这个脚本的完美宿主——两者互补,面板里还能直接看执行日志 |
| Uptime Kuma 等外部拨测 | 是三角里的"喊人"一角,不替代自愈;1Panel 专业版的告警能力对应也是这一角 |
结论:生态里最接近的是 autoheal,但它恰好错在最核心的依赖顺序上;
而"面板类工具"(1Panel 等)与本方案是互补关系——换面板、换裸机,看门狗跟着脚本走。
八、给"AI 时代"准备的开源仓库
仓库(compose-watchdog,MIT © 2026 firstsaofan)除了脚本本身,工程化上有几件值得说的事:
.gitattributes强制*.sh纯 LF:Windows 上经手的 shell 脚本一旦混进 CRLF,
到 Linux 就是^M: bad interpreter——这是跨平台作者的头号坑,规则写死在仓库入口- CI 四道关(GitHub Actions):
bash -n语法 → shellcheck 静态检查 → LF 检查
→ 缺参守卫实跑(必须 stderr 打印用法 + exit=2)。shellcheck 0.11.0 实测零警告 AGENTS.md:给 AI 代理的操作红线(能动什么、不能动什么、改完必跑的校验)——
Codex / Cursor / Gemini CLI 等工具会自动读取SKILL.md:通用 Agent Skills 标准的浓缩入口,任何支持该规范的 agent 一键加载- 凭据零容忍:
.gitignore拦截.env/*.pem/*.key/id_rsa*等 20+ 模式,
文档示例一律占位符<你的容器名>/<你的IP>/<你的KEY>——而且这个工具架构上
就不需要任何密钥,只调本机dockerCLI
单文件、零依赖是刻意的:一个 .sh 拷过去 chmod +x 即用,没有守护进程、没有包管理器、
没有"装一下试试"。
九、红线速查(抄作业前必读)
- 生产恢复一律
down+up -d,禁用裸docker compose restart down绝不带-v(删数据卷)- 演习时"恢复 compose 文件"必须紧跟"坏探活重建"
- 没有 healthcheck = 看门狗形同虚设——部署前必查
- 建议配合外部拨测告警组成三角,自愈失败要有人知道
十、结语
这次方案的完整时间线:2026-10-18 两套生产服务被云厂商重启打挂 → 当天做出自愈闭环 →
真实事故与人工演习双重验证 → 同日从生产项目抽离泛化 → 2026-10-23 整理为通用开源仓库。
事故是所有运维知识里最贵的一种学费——把它固化成 68 行脚本之后,这笔学费只用交一次。
- 仓库:https://github.com/firstsaofan/compose-watchdog
- 一个文件:
watchdog.sh| 文档:README.md| AI 操作入口:AGENTS.md/SKILL.md - License: MIT © 2026 firstsaofan
欢迎在仓库提 Issue 分享你的僵死场景——每一种新的"假活"姿势,都值得进排障表。