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

- Name
- Youngju Kim
- @fjvbn20031
- はじめに — 誰も開かないダッシュボード
- ダッシュボードが答えるべき問いを先に書く
- データソースと変数 — ひとつのダッシュボードを複数の対象で使う
- パネル設計 — 読まれる配置と読まれない配置
- アラートは原因ではなく症状に掛ける
- SLOとエラーバジェット — しきい値を決める根拠
- アラート疲れを減らすルール
- ダッシュボードとアラートをコードとして管理する
- おわりに — ダッシュボードとアラートは人の行動を設計する仕事だ
はじめに — 誰も開かないダッシュボード
ダッシュボードが60個ある組織をよく見かけます。そのうちオンコールが実際に開くのは二つか三つです。残りは作ったときに一度開かれ、その後は開かれません。
アラートも似ています。ルールが200個あるのにオンコールは通知チャンネルをミュートしており、本当に重要なものひとつがその中に埋もれています。
二つの問題の原因は同じです。ダッシュボードとアラートを「何を見せるか」から出発して作ったからです。出発点は「誰がいつどんな決定を下すべきか」でなければなりません。
この記事はその出発点からやり直します。Grafana 12系とPrometheus 3.13.0 LTS基準で確認しました。Grafanaは画面の操作経路が頻繁に変わるため、画面操作の代わりにプロビジョニングファイルとクエリで説明します。
ダッシュボードが答えるべき問いを先に書く
パネルを作る前に問いを書きます。問いがなければパネルは作りません。
ダッシュボードは種類によって答えるべき問いが違い、ひとつに全部詰め込もうとする試みが失敗の始まりです。
| 種類 | 見る人 | 答えるべき問い | パネル数 |
|---|---|---|---|
| サービス概要 | オンコール、最初の5分 | 今ユーザーが影響を受けているか、どのルートか | 6〜8 |
| サービス詳細 | そのサービスの担当者 | 原因はコードか依存先か資源か | 15〜25 |
| 依存先 | オンコール | 呼び出している先のどこかに問題があるか | 6〜10 |
| キャパシティ | 週次レビュー | 次の四半期に何が最初に限界に達するか | 10〜15 |
| SLO | チームリード、月次 | エラーバジェットがどれだけ残っているか | 4〜6 |
サービス概要ダッシュボードの問いは五つで十分です。
- リクエストが失敗しているか、何パーセントか
- 遅くなったか、どのルートが遅くなったか
- いつからか、デプロイと重なっているか
- トラフィック自体が変わったか
- 依存先のうち悪化しているものがあるか
この五つに答えるパネルだけを残せば、ダッシュボードは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はそのしきい値に根拠を与えます。手順は三つです。
- SLIを定義します。 「良いイベントの割合」として表現します。5xxではない応答の割合、300ms以内に終わったリクエストの割合。
- SLOを決めます。 30日間でSLIが何パーセント以上であるべきか。
- エラーバジェットを計算します。 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分 | 即座に呼び出し |
| 6 | 5日 | 6時間 | 30分 | 業務時間内に対応 |
| 3 | 10日 | 1日 | 2時間 | チケット作成 |
| 1 | 30日 | 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日間に発火したアラートを数え、そのうち実際に対応につながった割合を計算します。その割合が半分を下回るなら、アラートシステムはすでに信頼を失っています。
さらに掘り下げるための資料です。