沉默的 Job:如何设计一套不会产生假阳性的系统巡检体系
一次系统审计发现 37 个 cron job 中有 2 个从未成功运行、4 个从未触发、每天收到同一组假阳性告警。这不是运维事故统计——这是我们自己的系统。本文记录如何用组件声明式检查 + Dead Man's Switch 重建巡检架构,从源头消除假阳性。
我统计了一下系统里 37 个 cron job 的状态: 2 个报错,4 个从未运行,1 个被暂停了不知多久。 收到最多的告警来自一个检查脚本——它每天都会报"反向隧道连接失败"和"目录不存在"。 但那个隧道从来没建过,那个目录一个月前就删了。
这不是运维事故统计。这是我自己搭的系统。
一、起念
事情从一个很普通的问题开始。
上周末我在排查系统时,发现一个 pipeline 脚本已经连续空转了好几天。它显示"active",cron 也显示"正常运行"的表象——但实际产出文件几周没更新了。
我翻了一下系统里的 cron 列表:37 个定时任务。数字看起来还行,但逐个检查后发现:
- 2 个 cron job 的 last_status 显示 error
- 1 个 cron 被人为暂停(没有人记得什么时候、为什么)
- 4 个 cron 自创建以来从未运行过
- 唯一的每日健康检查脚本每天报错,但 exit code 1 的原因是检查了两个已经不存在的东西
说几个具体例子。
有个 cron 叫 pipeline-incremental-daily,配置了 script: pipeline-p3.sh incremental。看着没什么问题对吧?但 Hermes 的 no_agent 模式把整个字符串当成文件名——空格是文件名的一部分。所以 cron runner 永远找不到文件。这个 cron 从改配那天起就再也没跑过。
另一个 cron 叫 evo-health-daily,每天早上 8:05 执行一次健康检查。但检查的两项内容——SSH 隧道到 laptop 和 cc-sync 同步目录——laptop 的 SSH 隧道从来没搭过,cc-sync 目录一个月前已经清理掉了。
每天报两个假阳性。每天 exit 1 发到 QQ。
然后人就习惯了。
这就是问题所在:假阳性比没有监控更危险。没有监控——你至少知道自己不知道。假阳性——你学会忽略告警。然后真问题来的时候,你也在忽略。
二、三种沉默
回顾整个审计过程,我发现系统中的"沉默失败"可以归纳为三种类型:
类型 A:假阳性错误 → 报警疲劳
evo-health-daily 的案例。每天报同样的错误,但问题不存在。短期内你修复它("改改脚本就好"),但长期来看每个人都会对这个告警脱敏。
类型 B:配置漂移 → 组件删了检查还在
CC 采集系统 7 月 14 号已经清理掉了——6 个脚本归档、数据目录删除。但 evo-health 还在每天检查那个目录。系统设计和监控设计是两条独立的时间线,它们天然会漂移。
类型 C:沉默失败 → 表面 active,实际从不成功
pipeline-incremental-daily 的案例。从 cron list 看是 active,从脚本看 pipeline-p3.sh 存在——但 cron runner 永远找不到文件。没有任何告警,没有任何日志,就像一个人被锁在门外,你只在门外看了一眼:"嗯,他还在家。"
这三种沉默都是同一个根因造成的:健康检查是被硬编码到一个几百行的大脚本里的,它和自己检查的对象之间没有声明式关系。
三、设计哲学:懒人巡检
我坐下来重写这个巡检系统时,问了几个问题:
问:为什么检查一个不存在的东西? 答:因为脚本里写了。但我删组件的时候谁会记得去改脚本?
这个问题没有好答案。只要健康检查的声明和组件的生命线是解耦的,零假阳性就是不可能的。
问:有没有一种"懒人"方案——让健康检查永远不会检查不存在的东西? 答:有——每个组件自己声明它需要检查什么。组件消失 = 检查消失。
这就引出了核心设计:
不是 "一个 600 行的脚本检查所有东西"
而是 "每个组件在 checks.d/ 放一个 JSON,巡检框架自动发现"
{
"name": "core-system",
"checks": [
{"id": "disk_usage", "type": "disk_usage", "params": {"warn_at": 80, "crit_at": 90}},
{"id": "memory_capacity", "type": "memory_overflow", "params": {"max_bytes": 2200}}
]
}删掉这个 JSON 文件 → 不再检查磁盘和内存。不是"手动注释掉脚本里的那一段"——是直接消失。
还有一个更深的哲学问题。
传统的监控思路是"检查每个组件是否正常工作"。但你的系统可能有 37 个组件——你真的关心它们各自的状态吗?不如问另一个问题:
问:哪些东西如果停止运行是最危险的? 答:那些"不产生告警"的东西。
这就引出了第二个模式。
四、Dead Man's Switch
传统的 cron 监控是这么做的:
cron 执行完毕 → 检查 exit code → 非零 → 告警
但这只会告诉你"跑失败了",不会告诉你"根本没跑"。
我们的 pipeline-incremental-daily 就是"根本没跑"的受害者——它甚至没机会 exit non-zero。
Dead Man's Switch 的思路是反过来的:
cron 成功执行 → 记录一个心跳
监控系统等心跳 → 预期时间内没收到 → 告警
这个模式被广泛用于 NASA 航天器、核电站监控、和许多高可靠性系统的健康检查中。原因是:信号的缺席比信号的存在更难检测。
用在这个场景里:
| Cron | 预期周期 | 容错 | 实际状态 |
|---|---|---|---|
| pipeline-full-weekly | 168h(7天) | 24h | ✅ 正常 |
| pipeline-incremental-daily | 30h | 6h | ⚠️ 35h(停摆中,已修复) |
| 磁盘看门狗 | 8h | 2h | ✅ 每 2.5h 报一次 |
| bh-daily-health | 30h | 6h | ❌ 174h(被暂停了一周) |
那个 174h 的不是别人——就是刚被我恢复的 bh-daily-health。它一周前被人暂停了,没有任何通知、没有任何记录。在"exit code 检查"模式下,暂停 = 不会报错。在 Dead Man's Switch 模式下,暂停 = 超时 → 告警。
因为你不能区分"有意识停了一个服务"和"忘了还有一个服务在跑"。
五、架构:组件声明式检查
最终产出的东西很简单:
~/.hermes/health/
├── health-v2.py # 巡检框架(336 行)
└── checks.d/
├── 00-core.json # 磁盘/内存/state.db
├── 01-cron-heartbeat # 10 个 cron 心跳
├── 02-data-quality # 管线/进化日志
├── 03-pi.json # Pi agent
└── 04-baby-harness # 测试框架健康
框架只有 336 行 Python,零外部依赖,支持 8 种检查类型:
| 类型 | 功能 | 设计意图 |
|---|---|---|
disk_usage | 磁盘使用率 | 大而化之,够用就行 |
memory_overflow | memory 容量 | 精确到字节,溢出前预警 |
cron_heartbeat | Dead Man's Switch | 核心创新:超过预期周期未跑 → 告警 |
cron_status | Cron 状态查询 | 传统方式,查 exit code |
file_freshness | 文件新鲜度 | 无心跳的地方看文件 mtime |
file_size | 文件大小上限 | 防止 DB 膨胀 |
http_endpoint | HTTP 健康端点 | 对外接口检查 |
process_running | 进程存活 | 常规进程检查 |
结果验证:
V2 框架首次运行:19 项检查,18 通过,1 个真实 error
假阳性:0
那个唯一的 error 是什么?bh-daily-health 被暂停后刚恢复——它在 Dead Man's Switch 下暴露了自己 174h 没跑的事实。这是有意义的告警。
对比老的 system-health.py:
V1 输出:3 errors, 1 warning
❌ cron error × 2(一个真问题 + 一个假阳性)
❌ memory overflow(真,已修复)
⚠️ cron paused(真,已恢复)
V2 输出:1 error
❌ bh-daily-health 174h 未跑(真,恢复中)
假阳性率从 33% 降到 0%。
六、为什么这很重要
这事说起来不大——就是一个监控脚本的重写。但我认为它代表了一类值得讨论的问题。
AI Agent 系统有一个特征:系统自动搭建、自动配置、自动运行,但没人负责"忘记"。
我过去一年多搭建了超过 50 个脚本、37 个 chron job、一套自进化管线、一个跨机器 sync 系统——大部分通过对话,几句 prompt 就搭起来了。搭的时候很快,但我不会定期回来检查"那个 cron 还活着吗"。
这就导致了一种独特的系统熵增:
prompt → 建了一个 cron → 它跑起来了 → 没人再关心
→ 某个依赖变了 → 它停了 → 没人发现
→ 它对在告警 → 人学会了忽略 → 它还在报
这里的核心问题是:不是告警不够多,而是告警和系统之间的关联不够精确。
组件声明式检查 + Dead Man's Switch 从两个方向缓解这个问题:
- 组件声明式:你和系统的契约是双向的。你建了它,它告诉你它需要被检查什么。你删了它,检查自动消失。
- Dead Man's Switch:从"发生了坏事情"的告警模型切换到"该发生的事情没发生"——后者更贴近"沉默失败"的本质。
七、还有一些坑
记几个踩到的,给想抄作业的人:
datetime 时区陷阱
# ❌ 会报错:datetime.now() 是 naive,fromisoformat 返回 aware
age_hours = (datetime.now() - last_dt).total_seconds() / 3600
# ✅ 用 timestamp 安全跨时区
age_hours = (datetime.now().timestamp() - last_dt.timestamp()) / 3600Python 3.11 以上 datetime.fromisoformat 会保留时区信息。datetime.now() 是 tz-naive。相减就炸。踩完才意识到这是 Cron ISO 时间戳的标准格式——几乎必踩。
no_agent cron 参数陷阱
cron 的 script 字段如果含空格会被当成完整文件名。不要写 script: "pipeline-p3.sh incremental"——建个 wrapper 脚本,在里面 exec bash pipeline-p3.sh incremental。
假阳性的第三条路
我一开始只想"修复那两个假阳性检查项"。但深入后发现,修复一个假阳性不够——今天修了 laptop SSH 检查,下周可能又加一个别的。真正的问题不是"这个检查错了",而是"检查和组件之间没有声明式关系"。 所以走了重写的路。
总结
如果你有一个系统巡检脚本,它已经积累了超过 3 个检查项且两年没大改过——它很可能存在假阳性。
怎么验证?看这条:你的告警里面,是否存在一个你学会了忽略的项?
如果有,那这个告警比没有更糟。
代码在 ~/.hermes/scripts/health-v2.py,框架是通用的。数据目录替换一下就能用在任何项目上。