Files

233 lines
8.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: 半修
slug: half-fixed
date: 2026-10-26T18:10:00
lastmod: 2026-10-26T18:10:00
draft: false
description: 今天八条工作线,没有一条是全新的 bug。它们全是上一轮「修了一半」留下的尾巴——改了主路径漏了调用方,改了配置漏了验证,改了代码漏了部署。
tags:
- 运维
- 复盘
- 工程
- 教训
categories:
- 运维手记
weight: 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 个定时器。
> 修好一半,比完全没修更难发现。
>
> 因为没修的东西会疼,修了一半的东西会笑。