背景脱敏:某出海棋牌项目,Go 微服务跑在自建 K8s 上,监控栈是 kube-prometheus-stack,告警经 AlertManager 投递到飞书。两个月里陆续查出 80 条告警规则从上线那天起就没有响过一次:21 条是 Helm 占位符没被渲染,59 条是选择器空转。本文讲它们各自是怎么失效的,以及最后落成的一套「对着运行时验证」的机制。
现象:一台节点内存 97%,零告警
事情的起点是一台宿主机被压测打爆。内存从常态 12G 爬到 97%,kubelet、sshd 全部僵死,节点 NotReady,监控栈自己也住在这台机器上,一起瞎了。
事后翻告警记录,一条内存告警都没有。而「节点内存压力」这条规则明明前一天刚上线——它本身就是上一次事故复盘写出来的预防措施。
规则在 /api/v1/rules 里能查到,语法校验通过,Prometheus 加载成功,health 恒为 ok。它只是从来没有匹配到任何序列。
排查:同一种病的七种形态
顺着这条线往下查,一天之内又实锤了好几处,之后两周越挖越多。把它们摊开看,失效方式各不相同,但共性只有一个:全都不报错。
1. Helm 占位符没被渲染(21 条)
告警规则写在 kube-prometheus-stack 的 additionalPrometheusRulesMap 里,有人图省事在表达式里用了 ⟨⟨ .Values.namespaces ⟩⟩ 这种 Helm 模板变量(⟨⟨ ⟩⟩ 代指双花括号,避开本站模板引擎)。问题是这个 chart 不渲染 这段 values——占位符以字面量原样进了线上正则。namespace=~"⟨⟨ .Values.namespaces ⟩⟩" 永远匹配不到任何东西,但它是合法正则,health 依然 ok。从 6 月初到 8 月中,21 条告警就这样静静躺了两个多月。
2. job 标签写错
节点内存告警过滤 job="node-exporter",而本栈的 job 实际叫 k3s-node-exporter / prod-node-exporter。一字之差,两条告警自上线起恒空。
3. 抑制规则指向不存在的告警名
AlertManager 的 inhibit 写的是 NodeDown → Pod.*,可这个栈里根本没有叫 NodeDown 的告警(真名是 KubeNodeNotReady),而且 equal: [instance] 两侧的 instance 格式还不一样。节点一失联,飞书被几十条 Pod 级告警轰炸——抑制规则从来没抑制过任何东西。
4. 死面板与死告警
一个大厅服务退役后,8 个面板和 1 条告警还在引用它上报的指标。指标断流 8 天,面板恒 No data,告警恒空。退役时收口了代码和流量,没有人把监控资产当「调用方」盘点。
5. 压根没 apply
「6.193 CPU 98.8% 怎么不报警」——这次 job 正则是对的,但含 CPU 告警的那一组规则合入主干后从未 kubectl apply,线上 CR 的 creationTimestamp 还停在三天前。放大器是部署脚本只 apply exporter 和 webhook,从不 apply 任何 PrometheusRule,规则全靠人手敲命令。
6. 阈值与生命周期不相容
排空告警「排空耗时 > 600 秒」是按老玩法 600 秒上界定的,套到新玩法上,它的 preStop 120 秒就放弃、grace 180 秒就 SIGKILL,duration 永远到不了 600。规则正确、语法正确、加载成功,就是物理上不可能触发。
7. 常亮等于没有
某节点过去 7 天 100% 的时间都在报内存压力。另一台节点从 08:36 告警到 11:16 崩掉,2 小时 40 分钟没人反应——因为大家早就把那条告警当成了背景噪音。
关键机制
判据只有一条:它真的动过一次
所有这些的共同点是「机制存在」被当成了「机制生效」。后来定下的域级判据很朴素:
新指标 = 造一次真实事件后查
count(<指标>) > 0;新告警 = 当场验证选择器能匹配到序列;新 inhibit = 验 alertname 存在于/api/v1/rules;新面板 = 保存后非 No data。「apply 成功」四个字不构成验证。
人不会记得做这件事(三处空转的作者都没做,其中一处还是复盘产物),所以交给脚本。
空转体检脚本:三层判据
check_rules_liveness.py 对着运行中的 Prometheus / AlertManager 验证每条规则。难点在稀疏事件型指标——result="failed" 这种标签值可能几天都不出现,直接查「组合为空」会误报一片。判据分三层:
指标近 7 天零序列 → 疑似退役 / 未接入 FAIL
label 匹配值不在实际取值域 → 写错(job/env/cluster) FAIL
组合为空但各 label 都在取值域 → 事件型计数器,未发生 不报
取值域必须用 count_over_time[7d] 窗口算,瞬时取值域对稀疏指标会误报。
首跑抓到 59 条存量。逐条定性后:27 条是 job="node-exporter" 同一个病根;一批是已退役数据库的整条监控链路(mongodb_up=0 已经 110 天);还有 Helm 默认规则组里在本栈不适用的一批。全部清零后脚本转为阻断。
后来又补了检查 C:仓库里的告警名 ⊆ 线上已加载的告警名。因为前两层只体检「已加载的」规则,天生查不出「压根没上线」那一类。上线前先跑一次让它精确报出那条缺失的规则,再 apply 后复跑变绿——只看一次绿等于没验。
真源只能有一个:GitOps 与「生效了又消失」
把规则纳入 ArgoCD 管理后,出现了一种新症状:kubectl apply 上去的规则几分钟后静默消失。原因是 ArgoCD 开着 selfHeal + prune,任何直接写集群的改动都会被它按 Git 真源回退。文件头的注释还写着旧的 make monitoring-deploy 口径,照着注释操作就是往一个会被自动撤销的地方写东西。
真源确定之后又冒出第二层漂移:源码仓的规则要人工拷到部署账本仓。改了源码、CI 全绿、合入主干,集群行为纹丝不动,全程零报错。于是加了两件东西:
- CI 单向投影:主干上规则文件一变,job 自动把
kind: PrometheusRule的 yaml 投影到账本仓,含删除同步——源里删了账本必须也删,否则就是「规则已从源码移除、集群还跑着」。 - 漂移门禁只硬拦一个方向:「账本有、源没有」拦;「源领先」只警告。因为任何合法改规则的 MR 在合入并同步之前天然就是源领先,硬拦的话解除拦截的唯一办法是先合入——死锁。
同一时期还把并行了很久的两套 Prometheus 合成一套。双栈意味着同一条告警要改两处,长期必然漂移;合并前连续 7 次比对两栈 firing 全部一致(含一次压测洪峰下的有信号实测),才退役其中一套。
每日巡检与飞书卡片
体检脚本从「部署后跑一次」升级成每日 CI job,两仓漂移与空转规则从「偶然发现」变成「每日点名」。有意思的是这个巡检 job 本身也空转过三天——它跑在一个与监控栈不同网段的 runner 上,三次运行全红且从未成功。判据是同 runner、同 schedule 下只有需要访问监控栈的两个 job 红,不访问的那个绿。修法除了换 runner,还给脚本加了起手探活:不通就打印「runner 网络不通」退出码 2,而不是丢一堆 urllib traceback 把人引向「Prometheus 挂了」。
最后是投递端。告警文本改成交互卡片:标题按状态取色(恢复绿、critical 红、warning 橙),标题直接用规则里写成人话的业务影响,正文字段化,firing 时 @ 值班人、恢复不 @,同组超过 5 条只报数量。这一步不解决空转,但解决「响了没人看」。
教训
- 「机制存在」不是「机制生效」。语法校验通过、apply 成功、页面能打开,都不构成证据;唯一的证据是它真的动过一次——序列出现过、告警匹配到过、门禁红过一次。
- 监控配置的失效全是安静的,所以验证必须由机器做。人不会记得,包括写复盘的那个人。
- 服务退役的收口清单必须包含监控资产:面板、告警、指标常量、上报函数,按「调用方计数归零」清点。
- 真源只能有一个,且必须查集群里的 Application 定义确认,不信文件头注释。有了真源之后还要盯第二层漂移:源码仓与账本仓之间。
- 常亮的告警等于没有告警。阈值按实测基线留 13~25 倍余量,报了没人理的规则要么改要么删。