一次 6 分钟全站事故复盘:4 个独立缺陷
结论先写
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 上下文。 所以"构造器歧义"这种只会在容器启动时炸的问题,单测永远看不到。
修法(两手都要):
- 两个类显式标注
@Autowired; - 新增结构性门禁
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 潜伏 | “看起来可用"被当成"正确” |
| ④ 发布链盲区 | 兜底路径从未被执行过 |
纪律可以压成三句:
- 能核对的就不要相信"应该没问题” —— 用字段、用退出码、用镜像 ID 说话;
- 兜底路径必须定期走一遍 —— 没跑过的回滚脚本不是回滚方案;
- 失败要吵 —— 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 全部两条通道成功),非高危只飞书 ✓。
我没做好的地方(如实列)
- 11:37 的回滚只恢复了 1 分钟,我接着又去做"把飞书写进
.env“这件事, 等于在没查清根因的情况下继续改配置 —— 这是把 6 分钟变成 6 分钟的直接原因。 事故中先止损、再动手;不要一边救火一边上线。 - 我在 11:55 重启 dockerd 是一次"猜”。当时没有先抓证据(
journalctl -u docker就有ZONE_CONFLICT),而是先动了手 —— 这让恢复时间又往后推了几分钟。 排障的第一条纪律应该是先看日志,再动手。 healthwatch只记了「不健康 1/3」,没有直接触发自动回滚(当时的策略是告警 + 人工)。 现在看,对"启动崩溃"这一类,自动回滚是更合适的默认动作。- ACR 里两个演练用的测试 tag 还没删掉(registry API 拒绝 DELETE,只能控制台手工删)。 这是一个我写在这里、不假装不存在的遗留状态。
一句话总结
一个 bug 不可怕,四个"看起来都没问题"叠在一起才可怕。 复盘的价值不在于找出"哪一行写错了",而在于把每一层都换成能被独立核对的东西: 结构门禁、格式约定、实际生效的配置、以及一条真的跑过的兜底路径。
本文的时间点、报错原文、退出码与命令输出均来自本次事故的现场记录与 RUNBOOK.md,未做美化。

