Skip to content
Published on

読まれるダッシュボードと鳴らす価値のあるアラート — 問いの定義、変数構成、SLO、そしてアラート疲れ

シェア
Authors

はじめに — 誰も開かないダッシュボード

ダッシュボードが60個ある組織をよく見かけます。そのうちオンコールが実際に開くのは二つか三つです。残りは作ったときに一度開かれ、その後は開かれません。

アラートも似ています。ルールが200個あるのにオンコールは通知チャンネルをミュートしており、本当に重要なものひとつがその中に埋もれています。

二つの問題の原因は同じです。ダッシュボードとアラートを「何を見せるか」から出発して作ったからです。出発点は「誰がいつどんな決定を下すべきか」でなければなりません。

この記事はその出発点からやり直します。Grafana 12系とPrometheus 3.13.0 LTS基準で確認しました。Grafanaは画面の操作経路が頻繁に変わるため、画面操作の代わりにプロビジョニングファイルとクエリで説明します。

ダッシュボードが答えるべき問いを先に書く

パネルを作る前に問いを書きます。問いがなければパネルは作りません。

ダッシュボードは種類によって答えるべき問いが違い、ひとつに全部詰め込もうとする試みが失敗の始まりです。

種類見る人答えるべき問いパネル数
サービス概要オンコール、最初の5分今ユーザーが影響を受けているか、どのルートか6〜8
サービス詳細そのサービスの担当者原因はコードか依存先か資源か15〜25
依存先オンコール呼び出している先のどこかに問題があるか6〜10
キャパシティ週次レビュー次の四半期に何が最初に限界に達するか10〜15
SLOチームリード、月次エラーバジェットがどれだけ残っているか4〜6

サービス概要ダッシュボードの問いは五つで十分です。

  1. リクエストが失敗しているか、何パーセントか
  2. 遅くなったか、どのルートが遅くなったか
  3. いつからか、デプロイと重なっているか
  4. トラフィック自体が変わったか
  5. 依存先のうち悪化しているものがあるか

この五つに答えるパネルだけを残せば、ダッシュボードは8パネルを超えません。そして順序が重要です。上から下へ読んだときに「影響があるか → どこか → なぜか」という流れになるべきです。CPUのグラフが一番上にあるなら、そのダッシュボードはすでに順序が間違っています。

データソースと変数 — ひとつのダッシュボードを複数の対象で使う

同じダッシュボードを環境ごとにコピーし始めると管理が崩壊します。ステージングで直したものがプロダクションに反映されず、三か月後には互いに異なる六つのコピーができています。

解決策はデータソース自体を変数にすることです。

# 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は時間範囲が変わったときに更新します。ローリングデプロイでポッド名が変わり続ける環境では2が必要です。1のままにすると、昨日開いたタブですでに消えたポッドをずっと参照し続けます。

複数選択の変数をクエリに入れるときは正規表現マッチを使います。

# 複数選択の変数は正規表現で受ける。等号で受けると値がひとつのときしか動かない
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ミリ秒なのか読む人には分かりません。その判断を深夜3時にさせてはいけません。

ひとつのパネルに時系列を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推移、現在の消費率、そしてバジェットをもっとも多く使ったルートの上位リスト。この四つが「今リスクを取ってよいか」という問いに答えます。

アラート疲れを減らすルール

アラートが機能しなくなる典型的な経路はうるさくなることです。以下のルールが効果的です。

すべてのアラートにランブックのリンクを付けます。 リンクがないアラートは作りません。このルールひとつでアラートの数が自然に減ります。ランブックを書く過程で「これは人が見る必要がないな」と気づくからです。

呼び出しとチケットを区別します。 今すぐ人が起きなければならないものだけが呼び出しです。残りはチケットかチャンネル通知です。深夜に鳴ったアラートのうち朝まで待ってもよかったものがあれば、そのアラートの等級を下げます。

抑制ルールを使います。 上位の障害が進行中のとき、それに起因する下位のアラートをまとめます。

# 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

定期的にアラートを見直します。 月に1回、先月発火したアラートを二つの軸で分類します。

発火したか行動を引き起こしたか対応
はいはい維持
はいいいえしきい値の調整または等級の引き下げ、繰り返すなら削除
いいえ該当なしルールが動作するかテストし、ダメなら修正か削除
発火履歴がまったくない該当なし単体テストで発火可能性を検証

二番目の行がもっとも重要です。鳴ったのに誰も何もしなかったアラートは、来月も同じことを繰り返します。行動を引き起こさないアラートは情報であって、アラートではありません。

ダッシュボードとアラートをコードとして管理する

UIで作ったダッシュボードはレビューもロールバックもできません。Grafana 12はプロビジョニングと観測可能性のコード化が整理されているので、ファイルで管理するほうが確実に優れています。

# provisioning/dashboards/dashboards.yaml
apiVersion: 1
providers:
  - name: gitops
    orgId: 1
    folder: Services
    type: file
    disableDeletion: true
    updateIntervalSeconds: 60
    allowUiUpdates: false        # UI編集を禁止する。変更はリポジトリを経由する
    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をリポジトリに入れるとき、注意すべき点が二つあります。第一に、UIで保存するとパネルの座標やバージョン番号のようなノイズが大量に混じったdiffが生じます。保存前に不要なフィールドを取り除く正規化スクリプトをCIに置けば、レビューが可能になります。第二に、データソースをUIDでハードコードすると他の環境で壊れます。データソース変数を使い、その変数を参照させます。

おわりに — ダッシュボードとアラートは人の行動を設計する仕事だ

パネルとルールは成果物であり、本当の設計対象は深夜3時に起こされた人が何を見て何をするかです。その観点で見れば判断は簡単になります。開いても次の行動が決まらないパネルは消します。鳴ってもやることがないアラートは等級を下げるか、なくします。

今すぐできる点検を二つ挙げます。第一に、過去30日間一度も開かれていないダッシュボードの一覧を抜き出します。開かれていなくても維持コストは払い続けています。第二に、過去30日間に発火したアラートを数え、そのうち実際に対応につながった割合を計算します。その割合が半分を下回るなら、アラートシステムはすでに信頼を失っています。

さらに掘り下げるための資料です。