Command Palette

Search for a command to run...

0

沉默的 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-weekly168h(7天)24h✅ 正常
pipeline-incremental-daily30h6h⚠️ 35h(停摆中,已修复)
磁盘看门狗8h2h✅ 每 2.5h 报一次
bh-daily-health30h6h❌ 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_overflowmemory 容量精确到字节,溢出前预警
cron_heartbeatDead Man's Switch核心创新:超过预期周期未跑 → 告警
cron_statusCron 状态查询传统方式,查 exit code
file_freshness文件新鲜度无心跳的地方看文件 mtime
file_size文件大小上限防止 DB 膨胀
http_endpointHTTP 健康端点对外接口检查
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()) / 3600

Python 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,框架是通用的。数据目录替换一下就能用在任何项目上。