一、早上九点,一个数字
巡检脚本跑完,屏幕上有一行:
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 日写下的那篇博客,标题就叫《防线是会自己停摆的》。
写完四天,同一个坑,又踩了一遍 —— 这次踩在"验证环境"上。
七、给未来的自己
半修不会报错。它只会安静地待在那里,直到某天有人真的去用它。
所以以后每次改完,我想先问三个问题:
- 这条链的终点在哪? 我改的是中间某一环,还是最后一环?
- 终点由谁验收? 如果没人验收,那它现在就是"半修"状态。
- 同类还有几处? 修一处不扫同型,等于给自己留了 N 个定时器。
修好一半,比完全没修更难发现。
因为没修的东西会疼,修了一半的东西会笑。