Skip to content
Published on

SLOとError Budget実行マニュアル

Authors
SLOとError Budget実行マニュアル

SLOがダッシュボードの数字で終わる理由

ほとんどの組織でSLO(Service Level Objective)の導入は次のようなパターンで失敗する。「99.9%可用性」という数字を決めてGrafanaダッシュボードに表示するが、その数字がリリース判断、オンコール優先順位、技術的負債の返済スケジュールに全く影響を与えない。SLOは測定ツールではなく意思決定フレームワークだ。Error budgetが消費されたら機能開発を止めて安定性作業に集中するという組織的合意がなければ、SLOはただの見栄えの良い数字に過ぎない。

このマニュアルはSLOの数字を組織の実際の行動に結びつける実行手順を扱う。Google SRE Workbookのerror budget policy(sre.google/workbook/error-budget-policy)を実務に適用できるレベルに具体化した。

ステップ1:SLI定義 -- 何を測定するか

SLI(Service Level Indicator)はSLOを計算する生の指標だ。「良いリクエストとは何か」を明確に定義する必要がある。

SLIの種類と計算式

SLIの種類計算式適するサービス
可用性(Availability)good_requests / total_requestsAPIサーバー、Webサービス
レイテンシ(Latency)requests_below_threshold / total_requestsユーザー向けサービス
スループット(Throughput)processed_jobs / submitted_jobsバッチ処理、パイプライン
正確性(Correctness)correct_responses / total_responsesMLモデルサービング、検索
鮮度(Freshness)fresh_data_reads / total_readsキャッシュ、データ同期

PrometheusベースのSLI収集設定

# prometheus_rules/sli_recording_rules.yaml
groups:
  - name: sli_availability
    interval: 30s
    rules:
      # APIサーバー可用性SLI
      # 「良いリクエスト」= HTTP 5xxを除くすべてのレスポンス
      - record: sli:api_availability:ratio_rate5m
        expr: |
          sum(rate(http_requests_total{status!~"5.."}[5m])) by (service)
          /
          sum(rate(http_requests_total[5m])) by (service)

  - name: sli_latency
    interval: 30s
    rules:
      # レイテンシSLI
      # 「良いリクエスト」= 300ms以内に完了したリクエスト
      - record: sli:api_latency:ratio_rate5m
        expr: |
          sum(rate(http_request_duration_seconds_bucket{le="0.3"}[5m])) by (service)
          /
          sum(rate(http_request_duration_seconds_count[5m])) by (service)

SLI定義時のよくある間違い

# 悪いSLI定義の例

# 1. サーバー側のヘルスチェックをSLIとして使用(ユーザー体験と無関係)
bad_sli_1 = "health_check_success_rate"  # サーバーは生きているがレスポンスが遅い可能性

# 2. 内部リトライを含む成功率(実際のユーザー体感と異なる)
bad_sli_2 = "requests_eventually_succeeded / requests_total"  # リトライ含む

# 3. 平均レイテンシ(p50は問題ないがp99が10秒の可能性)
bad_sli_3 = "avg(request_duration)"  # 平均はテールレイテンシを隠す

# 良いSLI定義
good_sli = {
    "availability": "ユーザーが受け取った最初のレスポンスがnon-5xxである割合",
    "latency": "ユーザーが体感したレスポンス時間が300ms以内の割合",
    "correctness": "検索結果の上位5件中関連する結果が3件以上の割合",
}

ステップ2:SLO目標設定 -- どの程度良ければいいか

SLOを100%に設定してはいけない。100%は「絶対に障害があってはならない」という意味であり、「新機能を絶対にデプロイしない」と同義だ。

SLO目標算定プロセス

def calculate_error_budget(slo_target: float, window_days: int = 30) -> dict:
    """SLO目標からerror budgetを計算"""
    total_minutes = window_days * 24 * 60
    error_budget_fraction = 1.0 - slo_target
    allowed_bad_minutes = total_minutes * error_budget_fraction

    # リクエストベースの計算(1日平均100万リクエストを想定)
    daily_requests = 1_000_000
    total_requests = daily_requests * window_days
    allowed_bad_requests = int(total_requests * error_budget_fraction)

    return {
        "slo_target": f"{slo_target * 100:.2f}%",
        "window_days": window_days,
        "error_budget_fraction": f"{error_budget_fraction * 100:.3f}%",
        "allowed_bad_minutes": round(allowed_bad_minutes, 1),
        "allowed_bad_minutes_per_day": round(allowed_bad_minutes / window_days, 2),
        "allowed_bad_requests_30d": allowed_bad_requests,
    }

# SLO別error budget比較
for target in [0.999, 0.995, 0.99, 0.9]:
    budget = calculate_error_budget(target)
    print(f"SLO {budget['slo_target']:>7s}: "
          f"月 {budget['allowed_bad_minutes']:>7.1f}分 = "
          f"日 {budget['allowed_bad_minutes_per_day']:>5.2f}分, "
          f"月 {budget['allowed_bad_requests_30d']:>8,}件許容")

出力:

SLO  99.90%:43.2=1.44,30,000件許容
SLO  99.50%:216.0=7.20,150,000件許容
SLO  99.00%:432.0=14.40,300,000件許容
SLO  90.00%:4,320.0=144.00,3,000,000件許容

サービスティア別SLOガイドライン

サービスティア可用性SLOLatency SLO (P95)根拠
Tier 1(決済、認証)99.95%200ms売上に直結、障害時に即座にビジネスインパクト
Tier 2(検索、レコメンド)99.9%500msユーザー体験の核心、代替経路が存在
Tier 3(通知、ログ)99.5%2s遅延許容可能、非同期処理可能
Tier 4(内部ツール)99.0%5s内部ユーザー、業務時間外のメンテナンス可能

ステップ3:Burn Rateアラート設計 -- いつ対応するか

Google SRE Workbookで推奨されるMulti-Window、Multi-Burn-Rateアラートを実装する。核心概念:burn rateは「現在の速度でエラーが発生し続けた場合、compliance window内でerror budgetが何倍速く消費されるか」を表す。

Burn Rateの概念

def explain_burn_rate():
    """Burn rateの概念説明"""
    # burn rate = 1: error budgetをちょうど30日で消費
    # burn rate = 14: error budgetを約2.1日で消費
    # burn rate = 2: error budgetを約15日で消費

    examples = {
        "burn_rate_14": {
            "meaning": "30日分のbudgetを2.1日で消費する速度",
            "use_case": "急性障害。5分以内の検知が必要",
            "long_window": "1h",
            "short_window": "5m",
        },
        "burn_rate_6": {
            "meaning": "30日分のbudgetを5日で消費する速度",
            "use_case": "重大なパフォーマンス低下。30分以内の検知",
            "long_window": "6h",
            "short_window": "30m",
        },
        "burn_rate_2": {
            "meaning": "30日分のbudgetを15日で消費する速度",
            "use_case": "緩やかな品質低下。数時間以内の検知",
            "long_window": "3d",
            "short_window": "6h",
        },
        "burn_rate_1": {
            "meaning": "30日分のbudgetをちょうど30日で消費",
            "use_case": "週次レビューで確認。即座のアラート不要",
            "long_window": "N/A",
            "short_window": "N/A",
        },
    }
    return examples

Prometheusアラートルール

# prometheus_rules/slo_alerts.yaml
groups:
  - name: slo_burn_rate_alerts
    rules:
      # === Tier 1: 急性障害検知 (Burn Rate 14, 1h/5mウィンドウ) ===
      - alert: SLOBurnRateCritical
        # long window: 1時間でburn rate 14以上
        # short window: 5分間でもまだ高いか確認(誤検知防止)
        expr: |
          (
            1 - (sum(rate(http_requests_total{status!~"5.."}[1h])) by (service)
                 / sum(rate(http_requests_total[1h])) by (service))
          ) > (14 * (1 - 0.999))
          and
          (
            1 - (sum(rate(http_requests_total{status!~"5.."}[5m])) by (service)
                 / sum(rate(http_requests_total[5m])) by (service))
          ) > (14 * (1 - 0.999))
        for: 1m
        labels:
          severity: critical
          slo_window: '1h/5m'
          burn_rate: '14'
        annotations:
          summary: '{{ $labels.service }}: SLO burn rate critical (14x)'
          description: |
            サービス{{ $labels.service }}のエラー率がSLO比14倍の速度で
            error budgetを消費中です。約2.1日以内に全budgetが消費されます。
            直ちに確認してください。
          runbook: 'https://wiki.internal/runbook/slo-critical'

      # === Tier 2: 重大なパフォーマンス低下 (Burn Rate 6, 6h/30mウィンドウ) ===
      - alert: SLOBurnRateHigh
        expr: |
          (
            1 - (sum(rate(http_requests_total{status!~"5.."}[6h])) by (service)
                 / sum(rate(http_requests_total[6h])) by (service))
          ) > (6 * (1 - 0.999))
          and
          (
            1 - (sum(rate(http_requests_total{status!~"5.."}[30m])) by (service)
                 / sum(rate(http_requests_total[30m])) by (service))
          ) > (6 * (1 - 0.999))
        for: 5m
        labels:
          severity: warning
          slo_window: '6h/30m'
          burn_rate: '6'
        annotations:
          summary: '{{ $labels.service }}: SLO burn rate high (6x)'
          description: |
            サービス{{ $labels.service }}のエラー率がSLO比6倍の速度で
            error budgetを消費中です。約5日以内に全budgetが消費されます。

      # === Tier 3: 緩やかな品質低下 (Burn Rate 2, 3d/6hウィンドウ) ===
      - alert: SLOBurnRateLow
        expr: |
          (
            1 - (sum(rate(http_requests_total{status!~"5.."}[3d])) by (service)
                 / sum(rate(http_requests_total[3d])) by (service))
          ) > (2 * (1 - 0.999))
          and
          (
            1 - (sum(rate(http_requests_total{status!~"5.."}[6h])) by (service)
                 / sum(rate(http_requests_total[6h])) by (service))
          ) > (2 * (1 - 0.999))
        for: 30m
        labels:
          severity: info
          slo_window: '3d/6h'
          burn_rate: '2'
        annotations:
          summary: '{{ $labels.service }}: SLO burn rate elevated (2x)'

なぜMulti-Windowなのか

単一ウィンドウの問題点:

[単一ウィンドウ: 1h]
10:00 - 10:05  障害発生、エラー率50%
10:05 - 10:10  障害解消、エラー率0%
...
10:55 - 11:00  エラー率0%

-> 1時間ウィンドウの平均エラー率:4.2%
-> burn rate 14 threshold(0.014) 超過 -> アラート発火

しかし障害は55分前にすでに終了していた!
=> 誤検知(false positive)。オンコールエンジニアがすでに解決済みの問題で呼び出される。
[マルチウィンドウ: 1h + 5m]
Long window (1h): エラー率 4.2% -> threshold超過 (O)
Short window (5m): エラー率 0% -> threshold未達 (X)
=> 両方の条件が満たされないためアラート発火しない。正確な判断。

ステップ4:Error Budget Policy -- 消費されたら何をするか

Error budget policyはSLOの核心だ。これなしにSLOの数字だけ設定しても意味がない。

Error Budget Policyテンプレート

# error_budget_policy.yaml
# この文書はエンジニアリングリーダーシップ、プロダクトチーム、SREチームが合意して署名する。

policy:
  version: '2.0'
  effective_date: '2026-01-15'
  review_cycle: 'quarterly'

  budget_thresholds:
    # Budget >= 50%: 通常運用
    green:
      remaining_budget: '>= 50%'
      actions:
        - '機能開発と安定性作業の比率: 8:2'
        - '通常のリリースプロセスを維持'
        - '週次SLOレビューでトレンドを確認'

    # Budget 20-50%: 注意
    yellow:
      remaining_budget: '20% ~ 50%'
      actions:
        - '機能開発と安定性作業の比率: 5:5'
        - 'リリース前の追加負荷テスト必須'
        - 'すべてのデプロイにカナリーステージを追加 (1% -> 10% -> 50% -> 100%)'
        - '週2回SLOレビュー'

    # Budget < 20%: 危険
    red:
      remaining_budget: '< 20%'
      actions:
        - '新規機能リリースを凍結'
        - '安定性改善作業に全人員集中'
        - 'すべての変更にVP承認が必要'
        - '毎日SLOレビュー'
        - 'ポストモーテム作成とアクションアイテム追跡'

    # Budget消費: 非常事態
    exhausted:
      remaining_budget: '<= 0%'
      actions:
        - 'すべての非必須デプロイを即座に停止'
        - '過去30日以内の変更事項から疑わしい対象のロールバックを検討'
        - 'CTO/VPにエスカレーション'
        - '日次状況報告'
        - 'ウィンドウリセットまで機能凍結を維持'

  exceptions:
    - 'セキュリティパッチはbudget状態に関わらず即座にデプロイ'
    - '法的コンプライアンス要件は例外'
    - 'データ損失防止のための緊急修正は例外'

  escalation:
    - level: 'L1(オンコールエンジニア)'
      condition: 'burn rateアラート発火'
      response_time: '15分'
    - level: 'L2(チームリード)'
      condition: 'budget < 50%'
      response_time: '1時間'
    - level: 'L3(VP Engineering)'
      condition: 'budget < 20%またはexhausted'
      response_time: '当日中'

Error Budget残量計算とレポーティング

import datetime
import requests
from dataclasses import dataclass

@dataclass
class ErrorBudgetReport:
    service: str
    slo_target: float
    window_days: int
    current_sli: float
    budget_remaining_pct: float
    budget_remaining_minutes: float
    estimated_exhaustion_date: str
    policy_status: str  # green, yellow, red, exhausted

def calculate_error_budget_status(
    prometheus_url: str,
    service: str,
    slo_target: float = 0.999,
    window_days: int = 30,
) -> ErrorBudgetReport:
    """Prometheusから現在のSLIを照会してerror budget状態を計算"""

    # 現在のSLI照会(30日ウィンドウ)
    query = f'''
        sum(rate(http_requests_total{{service="{service}",status!~"5.."}}[{window_days}d]))
        /
        sum(rate(http_requests_total{{service="{service}"}}[{window_days}d]))
    '''
    resp = requests.get(
        f"{prometheus_url}/api/v1/query",
        params={"query": query},
    )
    result = resp.json()["data"]["result"]
    current_sli = float(result[0]["value"][1]) if result else 0.0

    # Error budget計算
    total_budget = 1.0 - slo_target  # 例: 0.001
    consumed = max(0.0, (1.0 - current_sli) - 0)  # 実際のエラー率
    remaining = max(0.0, total_budget - consumed)
    remaining_pct = (remaining / total_budget) * 100 if total_budget > 0 else 0

    # 時間換算
    total_minutes = window_days * 24 * 60
    remaining_minutes = total_minutes * (remaining / total_budget) if total_budget > 0 else 0

    # 消費予想日計算
    if consumed > 0 and remaining > 0:
        burn_rate = consumed / total_budget
        days_to_exhaustion = window_days * (remaining / total_budget) / burn_rate
        exhaustion_date = (
            datetime.date.today() + datetime.timedelta(days=days_to_exhaustion)
        ).isoformat()
    elif remaining <= 0:
        exhaustion_date = "EXHAUSTED"
    else:
        exhaustion_date = "N/A (no errors)"

    # Policy状態判定
    if remaining_pct >= 50:
        status = "green"
    elif remaining_pct >= 20:
        status = "yellow"
    elif remaining_pct > 0:
        status = "red"
    else:
        status = "exhausted"

    return ErrorBudgetReport(
        service=service,
        slo_target=slo_target,
        window_days=window_days,
        current_sli=round(current_sli, 6),
        budget_remaining_pct=round(remaining_pct, 2),
        budget_remaining_minutes=round(remaining_minutes, 1),
        estimated_exhaustion_date=exhaustion_date,
        policy_status=status,
    )

Slack週次レポート自動化

import json
from slack_sdk import WebClient

def send_weekly_slo_report(
    slack_token: str,
    channel: str,
    services: list[str],
    prometheus_url: str,
):
    """週次SLOレポートをSlackチャンネルに送信"""
    client = WebClient(token=slack_token)
    reports = []

    for service in services:
        report = calculate_error_budget_status(prometheus_url, service)
        reports.append(report)

    # 状態別絵文字マッピング(Slack用)
    status_emoji = {
        "green": ":large_green_circle:",
        "yellow": ":large_yellow_circle:",
        "red": ":red_circle:",
        "exhausted": ":rotating_light:",
    }

    blocks = [
        {"type": "header", "text": {"type": "plain_text", "text": "Weekly SLO Report"}},
        {"type": "divider"},
    ]

    for r in sorted(reports, key=lambda x: x.budget_remaining_pct):
        emoji = status_emoji[r.policy_status]
        blocks.append({
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": (
                    f"{emoji} *{r.service}*\n"
                    f"  SLO: {r.slo_target*100:.2f}% | "
                    f"Current SLI: {r.current_sli*100:.4f}%\n"
                    f"  Budget remaining: {r.budget_remaining_pct:.1f}% "
                    f"({r.budget_remaining_minutes:.0f} min)\n"
                    f"  Estimated exhaustion: {r.estimated_exhaustion_date}"
                ),
            },
        })

    client.chat_postMessage(channel=channel, blocks=blocks)

ステップ5:リリースゲート連携 -- SLOがデプロイをブロックする仕組み

Error budget状態に基づいてCI/CDパイプラインでデプロイを自動的にブロックする。

GitHub Actionsリリースゲート

# .github/workflows/release-gate.yaml
name: SLO Release Gate
on:
  workflow_call:
    inputs:
      service:
        required: true
        type: string

jobs:
  check-error-budget:
    runs-on: ubuntu-latest
    steps:
      - name: Query error budget status
        id: budget
        run: |
          RESULT=$(curl -s "${{ secrets.PROMETHEUS_URL }}/api/v1/query" \
            --data-urlencode "query=slo:error_budget_remaining_pct{service=\"${{ inputs.service }}\"}" \
            | jq -r '.data.result[0].value[1]')
          echo "remaining_pct=$RESULT" >> $GITHUB_OUTPUT

      - name: Evaluate release gate
        run: |
          BUDGET="${{ steps.budget.outputs.remaining_pct }}"
          echo "Error budget remaining: ${BUDGET}%"

          if (( $(echo "$BUDGET < 20" | bc -l) )); then
            echo "::error::ERROR BUDGET CRITICAL (${BUDGET}%). Release blocked."
            echo "Error budgetが20%未満です。安定性作業を先に完了してください。"
            exit 1
          elif (( $(echo "$BUDGET < 50" | bc -l) )); then
            echo "::warning::Error budget at ${BUDGET}%. Canary deployment required."
            echo "canary_required=true" >> $GITHUB_OUTPUT
          else
            echo "Error budget healthy at ${BUDGET}%. Proceeding."
          fi

      - name: Notify Slack on block
        if: failure()
        run: |
          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
            -H 'Content-Type: application/json' \
            -d "{\"text\":\"Release blocked for ${{ inputs.service }}: error budget < 20%\"}"

ステップ6:ポストモーテムにSLO影響度を含める

障害ポストモーテムには「SLOにどれだけ影響を与えたか」を必ず含めるべきだ。これが障害のビジネスインパクトを定量化する最も明確な方法だ。

ポストモーテムSLO影響度セクションテンプレート

## SLO影響度分析

### 影響を受けたSLO

- サービス: payment-api
- SLO目標: 99.95% availability (30日ウィンドウ)
- 障害前SLI: 99.97%
- 障害後SLI: 99.93%

### Error Budget消費量

- 障害持続時間: 23分
- 障害中のエラーリクエスト数: 12,847件
- 全ウィンドウ対比budget消費: 28.5%
- 障害前budget残量: 71.2%
- 障害後budget残量: 42.7%

### Policy状態変更

- 障害前: GREEN (71.2%)
- 障害後: YELLOW (42.7%)
- 措置: 今後2週間のリリースにカナリーステージを追加

### ビジネスインパクト

- 失敗した決済試行: 約3,200件
- 推定売上損失: 約$48,000
- 顧客問い合わせ増加: 127件

トラブルシューティング

1. SLI値が100%で固定されている

原因: メトリクス収集の漏れ。エラーレスポンスが別のメトリクスとして記録されていない場合。

# 確認方法
curl -s http://prometheus:9090/api/v1/query \
  --data-urlencode 'query=http_requests_total{status=~"5.."}' | jq '.data.result | length'
# 0であれば5xxメトリクスが収集されていない

# 解決: アプリケーションのメトリクスミドルウェアでstatus codeラベルを確認

2. アラートが頻繁に発火する(1日10回以上)

診断順序:

  1. burn rate thresholdが低すぎないか確認(burn rate 1でアラートを設定すると正常状態でも発火)
  2. short windowが長すぎないか確認(30mが適切、5mだとノイズが多い)
  3. for durationが短すぎないか確認(criticalは最低1m、warningは5m)
  4. SLO目標自体が現在のサービスレベルとかけ離れていないか検討

3. Error budgetが新しいウィンドウでリセットされたが同じ問題が繰り返される

根本原因: ポストモーテムのアクションアイテムが完了していない状態でウィンドウだけリセットされた。

解決: Error budget policyに「前のウィンドウで消費された場合、アクションアイテム完了率が80%以上でなければ次のウィンドウでGREEN復帰不可」の条件を追加。

4. チーム間のSLI定義の不一致

事例: バックエンドチームは「サーバーレスポンス時間」を、フロントエンドチームは「ユーザー体感ローディング時間」をSLIとして使用。同じSLO 99.9%だが異なるものを測定している。

解決: SLI定義ドキュメントを組織全体の共有Wikiで管理し、四半期ごとにプロダクト/プラットフォーム/SREが共同レビュー。

クイズ

Q1. SLO 99.9%の30日error budgetは分単位でいくらか? 正解: 43.2分。30日 x 24時間 x 60分 = 43,200分。43,200 x 0.001 = 43.2分。

Q2. Burn rate 14はどのような状況を意味するか? 正解: 30日分のerror budgetを約2.1日(30/14)で消費する速度でエラーが発生しているという意味だ。 急性障害状況であり即座の対応が必要だ。

Q3. Multi-Windowアラートにおけるshort windowの役割は? 正解: Long windowで検知された異常が現在も進行中かを確認すること。すでに終了した過去の障害に 対する誤検知(false positive)を防止する。Google SRE Workbookではshort windowをlong windowの 1/12に設定することを推奨している。

Q4. Error budgetが消費された時、セキュリティパッチもデプロイを停止すべきか? 正解: いいえ。セキュリティパッチ、法的コンプライアンス要件、データ損失防止のための緊急修正は error budget状態に関わらずデプロイすべきだ。これらの例外はerror budget policyに明記する必要がある。

Q5. SLOを100%に設定してはいけない理由は? 正解: SLO 100%はerror budgetが0であるため、エラーの可能性がある限りいかなる変更もデプロイできない。 これは事実上「新機能を絶対にデプロイしない」という宣言であり、イノベーションを完全にブロックする。 またインフラ自体の不可避な障害(ハードウェア故障、ネットワーク問題)により100%は現実的に達成不可能だ。

Q6. SLIとして「平均レスポンス時間」を使ってはいけない理由は? 正解: 平均はテールレイテンシを隠す。P50が100msでP99が10秒のサービスの平均は約200msに見える 可能性があるが、100人中1人は10秒待つことになる。SLIには「threshold以内にレスポンスした割合」 (例: 300ms以内のレスポンス割合)を使用すべきだ。

参考資料