一个探针不是一个系统
一句话
写出第一个探针只证明了这个形态可行。探针库和告警规则是同一类东西,一堆随时间累积的判定,而告警规则已经把这类东西的结局演示过一次了。 所以「怎么做成一个能用的系统」这个问题,等价于「怎么不重演那个结局」。
前一篇讲了为什么要有探针层,以及第一个探针跑在生产规则集上的结果。这一篇讲从那一个探针到一个能被团队依赖的判定层,中间隔着什么。
先看那个已知的结局
告警规则腐烂有三条机制,它们不是执行力问题,是激励结构问题:
| 机制 | 内容 |
|---|---|
| 加告警免费 | 新增一条是一行代码,没有准入成本 |
| 删告警受罚 | 删掉之后万一出事,责任在删的人身上;留着不会有人怪你 |
| 失效无声 | 规则因指标改名而永久静默,表现出来和一切正常完全一样 |
任何判定库只要满足这三条,最终形态都是一样的:数量单调增长,信噪比单调下降,没有人敢清理。
所以设计探针层的时候,逐条问「这一条我怎么破」,比列一张功能清单有用得多。
破第一条:准入门槛是「能淘汰哪些假设」
告警规则的准入问题在于门槛太低且判据模糊。「这个指标异常时我想知道」听起来永远成立。
探针层的准入判据可以硬得多,因为探针的用途比告警窄且明确:它存在的意义是帮助收敛一次调查。
所以提案模板只有一个必填的核心问题:
1 | 这个探针能区分哪些假设? |
这条判据来自情报分析的 ACH 方法:证据的价值不在于它支持某个假设,在于它能把假设集合切开。一条对所有假设都支持的证据,信息量是零。
配套的三个必填项:
| 字段 | 作用 |
|---|---|
owner |
没有 owner 的探针在第一次退役审查时直接删除 |
cost_class |
这个探针的查询开销档位,用于配额,见后文 |
replaces |
它是否覆盖了某条既有告警规则的判定。填了就意味着那条规则可以被生成而不是手写 |
replaces 这一项承担了防重复的职责。判定重复是告警规则膨胀的一大来源,而重复往往不是有意的,是因为没人查得动几百条规则里有没有同义的那条。
破第二条:探针有调用记录,所以可以做基于使用的回收
这是探针相对告警规则的结构性优势,值得单独说。
告警规则没有「调用」这个概念,只有「触发」。一条从不触发的规则,你无法区分它是「守着一个从未发生的故障」还是「已经坏了」。所以删它永远有风险,于是没人删。
探针是被调用的。调用就有记录。于是第一次可以基于使用做垃圾回收,判据是行为不是猜测:
三个设计点:
零调用不等于立刻删除,先降级。 deprecated 是一个可逆状态,给 30 天窗口。这样清理的心理成本降到接近零,而心理成本正是「删告警受罚」的真实内容。
draft 有过期时间。 提出探针很容易,写完就忘也很容易。60 天不晋升自动丢弃,避免 draft 区变成垃圾场。
晋升到 stable 的判据是「在一次真实调查里被采用」,不是「代码写完了」。 这条判据无法靠自觉满足,需要调用记录来证明,所以它也是可执行的。
调用记录同时提供了一个别的东西:哪些探针在被高频调用,说明它们对应的判定值得考虑固化成常驻告警。 使用数据是双向的,既指导删除,也指导晋升。
破第三条:失效无声,靠元探针加一个 SLI
第三条最贵,因为前两条至少还看得见。
元探针(检查这个观测本身还有效吗)解决的是存量检测,这个前一篇讲过。这里补另一半:探针层自己需要一个 SLI,而最有诊断力的那个是 unknown 率。
1 | probe_unknown_ratio = unknown 返回数 / 总调用数 |
单个探针返回 unknown 是正常的,数据缺失本来就该显式说。但unknown 率的突然上升几乎总是意味着数据源那一侧出了问题:exporter 挂了、指标改名了、relabel 规则改动了、租户标签变了。
这个信号在传统告警体系里根本不存在,因为传统体系里数据缺失是静默的。把它显式化之后,它成了整个观测栈健康度的一个前置指标。
探针层的三个基础 SLI:
| SLI | 说的是什么 | 异常时的含义 |
|---|---|---|
| 调用成功率 | 探针本身有没有跑挂 | 探针代码或 TSDB 的问题 |
| P95 调用延迟 | 会不会拖慢调查 | 表达式退化或 TSDB 压力 |
| unknown 率 | 数据源健康度 | 上游观测栈出问题了 |
单一定义,多种绑定
这是和现有体系共存时最关键的一个架构决策。
天真的做法是探针库和告警规则并存,两边各自维护。结果必然是漂移:同一个判定在两处定义,改了一处忘了另一处,然后没有人知道哪个是对的。
正确的做法是方向明确的单向生成:
告警规则从探针定义生成,不是手写另一份。这样带来三件事:
- 判定只有一个真相源,漂移在结构上不可能发生
- 迁移可以是渐进的:先把新增的判定走探针定义,存量规则按需回收,不需要一次性重写
- 「这条告警背后的判定是什么」这个问题第一次有确定答案,因为它有一个探针 ID
需要接受的代价是多了一层生成,调试告警时要看生成产物而不是手写文件。这个代价和 IaC 相对手工改配置的代价是同一种,我认为同样值得。
正确性怎么验证:回放历史事故就是 eval
探针会写错,而且写错的方向通常是误报。我自己写第一个探针时就有两个解析 bug,两个都是把活着的规则报成死的。一个会驱动人删好规则的工具,比没有这个工具更糟。
所以探针必须有测试,而探针的测试有一个天然的数据来源:历史事故。
每一份事故记录里都有时间窗、受影响的范围、以及事后确认的结论。这三样正好构成一条测试用例:
1 | - probe: decompose_latency |
三层测试,成本递增:
| 层 | 测什么 | 成本 |
|---|---|---|
| 静态 | 表达式语法、参数约束是否自洽、返回 schema 是否合规 | 极低,可以进 pre-commit |
| 固定 fixture | 用一段录制的时序数据,断言已知输入得到已知输出 | 低,需要一次录制 |
| 事故回放 | 用真实事故的时间窗,断言探针能得出事后确认的结论 | 中,但每次事故复盘顺带产出一条 |
第三层是最有价值的一层,而且它有复利:每一次事故复盘顺带产出一条回归用例,探针库的测试集随着事故积累自动增长。 这也倒逼一件好事,事故复盘必须写清楚时间窗和结论,否则它无法被转成用例。
需要注意的约束是数据保留期。事故回放依赖那个时间窗的数据还在,所以关键事故的原始查询返回应该在事中就落盘,而不是指望事后还能查到。
运行时的三件事
前面讲的是生命周期,这一节是跑起来之后的。
配额。 探针被自由调用,就可能打爆 TSDB。三条约束:每个探针在提案时标 cost_class;高开销探针在单次调查内有调用次数上限;同一次调查内相同的 probe + args 结果复用,不重复查。第三条的收益比想象中大,因为调查过程里反复确认同一件事是常态。
权限。 这是一个容易漏的坑。探针如果以服务身份查 TSDB,就绕过了调用者的租户隔离,一个只该看 A 租户的调用者可以通过探针读到 B 租户的数据。探针必须继承调用者的权限,不能提权。 实现上是把调用者身份透传到查询层,而不是让探针持有一个全权凭证。
降级。 观测栈自己会出问题,这时候探针返回什么至关重要:
| 情况 | 必须返回 | 绝不能返回 |
|---|---|---|
| TSDB 不可用 | unknown + reason |
false |
| 查询超时 | unknown + 已用时长 |
部分结果当完整结果 |
| 数据有 gap | 带低 data_completeness 的结果 |
不标注的结果 |
| 表达式报错 | unknown + 原始错误 |
静默跳过 |
右边那一列全是同一类错误:把「不知道」表达成「没问题」。 这正是告警规则第三条腐烂机制的根源,探针层如果在降级路径上重犯,前面所有设计都白做。
最小可行的成熟形态
不需要一次做全。按依赖顺序,前四件是地基:
| 顺序 | 做什么 | 不做的后果 |
|---|---|---|
| 1 | 返回契约定死并版本化 | 后面一切都要返工 |
| 2 | 调用审计落盘 | 生命周期和配额都无从谈起,它们都依赖调用记录 |
| 3 | 降级路径全部返回 unknown | 直接重演失效无声 |
| 4 | 静态测试进 CI | 误报会驱动人做错误的清理 |
| 5 | 生命周期状态机与退役规则 | 库开始膨胀 |
| 6 | 从探针生成告警规则 | 判定开始漂移 |
| 7 | 事故回放测试集 | 正确性只能靠人工检查 |
第 2 件最容易被跳过,因为它在只有几个探针的时候看不出价值。但生命周期管理、配额、使用统计三件事全部建立在调用记录之上,没有它,后面就只能靠人工判断,而人工判断正是告警规则失败的地方。
探针和 agent 之间还有一层
单个探针一次调用只回答一个问题。真实调查从来不是一个问题接一个问题地问,而是一开始就有一堆信号同时冒出来。一次线上事故,同一个时间窗里可能有五六条告警同时触发,分布在不同租户、不同组件。这批信号是不是同一件事,谁跟谁沾边,这件事本身不需要世界知识,同 cluster、时间窗是否重叠、有没有共享 label,几条确定性判据就能判完。
如果把这批原始信号直接丢给 agent,它要先花一轮推理把这件事重新做一遍。这违背了前面反复用到的那条设计目标:能确定性算出来的判断,不该留给会幻觉的那一层去做。所以探针和 agent 之间需要一层编排,做的事是关联同一批信号、把该跑的那批探针跑一遍、按影响面排序,最后拼成一份东西交给 agent。这一层本身不调用模型,和探针一样是纯确定性代码。
agent 的输入因此不是原始探针返回值,是编排层产出的一份 incident bundle。
bundle 里最要紧的四个字段
1 | { |
correlation_basis 存在的理由是防止 agent 把编排层的分组当成既定事实。这几条信号为什么被认为是同一件事,得显式写出来,不然 agent 拿到一批已经分好组的信号,会默认这个分组是对的,调查方向从第一步就被这个没说出口的假设锁死。写成 same cluster 加 window overlap 4m 之后,这个分组本身变成一条可以被推翻的证据,不再是一个不能质疑的前提。
impact 对应的是前面「还剩多久」那类判断,受影响的租户数、预算消耗速率、预计耗尽时间,在编排层就该算完。这些数字不该留给 agent 去估,道理和探针要返回标量而不是布尔是一样的:确定性能算的东西,交给模型只会更贵、更不可信。
unknowns 把探针的三态往上聚合了一层。单个探针返回 unknown,说明的是这一次查询没查到。unknowns 说明的是这次调查里,哪几个观测整体上不可信。少了这个字段,agent 会把「没查到」直接当成「没问题」,这正是前面反复出现的那个坑,只是这次它出现在编排层,不是单个探针里。
not_queried 解决的是另一个更隐蔽的问题:覆盖率如果不带着「查了哪些维度、没查哪些」一起报,它会自我恭维。一次调查只看了三个维度,覆盖率写 100% 没有意义。这个字段把「没想到要查」变成「想到了但明确没查」,区别很大,前者是盲区,后者至少是一个可以被拿出来质疑的决定。
人拿到手的,应该是已经查过一轮的东西
不是两套系统。判定只有一个定义,告警规则从探针生成这件事前面讲过。这里要补一个此前没讲的整合点:agent 产出的东西最终要交到人手上,交接发生在哪一刻,决定了这套系统到底值多少。
理想的顺序是,告警触发之后,agent 在人打开 Slack 之前就把 bundle 和第一轮假设准备好。人看到的不再是一条原始 alert,而是一份已经跑过一轮的调查:哪些假设被验证掉了,剩下哪几条,影响面有多大。人的第一个动作从「开始查」变成「审阅并决定」。
这个定位给出了一个可测量的成功判据:从 alert firing 到人做出第一个决策的时间。这个指标比「MTTR 降低了多少」诚实,因为 MTTR 里混着这套系统完全管不到的部分,比如修复动作本身要跑多久,要不要等别的团队配合。人做出第一个决策这一段,恰好就是 agent 的准备工作实际能影响到的那一段,系统做得好不好,直接体现在这段时间上,不会被后面一大段无关的执行时间稀释掉。
接的时候有一步不能乱:bundle 没填满之前,不要接 agent。前面几类探针、编排层的关联与富化都得先跑通,bundle 里 impact 这些字段能自动填出来之后,再让 agent 消费它。提前接的后果很直接:bundle 是空的或者半空的,agent 发现能用的信息不够,就会退回去自己直接查 TSDB,绕开探针和编排层,变回一开始就想避免的那种自由查询。
边界
我做到第 1 到第 4 之间。返回契约有了,第一个元探针跑通并在生产规则集上出了结果,静态测试有但不完整。生命周期状态机、从探针生成告警规则、事故回放测试集都还是设计。编排层、bundle 契约、和 agent 对接的那几步,同样只停留在设计阶段,没有代码。
所以这篇文章的定位是设计而不是复盘。我把它写出来,是因为「写出第一个探针」和「有一个能用的判定层」之间的距离,比写第一个探针时看起来要大得多,而这个距离恰恰是判定库这类东西成败的地方。第一步是最容易的那一步。