- 引言 — 昨晚响过的呼叫里,有几个真的带来了动作
- 告警疲劳让人错过真实事故的机制
- 对症状挂呼叫,把原因放到仪表盘上
- SLO 与错误预算 — 把阈值定得站得住脚
- 多窗口燃烧率告警 — 同时兼顾灵敏度与误报
- 呼叫、工单、仪表盘 — 三级分类与强制 runbook
- 复盘与删除告警的流程
- 结语 — 告警的目的是动作,不是检测
引言 — 昨晚响过的呼叫里,有几个真的带来了动作
如果你没法立刻回答这个问题,那么告警系统已经坏了。如果答案是「几乎没有」,那就坏得很严重。
on-call 崩溃的典型路径是这样的。出了一次事故,复盘得出「这个本该提前知道」的结论,于是加了一条告警。这个过程重复两年,告警规则就变成 300 条,Slack 频道每天堆进 400 条消息,on-call 一晚醒三次、三次都什么也没做又睡回去。
本文要讲的不是怎么把那 300 条削下去,而是怎么重新决定「最初到底该对什么挂呼叫」。
告警疲劳让人错过真实事故的机制
告警疲劳不是懒惰的问题,是概率的问题。
一天 400 条告警里真正需要动手的只有 4 条,那么精确率就是 1 个百分点。在这种状态下,人学到的最优策略就是「先无视,回头再看」。正因为它是理性的,所以更危险。靠训练或者决心是扭转不过来的。
具体来说会坏掉三样东西。
第一,反应时间变长。在打开告警之前,「八成又是那个」的预判就已经贴上去了。真实事故最初的十分钟就消失在这里。
第二,信号被噪声淹没。400 条里真的只有 4 条,那这 4 条在视觉上和剩下的 396 条毫无区别。就算打了严重度标签也一样。所有东西都是 critical,就等于没有东西是 critical。
第三,人被磨损。夜里的呼叫会降低第二天的判断力。为了不需要动手的事把 on-call 叫醒,等于提前削掉下一次事故的响应质量。
由此得出实务目标。以 12 小时轮班为基准,呼叫平均不超过 2 条,并且呼叫的动作转化率目标至少要在 70 个百分点以上。一旦越过这两个数字,该做的就不是加告警,而是删告警。
对症状挂呼叫,把原因放到仪表盘上
如果只能挑一条最强的规则,那就是这条。只对用户会经历的症状挂呼叫。
CPU 使用率 90 个百分点不是症状。用户感受不到 CPU。CPU 到了 90 个百分点而响应时间正常,那就是什么也没发生;CPU 只有 40 个百分点而响应要花 5 秒,那就是严重事故。对原因指标挂呼叫,这两种情况都会判断错。
该挂呼叫的对象,是服务对用户许下的那些承诺。
- 可用性:请求中因服务端错误而失败的比例
- 延迟:没有在阈值内完成的请求比例
- 吞吐量骤降:相对正常水平流量消失的情况(这是故障的常见症状)
- 新鲜度:批处理或流式管道中数据积压了多久
- 正确性:对账作业发现的不一致条数
不该挂呼叫的对象是原因指标。CPU、内存、磁盘 IOPS、Pod 重启次数、线程池使用率、GC 时间、副本数。这些放到排查用的仪表盘上,在事故发生时用来收窄原因。
例外有两类。第一类,无法挽回且需要提前量的东西 — 磁盘剩余空间、证书过期、配额耗尽这类一旦到达就很难恢复、而提前知道就能预防的项目,用预测的方式挂呼叫。
# 磁盘是否处在 4 小时内会被写满的趋势上 — 等真写满就晚了
predict_linear(node_filesystem_avail_bytes{mountpoint="/data"}[6h], 4 * 3600) < 0
and
node_filesystem_avail_bytes{mountpoint="/data"} / node_filesystem_size_bytes{mountpoint="/data"} < 0.2
第二类,可观测体系本身的故障。指标进不来的状态会让所有告警失效,所以必须是呼叫对象。
# 抓取已中断的 job — 没有这条告警,其余告警就会悄无声息地死掉
up{job="checkout-api"} == 0
or
absent(up{job="checkout-api"})
SLO 与错误预算 — 把阈值定得站得住脚
「错误率超过 5 个百分点就告警」里的这个 5,是从哪儿来的?大多数情况下哪儿也没来。用「历史最大值的 1.2 倍」这种方式定出来的数字,说不清为什么是这个值,而说不清的阈值会在每次事故之后一点点往上抬。
从 SLO 倒推,阈值就有了依据。
如果目标是 30 天 99.9 个百分点,错误预算就是 0.1 个百分点。30 天是 43,200 分钟,所以预算是 43.2 分钟。
python3 - <<'PY'
slo = 0.999
days = 30
budget_ratio = 1 - slo
minutes = days * 24 * 60 * budget_ratio
print(f"错误预算: {budget_ratio*100:.2f}% = {minutes:.1f}分 / {days}天")
for br in (14.4, 6, 3, 1):
# 以燃烧率 br 持续消耗时,预算见底所需的时间
hours = days * 24 / br
print(f" 燃烧率 {br:>4}: {hours:6.1f}小时耗尽")
PY
# 错误预算: 0.10% = 43.2分 / 30天
# 燃烧率 14.4: 50.0小时耗尽
# 燃烧率 6: 120.0小时耗尽
# 燃烧率 3: 240.0小时耗尽
# 燃烧率 1: 720.0小时耗尽
燃烧率就是实际错误率除以预算比例。目标是 99.9 个百分点时,错误率 1.44 个百分点对应的燃烧率是 14.4。这个速度持续一小时,30 天预算的 2 个百分点就没了。
SLI 要用记录规则预先算好。每次评估告警都去算 3 天窗口的 rate,先死的是 Prometheus。
# rules/slo.yml
groups:
- name: checkout-slo-sli
interval: 30s
rules:
- record: job:slo_errors:ratio_rate5m
expr: |
sum by (job) (rate(http_requests_total{job="checkout-api", code=~"5.."}[5m]))
/
sum by (job) (rate(http_requests_total{job="checkout-api"}[5m]))
- record: job:slo_errors:ratio_rate1h
expr: |
sum by (job) (rate(http_requests_total{job="checkout-api", code=~"5.."}[1h]))
/
sum by (job) (rate(http_requests_total{job="checkout-api"}[1h]))
- record: job:slo_errors:ratio_rate30m
expr: |
sum by (job) (rate(http_requests_total{job="checkout-api", code=~"5.."}[30m]))
/
sum by (job) (rate(http_requests_total{job="checkout-api"}[30m]))
- record: job:slo_errors:ratio_rate6h
expr: |
sum by (job) (rate(http_requests_total{job="checkout-api", code=~"5.."}[6h]))
/
sum by (job) (rate(http_requests_total{job="checkout-api"}[6h]))
# 用来防止低流量时段比率剧烈抖动的最小流量闸门
- record: job:http_requests:rate5m
expr: sum by (job) (rate(http_requests_total{job="checkout-api"}[5m]))
最后那条记录规则很重要。在 5 分钟只进来 3 次请求的时段里,只要失败 1 次错误率就成了 33 个百分点,一次性越过所有燃烧率阈值。必须把最小流量条件用 AND 串上去,凌晨的幽灵告警才会消失。而流量如果干脆是 0,比率就变成 0 除以 0 的 NaN,告警会悄悄消失 — 这种情况得由吞吐量骤降告警另行捕捉。
多窗口燃烧率告警 — 同时兼顾灵敏度与误报
单窗口告警总要放弃一边。窗口短就会对瞬间波动做出反应而产生误报,窗口长就会把大事故抓得太晚。
解法是把两个窗口用 AND 串起来。长窗口判定「是不是在以这个速度燃烧」,短窗口判定「现在是否还在进行中」。关键在于,短窗口的作用不是灵敏度,而是消解速度。没有短窗口,事故结束之后长窗口还会按窗口长度继续把告警挂着。
| 预算消耗量 | 长窗口 | 短窗口 | 燃烧率阈值 | 应对 |
|---|---|---|---|---|
| 2% | 1 小时 | 5 分钟 | 14.4 | 立即呼叫 |
| 5% | 6 小时 | 30 分钟 | 6 | 立即呼叫 |
| 10% | 1 天 | 2 小时 | 3 | 工单 |
| 10% | 3 天 | 6 小时 | 1 | 工单 |
惯例是把短窗口取为长窗口的十二分之一。按这个比例,事故结束后大约在一个短窗口的时间内告警就会落下去。
# rules/slo.yml (续)
- name: checkout-slo-alerts
rules:
- alert: CheckoutErrorBudgetBurnFast
expr: |
job:slo_errors:ratio_rate1h{job="checkout-api"} > (14.4 * 0.001)
and
job:slo_errors:ratio_rate5m{job="checkout-api"} > (14.4 * 0.001)
and
job:http_requests:rate5m{job="checkout-api"} > 1
for: 2m
labels:
severity: page
slo: checkout-availability
annotations:
summary: 'checkout-api 正以每小时 2% 的速度消耗错误预算'
description: '当前 1 小时错误率 {{ printf "%.2f" $value }} — 剩余预算请查看仪表盘'
runbook_url: 'https://runbooks.internal/checkout/error-budget-burn'
dashboard_url: 'https://grafana.internal/d/checkout-slo'
- alert: CheckoutErrorBudgetBurnSlow
expr: |
job:slo_errors:ratio_rate6h{job="checkout-api"} > (6 * 0.001)
and
job:slo_errors:ratio_rate30m{job="checkout-api"} > (6 * 0.001)
for: 15m
labels:
severity: ticket
slo: checkout-availability
annotations:
summary: 'checkout-api 的错误预算在 6 小时窗口上持续被消耗'
runbook_url: 'https://runbooks.internal/checkout/error-budget-burn'
for 子句实际在做什么
for 的意思是:表达式为真的状态必须连续保持那么长时间才会触发。中途只要有一次变为假,计时器就会归零。这就是它防止抖动的原理。
这里有一条常见的错误建议。「误报太多了,把 for 拉到 30 分钟吧。」这个办法有效,但代价很大。检测被推迟 30 分钟,而如果事故 25 分钟就结束了,告警根本不会响。而且真正的问题依然在 — 表达式本身看的就是噪声。
正确的顺序是这样。先用长窗口的 rate 把噪声平滑掉。然后把短窗口用 AND 串上,确认它还在进行中。for 放在最后,只挂 2 到 5 分钟,用来吸收抓取失败之类一两次的缺测。如果已经在用长窗口,却还把 for 设到 15 分钟以上,那就是窗口设计错了。
规则一定要测试。告警规则是生产代码,而生产代码不会不经测试就上线。
# rules/slo_test.yml
rule_files:
- slo.yml
evaluation_interval: 30s
tests:
- interval: 30s
input_series:
# 每秒成功 95 次、失败 5 次 = 错误率 5% (超过 1.44% 的阈值)
- series: 'http_requests_total{job="checkout-api", code="200"}'
values: '0+2850x240'
- series: 'http_requests_total{job="checkout-api", code="500"}'
values: '0+150x240'
alert_rule_test:
- eval_time: 70m
alertname: CheckoutErrorBudgetBurnFast
exp_alerts:
- exp_labels:
severity: page
slo: checkout-availability
job: checkout-api
promtool check rules rules/slo.yml
# Checking rules/slo.yml
# SUCCESS: 7 rules found
promtool test rules rules/slo_test.yml
# Unit Testing: rules/slo_test.yml
# SUCCESS
呼叫、工单、仪表盘 — 三级分类与强制 runbook
所有告警都必须是这三者之一。没有第四种。
- 呼叫:现在就得把人叫醒。用户影响正在发生,不会自动恢复,而且人的动作能改变局面。目标是每 12 小时轮班不超过 2 条。
- 工单:在工作时间处理就行。预算在漏,但速度慢。系统自动创建 issue 并指派负责人。
- 仪表盘:不制造告警。这是排查时才看的指标。想给这里挂上告警的冲动,正是 300 条告警的起点。
明确设立第三个分类很重要。「先只发到 Slack 频道吧」这种折中方案是最糟的。告警会在不归属于任何地方的状态下无限增长,那个频道再也没人看,日后真有信号流进去也会被漏掉。
呼叫级别的告警必须强制附上 runbook 链接。凌晨三点被叫醒的人不处在能发挥创造力的状态。他需要的是一份写着三件要确认的事和两件要执行的动作的文档。
# CI 闸门: 若存在 severity=page 却没有 runbook_url 的告警,就让构建失败
missing=$(yq -r '
.groups[].rules[]
| select(.labels.severity == "page")
| select(.annotations.runbook_url == null)
| .alert
' rules/*.yml)
if [ -n "$missing" ]; then
echo "缺少 runbook 链接的呼叫级告警:"
echo "$missing"
exit 1
fi
echo "所有呼叫级告警都已关联 runbook。"
runbook 里必须包含四样内容。这条告警意味着什么样的用户影响、马上要看的仪表盘与查询、已知原因及各自的处置、以及升级对象。只有链接而内容为空的 runbook,比没有链接更糟。
复盘与删除告警的流程
告警之所以越来越多,是因为只有添加的流程而没有删除的流程。需要一个把删除设为默认动作的定期评审。
每月一次,先把下面这些数据拉出来再开始。
# 每条告警的总触发时长(分钟)。评估周期是 30 秒,所以样本数要乘以 0.5。
sort_desc(
sum by (alertname) (
count_over_time(ALERTS{alertstate="firing", severity="page"}[30d])
) * 0.5
)
# 现在正在触发的,以及被悄悄压制着的
amtool alert query --alertmanager.url=http://alertmanager:9093 --output=extended
amtool silence query --alertmanager.url=http://alertmanager:9093 --expired=false
# 如果有不设过期、挂了好几个月的静默,那就是一条该被删掉的告警
每条告警要填四项。30 天触发次数、其中真正带来动作的比例、消解时间中位数、夜间触发比例。然后机械地套用下面的规则。
- 连续三个月没有带来任何动作的呼叫,降级为工单或者删除。要保留就必须把保留理由写进文档。
- 静默维持超过 30 天的告警一律删除。静默是「这条告警是错的」最诚实的信号。
- 同一次事故里总是一起响的那一组告警,要合并成一条或者用抑制规则捆在一起。一次事故飞出 12 条呼叫的结构会妨碍响应。
- 在事故复盘里添加告警时,必须同时删掉一条,或者写清楚为什么没有可删的。
抑制规则是削减告警数量最便宜的工具。整个集群挂掉的时候,不要发出 40 条单个服务的告警,只发一条。
# alertmanager.yml
inhibit_rules:
- source_matchers: ['severity="page"', 'alertname="ClusterDown"']
target_matchers: ['severity=~"page|ticket"']
equal: ['cluster']
route:
group_by: ['alertname', 'cluster', 'slo']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: 'ticket-queue'
routes:
- matchers: ['severity="page"']
receiver: 'oncall-pager'
repeat_interval: 1h
group_by 里放什么,决定了告警的条数。按实例分组,有多少 Pod 就来多少条告警。按服务或 SLO 来聚,条数才会落到人能读得完的量级。
结语 — 告警的目的是动作,不是检测
要记住的只有一句话。告警的成功标准不是「有没有检测到问题」,而是「人现在是不是必须做点什么」。
通不过这个标准的告警,即使准确也是有害的。如果 400 条准确的告警把 on-call 的反应速度打垮了,那么准确毫无意义。所以顺序变成这样。用用户症状定义 SLO,从预算倒推阈值,把长窗口与短窗口用 AND 串起来,给呼叫强制附上 runbook,每个月删掉没有带来动作的告警。
如果明天只能做一件事,那就是把过去 30 天触发时长最长的三条呼叫级告警拉出来,逐一确认它们是否曾经带来过动作。在大多数团队里,这三条就占了 on-call 疲劳的一半。
值得继续深入的资料。
현재 단락 (1/189)
如果你没法立刻回答这个问题,那么告警系统已经坏了。如果答案是「几乎没有」,那就坏得很严重。