← All posts

我们证明了每条告警都能触发,结果大多数都不能。

Authagonal·July 31, 2026

监控有一个让它格外危险的特性:它是一种只在灾难发生时才运行的代码。你系统里的其他每一个部分都在持续运行,直面用户,抛出你能注意到的错误。而你的告警只在数据库被写满的那一天才运行,在那之前从不运行;一旦它坏了,你恰好会在最依赖它的那一刻才发现,并且是以一种保证当时无人盯着的方式发现。一条沉默的告警比没有告警更糟,因为没有告警你清楚自己没有,而一条沉默的告警却让你以为自己有。

于是我们不再因为告警是我们亲手写的就相信它们能工作,而是搭建了一套测试装置来加以证明。对每一条我们部署的告警规则,这套装置都会注入一个本应触发它的真实信号,然后去做那件真正重要的事:它会等待通知真正抵达一个捕获中继,那是一个小服务,替代了本该震动的那部手机。在监控工具自己的 UI 里触发不算数。送达才算数。要么走完从信号到消息在手的完整链路,要么就算失败。

第一次真正运行时,大多数规则都没能通过。下面就是那些藏在绿色仪表盘背后的东西。

最核心的那条告警已经死了

我们拥有的最重要的规则,就是在错误率攀升时触发的那条:HTTP 500 太多了,呼叫某个人。测试装置向它注入了四百五十个真实的服务器错误,核实其中至少有百分之九十确实返回了 500,然后等待。什么都没来。这条我们最希望能够信任的告警,一次都没有触发过,在真正的事故里也不会触发。

根源是一个名字。我们的指标来自 OpenTelemetry,那里的名字是用点号分隔的,比如 http.server.request.duration。在读教程和写查询之间的某个环节,我们想当然地以为监控后端会像很多 Prometheus 工具那样,把这些点号规范化成下划线,于是照着下划线的形式写了告警查询。可后端并不做规范化。它原样保留发过来的带点名字。于是这条告警查询的是一个并不存在的指标,什么都匹配不到,永远算出零错误率,安安静静一片绿色地待在那里;而真正的指标以它真正的带点名字,把那些 500 一个不落地记录了下来。这个查询错得不会报错。它错的方式是悄悄返回空集,而算术又把空集变成了一个看起来完全合理的零。

把每一条日志告警都一起拖垮的那个

那已经够糟了。下一个更糟,而测试装置之所以能抓到它,只是因为它坚持要通知送达,而不是只看规则本身是否正确。

我们有一整类告警是基于日志的:匹配日志里的某一行,就触发。它们中的每一条都在空无一物上做判断,而原因和告警规则毫无关系。是一个保留期设置。

我们通过后端的设置 API 来对齐日志的保留期。那个 API 已经升级到了带有新请求结构的新版本,而我们还在发送旧的结构。旧的请求体并没有被拒绝。对端的解码器会悄悄忽略它认不出的字段,于是它把我们那些如今已成未知的字段当作缺失,对找不到的保留期取了默认值,而那个默认值是零。这次调用欢天喜地地返回了 200。而零天的保留期意味着即时过期:新写入的日志数据被盖上了立刻过期的印记,于是日志告警所查询的那个存储,实际上永远是空的。这段时间里数据摄取看起来完全健康,因为日志确实在到达。只不过它们一落地就立刻老化清除了。

两个系统各自都做了一件说得过去的事。设置 API 接受了一个它并不完全理解的请求,而不是直接失败,这是一种常见且往往合理的选择。我们的部署发送了一个已经因版本升级而过时的请求体。两边都没有报出任何异常。结果是,我们日志流水线里最要紧的那个数字,也就是一条日志能活多久,被悄悄设成了零,而唯一可见的症状,就是每一条日志告警都永久地、健康地保持沉默。如果我们检查的是规则看起来对不对,它们看起来完美无缺。只有去检验一个真实记录下来的事件是否能产出一次送达的呼叫,才能把它暴露出来,因为只有那种检验才会真正从那个已被清空的存储里读回数据。

那些永远在触发的告警,而永远触发等于从不触发

第三个故障朝着相反的方向发展,也是最微妙的一个。有几条审计告警在不停地触发。这听起来像是沉默告警的反面,但它产生的效果一模一样,而弄清楚为什么,值得我们绕这一小段路。

当这些告警中的一条触发时,监控后端会写下一行关于这次判断的日志,一条痕迹,而这一行里以文本形式包含了告警自己的过滤表达式。这条痕迹本身也像其他任何东西一样被作为日志摄取了进来。于是下一次规则判断时,它就找到了一行匹配的记录:上一次自己留下的足迹。这条规则变得自我维持了。它匹配的是自己的影子,每一次判断,永远如此。

一条始终在触发的告警,永远不会从未触发转变为触发,而正是这个转变才会发出通知。于是告警层看到一个已经为真且一直为真的条件,就按照设计压制了后续的通知,好让你不必每分钟都收到一次关于你早已知晓之事的呼叫。后果是,一个真实的事件,一次真正值得呼叫的审计动作,落进了一条已经卡在触发状态的规则里,于是它什么也没说。一条被永久钉在开启状态的告警,和一条被永久钉在关闭状态的告警一样哑。修复办法是把这些规则的范围限定在我们自己的服务发出的日志行上,让它们不再匹配后端关于自身的絮叨,只保留一个刻意的例外,它的真实信号确实来自我们服务之外。

把一个 Pod 的第一次呼吸当成正常的那条规则

最后一个,是关于速率计算的一堂短小而尖锐的课。一条建立在某个指标变化率之上的告警,无法从一个序列的最初一个样本算出速率,因为它前面没有任何东西可供比较。于是当一个刚起来的 Pod 喷出它第一波错误时,那一波成了基线,而不是尖峰,那条本应抓住它的规则只看到了一个起点。我们在测试装置里修复了这一点:把这类信号分两波注入,先来一小波来建立序列,再来一波真实的,这也恰好描述了生产流量自己会做的事,以及一个合成测试必须刻意去模仿的东西。

每一个故障都藏在一道只有实际演练才能找到的接缝里

这些故障里的每一个,对检视而言都是隐形的。查询读起来是对的。规则已经部署。仪表盘一片绿。数据摄取健康。每个故障都住在两样各自都没问题的东西之间的接缝里:一个指标名字和一个关于规范化的假设,一个请求体和一次版本升级,一条告警和它自己触发所产生的日志,一个速率函数和一个刚刚开始的序列。你无法读出一道接缝。你必须去演练它。

所以,把监控当作它本来的样子来对待,那就是代码,而且是唯一一次生产环境运行就发生在紧急关头的代码。只在紧急关头才运行的代码,需要它的测试在其余每一天都运行,因为没有一次温和的初次失败来给你预警。端到端地注入一个真实信号,并对链条最末端的那件产物,也就是送达的通知做断言,而不是对中间那条看起来健康的规则做断言。任何达不到这一点的做法,检验的都只是你写过一条告警,这你本来就知道,而不是检验它会触发,这才是你真正想要的唯一一件事。

如果你更希望自己的身份提供商早已在这样盯着自己,那么 Authagonal 正是通过这套测试装置来运行自己的告警的,这样当某天出问题时,那条本该触发的呼叫真的会触发。