告警发多了,我就再也不会点开

ops-agent 建造笔记第 3 篇:通知状态、去重、静默与补发分别记录什么,如何避免告警噪声让真正严重的异常被淹没。

告警发多了,我就再也不会点开

《ops-agent 建造笔记》第 3 篇 代码基线:v0.1.0-rc.1,提交 d897d2c95e70(2026-10-25 发布,候选版) 主要代码:agent/notify.py、agent/events.py、agent/schedule.py


0. 个人告警系统是怎么死的

我给个人项目设计通知时,除了漏报,还担心另一种失败:消息很多,但我慢慢不再看它。

拿一组假设数字算一下:磁盘 87% 超过 85% 的警告线,定时巡检每 5 分钟跑一轮,每轮都发一条消息,一小时就是 12 条。如果真这样连续跑,我很可能看见“ops-agent”就直接划掉;更严重的异常也会混进重复消息里。这是设计时的噪声推演,不是已经发生过的线上告警记录。

所以 W4 做通知的时候,我把"少发"放在了"发到"前面。整个设计其实围绕一个问题:什么情况下,一条消息值得打断我。


1. 先分清新异常、变化、提醒和恢复

每条异常按 fingerprint 记状态。规则检查使用 check:<目标>/<分组>/<检查项> 这一类身份,任务失败、模型结论和错过时点有各自前缀。不同任务查到同一个检查项,可以落在同一份通知状态里。主要发送条件是:

  1. 新出现:这个 fingerprint 第一次异常;
  2. 严重度变化:例如 warning → critical,或原先有异常、后来变成 unreachable,不能把“测不到了”当成恢复;
  3. 超过提醒间隔:renotify_after 默认 6 小时,异常持续存在,隔段时间再敲一次,防"忘了";
  4. 恢复:这个 fingerprint 回到正常。

这比“只在升级时发”多一点:代码里的 _plan() 区分 escalated 与 changed,下降或其他严重度变化也有机会提醒;为了避免来回抖动,5 分钟内不高于上次已通知严重度的变化会被压住。这里比较的是通知层的 warning/critical/unreachable/info,不是把检查层 warn/unknown 原样当发送级别。

同一次运行最多聚合成一条通知。这条规则和去重叠加,效果是:如果同一次检查发现磁盘、内存、负载三个异常,得到一条“3 项异常”,不是三条。daemon 顺带补发历史发件箱是另一条路径,不能因此承诺“每次扫描总共只会发一条”。

文件也要分清。notify.json 是带真实 webhook URL 的渠道配置;指纹状态在 notify-state.json;待发送与发送结果在 notifications/<id>.json。恢复后的旧指纹超过 30 天会清理,仍处于异常状态的条目不会仅因时间久就被抹掉。

这四个条件之外,还有一种"想发但不该发"被我显式拦住了:部分检查的恢复。规则是恢复通知只在"本次确实检查过该目标、且采集完整"时才发。原因很实际——我跑 inspect --target web1,报告里没有 db1 的异常,这不意味着 db1 恢复了,意味着我没查它。如果只凭"这次没看到异常"就发恢复消息,一次局部检查会把所有旧告警错误地清成已恢复。采集不完整就闭嘴,不猜。

恢复还得回答“之前真的送达过没有”。静默或未配置渠道期间发现、又自行消失的异常,会静默清状态,不补一条没人理解的“已恢复”。规则 signal 被截断、目标未覆盖、通知状态不可信时,也不能凭缺失项推断恢复。迟到的慢模型结果用采样时间比较,避免把已经恢复的旧异常重新打开。


2. 三档安静

告警系统还得能关掉,而且关法要有层次:

机制 作用域 说明
quiet_hours 警告与恢复 静默时段(如 23:00–07:00)内压掉 warn 和恢复;异常和"不可采集"仍然发送
mute_until 全部 全静默,连 critical 一起压,只写事件和发件箱;notify mute --for 2h 设置
enabled: false 全部 总开关,等于不发送,事件照记

quiet_hours 里有个不对称:它不会压住 critical 和 unreachable。如果夜间实际采到了这两类结果,发送计划仍会执行;这不等于我已经验证过夜间真实渠道能送达。真到了什么都不想听的时候用 mute,而且 mute 是有明确语义的全静默,事件照记,事后可以翻 events.jsonl 补看。总开关和 mute 仍然能压住 critical,不能把“quiet_hours 不压异常”写成“异常永远不能静默”。

三档之外还有个第四层:notify test 发测试消息不受静默影响。配置完渠道第一件事是验证它能通,如果测试消息也被静默吞了,就没法区分"渠道坏了"和"被静默了"。


3. 发送失败了,不能算成发过了

通知发送本身是网络调用,会失败。我定的规矩是:失败独立记录、有限重试,绝不反过来影响检查结论。

发送失败落 ~/.ops-agent/notifications/<id>.json,保留失败原因、已有的 HTTP 状态码和下次重试时间。调度 daemon 每轮最多补发 5 条,指数退避从 60 秒起、公式上限 3600 秒;自动补发会检查 attempts < 5,首次发送也算一次尝试。默认这几次实际等待是 60、120、240、480 秒,不是会一直重试到 1 小时。人工明确指定 notify retry --id <id> 是单独的重试入口,不受自动到期/次数筛选限制,但仍受静默与陈旧消息检查约束。

重试里有个细节:补发前会用当前状态刷新消息内容,已经恢复或被更新通知取代的内容不再照旧发;整条没有有效项目时标记 superseded。我用时间顺序推演过这个风险——10:00 异常消息失败,10:20 状态恢复,10:30 才补发旧的异常,如果原文照发,就会把新状态倒着讲。一个聚合消息里只有部分项目过期时,还要剔除过期项,而不只是整条删掉。

HTTP 2xx 也不是所有渠道都算成功。Slack/飞书/钉钉的响应内容还要按格式检查;只有实际送达分支才更新 delivered_at 等状态。失败的恢复消息留在同一份发件箱里重试,不能每轮新建一条,把次数重新清零。

凭据这块的口径和秘密保护一致:webhook URL 的 path 往往就是 token(Slack/钉钉都是这样)。notify.json 存原文但权限 0600,且被列进敏感文件清单,read 和命令入口都拒绝读它;事件、发件箱、审计里只存 scheme://host/…。notify status 显示渠道时也只显示到主机名。


4. 时间线只记变化

和通知配套的是 ~/.ops-agent/events.jsonl(0600,5MB 轮转)。一开始我想记全量,每次巡检一条事件。想了一下就否了:每 5 分钟一轮、十几个检查项,一天几千条"一切正常",时间线变成第二个日志洪水,真出事的记录反而找不到。

改成只记状态变化:异常出现/变化/升级/提醒/恢复、通知发送/失败/静默、运行失败/超时/中断/错过。持续正常的检查不单独制造“正常”事件,但一次正常结果如果证明了旧告警恢复,仍会产生恢复事件。这样 opsagent events --tail 30 翻出来的是变化时间线,不是每轮检查的流水账,更不能把当前这些测试记录称作生产事故史。

事件、发件箱和运行记录之间保留关联证据:run、session、报告路径、异常的探针命令和输出片段。各类事件不保证都塞进全部字段,它们是索引,不是完整证据仓库;消息正文还会受 3500 字符和最多 50 项的上限影响,因此必须保住报告路径与追溯入口。我可以先沿这些记录核对,证据不足时仍要去目标机确认。

候选版补了读侧保护:events.tail() 不只相信历史文件已经脱敏,读取旧版或人工导入的记录时再次调用 core.redact_obj();坏 JSON 或递归过深的记录跳过。原始事件文件不重写。这和记忆召回是同一条理由:旧文件迟早也会进入 CLI 或模型上下文。


5. 现在只做了一个渠道

W4 刻意只做单渠道(一个 webhook,支持 slack/feishu/dingtalk/generic 四种格式),没做多渠道路由、没做告警分级路由(critical 走电话、warn 走 IM 之类)。一个人用,一个出口够了,多出来的路由逻辑全是没有用户的复杂度。路线图里写了,多渠道按实际使用频次进下一期。

我希望最后能观察到的是:持续同一个异常不反复刷屏,真正的状态变化能被我看到。但“连续几天没有消息”本身不能证明正常,也可能是 daemon 没跑或渠道坏了,这要和运行记录、心跳、发件箱一起核对。

目前能证明的范围是隔离测试:本地 HTTP 桩演练验证了通知失败、补发、恢复与去重,系列对应的候选版已有四组发布 CI 通过。本版 W8 的真实通知渠道、SSH/LLM 外部验收及修复后连续 7 天观察仍未完成,我没有把“已收到真实告警”写进验收结果。

已知边界也认账:单机部署,宿主彻底宕机时通知发不出去。要防这个,得把心跳超时检查放到另一台机器或外部服务上,README 里写了这个选项,我自己还没做到那一步。

下一篇换个话题,讲变更侧:远程执行。SSH 断线之后那条命令到底执行了没有——unknown 状态的整个设计,以及为什么它是我认为整个项目里最重要的一个状态。

这篇对应的代码


系列总览 · 目录 · 上一篇:巡检我不交给模型判 · 下一篇:SSH 断了,那条命令到底跑没跑