Skip to content
Published on

凌晨三点不会把你叫醒的告警设计 — 症状告警与燃烧率实战

分享
Authors

引言 — 昨晚响过的呼叫里,有几个真的带来了动作

如果你没法立刻回答这个问题,那么告警系统已经坏了。如果答案是「几乎没有」,那就坏得很严重。

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 疲劳的一半。

值得继续深入的资料。