Skip to content
Published on

会被读的仪表盘与值得呼人的告警 — 定义问题、组织变量、SLO 与告警疲劳

分享
Authors

引言 — 没人打开的仪表盘

经常能看到有 60 个仪表盘的组织。其中值班真正会打开的只有两三个。剩下的在做出来的时候被打开过一次,之后就再也没打开过。

告警也差不多。规则有 200 条,值班却把通知频道静音了,真正重要的那一条就埋在里面。

这两个问题的根源是同一个:仪表盘和告警都是从「要展示什么」出发做出来的。出发点本该是「谁需要在什么时候做出什么决定」。

本文就从这个出发点重新开始,内容以 Grafana 12 系列和 Prometheus 3.13.0 LTS 为准验证。由于 Grafana 的界面路径经常变动,本文一律用 provisioning 文件和查询来说明,而不是靠截图操作。

先写下仪表盘要回答的问题

在做面板之前先把问题写下来。没有问题,就不做面板。

不同类型的仪表盘要回答的问题也不同,想把所有东西都塞进一个仪表盘,正是失败的开始。

类型使用者要回答的问题面板数
服务概览值班,最初 5 分钟用户现在是否受影响,是哪条路由6~8
服务详情该服务的负责人原因是代码、依赖还是资源15~25
依赖关系值班我们调用的对象里有没有出问题的6~10
容量周度评审下个季度谁会最先触顶10~15
SLO团队负责人,月度错误预算还剩多少4~6

服务概览仪表盘只需要五个问题就够了。

  1. 请求是否在失败,失败了百分之多少
  2. 是否变慢了,哪条路由变慢了
  3. 从什么时候开始的,是否和某次发布重合
  4. 流量本身有没有变化
  5. 我们依赖的东西里有没有变差的

只保留能回答这五个问题的面板,仪表盘就不会超过 8 个面板。顺序也很重要。从上往下读时应该是「是否有影响 → 在哪里 → 为什么」这样的顺序。如果 CPU 图表放在最上面,说明这个仪表盘的顺序已经错了。

数据源与变量 — 让一个仪表盘服务于多个对象

一旦开始为每个环境复制同一个仪表盘,管理就会崩溃。在 staging 上改的东西不会同步到 production,三个月后就会出现六份互不相同的副本。

解法是把数据源本身变成一个变量。

# provisioning/datasources/prometheus.yaml
apiVersion: 1
datasources:
  - name: Prometheus-prod
    uid: prom-prod
    type: prometheus
    access: proxy
    url: http://prometheus.observability.svc:9090
    jsonData:
      httpMethod: POST
      timeInterval: 15s          # 抓取间隔,是计算 rate 窗口的基准
      prometheusType: Prometheus
      prometheusVersion: 3.13.0
      exemplarTraceIdDestinations:
        - name: trace_id
          datasourceUid: tempo-prod
    isDefault: true

  - name: Prometheus-staging
    uid: prom-staging
    type: prometheus
    access: proxy
    url: http://prometheus.staging.svc:9090
    jsonData:
      httpMethod: POST
      timeInterval: 15s

timeInterval 和实际的抓取间隔对齐很重要。Grafana 的 rate 窗口变量会根据这个值和面板宽度来计算一个安全的窗口。如果这个值空着,就会按默认值计算,一旦你把面板看窄或者把时间范围取短,rate 的结果就会变空。

变量按级联方式组织,前一个变量的选择会缩小后一个变量的候选范围。

{
  "templating": {
    "list": [
      {
        "name": "datasource",
        "type": "datasource",
        "query": "prometheus",
        "current": { "text": "Prometheus-prod", "value": "prom-prod" }
      },
      {
        "name": "namespace",
        "type": "query",
        "datasource": { "type": "prometheus", "uid": "${datasource}" },
        "query": "label_values(kube_namespace_status_phase, namespace)",
        "refresh": 1,
        "sort": 1
      },
      {
        "name": "service",
        "type": "query",
        "datasource": { "type": "prometheus", "uid": "${datasource}" },
        "query": "label_values(http_requests_total{namespace=\"$namespace\"}, service)",
        "refresh": 2,
        "includeAll": true,
        "multi": true
      },
      {
        "name": "route",
        "type": "query",
        "datasource": { "type": "prometheus", "uid": "${datasource}" },
        "query": "label_values(http_requests_total{namespace=\"$namespace\", service=~\"$service\"}, route)",
        "refresh": 2,
        "includeAll": true,
        "multi": true
      }
    ]
  }
}

refresh 的值经常被搞混。1 是打开仪表盘时刷新,2 是时间范围变化时刷新。在滚动发布导致 Pod 名字一直在变的环境里,需要用 2。如果留成 1,昨天打开的标签页会一直查询早就消失了的 Pod。

把多选变量放进查询时,要用正则匹配。

# 多选变量要以正则形式接收。用等号接收的话只有单个值时才生效
sum by (route) (
  rate(http_requests_total{namespace="$namespace", service=~"$service", route=~"$route"}[$__rate_interval])
)

# 仪表盘面板里要用 rate 窗口变量。
# 用固定的 [5m],时间范围拉宽时会浪费分辨率,
# 时间范围收窄时又会因为样本不够在图上留下空洞
histogram_quantile(0.99,
  sum by (le, route) (
    rate(http_request_duration_seconds_bucket{namespace="$namespace", service=~"$service"}[$__rate_interval])
  )
)

如果允许全选,也要检查一下 includeAll 的自定义值。如果默认值不能作为正则表达式生效,全选时结果就会变空。

面板设计 — 会被读的排布和不会被读的排布

同样的数据,也有会被读的排布和不会被读的排布之分。

按行归拢问题。 第一行是「是否有影响」。错误率、p99、流量三个面板就够。第二行是「在哪里」。按路由拆解,以及排名靠前的错误类型。从第三行开始才是候选原因。

固定坐标轴。 如果错误率面板的最大值设成自动,哪怕错误率只有 0.01%,图表也会晃得看起来很严重;反过来,真出事故时又没法和之前对比。百分比轴应该从 0 开始,并且和阈值线一起画出来。

指定单位。 秒级的延迟不设 unit,读的人就分不清 1.4 是 1.4 秒还是 1.4 毫秒。不该把这种判断留到凌晨三点去做。

一个面板里不要超过 20 条时间序列。 一个有 120 条路由的服务如果全部画出来,什么都看不清。只画排名靠前的 N 条,其余的合并处理。

# 只画排名前 10 的路由。想看其余的就用变量收窄范围
topk(10,
  sum by (route) (
    rate(http_requests_total{service=~"$service", status_class="5xx"}[$__rate_interval])
  )
)

# 把发布时间当作标注叠加上去,「从什么时候开始」这个问题立刻就解决了
changes(kube_deployment_status_observed_generation{namespace="$namespace"}[$__rate_interval]) > 0

发布标注是投入产出比最高的一项。如果图表拐点处正好有一条竖线,而那条线是一次发布,排查在那一刻就结束了。

告警要挂在症状上,而不是原因上

如果只能选一条告警设计的核心原则,那就是这一条:把告警挂在用户能体感到的东西上。

先看一个基于原因告警的问题案例。

# 基于原因 — 这类规则堆到 40 条左右,告警疲劳就开始了
- alert: HighCPU
  expr: rate(process_cpu_seconds_total[5m]) > 0.8
  for: 5m
- alert: HighMemory
  expr: process_resident_memory_bytes / container_spec_memory_limit_bytes > 0.9
  for: 5m
- alert: ManyGoroutines
  expr: go_goroutines > 10000
  for: 5m
- alert: DBConnectionsHigh
  expr: pg_stat_activity_count > 80
  for: 5m

这四条有三个问题。第一,哪怕对用户毫无影响也会响 —— CPU 到 85% 可能只是资源用得好。第二,真出故障时四条会同时响,反而让人搞不清到底是什么原因。第三,会漏掉用户正在经历失败、而这四个指标却全部正常的情况。

# 基于症状 — 挂在用户实际体验到的东西上
groups:
  - name: symptom_alerts
    rules:
      - alert: HighErrorRate
        expr: |
          sum by (service) (rate(http_requests_total{status_class="5xx"}[5m]))
            /
          sum by (service) (rate(http_requests_total[5m]))
            > 0.02
        for: 5m
        labels:
          severity: page
        annotations:
          summary: '5xx 比率已超过 2%'
          runbook_url: https://wiki.internal/runbook/high-error-rate

      - alert: HighLatency
        expr: |
          histogram_quantile(0.99,
            sum by (le, service) (rate(http_request_duration_seconds_bucket[5m]))
          ) > 1.5
        for: 10m
        labels:
          severity: page
        annotations:
          summary: 'p99 延迟已超过 1.5 秒'

      - alert: RequestsStopped
        expr: |
          sum by (service) (rate(http_requests_total[5m])) == 0
            and
          sum by (service) (rate(http_requests_total[5m] offset 1h)) > 1
        for: 5m
        labels:
          severity: page
        annotations:
          summary: '平时有流量的服务现在没有收到请求'

第三条规则经常被漏掉。错误率在分母为 0 时无法计算,所以如果服务彻底挂掉、完全收不到请求,错误率告警反而不会响。流量彻底消失需要单独监控。

原因类指标该放在仪表盘上,而不是告警里。它们是值班被症状告警唤醒之后,用来收窄原因范围的材料。

揪出永远不会触发的告警

最危险的告警不是吵闹的告警,而是悄无声息地失效了的告警。

# 有问题的规则 —— 5 分钟平均值几乎不可能超过阈值
avg_over_time(http_request_duration_seconds_sum[5m])
  / avg_over_time(http_request_duration_seconds_count[5m]) > 5

# 为什么不会响: 平均值会掩盖长尾。p99 可能是 8 秒,平均值却只有 0.2 秒。
# 这条规则只有在整个服务都普遍耗时 8 秒时才会触发。

这种规则哪怕上线几个月,也不会有人发现。要定期把发火次数为 0 的规则挑出来看看。

# 找出过去 30 天里一次都没触发过的规则
# ALERTS_FOR_STATE 只在告警处于激活状态时存在,所以要和规则列表交叉核对
count by (alertname) (max_over_time(ALERTS[30d]))

# 同时确认规则评估本身有没有失败
increase(prometheus_rule_evaluation_failures_total[1h]) > 0

把规则级别的单元测试放进 CI,就能在上线前抓住这个问题。

# tests/alerts_test.yml
rule_files:
  - ../rules/symptom_alerts.yml

evaluation_interval: 30s

tests:
  - interval: 15s
    input_series:
      - series: 'http_requests_total{service="checkout", status_class="2xx"}'
        values: '0+90x60'
      - series: 'http_requests_total{service="checkout", status_class="5xx"}'
        values: '0+10x60'
    alert_rule_test:
      - eval_time: 10m
        alertname: HighErrorRate
        exp_alerts:
          - exp_labels:
              service: checkout
              severity: page
            exp_annotations:
              summary: '5xx 比率已超过 2%'
              runbook_url: https://wiki.internal/runbook/high-error-rate

SLO 与错误预算 — 给阈值一个依据

「错误率 2%」这个数字是从哪来的?大多数时候哪儿都不是——是某个人凭感觉定的,之后再没人重新问过。

SLO 能给这个阈值一个依据,过程分三步。

  1. 定义 SLI。 表达为「好事件的比例」——非 5xx 响应的比例,300ms 内完成的请求比例。
  2. 定 SLO。 30 天内 SLI 至少要达到百分之多少。
  3. 算出错误预算。 SLO 是 99.9%,预算就是 0.1%;按 30 天算,就是 43.2 分钟。
# rules/slo.yml
groups:
  - name: slo_sli
    interval: 30s
    rules:
      # 好请求的比例 —— 排除健康检查
      - record: service:sli_availability:ratio_rate5m
        expr: |
          sum by (service) (
            rate(http_requests_total{status_class!="5xx", route!~"/healthz|/readyz"}[5m])
          )
          /
          sum by (service) (
            rate(http_requests_total{route!~"/healthz|/readyz"}[5m])
          )

      # 各个窗口的错误比率 —— 供燃烧率告警引用
      - record: service:http_error_ratio:rate5m
        expr: |
          1 - service:sli_availability:ratio_rate5m

      - record: service:http_error_ratio:rate1h
        expr: |
          sum by (service) (rate(http_requests_total{status_class="5xx", route!~"/healthz|/readyz"}[1h]))
          /
          sum by (service) (rate(http_requests_total{route!~"/healthz|/readyz"}[1h]))

      - record: service:http_error_ratio:rate6h
        expr: |
          sum by (service) (rate(http_requests_total{status_class="5xx", route!~"/healthz|/readyz"}[6h]))
          /
          sum by (service) (rate(http_requests_total{route!~"/healthz|/readyz"}[6h]))

      - record: service:http_error_ratio:rate30m
        expr: |
          sum by (service) (rate(http_requests_total{status_class="5xx", route!~"/healthz|/readyz"}[30m]))
          /
          sum by (service) (rate(http_requests_total{route!~"/healthz|/readyz"}[30m]))

      # 剩余错误预算比例 —— 仪表盘上的核心面板
      - record: service:error_budget_remaining:ratio30d
        expr: |
          1 - (
            (
              sum by (service) (rate(http_requests_total{status_class="5xx", route!~"/healthz|/readyz"}[30d]))
              /
              sum by (service) (rate(http_requests_total{route!~"/healthz|/readyz"}[30d]))
            )
            / 0.001
          )

燃烧率是「按现在的速度继续下去,预算还要多久才会耗尽」的倍数。燃烧率为 1 就是正好 30 天用完的速度,14.4 就是大约 2 天用完的速度。

燃烧率30 天预算耗尽所需时间长窗口短窗口应对方式
14.4约 2 天1 小时5 分钟立即呼人
65 天6 小时30 分钟工作时间内处理
310 天1 天2 小时建工单
130 天3 天6 小时周度评审

同时使用长窗口和短窗口,是为了分别挡住两种误报。只用长窗口的话,一场已经结束的事故还会持续响上好几个小时。只用短窗口的话,一次瞬时的尖峰就会触发告警。

groups:
  - name: slo_burn_rate
    rules:
      - alert: ErrorBudgetBurnFast
        expr: |
          service:http_error_ratio:rate1h > (14.4 * 0.001)
            and
          service:http_error_ratio:rate5m > (14.4 * 0.001)
        for: 2m
        labels:
          severity: page
          slo: availability
        annotations:
          summary: '正以 14.4 倍速度消耗错误预算,约 2 天后将全部耗尽'
          runbook_url: https://wiki.internal/runbook/slo-burn

      - alert: ErrorBudgetBurnSlow
        expr: |
          service:http_error_ratio:rate6h > (6 * 0.001)
            and
          service:http_error_ratio:rate30m > (6 * 0.001)
        for: 15m
        labels:
          severity: ticket
          slo: availability
        annotations:
          summary: '正以 6 倍速度消耗错误预算'

SLO 仪表盘只要四个面板就够了:剩余预算比例、30 天 SLI 走势、当前燃烧率,以及消耗预算最多的路由排行榜。这四项一起回答「现在能不能承担风险」这个问题。

减少告警疲劳的规则

告警失效的方式就是变得吵闹。下面这些规则效果很明显。

给每条告警都挂上 runbook 链接。 没有链接就不建告警。光这一条规则就能自然减少告警数量,因为写 runbook 的过程会让你意识到「这条其实不需要人来看」。

区分呼人和建工单。 只有需要人现在立刻起床处理的才用呼人,其余的都走工单或频道通知。如果凌晨响起的告警其实等到早上也没关系,就把它的级别调低。

使用抑制规则。 当上游故障正在发生时,把由它引起的下游告警归并起来。

# alertmanager.yml
route:
  group_by: ['alertname', 'service']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: slack-default
  routes:
    - matchers: [severity = page]
      receiver: pagerduty
      group_wait: 10s
      repeat_interval: 1h
      continue: true
    - matchers: [severity = ticket]
      receiver: jira
      repeat_interval: 24h

inhibit_rules:
  # 整个集群故障期间,抑制单个服务的告警
  - source_matchers: [alertname = ClusterUnreachable]
    target_matchers: [severity =~ 'page|ticket']
    equal: [cluster]
  # 同一个服务如果已经有 page 在响,就抑制它的 ticket
  - source_matchers: [severity = page]
    target_matchers: [severity = ticket]
    equal: [service]

receivers:
  - name: pagerduty
    pagerduty_configs:
      - routing_key_file: /etc/alertmanager/pd_key
  - name: jira
    webhook_configs:
      - url: http://alert-to-jira.internal/hook
  - name: slack-default
    slack_configs:
      - channel: '#alerts'
        send_resolved: true

定期复盘告警。 每月一次,把上个月触发过的告警按两个维度分类。

是否触发过是否引发了行动处置方式
保留
调整阈值或降级,如果反复出现就删除
不适用测试规则是否能正常工作,不行就修复或删除
完全没有触发历史不适用用单元测试验证是否具备触发能力

第二行最重要。响过但没人做任何处理的告警,下个月还会重复同样的事。不会引发行动的告警是信息,不是告警。

把仪表盘和告警当作代码来管理

在界面里做出来的仪表盘,既没法评审也没法回滚。Grafana 12 在 provisioning 和「可观测性即代码」方面已经打磨得比较完善,用文件来管理明显更靠谱。

# provisioning/dashboards/dashboards.yaml
apiVersion: 1
providers:
  - name: gitops
    orgId: 1
    folder: Services
    type: file
    disableDeletion: true
    updateIntervalSeconds: 60
    allowUiUpdates: false        # 禁止界面修改,变更必须走仓库
    options:
      path: /etc/grafana/dashboards
      foldersFromFilesStructure: true
# provisioning/alerting/rules.yaml — 把 Grafana 管理的告警定义成文件
apiVersion: 1
groups:
  - orgId: 1
    name: checkout_slo
    folder: Alerts
    interval: 1m
    rules:
      - uid: checkout-burn-fast
        title: Checkout error budget burn (fast)
        condition: THRESHOLD
        data:
          - refId: BURN
            relativeTimeRange:
              from: 3600
              to: 0
            datasourceUid: prom-prod
            model:
              expr: service:http_error_ratio:rate1h{service="checkout"}
              instant: true
              refId: BURN
          - refId: THRESHOLD
            datasourceUid: __expr__
            model:
              type: threshold
              expression: BURN
              conditions:
                - evaluator:
                    type: gt
                    params: [0.0144]
              refId: THRESHOLD
        for: 2m
        noDataState: NoData
        execErrState: Alerting
        labels:
          severity: page
          slo: availability
        annotations:
          summary: Checkout 的错误预算正以 14.4 倍速度消耗
          runbook_url: https://wiki.internal/runbook/slo-burn

noDataState 该设成什么,实务中会有分歧。设成 Alerting,抓取哪怕只是短暂中断也会响。设成 OK,就算数据采集整个停掉了也会保持沉默。大多数情况下把它设成 NoData,再单独建一条专门监控「采集本身是否中断」的告警会更好。

- alert: ScrapeTargetDown
  expr: up{job=~"checkout.*|payment.*"} == 0
  for: 5m
  labels:
    severity: page
  annotations:
    summary: '抓取目标已经 5 分钟没有响应'

把仪表盘的 JSON 放进仓库时,有两点要注意。第一,从界面保存会生成一份混入了面板坐标、版本号之类噪声的 diff。在保存前用一个正规化脚本剥掉不必要的字段并放进 CI,评审才有可能进行。第二,如果把数据源用 UID 硬编码进去,在别的环境里就会失效。应该使用数据源变量,让面板引用这个变量。

结语 — 仪表盘和告警,设计的其实是人的行为

面板和规则只是产出物,真正要设计的是凌晨三点被叫醒的人会看到什么、会做什么。从这个角度看,判断就变得容易了。打开之后决定不了下一步行动的面板,删掉。响了之后无事可做的告警,降级或者去掉。

留下两项现在就能做的检查。第一,把过去 30 天里一次都没被打开过的仪表盘列出来——没人打开,维护成本却一直在产生。第二,数一下过去 30 天里触发过的告警,算出其中真正引发了处置动作的比例。如果这个比例低于一半,告警系统其实已经失去了可信度。

值得继续深入的资料。