跳到主要内容

一次 6 分钟全站事故复盘:4 个独立缺陷

2026-10-13工程实践 8 分钟阅读 3774 字站点 502 了约 6 分钟。查下来不是「一个 bug」,而是四个各自独立、每一个单独都足以致命的缺陷:启动崩溃、配置格式、潜伏 3 天的防火墙冲突、以及发布链的盲区。这篇按时间线还原,并给出正在生效的对照组。

结论先写

2026-10-13 上午,站点 502 了大概 6 分钟。事后查清:不是四个 bug 里的某一个害了我, 是四个各自独立、每一个单独都足以把站点打死,而它们恰好凑在同一个上午。

更要紧的是这四个缺陷的性质完全不同:

#缺陷层级为什么 CI 抓不到
①Spring 构造器歧义 → 启动崩溃代码单测直接 new,不加载 Spring 上下文
②.env 里放 JSON → env_file 解析失败配置配置文件本身"语法没错",是格式约定问题
③firewalld 与 docker0 冲突环境潜伏 3 天,只在 dockerd 重启时才发作
④发布链"容器存在但没运行"的盲区流程脚本逻辑缺陷:digest 相同就判"无需变更"

这一篇不发观点,只发时间线和证据。 所有时间点、报错原文、命令都是当时的记录。


时间线(CST)

11:35   提交通知中枢(239f36f)→ CI 全绿 → 定时器自动部署
11:35–  应用崩溃循环:UnsatisfiedDependencyException: ResponseActionService:
         No default constructor found(RestartCount 冲到 11)
11:37   healthwatch 记「不健康 1/3」;我停掉 redeploy.timer 并执行回滚
         → 回到好版本 c4f3f24(短暂恢复)
11:50   我把飞书写进 .env(NOTIFY_WEBHOOK_TEMPLATE 含 JSON 大括号)→ 手工部署
11:52:40 redeploy 失败:
         failed to read /opt/secagent/.env: line 74: unexpected character "{"
         → 容器被留在 Created(没运行);
         同一日志里回滚也失败:srv1-redeploy.sh: line 385: Permission denied
11:55   timer 重试却判定「digest 相同 → 无需变更」安静退出
         → 站点持续 502,不自愈
11:55–  我重启 dockerd 想恢复 iptables 链,结果 dockerd 起不来:
         ZONE_CONFLICT: 'docker0' already bound to a zone
         (firewalld 自 10-10 22:36 一直开着)
11:59   停用 firewalld → dockerd 恢复 → 重新部署
12:00   恢复 ok/3974a5b,公网 200

时间线里有三处值得单独看:11:37 的短暂恢复、11:52:40 的"回滚也失败"、 11:55 的"安静退出"。下面逐个拆。


缺陷 ①:启动崩溃(代码层)

UnsatisfiedDependencyException: ...
ResponseActionService: No default constructor found

ResponseActionService 与 UnblockScheduler 各有两个构造器,且都没有标 @Autowired, Spring 无法决定用哪个 —— 这是启动期异常,不是运行期。

为什么 CI 是绿的(这一段最关键):

单测是直接 new 这个类的,根本不加载 Spring 上下文。 所以"构造器歧义"这种只会在容器启动时炸的问题,单测永远看不到。

修法(两手都要):

  1. 两个类显式标注 @Autowired;
  2. 新增结构性门禁 SpringBeanConstructorTest:用反射扫描所有 Spring 组件, 要求"多构造器时必须恰好一个带 @Autowired"。

第 2 条不是"顺便加的测试"。它上线当场又抓出了第二个类 —— 这就是它值得存在的证明:如果只修第 1 条,同类问题还会以别的形式回来。

这一条的一般化教训:单元测试的覆盖率和"能不能启动"是两件事。 CI 里必须有一道"真的把上下文拉起来"的门禁。


缺陷 ②:.env 里放 JSON(配置层)

failed to read /opt/secagent/.env: line 74: unexpected character "{"

NOTIFY_WEBHOOK_TEMPLATE 的值是一个带大括号的 JSON 模板。 env_file 的解析器不认含 { 的值,于是整个 .env 读取失败, 连带 docker compose 拒绝启动 —— 容器被留在 Created 状态(没运行)。

注意这里有个很容易忽略的点:.env 自己"语法没错"(对 shell 来说甚至能 source), 错的是 compose 的 env_file 格式约定。所以:

  • 单测抓不到;
  • shell 语法检查抓不到;
  • 只有"真的走一遍 compose"才会炸。

修法:.env 里只放简单 URL;JSON 形状与双通道发送交给 本机中继 scripts/notify-relay.py(Python 标准库,不引依赖)。

一般化教训:别把结构化数据塞进为"键=简单值"设计的格式里。 这是一个"能跑"和"规范"之间的经典陷阱 —— 它在别的机器/别的版本上就变成"不能跑"。


缺陷 ③:firewalld 与 docker0 冲突(环境层,潜伏 3 天)

ZONE_CONFLICT: 'docker0' already bound to a zone

我为了恢复 iptables 链去重启 dockerd,结果起不来了。 根因:firewalld 自 10-10 22:36 就是 active, 而 dockerd 只要一重启就会因 ZONE_CONFLICT 起不来。

这一条的杀伤力被严重低估:它意味着 在那 3 天里,任何一次 dockerd 重启、甚至任何一次重启机器,都会导致全站宕机。 它不是"今天引入的 bug",是一颗躺了 3 天的雷,而我不需要碰它就会踩到。

修法:停用 firewalld(恢复到 10-10 之前的已知可用态),docker 26.1.3 恢复正常。 职责重新划分:srv1 的防火墙职责交给云安全组 + docker 自身链,不再开 firewalld。

一般化教训:“当前可用"不等于"配置正确”。 一个潜伏的环境冲突,和一个刚刚引入的 bug,在事故现场看起来一模一样 —— 唯一的区别是你能不能把"最近改了什么"和"实际生效的配置"对上。


缺陷 ④:发布链的盲区(流程层)

这一条是事故的直接放大器。它由两个小问题组成:

(a)“容器存在但没运行"时,脚本判"无需变更”。 部署脚本的 no-op 判据是"线上 digest == 指针 digest"。 但 ② 之后容器处于 Created(存在、但没在运行)—— 两个 digest 确实相同,于是脚本安静退出:

11:55  timer 重试却判定「digest 相同 → 无需变更」安静退出
       → 站点持续 502,不自愈

“安静退出"在正常情况下是好设计(5 分钟巡检不刷日志), 但在这个状态下它变成了永不自愈。

修法:加一条硬条件 —— 必须 docker inspect 到 .State.Running == true。 容器存在不算"在跑”。

(b)自动回滚因为脚本自己缺 +x 而失败。

srv1-redeploy.sh: line 385: Permission denied

自动回滚是这套系统的"最后一道兜底",而它当时是 644,根本执行不了。 兜底路径从来不执行,就等于没有兜底 —— 而且这一点在平时完全看不出来。

修法:chmod +x 已执行,仓库里也补了说明。


通知链路:这场事故直接催生出来的能力

事故之后补的东西里,有一部分在事故当天就起作用了:

· 外部心跳(srv2 → srv1 的 dead man switch)真的在 11:54 抓到了这次故障
  并尝试告警(当时 srv2 未配 webhook → notify.sh 如实返回 exit 3 只写日志;
  现已配好)
· 启动自检把"通知未配置"从"静默失效"变成"日志里明写",
  才让 ②/③ 这两处暴露

第二行值得展开:上一次通知为什么没生效? 因为 Spring 属性是 app.notify.*,环境变量必须写 APP_NOTIFY_*, 而 .env.example 里只写了 NOTIFY_* —— 等于应用侧通知从未真正开启过。 它暴露出来的方式是"启动自检如实打印 DISABLED"。

“配置写错了但没报错"是最贵的一类故障。 把它变成启动日志里的一行明写, 是一次性投入、长期收益。


四个缺陷背后的同一条纪律

把四条并排看,会发现它们是同一件事的四种表现:

缺陷共同点
① 启动崩溃没有在真实条件下验证(单测没加载上下文)
② .env 放 JSON没有走真实路径(shell 能读 ≠ compose 能读)
③ firewalld 潜伏“看起来可用"被当成"正确”
④ 发布链盲区兜底路径从未被执行过

纪律可以压成三句:

  1. 能核对的就不要相信"应该没问题” —— 用字段、用退出码、用镜像 ID 说话;
  2. 兜底路径必须定期走一遍 —— 没跑过的回滚脚本不是回滚方案;
  3. 失败要吵 —— fail-closed 的退出码 + 明写的启动自检日志,比"静默降级"安全得多。

事后:这些改动现在长什么样

事故后的同一天,这几件事落地并留了证据:

  • SpringBeanConstructorTest 门禁在仓库里,且当场抓出第二个类;
  • 部署脚本增加 .State.Running == true 判定(并写进 RUNBOOK 的排障表);
  • notify-relay.py 走 systemd(secagent-notify-relay,只监听 127.0.0.1:8899), 分级路由:含 [严重]/[CRITICAL]/[高危]/[HIGH] 双发(飞书 + QQ 邮件),其余只飞书;
  • srv2 独立配了自己的 NOTIFY_WEBHOOK_URL —— 这样 srv1 全死时它自己发得出去; 实测 notify.sh HIGH ... --force → HTTP 200 / exit 0;
  • 飞书 sent + 邮件 sent(seq=1..4 全部两条通道成功),非高危只飞书 ✓。

我没做好的地方(如实列)

  1. 11:37 的回滚只恢复了 1 分钟,我接着又去做"把飞书写进 .env“这件事, 等于在没查清根因的情况下继续改配置 —— 这是把 6 分钟变成 6 分钟的直接原因。 事故中先止损、再动手;不要一边救火一边上线。
  2. 我在 11:55 重启 dockerd 是一次"猜”。当时没有先抓证据(journalctl -u docker 就有 ZONE_CONFLICT),而是先动了手 —— 这让恢复时间又往后推了几分钟。 排障的第一条纪律应该是先看日志,再动手。
  3. healthwatch 只记了「不健康 1/3」,没有直接触发自动回滚(当时的策略是告警 + 人工)。 现在看,对"启动崩溃"这一类,自动回滚是更合适的默认动作。
  4. ACR 里两个演练用的测试 tag 还没删掉(registry API 拒绝 DELETE,只能控制台手工删)。 这是一个我写在这里、不假装不存在的遗留状态。

一句话总结

一个 bug 不可怕,四个"看起来都没问题"叠在一起才可怕。 复盘的价值不在于找出"哪一行写错了",而在于把每一层都换成能被独立核对的东西: 结构门禁、格式约定、实际生效的配置、以及一条真的跑过的兜底路径。


本文的时间点、报错原文、退出码与命令输出均来自本次事故的现场记录与 RUNBOOK.md,未做美化。