- はじめに — 昨夜鳴ったページのうち何件が対応につながったか
- アラート疲れが本当の事故を見逃させるメカニズム
- 症状にページを掛け、原因はダッシュボードに置く
- SLOとエラーバジェット — しきい値を防御できるように決める方法
- 多重ウィンドウのバーンレートアラート — 感度と誤検知を同時に押さえる
- ページ、チケット、ダッシュボード — 三段分類とランブックの強制
- アラートを振り返って削除する手順
- おわりに — アラートの目的は検知ではなく対応である
はじめに — 昨夜鳴ったページのうち何件が対応につながったか
この問いに即答できないなら、アラートシステムはすでに壊れています。答えが「ほとんどない」なら、壊れ方は深刻です。
オンコールが崩壊する典型的な経路はこうです。事故がひとつ起きると振り返りで「これを事前に知っておくべきだった」という結論が出て、アラートがひとつ追加されます。この過程が2年繰り返されるとアラートルールは300個になり、Slackチャンネルには一日400件が積み上がり、オンコールは夜に三度起きて三度とも何もせずにまた寝ます。
この記事はその300個を減らす方法ではなく、そもそも何にページを掛けるべきなのかを決め直す方法を扱います。
アラート疲れが本当の事故を見逃させるメカニズム
アラート疲れは怠惰の問題ではなく確率の問題です。
一日400件のアラートのうち実際に対応が必要なのが4件なら、精度は1パーセントです。この状態で人が学習する最適戦略は「とりあえず無視してあとで確認」です。それが合理的だからこそ、より危険です。訓練や覚悟では取り戻せません。
具体的に三つが壊れます。
第一に、反応時間が伸びます。アラートを開く前に「またあれだろう」という事前判断が付きます。本当の事故の初動10分がここで消えます。
第二に、信号がノイズに埋もれます。400件のうち本物が4件なら、その4件は視覚的に残りの396件と区別できません。深刻度のラベルがあっても同じです。すべてがcriticalなら何もcriticalではありません。
第三に、人がすり減ります。夜間の呼び出しは翌日の判断力を落とします。対応の必要がない呼び出しでオンコールを起こすことは、次の事故の対応品質を前もって削り取る行為です。
ここから実務目標が出てきます。12時間交代を基準にページは平均2件以下、そしてページの対応転換率は最低70パーセント以上を目標に置きます。この二つの数字を超えたら、アラートを追加するのではなく削除するときです。
症状にページを掛け、原因はダッシュボードに置く
もっとも強力なルールをひとつ選ぶならこれです。ユーザーが経験する症状にだけ呼び出しを掛けます。
CPU使用率90パーセントは症状ではありません。ユーザーはCPUを感じません。CPUが90パーセントでも応答時間が正常なら何も起きていないのであり、CPUが40パーセントでも応答に5秒かかるなら深刻な事故です。原因指標に呼び出しを掛けると、どちらの場合も間違えます。
呼び出しを掛ける対象は、サービスがユーザーに約束したものです。
- 可用性: リクエストのうちサーバーエラーで失敗した比率
- レイテンシ: しきい値の中に完了しなかったリクエストの比率
- スループット急減: 正常時に比べてトラフィックが消えた場合(障害のよくある症状です)
- 鮮度: バッチやストリームのパイプラインでデータがどれだけ遅れているか
- 正確性: 突合作業で見つかった不一致の件数
呼び出しを掛けない対象は原因指標です。CPU、メモリ、ディスクIOPS、ポッドの再起動回数、スレッドプール使用率、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
第二に、観測体系そのものの故障です。メトリクスが入ってこない状態はすべてのアラートを無力化するので、必ず呼び出し対象にします。
# スクレイプが途切れたジョブ — このアラートがなければ残りのアラートが静かに死ぬ
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です。この速度が1時間続くと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 | チケット |
短い窓は長い窓の12分の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は、式が真である状態がその時間だけ連続して維持されなければ発火しないという意味です。途中で一度でも偽になればタイマーは0に初期化されます。これがフラッピングを防ぐ原理です。
ここでよくある誤った助言があります。「誤検知が多いので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
ページ、チケット、ダッシュボード — 三段分類とランブックの強制
すべてのアラートは三つのうちのひとつでなければなりません。四つ目はありません。
- ページ: 人が今すぐ起きなければなりません。ユーザー影響が進行中で、自動復旧せず、人の対応で状況が変わります。目標は12時間交代あたり2件以下です。
- チケット: 業務時間に処理すれば十分です。予算は漏れていますが速度が遅いです。自動でイシューが作成され担当者が割り当てられます。
- ダッシュボード: アラートを作りません。調査するときに見る指標です。ここにアラートを付けたくなる衝動が300個のアラートの出発点です。
三つ目の分類を明示的に置くことが重要です。「とりあえずSlackチャンネルにだけ送ろう」という折衷案は最悪です。アラートがどこにも所属しないまま無限に増え、そのチャンネルは誰も見なくなり、あとでそのチャンネルに本物の信号が流れても見逃します。
ページのアラートにはランブックのリンクを強制します。深夜3時に起きた人は創意工夫を発揮できる状態ではありません。必要なのは、確認すること三つと対応すること二つが書かれた文書です。
# 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 "ランブックのリンクがないページアラート:"
echo "$missing"
exit 1
fi
echo "すべてのページアラートにランブックが接続されています。"
ランブックに必ず入れるべき内容は四つです。このアラートが意味するユーザー影響、すぐに確認するダッシュボードとクエリ、既知の原因とそれぞれの対応、そしてエスカレーション先。リンクだけあって中身が空のランブックは、リンクがないより悪いです。
アラートを振り返って削除する手順
アラートは追加する手順だけがあって削除する手順がないので増えます。削除を既定動作にする定期レビューが必要です。
月に一度、次のデータを取り出してから始めます。
# アラート別の総発火時間(分)。評価周期が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日の発火回数、そのうち実際の対応につながった比率、中央値の解消時間、夜間発火の比率。そして次のルールを機械的に適用します。
- 3か月連続で対応につながらなかったページはチケットに降格するか削除します。維持するなら維持理由を文書に書かなければなりません。
- サイレンスが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に何を入れるかがアラートの個数を決めます。インスタンス単位でグルーピングすればポッドの数だけアラートが来ます。サービスやSLO単位で束ねてはじめて、人が読める個数になります。
おわりに — アラートの目的は検知ではなく対応である
覚えておくべきことは一文です。アラートの成功基準は「問題を検知したか」ではなく「人が今すぐ何かをしなければならないか」です。
この基準を通過しないアラートは、正確であっても有害です。正確なアラート400個がオンコールの反応速度を崩壊させるなら、正確さには何の意味もありません。だから順序はこうなります。ユーザー症状でSLOを定義し、予算からしきい値を逆算し、長い窓と短い窓をANDで結び、ページにはランブックを強制し、毎月対応につながらなかったアラートを削除します。
明日できることがひとつあるとすれば、この30日間で発火時間がもっとも長いページアラートを三つ取り出し、それぞれが対応につながったことがあるかを確認することです。ほとんどのチームで、その三つがオンコール疲労の半分を占めています。
さらに掘り下げるための資料です。
현재 단락 (1/189)
この問いに即答できないなら、アラートシステムはすでに壊れています。答えが「ほとんどない」なら、壊れ方は深刻です。