08 告警工程
概述#
告警是监控系统里唯一会主动打扰人的部分。它的质量直接决定监控系统是被信任还是被无视。本章从症状告警 vs 原因告警的根本性选型开始,到燃烧率告警的工程实现,到告警疲劳的治理策略,建立一套"少而精、值得半夜爬起来"的告警体系。
一条好的告警必须满足三个条件:是真问题、需要立刻处理、知道怎么处理。三个条件缺一:不是真问题 = 噪音;不需要立刻处理 = 不该是 page;不知道怎么处理 = Runbook 缺失。这三个条件是告警工程的底线。
Alertmanager 的部署配置(路由/分组/抑制/去重)见 DevOps Ch25。本章聚焦告警设计方法论和治理策略。
一、症状告警 vs 原因告警#
这是告警工程最核心的选型决策。
症状型告警(Symptom-based)#
基于用户实际感受到的问题。
✅ Payment API 错误率 > 5%
✅ 下单成功率低于 99%
✅ P99 延迟超过 2s
✅ 用户登录失败比例 > 1%原因型告警(Cause-based)#
基于系统内部某个组件的状态。
❌ 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 逻辑——短窗口确保"现在在出问题",长窗口确保"不是瞬时抖动"。
# 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 Critical | 1h / 5m | > 14.4x | 2m | page(钉钉/电话) | 1 小时内烧 2% 月度预算 |
| P1 Critical | 6h / 30m | > 6x | 15m | page(钉钉/电话) | 6 小时内烧 5% 月度预算 |
| P2 Ticket | 3d / 6h | > 1x | 1h | ticket(工单) | 3 天内烧 10% 月度预算(慢烧,月底会超支) |
为什么用 AND 不用 OR#
# 错误:OR 太宽松,短窗口抖动频繁触发
expr: |
ratio_rate1h > 0.0144
or
ratio_rate5m > 0.0144
# 正确:AND,两个窗口都超标才告警
expr: |
ratio_rate1h > 0.0144
and
ratio_rate5m > 0.0144OR 条件会因为短窗口抖动频繁触发;AND 条件要求两个窗口都确认问题,能滤掉瞬时毛刺。这是燃烧率告警的精髓——用窗口组合实现"既快又准"。
Recording Rules 前置#
燃烧率告警依赖 Recording Rules 预计算。如果没有 Recording Rules,Prometheus 要在告警评估时实时计算多个长时间窗口的 rate 表达式,查询必定超时。
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 的燃烧率#
错误率有燃烧率告警,延迟同样需要。用户感受到的"慢"和"错"同样影响体验。
# 延迟燃烧率告警: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:
- 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.0144 | Helm values 注入或 Recording Rule 存储 |
| 只有错误率 SLO | 忽略延迟 | 延迟也需要独立的 SLO 和 Burn Rate |
| 多 Prometheus 副本不去重 | 两个副本各发一次 | Alertmanager 自动去重 |
| 告警没有 runbook | 收到不知道怎么处理 | PR 强制附 runbook 链接 |
| 告警 annotation 太简单 | 只有 summary | summary + description + runbook_url + grafana_url |
三、告警疲劳(Alert Fatigue)#
告警疲劳是监控系统最危险的状态——告警太多太吵,工程师开始无视所有告警,真故障也被忽略。
告警疲劳的成因#
噪音告警 > 有效告警 → 告警群被静音 → 工程师生理脱敏
→ 真正的事故发生时响应延迟 → MTTR 飙升告警疲劳不是"工程师不负责任",是生理性的脱敏反应。每天看 50 条告警,大脑会自动忽略"看起来不重要"的——这是人类适应环境的本能。治理告警疲劳不能靠"要求工程师认真看每条告警",要靠减少告警数量到人能处理的水平。
告警疲劳的量化指标#
# 每班次告警数(按值班人聚合)
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 | 真告警,需要立刻响应 | 钉钉/电话/PagerDuty | 5 分钟内 ack |
| Ticket | 工单级 | Jira/Issues | 下个工作日处理 |
| Log | 仅记录 | 团队频道 | 不强制响应 |
默认所有告警是 ticket,只有满足"立即响应有意义"才升级为 page。
3. 告警必须有 Runbook
Page 级告警必须附 Runbook,内容包含:
- 这个告警意味着什么?
- 如何判断是真问题?
- 第一响应动作是什么?
- 如何回滚 / mitigation?
- 如果处理不了升级给谁?
没 Runbook 的告警不能是 page。实际操作:新增 page 级告警的 PR 必须附 Runbook 链接,否则 review 拒绝。
4. 告警可执行性审查
每条告警回答三个问题:
- 我收到后需要立刻做一个动作吗?→ 是 = page,否 = ticket
- 这个动作能在 5 分钟内完成吗?→ 是 = 保留,否 = 降低级别或修复 Runbook
- 如果不做这个动作,用户会在 1 小时内受影响吗?→ 是 = 保留 page,否 = ticket
三个问题都是"是"才配为 page。任一为"否"就降级。
告警治理的实际案例(来自 On-Call 实战)#
某团队历时 4 周的告警清理:
| 告警名 | 治理前 | 治理动作 | 治理后 |
|---|---|---|---|
| DiskUsageHigh | 126 次/月 | 改 ticket 级 | 0 page |
| PodRestartFrequent | 98 次 | 条件改严,1min >5 次才 page | 12 次 |
| HTTP_5xx_high | 74 次 | 改成 SLO burn rate 告警 | 30 次 |
| CertExpirySoon | 56 次 | 90 天提醒一次 | 4 次 |
| NodeCPUHigh | 49 次 | 砍掉,换成 SLO-driven | 0 次 |
总告警量从 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 两级路由:
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抑制规则设计#
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 标签。
静默的安全使用#
# 错误:用 .+ 会误伤 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)#
一条永远在触发的告警,反向使用:
Watchdog 告警持续 firing → healthchecks.io 定期收到心跳
如果某天 healthchecks.io 超过一定时间没收到心跳
→ 说明整条告警链路(Prometheus → Alertmanager → 通知)自己挂了
→ 触发反向告警这是告警系统自身的告警。每一套监控都必须有——否则监控死了没人知道是最危险的盲区。
Watchdog 配置#
# PrometheusRule
- alert: Watchdog
expr: vector(1)
for: 0s
labels:
severity: none
channel: heartbeat
annotations:
summary: "Watchdog heartbeat — 永远 firing,无心跳即链路异常"# 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 一眼就能判断严重程度和行动方向:
[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"}关键元素:
- 严重级别(视觉区分):CRITICAL / WARNING / INFO
- 数值(燃烧率/错误率,快速判断严重程度)
- 时间紧迫度(多久耗尽预算)
- 行动链接(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 清理
告警生命周期管理#
每条告警有生命周期:
新建 → 试运行(2 周,ticket 级)→ 评估 → 升 page 级 / 调整 / 删除
→ 持续运行 → 月度审计 → 调整或退役不允许"告警上线后就永远存在"——每月审计时必须给出"为什么还需要这条告警"的理由。
告警作为代码#
所有告警规则进 GitOps:
- PR review 才能新增/修改告警
- 变更有 commit history
- 可以回滚
- 配置不漂移
告警 SLO#
告警系统本身也需要 SLO:
- 告警延迟 < 1 分钟(从条件满足到通知送达)
- 告警可用性 > 99.9%(告警系统不能挂)
- 误报率 < 30%(误报过多说明规则有问题)
实战要点#
- 先审计再设计:拿到一个月告警数据,砍噪音,再建新规则。
- 症状型告警是底线:任何 page 级告警必须能从"用户感受到了什么"倒推。
- 死机开关不可省:每套监控栈必须配 Watchdog。反向告警通道必须独立于主通道。
- 告警配置进 GitOps:告警规则不是手动 apply 的,是 Git 管理 + CI/CD 部署的。
- 告警上线必须主动制造一次 firing:验证全链路真能推到群里(包含 firing + resolved 两类消息)。
- 配置工具层:Prometheus Rule / Alertmanager / Grafana 部署见 DevOps Ch25-28。本章内容必须落到具体的 Recording Rules 和告警规则 YAML 上才能生效。
- 告警数量是 KPI:不是越多越好。每周 page 告警超过 5 次就要治理。
- Runbook 是告警的伴侣:没有 Runbook 的告警不应该 page。Runbook 必须定期更新,每次事故后复盘时检查 Runbook 是否需要补充。
- 多窗口多燃烧率是 SLO 告警的标配:不要再用简单的"错误率 > X"做 SLO 告警。
- 沉默是临时措施:任何 silence 必须有 endsAt,不允许永久 silence。
小结#
告警工程的核心是让每一条告警都值得被信任。三个关键决策:用症状型告警代替原因型告警、用燃烧率告警代替简单阈值告警、用持续审计治理告警疲劳。
最终目标:告警数量从不计其数降到每周个位数,每条响起来都是真正需要行动的。当工程师收到告警的第一反应从"又来了"变成"出事了,立即处理",告警工程就成功了。
判断告警体系是否健康,看三件事:每周 page 告警数 < 5、真阳率 > 70%、每条告警都有 Runbook。三件事都做到是健康体系;只做到一两件是"在治理中";一件都没做,告警系统就是装饰品。