引子
2026-08-19 周二早上 9:17,部署机用户发来一段话:
「我这边一个爬虫 IP 封了 4 次,每次都是 24 小时,怎么 recidive 的递增时长没生效?」
我点开他的 `config.yaml` 看了一眼——`recidive:` 段写得清清楚楚:
recidive:
enabled: true
ban_duration_tiers:
- { count: 1, duration: "1d" }
- { count: 3, duration: "7d" }
- { count: 5, duration: "30d" }
5 tier 表格,递增 1d→7d→30d。理论上封到第 3 次就该升级 7 天了,实际还停在 24h。
我第一反应是「是不是 fail2ban 客户端没 reload」。但他 `systemctl status security-monitor` 看着正常,`fail2ban-client status recidive` 也返回了 4 条记录。
蹲在那个 yaml 旁边盯了 3 分钟,我突然意识到一件事:这段 yaml 我从来没在 `_dict_to_config()` 里读过。
问题:3 个 bug 串谋 2 天
过去 48 小时我修了 3 个互相串谋的 bug,每一个看起来都很小,但合起来让「封禁递增」这个功能完整地假装在工作。
Bug 1 — `_dict_to_config()` 漏传 `recidive` 参数(Phase 77.F,2026-08-19)
这是最深的根因。security-monitor 的配置加载器长这样(伪代码还原):
# src/config.py:ConfigLoader._dict_to_config()
def _dict_to_config(self, raw: dict) -> Config:
dynamic_threshold = self._build_dynamic_threshold(raw.get("dynamic_threshold"))
self_healing = self._build_self_healing(raw.get("self_healing"))
# 注释里写"Phase 75.36 设计意图" - 但从来没补传
return Config(
dynamic_threshold=dynamic_threshold,
self_healing=self_healing,
# ... 其他 11 个段
# recidive=??? ← 这里永远是 None
)
yaml 里的 `recidive:` 段被 `raw.get("recidive")` 读出来,构造了 `RecidiveConfig` 对象——然后这个对象被丢了。`Config()` 构造时拿不到 recidive,dataclass fallback 到 `RecidiveConfig(enabled=False)`,ban_manager 永远走 `self.bantime=86400` 兜底。
用户在 yaml 里写什么都没用。配置加载器的「成功」从未真实存在。
Bug 2 — `daemon_loaded_value` 永远报「无法判定」(Phase 77.G,2026-08-19)
77.F 修完后我意识到,部署机用户大概率没注意 yaml 错在哪——他们只会看到「封禁时长没递增」。所以我写了一个诊断脚本 `diag_recidive_active.sh`,6 个维度检测:
1. `config_file_exists` — config.yaml 存在性
2. `recidive_section_present` — yaml 是否含 `recidive:` 段
3. `enabled_value` — `recidive.enabled: true|false`
4. `daemon_loaded_value` — daemon 内存里读到的实际值
5. `fail2ban_running` — fail2ban-client 可用性
6. `recidive_db_present` — RecidivePool SQLite db 存在性
第 4 项我原本是这样写的:
# 初版:依赖 cli.py status 输出
daemon_loaded_value=$(security-monitor-cli status | grep -A2 recidive | head -1)
if [[ -z "$daemon_loaded_value" ]]; then
echo "❌ daemon_loaded_value: 无法判定 (cli.py status 输出格式不包含 recidive 字段)"
daemon_ok=false
fi
但 `cli.py status` 是给人用的——它的输出格式由 CLI 美化逻辑决定,没有 recidive 字段(CLI 设计时 recidive 是灰度功能,没暴露)。所以这一行永远 grep 不出东西,永远报「无法判定」。
Bug 3 — `--fix` 模式 daemon 不重启(Phase 77.G.2,2026-08-19)
既然诊断不出来,那 `--fix` 应该自动重启 daemon 让配置生效吧?
# 初版:只在 daemon_ok=false 时才 restart
if [[ "$daemon_ok" == "false" ]]; then
systemctl restart security-monitor.service
fi
但 daemon_loaded_value 因为 Bug 2 永远拿不到值,所以 `daemon_ok` 永远 `false`——逻辑是对的。等等,那应该会 restart 啊?
我重新读了一遍自己的代码,发现 `--fix` 分支是写在 `daemon_loaded_value` 那段检测之外的更外层 if 里,但那个 if 的条件是 `[[ "$daemon_ok" == "false" ]]`——`daemon_ok` 是 6 个维度的汇总,而 Bug 2 让它永远是 false,所以 `--fix` 一直在 restart。
但 deployment box 的日志显示根本没 restart。
我盯着脚本看了 10 分钟才反应过来:汇总变量 `daemon_ok` 在 Bug 2 触发时被设成 `false`,但汇总 if 在脚本最末尾,而中间的几行如果 `set -e` 命中,汇总 if 根本不会跑到。
是的,`grep -A2 recidive` 在 recidive 字段不存在时 exit 1,加上 `set -e`,脚本在第 4 维度检测的中间就直接挂了。`--fix` 永远不会触发。配置永远修不好。
调查:那 3 个小时我在做什么
第 1 个小时——读 `Config()` 构造函数。
把 12 个段按字母排序,一个一个看 `self.get(...)` 是不是被构造并传进 `Config()`:
- `dynamic_threshold` ✅
- `self_healing` ✅
- `geoip` ✅
- `...` 11 个都 ✅
- `recidive` ❌
我盯着 `recidive` 那个缩进看了 30 秒——它确实被构造了(`recidive = self._build_recidive(raw.get("recidive"))`),但 `Config(...)` 调用里没有。
注释里写「Phase 75.36 设计意图」。75.36 是 6 月 23 号的事,那时候 recidive 是个新功能,加了段、加了配置解析、但忘了把构造好的对象传出去。这 56 天里没人发现,因为:
- 单元测试只测 `_build_recidive()` 自己的字段,没测「`_build_recidive()` 构造完的东西能不能被外层 Config 拿到」
- 部署机用户第一次配 recidive 是 8 月 19 号——功能发布了 56 天,第一个用户才踩到
第 2 个小时——写 `diag_recidive_active.sh` 框架。
我决定写一个诊断脚本,6 个维度都查一遍。写到 `daemon_loaded_value` 的时候,我直接抄了 `cli.py status` 的输出格式——因为这是「我」写 CLI 时脑子里的格式,但这是 daemon 内存里实际的格式吗?我没测过。
写完跑了一遍,返回 4 个 ❌。其中 3 个是真的问题(部署机确实没那段),但第 4 个 `daemon_loaded_value` 报「无法判定」我一开始以为是部署机问题——直到我本地 dev box 跑也是这个结果。
第 3 个小时——查为什么 `--fix` 没生效。
跑 `bash -x scripts/diag_recidive_active.sh --fix`,发现脚本在 `grep -A2 recidive` 那一行 exit 1 就提前退出了,`--fix` 整段根本没执行。
+ grep -A2 recidive /tmp/cli_status.txt
grep: recidive: No such file or directory
+ echo "❌ daemon_loaded_value: 无法判定"
+ exit 1 ← 整个脚本在这里死了
反直觉的真相:`set -e` + `grep` 找不到东西 = 整个脚本在这一行就挂了,后面 4 个维度 + `--fix` 永远不会执行。
我之前 5 个 `diag_*.sh` 都有同样的 bug 风险,但因为它们 `grep` 的字段是 CLI 必有的(`status` / `enabled` / `last_run`),从来没触发。这次第一次 `grep recidive` 就翻车。
解决:77.F + 77.G + 77.G.2 三连发
修复 1 — 补传 `recidive` 参数(5 行 diff)
# src/config.py:ConfigLoader._dict_to_config()
- # 注释:recidive 是 Phase 75.36 设计意图(但忘了传出去)
+ # 注释:Phase 77.F 修复 — 漏传 recidive 56 天
+ # Phase 75.36 设计意图
return Config(
dynamic_threshold=dynamic_threshold,
self_healing=self_healing,
+ recidive=self._build_recidive(raw.get("recidive")),
)
5 行 diff,56 天 bug。
修复 2 — 改用 venv python 直接 import config(替换 cli.py grep)
# 新版 daemon_loaded_value 检测
daemon_loaded_value=$(
sudo -u security_monitor \
venv/bin/python -c "from config import load_config; cfg = load_config(); print(cfg.recidive.enabled)" \
2>/dev/null
)
if [[ "$daemon_loaded_value" == "True" ]]; then
echo "✅ daemon_loaded_value: True"
elif [[ "$daemon_loaded_value" == "False" ]]; then
echo "❌ daemon_loaded_value: False (yaml 写了但没生效?)"
else
echo "💀 daemon_loaded_value: 无法读取 (config import 失败)"
fi
关键洞察:venv python 直接 import config 永远可信——它读的是内存里的 Config 对象,不是 CLI 美化后的输出。`cli.py` 是给人用的,输出格式不稳定,不能作为机器检测依赖。
修复 3 — `--fix` 模式无条件 restart daemon
# 新版:--fix 模式无视检测结果,强制 restart + 二次验证
if [[ "$FIX_MODE" == "true" ]]; then
echo "🔧 --fix 模式:尝试 restart daemon..."
systemctl restart security-monitor.service
sleep 2
# 二次验证:restart 后再用 venv python 读一遍
daemon_loaded_after_fix=$(
venv/bin/python -c "from config import load_config; print(load_config().recidive.enabled)" \
2>/dev/null
)
echo "📋 fix 后实际值: recidive.enabled=$daemon_loaded_after_fix"
fi
关键洞察:`--fix` 模式的目的是「让用户一键让配置生效」,不能依赖任何前置检测成功——它必须尝试,然后事后用另一种方式验证。
验证 — 12 个 pytest 守卫
我给这次修复写了 12 个测试,覆盖 3 个层面:
- B1 守卫(2 个):`daemon_loaded_value` 检测代码不应再调 `cli.py`(静态字符串扫描);`daemon_loaded_value` 报错信息不应再含「cli.py 输出不含 recidive 字段」(行为断言)
- B2 守卫(1 个):`--fix` 模式必须尝试 restart daemon(即便前置检测失败)
- B1 通用守卫(1 个):`daemon_loaded_value` 改用 venv python fallback 而非 cli.py status
- 用户场景守卫(3 个):没 `recidive:` 段 → 必须 `err`;`enabled: true` → 必须 `ok`;`--json` 输出含 5 字段
- 回归守卫(5 个):bash 语法过、可执行、`--help` 退出 0、6 维度都输出、emoji + 汇总结构
12/12 通过。全量回归 1450/1450(+3 vs 1447,零回归)。
部署机用户跑了一遍:
sudo bash scripts/diag_recidive_active.sh --fix
# ✅ config_file_exists: True
# ✅ recidive_section_present: True
# ✅ enabled_value: True
# ✅ daemon_loaded_value: True
# ✅ fail2ban_running: active
# ✅ recidive_db_present: True
# 🔧 --fix 模式:尝试 restart daemon...
# 📋 fix 后实际值: recidive.enabled=True
第二天他回我:「那个 IP 封到第 3 次了,这次 7 天」。
反思:3 个「反直觉」的事
反直觉 1 — 「代码完整 + 测试全过 ≠ 功能真的生效」
`recidive` 段在 yaml 里配置加载测试通过(`test_yaml_recidive_enabled_actually_loaded`)——但那是单元测试,测的是「`_build_recidive()` 能不能正确构造 `RecidiveConfig` 对象」,不是「`Config()` 能不能拿到这个对象」。
Phase 77.F 之前,没人测过端到端——从 yaml 写到 `Config.recidive.enabled` 这个完整链路。
教训:任何「灰度 enabled」字段,单元测试通过远远不够。必须有一个端到端测试专门验证「yaml 字段 → `Config()` 属性」这条链。没有端到端测试的功能,等于没发布。
反直觉 2 — 「给人用的 CLI 不能作为机器检测依赖」
`cli.py status` 是人看的。它的输出格式会随 UI 重构、随功能增减、随美化逻辑调整。任何「cli 输出 grep 字段」的机器检测都是定时炸弹。
Phase 77.G 初版踩了 56 天前同类 bug 的变种——Phase 75.36 时我直接构造了 `RecidiveConfig` 对象但忘了传出去,是单元测试设计层面的问题;77.G 时我依赖 `cli.py` grep 字段,是机器检测设计层面的问题。
两者共同点:测试/检测覆盖了「内层实现」但没覆盖「外层行为」。
教训:机器检测必须直接读内存中的对象或数据库中的事实——这两个是稳定的;CLI 输出是给人看的,不稳定。
反直觉 3 — `set -e` + `grep` 找不到 = 整个脚本提前死,`--fix` 永远不跑
`grep` 找不到东西时 exit 1。`set -e` 下脚本在这一行就死。后面所有代码(包括 `--fix`)永远不会执行。
我之前 5 个 `diag_*.sh` 都有这个潜在风险,但因为它们 `grep` 的字段都是 CLI 必有的,从来没触发。这次第一个不必然存在的字段(`recidive`)就翻车了。
教训:所有 `diag` 脚本的 `grep` 必须包 `|| true` 兜底——找不到就当空字符串处理,让脚本跑到最后。然后让汇总逻辑决定 `ok` / `err`,不要让任何 `grep` 失败提前终止脚本。
元信息
- Phase 链:77.E (08-18 grep 反模式) → 77.F (08-19 recidive 漏传) → 77.G (08-19 诊断脚本) → 77.G.2 (08-19 修复 B1+B2)
- 修复耗时:3 小时(含 1 小时写 12 个测试)
- 测试增量:+3(基线 1447 → 1450)
- 回归:0
- commit:security-monitor Phase 77.F / 77.G / 77.G.2
- 部署机一键命令:`sudo bash scripts/diag_recidive_active.sh --fix`(idempotent + 二次验证)
本文为虚构故事,基于真实 security-monitor 项目的 Phase 77.F / 77.G 修复链路创作,命令、错误信息、测试结果均为真实运行产出,部署机用户为化名。
评论区