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 文件是原版(无残留)。

两个必须知道的坑:

  1. 网上流传的 docker update --health-cmd ... 不存在该参数——健康检查创建后不可变,
    compose 换坏探活法是唯一可靠的演习手段。
  2. 演习时"恢复 compose 文件"必须紧跟"坏探活重建",否则进入重建循环;
    真陷进去了:手动 cp /tmp/compose.bak 回文件再 up -d --force-recreate 即脱困。

万一自愈没生效,人工应急三步(先恢复业务,再查原因):

  1. 先手动 docker compose -f <compose文件> up -d --force-recreate <服务>,把业务拉回来
  2. 看 watchdog 日志里的 exit= 码,或手动跑一次脚本看 stderr 报什么错
  3. 对照仓库 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>——而且这个工具架构上
    就不需要任何密钥
    ,只调本机 docker CLI

单文件、零依赖是刻意的:一个 .sh 拷过去 chmod +x 即用,没有守护进程、没有包管理器、
没有"装一下试试"。


九、红线速查(抄作业前必读)

  1. 生产恢复一律 down + up -d,禁用裸 docker compose restart
  2. down 绝不带 -v(删数据卷)
  3. 演习时"恢复 compose 文件"必须紧跟"坏探活重建"
  4. 没有 healthcheck = 看门狗形同虚设——部署前必查
  5. 建议配合外部拨测告警组成三角,自愈失败要有人知道

十、结语

这次方案的完整时间线:2026-10-18 两套生产服务被云厂商重启打挂 → 当天做出自愈闭环 →
真实事故与人工演习双重验证 → 同日从生产项目抽离泛化 → 2026-10-23 整理为通用开源仓库
。

事故是所有运维知识里最贵的一种学费——把它固化成 68 行脚本之后,这笔学费只用交一次。

欢迎在仓库提 Issue 分享你的僵死场景——每一种新的"假活"姿势,都值得进排障表。