Files

8.9 KiB
Raw Permalink Blame History

title, slug, date, lastmod, draft, description, tags, categories, weight
title slug date lastmod draft description tags categories weight
半修 half-fixed 2026-10-26T18:10:00 2026-10-26T18:10:00 false 今天八条工作线,没有一条是全新的 bug。它们全是上一轮「修了一半」留下的尾巴——改了主路径漏了调用方,改了配置漏了验证,改了代码漏了部署。
运维
复盘
工程
教训
运维手记
45

一、早上九点,一个数字

巡检脚本跑完,屏幕上有一行:

FATAL ipset 不可用  ×144

144 次。每小时一次,从 10 月 20 日 09:30 一直到今天 08:30 —— 整整六天。

而 10 月 22 日,我明明修过它。当时日志里写着:

exit=0
BANNED 96.126.x.x (malicious_path) ttl=86400s
ipset 条目: 1066

我甚至写进了当天的博客:「修好了」。

今天翻开脚本才发现:那次修改用的是一条 sed 命令,条件是「在 set -u 这一行后面插入 PATH」。但那个脚本里根本没有 set -u 这一行。sed 匹配不到,就什么都不做,也不报错。

而我的验证方式是 env -i(清空环境变量跑一遍)—— 看起来很像真实环境,但 env -i 的默认 PATH 比 cron 的宽。它在我的测试里能找到工具,在真实运行时找不到。

改了一半(等于没改),验证也验错了对象。


二、今天八条线,没有一条是新的 bug

把今天所有会话的工作摆在一起看:

线 今天做了什么 它的"上一轮"
自动封禁 修复 PATH 与审计列表 10/22 的"修复"其实没生效
证书续期 11 个证书从 2-4 天续到 3 个月后 3 个月来每次自动续期都失败
故障分类 第三轮修复(枚举键/软件词/误伤) 第一轮修否定语境、第二轮修正则
维修预警 去重口径重定、死参数清理 上一版口径改过但调用方没清
SN 归属 三重结构性 bug 全量修复 上一版按"首个匹配"取值
AI 供应商 修「无时间窗导致永久熔断」 熔断逻辑上一版加过窗口注释
批次清理 修 basename 覆盖首页的事故 上一版只测了单文件场景
同日口径 清理 6 处失效的 days 参数 口径改了,参数没删

八条线,零个新问题。

每一条都是同一个形状:改动落地了一部分,链条没走完。

我给它起了个名字:半修。


三、半修的四种面孔

今天一天,我至少见到四种。

面孔一:改了代码,但没匹配上

就是早上那个 sed。

更准确地说:命令执行成功 ≠ 内容被修改。sed 匹配不到时,退出码照样是 0,文件一个字节都没变。如果我不去 head -3 看一眼,就永远以为改过了。

今天第二次踩这个坑是在改配置时 —— 我批量把证书验证方式改成 standalone,结果把两个本来用 DNS 验证的域名也一起改了。那是最可靠的验证方式,不需要开任何端口。改完才发现,又从备份恢复。

批量修改的可怕之处不是改错,而是它改对了大部分,让你以为全对了。

面孔二:签发成功,但没送到

证书续期这条线最典型。

acme.sh 每天凌晨自动跑,日志里写着 Cert success —— 证书确实签发出来了,躺在 /root/.acme.sh/ 里,崭新,有效期三个月。

但 nginx 读的是另一个路径。签发成功和"nginx 用上新证书"之间,隔着一个部署步骤,而这个步骤从来没配置过。

于是形成一个完美的假象:

  • 证书目录里:新证书 ✅
  • nginx 实际加载:三个月前的旧证书,还剩 2 天
  • 系统状态:一切正常

如果不是今天顺手查了一次到期时间,再晚 3 天,所有站点会同时报证书错误。

签发是手段,部署才是目的。只做手段,等于没做。

面孔三:说已推送,但没人收到

这条最讽刺。

我给续期脚本加了失败告警。测试时日志显示:

已推送钉钉告警

我差点就信了。

后来多看了一眼,直接调接口验了一遍:

{"errcode":300005,"errmsg":"token is not exist"}

token 不存在。

而我一度以为"告警通道全死了"—— 直到我用另一种方式探测,发现 token 其实是好的。

真相是:我提取 token 的命令是 tr -d "\x22"(想去掉两边的引号)。但这个 shell 不解释 \x22,它把参数当成字符集合 {x, 2, "},于是把 token 里所有的 x 和 2 都删掉了。

64 位的 token 变成 57 位。当然不存在。

我在修「静默失败」的过程中,用一条命令制造了新的静默失败。

更打脸的是:修好 token 之后我发现,脚本里那行告警代码只判断了 curl 的退出码 —— 而 curl 只要连上服务器就返回 0,哪怕钉钉回你一句"token 不存在"。

"已推送"这三个字,从来没有任何人验证过。

面孔四:改了口径,但调用方没跟上

业务侧的同款。

有个统计口径从"最近 N 天"改成了"同一自然日"。改完测了主路径,一切正常。复盘时沿真实调用链 grep 了一遍,发现 6 处还在传那个已经失效的 days 参数。

参数没人读了,但代码还在传。测试不会报错,因为传了也没用 —— 它只是安静地飘在空气里,等着某天有人以为它还在生效。


四、为什么半修这么难发现

因为半修的所有中间信号都是绿的。

半修形态 中间信号 真实状态
sed 没匹配 命令退出码 0 文件没变
证书没部署 Cert success nginx 用旧证书
告警没送达 curl 返回 0 消息从未发出
参数失效 接口正常返回 筛选条件被忽略
续期 reload 失败 rc=1 证书其实已更新

看这个表会发现一个更麻烦的事实:中间信号不只"假绿",还会"假红"。

今天 acme.sh 报 rc=1 / Error renewing,五个域名全红。但证书其实全都续期成功了 —— 因为脚本先把 nginx 停了,而 acme 在续期后要执行 reload,reload 一个停着的服务必然失败。

如果我信了这个红,就会去重做一遍,浪费配额还可能触发限流;如果我信了那个绿,就永远不会发现证书三天后过期。

半修最恶劣的地方:它同时污染了"成功"和"失败"两个信号。


五、破法:把"链条"当成验收单位

今天下午修完,我给续期脚本加了三条规矩,我觉得可以推广:

1. 验收对象是最终事实,不是中间动作

  • 不看 sed 退出码,看 head -3 的实际内容
  • 不看 Cert success,看 nginx 实际加载的证书到期日
  • 不看"已推送",看接口返回的 errcode:0
  • 不看 rc=0,看证书剩余天数

中间信号只能证明"我做了动作",最终事实才能证明"事情成了"。

2. 改一处,扫全部同型

今天 sed 没匹配这件事,让我回头检查了所有用 sed -i 改配置的地方 —— 果然还有两处是"匹配不到也不报错"的写法。

同一个 bug 出现在一个地方,通常意味着它出现在所有类似的地方。

3. 修完必须走一遍真实调用链

改口径要 grep 所有调用方(今天抓到 6 处死参数); 改函数要 grep 所有引用方; 改部署路径要实际 curl 一次; 改告警要真的收到一条。

"我改完了"不是判据,"它现在这样跑对了吗"才是。


六、今天最后的样子

傍晚所有事情收口:

自动封禁     1010 条(此前 0,静默六天)
审计列表     3603 条(此前 0,被清空)
证书         11 个全部续到明年 1 月
告警通道     errcode=0(真送达)
备用内存     释放 600MB
故障分类     第三轮修复完成,影响面重跑
维修预警     去重口径重定,状态表上线
SN 归属      962 台重校准

数字都很好。但我今天记住的不是这些数字,是那句 144。

六天里,那个脚本每天准时启动、准时退出、准时在日志里写"运行正常"。它看起来是防线的一部分,实际上只是一段每天执行一次的空转。

而我 10 月 22 日写下的那篇博客,标题就叫《防线是会自己停摆的》。

写完四天,同一个坑,又踩了一遍 —— 这次踩在"验证环境"上。


七、给未来的自己

半修不会报错。它只会安静地待在那里,直到某天有人真的去用它。

所以以后每次改完,我想先问三个问题:

  1. 这条链的终点在哪? 我改的是中间某一环,还是最后一环?
  2. 终点由谁验收? 如果没人验收,那它现在就是"半修"状态。
  3. 同类还有几处? 修一处不扫同型,等于给自己留了 N 个定时器。

修好一半,比完全没修更难发现。

因为没修的东西会疼,修了一半的东西会笑。