路线图

08 告警工程

星辉 2026-07-02 阅读 7 min 1,296 字 路线图
08 告警工程 封面

概述#

告警是监控系统里唯一会主动打扰人的部分。它的质量直接决定监控系统是被信任还是被无视。本章从症状告警 vs 原因告警的根本性选型开始,到燃烧率告警的工程实现,到告警疲劳的治理策略,建立一套"少而精、值得半夜爬起来"的告警体系。

一条好的告警必须满足三个条件:是真问题、需要立刻处理、知道怎么处理。三个条件缺一:不是真问题 = 噪音;不需要立刻处理 = 不该是 page;不知道怎么处理 = Runbook 缺失。这三个条件是告警工程的底线。

Alertmanager 的部署配置(路由/分组/抑制/去重)见 DevOps Ch25。本章聚焦告警设计方法论治理策略

一、症状告警 vs 原因告警#

这是告警工程最核心的选型决策。

症状型告警(Symptom-based)#

基于用户实际感受到的问题。

text
✅ Payment API 错误率 > 5%
✅ 下单成功率低于 99%
✅ P99 延迟超过 2s
✅ 用户登录失败比例 > 1%

原因型告警(Cause-based)#

基于系统内部某个组件的状态。

text
❌ CPU > 80%
❌ Redis master pod 不可用
❌ 内存使用 > 90%
❌ Pod 重启次数 > 3

为什么症状型优于原因型#

维度症状型原因型
与用户感受一致✅ 直接对应❌ 需要推断
遗漏率低——用户感知到的都能覆盖高——想不到的根因不会配告警
噪声率低——只在用户真受影响时响高——CPU 高但用户没事(狼来了)
可操作信息明确(服务有问题)模糊(CPU 高是什么导致?要查)
维护成本低——SLO 不变就不改高——架构变就要改告警
误报率

Google SRE 铁律:会 page(呼叫值班人)的告警必须是症状型。原因型指标留在排障时下钻用,不要直接触发 page。

原因型指标的归宿#

不是不监控 CPU/内存/连接数,而是不直接 page。它们有自己的位置:

  • Dashboard 面板(随时可以看)
  • Recording Rules 预计算(辅助分析)
  • 告警规则的子条件(如"错误率 > 5% AND 同时在 3 个以上节点 CPU > 90%")
  • 作为 Suppression 条件(抑制其他告警)
  • Ticket 级告警(不 page,但记录工单)

例外:什么时候原因型告警也合理#

少数场景原因型告警是必要的:

  • 磁盘空间:磁盘满会导致服务挂,提前告警有意义。但应配为 ticket 不是 page
  • 证书过期:证书过期会导致 TLS 失败,提前 30 天告警
  • 备份失败:备份失败本身不影响用户,但影响灾难恢复能力
  • 复制延迟:DB 复制延迟不影响读,但影响灾难切换

这些场景的共性:问题还没影响用户,但即将影响。提前告警有真实价值。但仍应避免 page——除非真的会立即影响用户。

二、燃烧率告警(多窗口多燃烧率)#

概念背景见 Ch04-错误预算。本章聚焦 Prometheus 工程实现。

为什么需要多窗口#

单窗口的固有问题:

  • 短窗口(5min):毛刺多,误报频繁
  • 长窗口(6h):真实故障已持续很久才发现

多窗口方案:用两个窗口 AND 逻辑——短窗口确保"现在在出问题",长窗口确保"不是瞬时抖动"。

yaml
# P0 告警:快速燃烧(14.4x:1 小时即烧掉月度预算的 2%)
- alert: HighErrorBudgetBurnRate
  expr: |
    (
      job:slo_errors_per_request:ratio_rate1h{job="payment"} > (14.4 * 0.001)
    )
    and
    (
      job:slo_errors_per_request:ratio_rate5m{job="payment"} > (14.4 * 0.001)
    )
  for: 2m
  labels:
    severity: critical
    team: payment
  annotations:
    summary: '{{ $labels.job }} Error Budget 快速消耗(1h 燃烧率超过 14.4x 阈值)'
    description: |
      服务 {{ $labels.job }} 当前 1h 燃烧率已达 14.4x 阈值(1 小时烧掉月度预算的 2%)。
      按此速度,月度 Error Budget 约 2 天(≈50 小时)内耗尽。
    runbook_url: "https://wiki.example.com/runbooks/slo-burn-rate"
    grafana_url: "https://grafana.example.com/d/slo?var-job={{ $labels.job }}"

三档告警分级#

级别长窗口/短窗口燃烧率阈值for 时间通道含义
P0 Critical1h / 5m> 14.4x2mpage(钉钉/电话)1 小时内烧 2% 月度预算
P1 Critical6h / 30m> 6x15mpage(钉钉/电话)6 小时内烧 5% 月度预算
P2 Ticket3d / 6h> 1x1hticket(工单)3 天内烧 10% 月度预算(慢烧,月底会超支)

为什么用 AND 不用 OR#

yaml
# 错误:OR 太宽松,短窗口抖动频繁触发
expr: |
  ratio_rate1h > 0.0144
  or
  ratio_rate5m > 0.0144

# 正确:AND,两个窗口都超标才告警
expr: |
  ratio_rate1h > 0.0144
  and
  ratio_rate5m > 0.0144

OR 条件会因为短窗口抖动频繁触发;AND 条件要求两个窗口都确认问题,能滤掉瞬时毛刺。这是燃烧率告警的精髓——用窗口组合实现"既快又准"。

Recording Rules 前置#

燃烧率告警依赖 Recording Rules 预计算。如果没有 Recording Rules,Prometheus 要在告警评估时实时计算多个长时间窗口的 rate 表达式,查询必定超时。

yaml
groups:
  - name: slo_recording_rules
    interval: 30s
    rules:
      - record: job:slo_errors_per_request:ratio_rate5m
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[5m])) by (job)
          / sum(rate(http_requests_total[5m])) by (job)
      - record: job:slo_errors_per_request:ratio_rate30m
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[30m])) by (job)
          / sum(rate(http_requests_total[30m])) by (job)
      - record: job:slo_errors_per_request:ratio_rate1h
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[1h])) by (job)
          / sum(rate(http_requests_total[1h])) by (job)
      - record: job:slo_errors_per_request:ratio_rate6h
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[6h])) by (job)
          / sum(rate(http_requests_total[6h])) by (job)
      - record: job:slo_errors_per_request:ratio_rate3d
        expr: |
          sum(rate(http_requests_total{status=~"5.."}[3d])) by (job)
          / sum(rate(http_requests_total[3d])) by (job)

Recording Rules 的 interval 必须 ≤ 告警 for 时间的一半。如果 interval 5m 而 for 2m,数据刷新不及时会产生奇怪行为。

延迟 SLI 的燃烧率#

错误率有燃烧率告警,延迟同样需要。用户感受到的"慢"和"错"同样影响体验。

yaml
# 延迟燃烧率告警:P99 > 500ms 的请求比例超标
- alert: HighLatencyBurnRate
  expr: |
    (
      job:slo_slow_requests:ratio_rate1h{job="payment"} > (14.4 * 0.01)
    )
    and
    (
      job:slo_slow_requests:ratio_rate5m{job="payment"} > (14.4 * 0.01)
    )
  for: 2m
  labels:
    severity: critical
    slo_type: latency

其中 slow_requests 是延迟超过阈值的请求计数,需要单独 Recording Rule:

yaml
- record: job:slo_slow_requests:ratio_rate5m
  expr: |
    1 - (
      sum(rate(http_request_duration_seconds_bucket{le="0.5"}[5m])) by (job)
      /
      sum(rate(http_request_duration_seconds_count[5m])) by (job)
    )

常见陷阱#

陷阱错误做法正确做法
没有 for瞬间触发P0 for 2m, P1 for 15m
OR 代替 AND短窗 OR 长窗短窗 AND 长窗
没有 Recording Rules实时计算长时间窗口预计算
SLO 值硬编码> 0.0144Helm values 注入或 Recording Rule 存储
只有错误率 SLO忽略延迟延迟也需要独立的 SLO 和 Burn Rate
多 Prometheus 副本不去重两个副本各发一次Alertmanager 自动去重
告警没有 runbook收到不知道怎么处理PR 强制附 runbook 链接
告警 annotation 太简单只有 summarysummary + description + runbook_url + grafana_url

三、告警疲劳(Alert Fatigue)#

告警疲劳是监控系统最危险的状态——告警太多太吵,工程师开始无视所有告警,真故障也被忽略。

告警疲劳的成因#

text
噪音告警 > 有效告警 → 告警群被静音 → 工程师生理脱敏
  → 真正的事故发生时响应延迟 → MTTR 飙升

告警疲劳不是"工程师不负责任",是生理性的脱敏反应。每天看 50 条告警,大脑会自动忽略"看起来不重要"的——这是人类适应环境的本能。治理告警疲劳不能靠"要求工程师认真看每条告警",要靠减少告警数量到人能处理的水平。

告警疲劳的量化指标#

promql
# 每班次告警数(按值班人聚合)
sum by(oncall_user) (increase(alertmanager_notifications_total{receiver="pager"}[7d]))

# 夜间告警占比
sum(increase(alertmanager_notifications_total{hour=~"22|23|00|01|02|03|04|05"}[30d]))
/
sum(increase(alertmanager_notifications_total[30d]))

# MTTA(平均响应时间)
avg(pd_incident_ack_time_seconds)

# 告警真阳率(需要手动标记或从 postmortem 推导)
count_over_time(incident_true_positive[30d])
/
count_over_time(incident_total[30d])

健康指标参考:

  • 每班次 page 告警:< 5 次
  • 夜间告警比例:< 10%
  • MTTA:< 5 分钟
  • 真阳率:> 70%

任一指标恶化就该启动告警治理。

治理策略#

1. 告警审计(每月 2 小时)

从 Alertmanager/PagerDuty 导出上个月的告警数据:

  • 按 alertname 统计出现次数
  • Top 10 最频繁的告警逐一 review:这是有效告警吗?
  • 上个月新增的告警是否合理
  • MTTA > 15 分钟的告警,为什么响应慢
  • 真阳率最低的告警,考虑降级或删除

实施效果(来自 On-Call 实战文章):第一次告警审计砍掉了 60% 的 page 级告警。

2. 三层分流

层级含义通道响应要求
Page真告警,需要立刻响应钉钉/电话/PagerDuty5 分钟内 ack
Ticket工单级Jira/Issues下个工作日处理
Log仅记录团队频道不强制响应

默认所有告警是 ticket,只有满足"立即响应有意义"才升级为 page。

3. 告警必须有 Runbook

Page 级告警必须附 Runbook,内容包含:

  1. 这个告警意味着什么?
  2. 如何判断是真问题?
  3. 第一响应动作是什么?
  4. 如何回滚 / mitigation?
  5. 如果处理不了升级给谁?

没 Runbook 的告警不能是 page。实际操作:新增 page 级告警的 PR 必须附 Runbook 链接,否则 review 拒绝。

4. 告警可执行性审查

每条告警回答三个问题:

  • 我收到后需要立刻做一个动作吗?→ 是 = page,否 = ticket
  • 这个动作能在 5 分钟内完成吗?→ 是 = 保留,否 = 降低级别或修复 Runbook
  • 如果不做这个动作,用户会在 1 小时内受影响吗?→ 是 = 保留 page,否 = ticket

三个问题都是"是"才配为 page。任一为"否"就降级。

告警治理的实际案例(来自 On-Call 实战)#

某团队历时 4 周的告警清理:

告警名治理前治理动作治理后
DiskUsageHigh126 次/月改 ticket 级0 page
PodRestartFrequent98 次条件改严,1min >5 次才 page12 次
HTTP_5xx_high74 次改成 SLO burn rate 告警30 次
CertExpirySoon56 次90 天提醒一次4 次
NodeCPUHigh49 次砍掉,换成 SLO-driven0 次

总告警量从 42 次/周降到 8 次/周,夜间告警从 11 次降到 2 次,真阳率从 35% 涨到 75%。

最核心的收获:工作量下降之后,真正重要的告警反而被好好处理。以前淹没在噪音里的关键告警被重视起来。

四、Alertmanager:告警分拣中心#

部署配置见 DevOps Ch25。本章讲设计原则。

Alertmanager 的核心能力:

能力作用举例
分组(Grouping)同类告警合并一条通知一次发布挂了 50 个 pod → 只发 1 条
去重(Dedup)多个 Prometheus 副本的同一告警合并HA 部署 2 副本 → 1 条消息
抑制(Inhibition)高级告警触发时压制低级整个集群挂了,别再报每个 pod
静默(Silence)维护窗口临时屏蔽计划内维护 2 小时
路由(Routing)按标签发到不同 on-call 群backend 团队 vs DBA 团队

路由设计#

按 severity + team 两级路由:

yaml
route:
  receiver: default
  group_by: ['alertname', 'cluster', 'namespace']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    # Critical → PagerDuty + 钉钉
    - matchers: [severity = "critical"]
      receiver: pagerduty-critical
      group_wait: 10s
      repeat_interval: 30m
      continue: true               # 同时发到钉钉
    
    - matchers: [severity = "critical"]
      receiver: dingtalk-critical
    
    # Warning → 钉钉普通群
    - matchers: [severity = "warning"]
      receiver: dingtalk-warning
      group_wait: 60s
      repeat_interval: 8h

抑制规则设计#

yaml
inhibit_rules:
  # Critical 抑制同组的 Warning
  - source_matchers: [severity = "critical"]
    target_matchers: [severity = "warning"]
    equal: ['alertname', 'cluster', 'namespace']
  
  # 节点 NotReady 时抑制其上 Pod 告警
  - source_matchers: [alertname = "NodeNotReady"]
    target_matchers: [alertname =~ "PodCrashLooping|PodNotReady"]
    equal: ['node', 'cluster']
  
  # 集群不可达时抑制所有该集群告警
  - source_matchers: [alertname = "ClusterUnreachable"]
    target_matchers: [cluster != ""]
    equal: ['cluster']

equal 字段的陷阱:列表里的标签名大小写敏感,且必须 source 和 target 都有这个标签。如果某条告警没有 'cluster' 标签,抑制规则对它无效。Prometheus 告警规则必须给所有告警都加上 cluster 标签。

静默的安全使用#

bash
# 错误:用 .+ 会误伤 Watchdog
amtool silence add alertname=~".+" --duration=2h

# 正确:用负匹配排除 Watchdog
amtool silence add alertname!=Watchdog --duration=2h --comment="上线窗口"

来自实战的教训:曾有团队上线日用 alertname=~".+" 全 silence,把 Watchdog 心跳也屏蔽了,反向告警机制失效几小时无人发现。所有"全部 silence"必须显式排除 Watchdog。

五、死机开关(Dead Man's Switch)#

一条永远在触发的告警,反向使用:

text
Watchdog 告警持续 firing → healthchecks.io 定期收到心跳

如果某天 healthchecks.io 超过一定时间没收到心跳
  → 说明整条告警链路(Prometheus → Alertmanager → 通知)自己挂了
  → 触发反向告警

这是告警系统自身的告警。每一套监控都必须有——否则监控死了没人知道是最危险的盲区。

Watchdog 配置#

yaml
# PrometheusRule
- alert: Watchdog
  expr: vector(1)
  for: 0s
  labels:
    severity: none
    channel: heartbeat
  annotations:
    summary: "Watchdog heartbeat — 永远 firing,无心跳即链路异常"
yaml
# Alertmanager 路由
routes:
  - matchers: [alertname = "Watchdog"]
    receiver: dead-mans-switch
    group_wait: 10s
    group_interval: 1m
    repeat_interval: 1m

receivers:
  - name: dead-mans-switch
    webhook_configs:
      - url: 'https://hc-ping.com/<uuid>'
        send_resolved: false

反向告警通道必须独立#

反向告警必须发到独立通道——不同的钉钉群、不同的 webhook、最好不同的云 region。原因:如果主告警通道和反向告警通道是同一个,通道本身挂了反向告警也发不出去。

来自 A 旗舰项目的教训:5 套并行的监控栈中,只有 US-prod 配了 dead-man-switch,其余 4 套完全无心跳覆盖。如果那 4 套的 Alertmanager 整体挂掉,不会有任何通知。心跳覆盖率 1/5 是最大的可靠性缺口

六、告警通知模板设计#

好的告警通知让 on-call 一眼就能判断严重程度和行动方向:

text
[CRITICAL] payment-service Error Budget 燃烧告警

服务:payment-service
燃烧率:18.3x(正常基准 1x)
当前 1h 错误率:1.83%
Budget 耗尽预计:~1.6 天(≈39 小时)

→ Runbook:https://wiki.example.com/runbook/payment-burn-rate
→ Grafana:https://grafana.example.com/d/slo?var-job=payment-service
→ 日志:https://loki.example.com/?query={job="payment-service"}

关键元素:

  1. 严重级别(视觉区分):CRITICAL / WARNING / INFO
  2. 数值(燃烧率/错误率,快速判断严重程度)
  3. 时间紧迫度(多久耗尽预算)
  4. 行动链接(Runbook、Grafana、Loki 一键直达)

钉钉关键词白名单陷阱#

钉钉机器人启用"自定义关键词"安全策略时,消息体必须含特定关键词(如"告警""运维")才能发送。firing 消息含"告警"过关,但 resolved 消息只含"恢复"会被拒——这是常见的"恢复通知黑洞"。

解决方案:

  • 模板 firing 和 resolved 都用 【告警】 / 【告警恢复】 前缀
  • 钉钉群关键词覆盖 告警 / 运维 / 恢复 三个全集
  • 切群/换 adapter 时主动 curl 测试 firing + resolved 两类消息

七、告警上线的验证清单#

新告警上线必须验证:

  • 规则语法通过 promtool check
  • 主动制造一次 firing,验证全链路真能推到群
  • 主动制造一次 resolved,验证恢复通知也送达
  • 钉钉关键词覆盖 firing + resolved 两类消息
  • Runbook 链接可达
  • Grafana 链接可达
  • 告警标签完整(severity, team, cluster, namespace)
  • 告警进 GitOps(不是手动 kubectl apply)
  • 配套的 Dashboard 已建
  • 告警规则在 Prometheus API 中可见(/api/v1/rules

最后一条特别重要——曾有团队的告警规则 75 天没生效,因为 PrometheusRule 的 label 不匹配 Prometheus 的 ruleSelector,但没人发现。规则在 kubectl get prometheusrule 里能看到,但没真正注入 Prometheus 配置。

八、告警治理的长效机制#

告警治理不是一次性项目,是持续过程。需要建立:

月度告警审计#

每月 2 小时会议,review:

  • Top 10 最频繁告警
  • 上月新增告警合理性
  • 真阳率最低的告警
  • MTTA 最长的告警
  • 长期 silence 清理

告警生命周期管理#

每条告警有生命周期:

text
新建 → 试运行(2 周,ticket 级)→ 评估 → 升 page 级 / 调整 / 删除
  → 持续运行 → 月度审计 → 调整或退役

不允许"告警上线后就永远存在"——每月审计时必须给出"为什么还需要这条告警"的理由。

告警作为代码#

所有告警规则进 GitOps:

  • PR review 才能新增/修改告警
  • 变更有 commit history
  • 可以回滚
  • 配置不漂移

告警 SLO#

告警系统本身也需要 SLO:

  • 告警延迟 < 1 分钟(从条件满足到通知送达)
  • 告警可用性 > 99.9%(告警系统不能挂)
  • 误报率 < 30%(误报过多说明规则有问题)

实战要点#

  1. 先审计再设计:拿到一个月告警数据,砍噪音,再建新规则。
  2. 症状型告警是底线:任何 page 级告警必须能从"用户感受到了什么"倒推。
  3. 死机开关不可省:每套监控栈必须配 Watchdog。反向告警通道必须独立于主通道。
  4. 告警配置进 GitOps:告警规则不是手动 apply 的,是 Git 管理 + CI/CD 部署的。
  5. 告警上线必须主动制造一次 firing:验证全链路真能推到群里(包含 firing + resolved 两类消息)。
  6. 配置工具层:Prometheus Rule / Alertmanager / Grafana 部署见 DevOps Ch25-28。本章内容必须落到具体的 Recording Rules 和告警规则 YAML 上才能生效。
  7. 告警数量是 KPI:不是越多越好。每周 page 告警超过 5 次就要治理。
  8. Runbook 是告警的伴侣:没有 Runbook 的告警不应该 page。Runbook 必须定期更新,每次事故后复盘时检查 Runbook 是否需要补充。
  9. 多窗口多燃烧率是 SLO 告警的标配:不要再用简单的"错误率 > X"做 SLO 告警。
  10. 沉默是临时措施:任何 silence 必须有 endsAt,不允许永久 silence。

小结#

告警工程的核心是让每一条告警都值得被信任。三个关键决策:用症状型告警代替原因型告警、用燃烧率告警代替简单阈值告警、用持续审计治理告警疲劳。

最终目标:告警数量从不计其数降到每周个位数,每条响起来都是真正需要行动的。当工程师收到告警的第一反应从"又来了"变成"出事了,立即处理",告警工程就成功了。

判断告警体系是否健康,看三件事:每周 page 告警数 < 5、真阳率 > 70%、每条告警都有 Runbook。三件事都做到是健康体系;只做到一两件是"在治理中";一件都没做,告警系统就是装饰品。