LabHub

ブログ

Grafana OnCallとインシデント管理自動化:PagerDuty統合からRunbook自動化まで

한국어English日本語

Grafana OnCall

はじめに

午前3時、眠りを破る通知音が鳴る。本番データベースのコネクションプールが枯渇したという警告だ。担当エンジニアを探してSlackチャンネルをたどり、誰がオンコールかを確認し、対応手順を思い出そうと苦心しているあいだにも、障害時間は分単位で伸びていく。これがインシデント管理の自動化を持たない組織の現実だ。

2025年、グローバルのインシデント管理市場は急速に成長している。マイクロサービスアーキテクチャとクラウドネイティブ環境が広がるにつれ、単一の障害が数十のサービスへ連鎖的に影響を及ぼす状況が日常になった。Google SRE Workbookによれば、オンコールエンジニアが1シフトあたりに引き受けられる持続可能なインシデント数は最大2〜3件だ。これを超えるとアラート疲労(Alert Fatigue)が発生し、対応品質が急激に低下する。

Grafana Labsはこの問題を解決するためにGrafana OnCallをオープンソースとして公開し、2025年3月にはOnCallとIncidentを統合したGrafana Cloud IRM(Incident Response Management)を正式リリースした。この記事ではGrafana OnCall/IRMを中心に、オンコールスケジューリングからエスカレーションポリシー、PagerDuty統合、Slack連携、Runbook自動化まで、インシデント管理自動化のパイプライン全体を実践的なコードとともに構築する。

インシデント管理自動化の必要性

インシデント管理を手動で運用すると、以下のような問題が繰り返される。

平均対応時間(MTTA)の増加:誰がオンコールかを確認し、関係者を招集し、対応手順を探すのに費やす時間が、実際に問題を解決する時間より長くなる。自動化がなければMTTAが15〜30分に達することも珍しくない。

エスカレーションの失敗:手動のエスカレーションは人の判断に依存する。深夜に通知を受けたエンジニアが重要度を過小評価したり、エスカレーション先を誤って選んだりすれば、障害は長期化する。

知識の断絶:インシデント対応手順がWikiやConfluenceに散らばっていると、緊急時に正しいRunbookを見つけるのが難しい。さらに深刻なのは、Runbookが最新でない場合だ。

バーンアウト:不公平なオンコール分配、過剰なアラート、非効率なエスカレーションが繰り返されると、エンジニアのバーンアウトにつながる。incident.ioの2025年の調査によれば、オンコールエンジニアの62%がアラート疲労を経験している。

自動化されたインシデント管理システムは、これらの問題を構造的に解決する。アラートが発生すると自動的に正しいオンコール担当者へルーティングし、応答がなければ定められたポリシーに従ってエスカレーションし、関連するRunbookを自動で添付し、Slackチャンネルを自動生成して対応チームを招集する。

Grafana OnCall のアーキテクチャ

Grafana OnCallは、Grafanaエコシステムに緊密に統合されたオンコール管理システムだ。2025年3月からGrafana CloudではOnCallとIncidentがGrafana Cloud IRMへ統合され、オープンソース版(OnCall OSS)はメンテナンスモードに入った。ただし中核となる概念とアーキテクチャは同一なので、両方のバージョンに当てはまる内容を扱う。

┌─────────────────────────────────────────────────────────────────────────┐
│                 インシデント管理自動化アーキテクチャ                       │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐                   │
│  │ Prometheus   │  │ Grafana      │  │ 外部監視       │                   │
│  │ Alertmanager │  │ Alerting (Datadog)  │                   │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘                   │
│         │                 │                  │                           │
│         └─────────────────┼──────────────────┘                          │
│                           ▼                                             │
│              ┌────────────────────────┐                                  │
│              │   Grafana OnCall/IRM   │                                  │
│              │  ┌──────────────────┐  │                                  │
│              │  │  Integration     │  │  Webhook / API 受信              │
│              │  │  Layer           │  │                                  │
│              │  └────────┬─────────┘  │                                  │
│              │           ▼            │                                  │
│              │  ┌──────────────────┐  │                                  │
│              │  │  Route Engine    │  │  ラベルルーティング               │
│              │  └────────┬─────────┘  │                                  │
│              │           ▼            │                                  │
│              │  ┌──────────────────┐  │                                  │
│              │  │  Escalation      │  │  エスカレーション実行             │
│              │  │  Engine          │  │                                  │
│              │  └────────┬─────────┘  │                                  │
│              │           ▼            │                                  │
│              │  ┌──────────────────┐  │                                  │
│              │  │  Schedule        │  │  オンコール表の参照               │
│              │  │  Manager         │  │                                  │
│              │  └──────────────────┘  │                                  │
│              └────────────────────────┘                                  │
│                       │          │                                       │
│          ┌────────────┘          └────────────┐                          │
│          ▼                                    ▼                          │
│  ┌──────────────────┐              ┌──────────────────┐                  │
│  │  Notification     │              │  Outgoing         │                 │
│  │  Channels         │              │  Webhooks         │                 │
│  │                   │              │                   │                 │
│  │  - Slack          │              │  - Runbook 実行   │                 │
│  │  - MS Teams       │              │  - Jira 起票      │                 │
│  │  - Phone Call     │              │  - PagerDuty 連携 │                 │
│  │  - SMS            │              │  - 復旧スクリプト │                 │
│  │  - Email          │              │                   │                 │
│  └──────────────────┘              └──────────────────┘                  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

中核となる構成要素は以下のとおりだ。

インストールと初期構成

Docker Compose による OnCall OSS のインストール

Grafana OnCall OSSをローカルまたは開発環境へデプロイする最も速い方法は、Docker Composeを使うことだ。

# docker-compose.yml
version: '3.8'

services:
  engine:
    image: grafana/oncall:latest
    restart: always
    ports:
      - '8080:8080'
    command: >
      sh -c "uwsgi --ini uwsgi.ini"
    environment:
      BASE_URL: http://localhost:8080
      SECRET_KEY: ${ONCALL_SECRET_KEY:-my-secret-key-change-in-production}
      RABBITMQ_USERNAME: rabbitmq
      RABBITMQ_PASSWORD: rabbitmq
      RABBITMQ_HOST: rabbitmq
      RABBITMQ_PORT: 5672
      RABBITMQ_DEFAULT_VHOST: /
      MYSQL_DB_NAME: oncall
      MYSQL_USER: root
      MYSQL_PASSWORD: oncall
      MYSQL_HOST: mysql
      MYSQL_PORT: 3306
      REDIS_URI: redis://redis:6379/0
      DJANGO_SETTINGS_MODULE: settings.hobby
      CELERY_WORKER_QUEUE: default,critical,long,slack,telegram,webhook,retry,celery,grafana
      GRAFANA_API_URL: http://grafana:3000
    depends_on:
      mysql:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
      redis:
        condition: service_healthy

  celery:
    image: grafana/oncall:latest
    restart: always
    command: >
      sh -c "./celery_with_exporter.sh"
    environment:
      BASE_URL: http://localhost:8080
      SECRET_KEY: ${ONCALL_SECRET_KEY:-my-secret-key-change-in-production}
      RABBITMQ_USERNAME: rabbitmq
      RABBITMQ_PASSWORD: rabbitmq
      RABBITMQ_HOST: rabbitmq
      RABBITMQ_PORT: 5672
      MYSQL_DB_NAME: oncall
      MYSQL_USER: root
      MYSQL_PASSWORD: oncall
      MYSQL_HOST: mysql
      MYSQL_PORT: 3306
      REDIS_URI: redis://redis:6379/0
      DJANGO_SETTINGS_MODULE: settings.hobby
      CELERY_WORKER_QUEUE: default,critical,long,slack,telegram,webhook,retry,celery,grafana
    depends_on:
      mysql:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy
      redis:
        condition: service_healthy

  mysql:
    image: mysql:8.0
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: oncall
      MYSQL_DATABASE: oncall
    volumes:
      - oncall-mysql:/var/lib/mysql
    healthcheck:
      test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost']
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: always
    healthcheck:
      test: ['CMD', 'redis-cli', 'ping']
      interval: 10s
      timeout: 5s
      retries: 5

  rabbitmq:
    image: rabbitmq:3.12-management-alpine
    restart: always
    environment:
      RABBITMQ_DEFAULT_USER: rabbitmq
      RABBITMQ_DEFAULT_PASS: rabbitmq
    healthcheck:
      test: ['CMD', 'rabbitmq-diagnostics', 'check_running']
      interval: 10s
      timeout: 5s
      retries: 5

  grafana:
    image: grafana/grafana:latest
    restart: always
    ports:
      - '3000:3000'
    environment:
      GF_SECURITY_ADMIN_USER: admin
      GF_SECURITY_ADMIN_PASSWORD: admin
      GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-oncall-app
      GF_INSTALL_PLUGINS: grafana-oncall-app
    volumes:
      - grafana-data:/var/lib/grafana

volumes:
  oncall-mysql:
  grafana-data:

インストール後に初期化を進める。

# 1. Docker Compose を起動
docker-compose up -d

# 2. DB マイグレーションを実行
docker-compose exec engine python manage.py migrate

# 3. Grafana OnCall プラグインの有効化を確認
# ブラウザで http://localhost:3000 にアクセス
# Grafana 左メニュー -> Alerts & IRM -> OnCall

# 4. OnCall API トークンを作成 (Terraform/API 連携用)
curl -X POST http://localhost:3000/api/plugins/grafana-oncall-app/resources/api/v1/api_token \
  -H "Authorization: Bearer <grafana-admin-api-key>" \
  -H "Content-Type: application/json"

# 5. ヘルスチェック
curl http://localhost:8080/health/

Helm Chart による Kubernetes へのデプロイ

本番環境では、Helm Chartを使ってKubernetesへデプロイすることを推奨する。

# Helm リポジトリを追加
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

# OnCall のインストール (デフォルト設定)
helm install oncall grafana/oncall \
  --namespace oncall \
  --create-namespace \
  --set base_url=oncall.example.com \
  --set grafana."grafana\.ini".server.domain=grafana.example.com \
  --set ingress.enabled=true \
  --set ingress.annotations."kubernetes\.io/ingress\.class"=nginx \
  --set celery.workers=4 \
  --set engine.replicaCount=2

オンコールスケジューリング

オンコールスケジュールはインシデント管理の土台だ。よく設計されたスケジュールは、公平な負担の分配と隙のないカバレッジを保証する。Grafana OnCallは、Web UI、iCal、API/Terraformの3つの方式でスケジュールを管理できる。

スケジュール設計の原則

Grafana公式ドキュメントが推奨するオンコールスケジュール設計の原則は以下のとおりだ。

  1. チーム規模に合ったローテーション周期の選択:4〜6名のチームには週次ローテーション、8名以上のチームには2日ローテーションが適する。
  2. Follow-the-Sun パターン:3つ以上のタイムゾーンに分散したチームは、各地域が業務時間だけオンコールを担当するように設計する。このモデルはエンジニアあたりのオンコール時間を最大67%まで削減できる。
  3. オーバーライドの仕組み:計画された不在(休暇、会議)にはシフトスワップを、緊急の不在にはオーバーライドを使う。
  4. バックアップスケジュール:プライマリスケジュールとは別に、必ずバックアップスケジュールを構成する。

Terraform によるスケジュール管理

オンコールスケジュールをコードで管理すると、変更履歴の追跡、レビュー、自動化が可能になる。

# terraform/oncall-schedules.tf

terraform {
  required_providers {
    grafana = {
      source  = "grafana/grafana"
      version = ">= 3.0.0"
    }
  }
}

provider "grafana" {
  url                  = var.grafana_url
  auth                 = var.grafana_auth
  oncall_access_token  = var.oncall_access_token
}

# チームのデータソース
data "grafana_oncall_user" "engineer_a" {
  username = "engineer-a"
}

data "grafana_oncall_user" "engineer_b" {
  username = "engineer-b"
}

data "grafana_oncall_user" "engineer_c" {
  username = "engineer-c"
}

data "grafana_oncall_user" "engineer_d" {
  username = "engineer-d"
}

# プライマリのオンコールスケジュール - 週次ローテーション
resource "grafana_oncall_schedule" "primary" {
  name      = "Platform Team - Primary"
  type      = "calendar"
  team_id   = var.team_id
  time_zone = "Asia/Seoul"

  shifts = [
    grafana_oncall_on_call_shift.primary_rotation.id,
  ]
}

resource "grafana_oncall_on_call_shift" "primary_rotation" {
  name       = "Primary Weekly Rotation"
  type       = "rolling_users"
  start      = "2026-03-09T00:00:00"
  duration   = 60 * 60 * 24 * 7  # 7日 (秒単位)
  frequency  = "weekly"
  by_day     = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
  time_zone  = "Asia/Seoul"

  rolling_users = [
    [data.grafana_oncall_user.engineer_a.id],
    [data.grafana_oncall_user.engineer_b.id],
    [data.grafana_oncall_user.engineer_c.id],
    [data.grafana_oncall_user.engineer_d.id],
  ]
}

# バックアップスケジュール - シニアエンジニア
resource "grafana_oncall_schedule" "backup" {
  name      = "Platform Team - Backup"
  type      = "calendar"
  team_id   = var.team_id
  time_zone = "Asia/Seoul"

  shifts = [
    grafana_oncall_on_call_shift.backup_rotation.id,
  ]
}

resource "grafana_oncall_on_call_shift" "backup_rotation" {
  name       = "Backup Bi-Weekly Rotation"
  type       = "rolling_users"
  start      = "2026-03-09T00:00:00"
  duration   = 60 * 60 * 24 * 14  # 14日
  frequency  = "weekly"
  interval   = 2
  time_zone  = "Asia/Seoul"

  rolling_users = [
    [data.grafana_oncall_user.engineer_a.id],
    [data.grafana_oncall_user.engineer_c.id],
  ]
}

エスカレーションポリシーの設計

エスカレーションポリシーは、アラートが発生したときに誰へ、どの順序で、どの方法で通知するかを決める中核のロジックだ。Grafana OnCallのエスカレーションチェーンは、段階ごとにさまざまなアクションを組み合わせられる。

基本のエスカレーションパターン

Grafana公式ドキュメントが推奨する基本のエスカレーションパターンは以下のとおりだ。

  1. オンコールスケジュールの担当者へ基本の通知を送る
  2. 5分待機(応答時間を確保する)
  3. 応答がなければ重要(Important)チャンネルへ再通知
  4. 10分待機
  5. バックアップスケジュールの担当者へエスカレーション
  6. 15分待機
  7. チーム全体へ通知(最後の手段)

Terraform によるエスカレーションチェーンの構成

# terraform/oncall-escalation.tf

# 重要度別のエスカレーションチェーン

# Critical (P1) - 高速なエスカレーション
resource "grafana_oncall_escalation_chain" "critical" {
  name    = "Critical - P1 Incidents"
  team_id = var.team_id
}

resource "grafana_oncall_escalation" "critical_step_1" {
  escalation_chain_id = grafana_oncall_escalation_chain.critical.id
  type                = "notify_on_call_from_schedule"
  notify_on_call_from_schedule = grafana_oncall_schedule.primary.id
  position            = 0
  important           = true
}

resource "grafana_oncall_escalation" "critical_wait_1" {
  escalation_chain_id = grafana_oncall_escalation_chain.critical.id
  type                = "wait"
  duration            = 300  # 5分
  position            = 1
}

resource "grafana_oncall_escalation" "critical_step_2" {
  escalation_chain_id = grafana_oncall_escalation_chain.critical.id
  type                = "notify_on_call_from_schedule"
  notify_on_call_from_schedule = grafana_oncall_schedule.backup.id
  position            = 2
  important           = true
}

resource "grafana_oncall_escalation" "critical_wait_2" {
  escalation_chain_id = grafana_oncall_escalation_chain.critical.id
  type                = "wait"
  duration            = 300  # 5分
  position            = 3
}

resource "grafana_oncall_escalation" "critical_step_3" {
  escalation_chain_id = grafana_oncall_escalation_chain.critical.id
  type                = "notify_whole_channel"
  position            = 4
}

# Warning (P2) - 標準のエスカレーション
resource "grafana_oncall_escalation_chain" "warning" {
  name    = "Warning - P2 Incidents"
  team_id = var.team_id
}

resource "grafana_oncall_escalation" "warning_step_1" {
  escalation_chain_id = grafana_oncall_escalation_chain.warning.id
  type                = "notify_on_call_from_schedule"
  notify_on_call_from_schedule = grafana_oncall_schedule.primary.id
  position            = 0
  important           = false
}

resource "grafana_oncall_escalation" "warning_wait_1" {
  escalation_chain_id = grafana_oncall_escalation_chain.warning.id
  type                = "wait"
  duration            = 900  # 15分
  position            = 1
}

resource "grafana_oncall_escalation" "warning_step_2" {
  escalation_chain_id = grafana_oncall_escalation_chain.warning.id
  type                = "notify_on_call_from_schedule"
  notify_on_call_from_schedule = grafana_oncall_schedule.backup.id
  position            = 2
  important           = false
}

# Integration の設定 - Alertmanager 連携
resource "grafana_oncall_integration" "alertmanager" {
  name = "Prometheus Alertmanager"
  type = "alertmanager"

  default_route {
    escalation_chain_id = grafana_oncall_escalation_chain.warning.id
  }
}

# ルーティングルール - 重要度に応じて別のエスカレーションチェーンを適用
resource "grafana_oncall_route" "critical_route" {
  integration_id      = grafana_oncall_integration.alertmanager.id
  escalation_chain_id = grafana_oncall_escalation_chain.critical.id
  routing_regex       = "\"severity\":\"critical\""
  position            = 0
}

resource "grafana_oncall_route" "warning_route" {
  integration_id      = grafana_oncall_integration.alertmanager.id
  escalation_chain_id = grafana_oncall_escalation_chain.warning.id
  routing_regex       = "\"severity\":\"warning\""
  position            = 1
}

Slack/Teams 統合

Grafana OnCallのSlack統合は、単なる通知の送信にとどまらず、Slack内で直接インシデントを管理できる双方向のインターフェースを提供する。確認(Acknowledge)、解決(Resolve)、エスカレーションを、Slackメッセージのボタンから実行できる。

Slack アプリの設定

OnCall OSSでSlack統合を設定するには、Slack APIでアプリを作成する必要がある。環境は必ずHTTPSでアクセスできなければならない。

# 1. Slack App を作成
# https://api.slack.com/apps で "Create New App" -> "From an app manifest" を選択

# 2. App Manifest (YAML 形式)
# Slack アプリ作成時に以下のマニフェストを使う
# slack-app-manifest.yml
display_information:
  name: Grafana OnCall
  description: On-call management and incident response
  background_color: '#1a1a2e'

features:
  bot_user:
    display_name: Grafana OnCall
    always_online: true
  shortcuts:
    - name: Create Incident
      type: message
      callback_id: incident_create
      description: Create a new incident from this message

oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - channels:history
      - channels:read
      - chat:write
      - commands
      - files:write
      - groups:history
      - groups:read
      - im:history
      - im:read
      - im:write
      - reactions:write
      - team:read
      - usergroups:read
      - usergroups:write
      - users:read
      - users:read.email

settings:
  event_subscriptions:
    request_url: https://oncall.example.com/slack/event_api_endpoint/
    bot_events:
      - app_mention
      - message.im
  interactivity:
    is_enabled: true
    request_url: https://oncall.example.com/slack/interactive_api_endpoint/
  org_deploy_enabled: false
  socket_mode_enabled: false

Slack統合が完了すると、アラート発生時に以下のような情報がSlackチャンネルへ自動的に送信される。

Microsoft Teams 統合

MS Teamsを使う組織は、Outgoing Webhookを通じて同様の統合を実装できる。Grafana OnCallはMS Teams専用の統合も提供しており、Grafana Cloud IRMではネイティブなTeams統合がサポートされる。

PagerDuty 連携

すでにPagerDutyを使っている組織がGrafana OnCallへ移行する場合や、2つのシステムを併用する場合がある。Grafana OnCallはPagerDutyとの双方向連携をサポートしており、移行ツールも提供している。

Grafana Alerting からの PagerDuty 連携

Grafana AlertingでPagerDutyをContact Pointとして設定すれば、特定のアラートをPagerDutyへ直接送信できる。

# Grafana Alerting - PagerDuty Contact Point の設定
# grafana/provisioning/alerting/contactpoints.yml

apiVersion: 1
contactPoints:
  - orgId: 1
    name: PagerDuty-Critical
    receivers:
      - uid: pagerduty-critical
        type: pagerduty
        settings:
          integrationKey: '${PAGERDUTY_INTEGRATION_KEY}'
          severity: critical
          class: 'production-incident'
          component: '{{ .CommonLabels.service }}'
          group: '{{ .CommonLabels.alertname }}'
        disableResolveMessage: false

  - orgId: 1
    name: PagerDuty-Warning
    receivers:
      - uid: pagerduty-warning
        type: pagerduty
        settings:
          integrationKey: '${PAGERDUTY_WARNING_KEY}'
          severity: warning
          class: 'production-warning'
          component: '{{ .CommonLabels.service }}'
          group: '{{ .CommonLabels.alertname }}'
        disableResolveMessage: false

# Notification Policy - 重要度別のルーティング
policies:
  - orgId: 1
    receiver: PagerDuty-Warning
    group_by: ['alertname', 'service']
    group_wait: 30s
    group_interval: 5m
    repeat_interval: 4h
    routes:
      - receiver: PagerDuty-Critical
        matchers:
          - severity = critical
        group_wait: 10s
        group_interval: 1m
        repeat_interval: 1h
        continue: false

PagerDuty から Grafana OnCall への移行

Grafana OnCallチームは、PagerDutyの設定を移行するツールを提供している。スケジュール、エスカレーションポリシー、サービス設定を自動で変換してくれる。

# PagerDuty 移行ツールの利用
# 1. PagerDuty API キーを作成 (Read-only 権限)
# 2. 移行スクリプトを実行

# PagerDuty 設定のエクスポート
pip install pdpyras

# 移行用の Python スクリプト
python3 migrate_pagerduty_to_oncall.py \
  --pagerduty-api-key="${PAGERDUTY_API_KEY}" \
  --oncall-api-url="http://localhost:8080" \
  --oncall-api-token="${ONCALL_API_TOKEN}" \
  --dry-run  # まずシミュレーションで確認する

双方向 Webhook 連携

PagerDutyとGrafana OnCallを併用する場合は、Outgoing Webhookを使って双方向の同期を構成する。

# webhook_sync.py - PagerDuty <-> Grafana OnCall の双方向同期
import os
import json
import hmac
import hashlib
from flask import Flask, request, jsonify
import requests

app = Flask(__name__)

ONCALL_API_URL = os.environ["ONCALL_API_URL"]
ONCALL_API_TOKEN = os.environ["ONCALL_API_TOKEN"]
PAGERDUTY_API_KEY = os.environ["PAGERDUTY_API_KEY"]
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]


def verify_signature(payload: bytes, signature: str) -> bool:
    """Webhook 署名の検証"""
    expected = hmac.new(
        WEBHOOK_SECRET.encode(), payload, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.route("/webhook/pagerduty-to-oncall", methods=["POST"])
def pagerduty_to_oncall():
    """PagerDuty のイベントを Grafana OnCall へ転送する"""
    payload = request.get_json()

    for message in payload.get("messages", []):
        event = message.get("event", "")
        incident = message.get("incident", {})

        if event == "incident.triggered":
            # OnCall にアラートを作成
            oncall_payload = {
                "title": incident.get("title", "PagerDuty Incident"),
                "message": incident.get("description", ""),
                "severity": map_severity(incident.get("urgency", "high")),
                "source_link": incident.get("html_url", ""),
            }

            headers = {
                "Authorization": ONCALL_API_TOKEN,
                "Content-Type": "application/json",
            }

            response = requests.post(
                f"{ONCALL_API_URL}/integrations/v1/webhook/<integration-id>/",
                json=oncall_payload,
                headers=headers,
                timeout=10,
            )
            app.logger.info(
                "Forwarded PagerDuty incident to OnCall: %s", response.status_code
            )

        elif event == "incident.resolved":
            # OnCall 側で該当アラートを解決扱いにする
            resolve_oncall_alert(incident.get("id"))

    return jsonify({"status": "ok"}), 200


@app.route("/webhook/oncall-to-pagerduty", methods=["POST"])
def oncall_to_pagerduty():
    """Grafana OnCall のイベントを PagerDuty へ転送する"""
    payload = request.get_json()
    event_type = payload.get("event", {}).get("type", "")
    alert_payload = payload.get("alert_payload", {})

    if event_type == "acknowledge":
        # PagerDuty 側で該当インシデントを Acknowledge
        pd_event = {
            "routing_key": os.environ["PAGERDUTY_ROUTING_KEY"],
            "event_action": "acknowledge",
            "dedup_key": alert_payload.get("id", ""),
        }
    elif event_type == "resolve":
        pd_event = {
            "routing_key": os.environ["PAGERDUTY_ROUTING_KEY"],
            "event_action": "resolve",
            "dedup_key": alert_payload.get("id", ""),
        }
    else:
        return jsonify({"status": "ignored"}), 200

    response = requests.post(
        "https://events.pagerduty.com/v2/enqueue",
        json=pd_event,
        timeout=10,
    )
    app.logger.info("Forwarded OnCall event to PagerDuty: %s", response.status_code)
    return jsonify({"status": "ok"}), 200


def map_severity(pd_urgency: str) -> str:
    """PagerDuty の urgency を OnCall の severity へマッピングする"""
    mapping = {"high": "critical", "low": "warning"}
    return mapping.get(pd_urgency, "warning")


def resolve_oncall_alert(pd_incident_id: str):
    """PagerDuty のインシデント ID で OnCall のアラートを解決する"""
    headers = {
        "Authorization": ONCALL_API_TOKEN,
        "Content-Type": "application/json",
    }
    # OnCall API で該当アラートを検索して解決する
    response = requests.get(
        f"{ONCALL_API_URL}/api/v1/alert_groups/",
        headers=headers,
        params={"search": pd_incident_id},
        timeout=10,
    )
    if response.status_code == 200:
        for alert_group in response.json().get("results", []):
            requests.post(
                f"{ONCALL_API_URL}/api/v1/alert_groups/{alert_group['id']}/resolve/",
                headers=headers,
                timeout=10,
            )


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

Runbook 自動化

Runbookはインシデント対応手順を文書化したものだ。しかし文書化だけでは足りない。緊急時に手動でRunbookをたどると、ミスが起きるうえ時間もかかる。Runbook自動化とは、繰り返しの対応手順をスクリプト化し、ワンクリックまたは自動で実行できるようにすることだ。

Outgoing Webhook による Runbook の自動実行

Grafana OnCallのOutgoing Webhookを使えば、特定のアラートが発生したときに自動でRunbookスクリプトを実行できる。

`

# runbook_executor.py - Runbook 自動実行サーバー
import os
import json
import subprocess
import logging
from datetime import datetime
from flask import Flask, request, jsonify
import requests

app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# Runbook レジストリ - アラート種別ごとの自動化スクリプトの対応表
RUNBOOK_REGISTRY = {
    "HighCPUUsage": {
        "script": "/opt/runbooks/high_cpu_usage.sh",
        "auto_execute": True,
        "severity_threshold": "warning",
        "description": "CPU 使用率がしきい値を超えたときの自動対応",
        "actions": [
            "プロセスごとの CPU 使用量を収集",
            "上位 5 プロセスを特定",
            "異常なプロセスを自動再起動 (ホワイトリストに基づく)",
        ],
    },
    "DiskSpaceCritical": {
        "script": "/opt/runbooks/disk_cleanup.sh",
        "auto_execute": True,
        "severity_threshold": "critical",
        "description": "ディスク容量が不足したときの自動クリーンアップ",
        "actions": [
            "一時ファイルの整理",
            "古いログの圧縮とアーカイブ",
            "未使用の Docker イメージの削除",
        ],
    },
    "DatabaseConnectionPoolExhausted": {
        "script": "/opt/runbooks/db_connection_pool.sh",
        "auto_execute": False,  # 手動承認が必要
        "severity_threshold": "critical",
        "description": "DB コネクションプールが枯渇したときの対応手順",
        "actions": [
            "アイドル状態のコネクションを強制終了",
            "コネクションプールのサイズを動的に拡張",
            "スロークエリを特定して kill",
        ],
    },
    "PodCrashLoopBackOff": {
        "script": "/opt/runbooks/pod_crashloop.sh",
        "auto_execute": True,
        "severity_threshold": "warning",
        "description": "Pod CrashLoopBackOff の自動診断",
        "actions": [
            "Pod のログを収集",
            "直前の Pod イベントを分析",
            "リソース制限を確認",
            "直近のデプロイをロールバックするか判断",
        ],
    },
}

SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL", "")
ONCALL_API_URL = os.environ.get("ONCALL_API_URL", "")
ONCALL_API_TOKEN = os.environ.get("ONCALL_API_TOKEN", "")


@app.route("/webhook/runbook", methods=["POST"])
def execute_runbook():
    """OnCall Outgoing Webhook から呼ばれる - Runbook を自動実行する"""
    payload = request.get_json()

    alert_name = extract_alert_name(payload)
    severity = extract_severity(payload)
    alert_id = payload.get("alert_group_id", "unknown")

    logger.info("Received alert: %s (severity: %s, id: %s)", alert_name, severity, alert_id)

    runbook = RUNBOOK_REGISTRY.get(alert_name)
    if not runbook:
        logger.warning("No runbook found for alert: %s", alert_name)
        return jsonify({"status": "no_runbook", "alert": alert_name}), 200

    # 自動実行が可能かどうかを確認
    if not runbook["auto_execute"]:
        notify_manual_runbook(alert_name, runbook, payload)
        return jsonify({"status": "manual_required", "alert": alert_name}), 200

    # Runbook スクリプトを実行
    result = run_script(
        runbook["script"],
        env_vars={
            "ALERT_NAME": alert_name,
            "ALERT_ID": alert_id,
            "SEVERITY": severity,
            "PAYLOAD": json.dumps(payload),
        },
    )

    # 実行結果を Slack へ通知
    notify_runbook_result(alert_name, runbook, result, alert_id)

    # 成功したら OnCall のアラートを自動解決
    if result["returncode"] == 0:
        auto_resolve_alert(alert_id)

    return jsonify({
        "status": "executed",
        "alert": alert_name,
        "success": result["returncode"] == 0,
        "output": result["stdout"][:500],
    }), 200


def extract_alert_name(payload: dict) -> str:
    """ペイロードからアラート名を取り出す"""
    alert_payload = payload.get("alert_payload", {})
    labels = alert_payload.get("labels", {})
    return labels.get("alertname", payload.get("title", "Unknown"))


def extract_severity(payload: dict) -> str:
    """ペイロードから重要度を取り出す"""
    alert_payload = payload.get("alert_payload", {})
    labels = alert_payload.get("labels", {})
    return labels.get("severity", "unknown")


def run_script(script_path: str, env_vars: dict, timeout: int = 300) -> dict:
    """Runbook スクリプトを実行して結果を返す"""
    env = os.environ.copy()
    env.update(env_vars)

    try:
        result = subprocess.run(
            ["/bin/bash", script_path],
            capture_output=True,
            text=True,
            timeout=timeout,
            env=env,
        )
        return {
            "returncode": result.returncode,
            "stdout": result.stdout,
            "stderr": result.stderr,
        }
    except subprocess.TimeoutExpired:
        return {
            "returncode": -1,
            "stdout": "",
            "stderr": f"Script timed out after {timeout}s",
        }
    except Exception as e:
        return {
            "returncode": -1,
            "stdout": "",
            "stderr": str(e),
        }


def notify_runbook_result(alert_name: str, runbook: dict, result: dict, alert_id: str):
    """Runbook の実行結果を Slack へ通知する"""
    if not SLACK_WEBHOOK_URL:
        return

    status_emoji = "white_check_mark" if result["returncode"] == 0 else "x"
    status_text = "SUCCESS" if result["returncode"] == 0 else "FAILED"

    slack_message = {
        "text": f"Runbook Execution: {status_text}",
        "blocks": [
            {
                "type": "header",
                "text": {
                    "type": "plain_text",
                    "text": f"Runbook: {alert_name} - {status_text}",
                },
            },
            {
                "type": "section",
                "fields": [
                    {"type": "mrkdwn", "text": f"*Alert ID:*\n{alert_id}"},
                    {"type": "mrkdwn", "text": f"*Description:*\n{runbook['description']}"},
                    {"type": "mrkdwn", "text": f"*Timestamp:*\n{datetime.utcnow().isoformat()}"},
                ],
            },
        ],
    }

    if result["stdout"]:
        slack_message["blocks"].append({
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": f"*Output:*\n```{result['stdout'][:1000]}```",
            },
        })

    requests.post(SLACK_WEBHOOK_URL, json=slack_message, timeout=10)


def notify_manual_runbook(alert_name: str, runbook: dict, payload: dict):
    """手動実行が必要な Runbook の案内を Slack へ送る"""
    if not SLACK_WEBHOOK_URL:
        return

    actions_text = "\n".join(f"  {i+1}. {a}" for i, a in enumerate(runbook["actions"]))
    slack_message = {
        "text": f"Manual Runbook Required: {alert_name}",
        "blocks": [
            {
                "type": "header",
                "text": {
                    "type": "plain_text",
                    "text": f"Manual Runbook: {alert_name}",
                },
            },
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": (
                        f"*Description:* {runbook['description']}\n\n"
                        f"*Steps:*\n{actions_text}"
                    ),
                },
            },
        ],
    }
    requests.post(SLACK_WEBHOOK_URL, json=slack_message, timeout=10)


def auto_resolve_alert(alert_id: str):
    """Runbook が成功したら OnCall のアラートを自動解決する"""
    if not ONCALL_API_URL or not ONCALL_API_TOKEN:
        return

    headers = {
        "Authorization": ONCALL_API_TOKEN,
        "Content-Type": "application/json",
    }
    try:
        requests.post(
            f"{ONCALL_API_URL}/api/v1/alert_groups/{alert_id}/resolve/",
            headers=headers,
            timeout=10,
        )
        logger.info("Auto-resolved alert: %s", alert_id)
    except Exception as e:
        logger.error("Failed to auto-resolve alert %s: %s", alert_id, e)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)

Runbook スクリプト例:ディスククリーンアップ

#!/bin/bash
# /opt/runbooks/disk_cleanup.sh
# ディスク容量が不足したときの自動クリーンアップ Runbook

set -euo pipefail

LOG_FILE="/var/log/runbook/disk_cleanup_$(date +%Y%m%d_%H%M%S).log"
mkdir -p /var/log/runbook

exec > >(tee -a "$LOG_FILE") 2>&1

echo "=== Disk Cleanup Runbook Started ==="
echo "Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "Alert: ${ALERT_NAME:-unknown}"
echo "Severity: ${SEVERITY:-unknown}"
echo ""

# 1. 現在のディスク使用量を確認
echo "--- Step 1: Current Disk Usage ---"
df -h / /var /tmp 2>/dev/null || df -h /
echo ""

# 2. 大容量ファイルを特定
echo "--- Step 2: Top 10 Largest Files in /var ---"
find /var -type f -size +100M -exec ls -lh {} \; 2>/dev/null | sort -k5 -hr | head -10
echo ""

# 3. 一時ファイルを整理
echo "--- Step 3: Cleaning Temporary Files ---"
TEMP_CLEANED=$(find /tmp -type f -atime +7 -delete -print 2>/dev/null | wc -l)
echo "Removed ${TEMP_CLEANED} temporary files older than 7 days"
echo ""

# 4. 古いログファイルを圧縮
echo "--- Step 4: Compressing Old Log Files ---"
LOG_COMPRESSED=0
for logfile in $(find /var/log -name "*.log" -size +50M -mtime +3 2>/dev/null); do
    gzip "$logfile" && LOG_COMPRESSED=$((LOG_COMPRESSED + 1))
done
echo "Compressed ${LOG_COMPRESSED} log files"
echo ""

# 5. Docker のクリーンアップ (Docker が入っている場合)
if command -v docker &> /dev/null; then
    echo "--- Step 5: Docker Cleanup ---"
    echo "Removing dangling images..."
    docker image prune -f 2>/dev/null || true
    echo "Removing unused volumes..."
    docker volume prune -f 2>/dev/null || true
    echo "Removing stopped containers older than 24h..."
    docker container prune -f --filter "until=24h" 2>/dev/null || true
    echo ""
fi

# 6. systemd journal のクリーンアップ
if command -v journalctl &> /dev/null; then
    echo "--- Step 6: Journal Cleanup ---"
    journalctl --vacuum-time=7d 2>/dev/null || true
    echo ""
fi

# 7. 整理後のディスク使用量を確認
echo "--- Step 7: Disk Usage After Cleanup ---"
df -h / /var /tmp 2>/dev/null || df -h /

# 8. 結果の判定
USAGE_PERCENT=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
if [ "$USAGE_PERCENT" -lt 85 ]; then
    echo ""
    echo "=== Disk Cleanup SUCCESS: Usage is now ${USAGE_PERCENT}% ==="
    exit 0
else
    echo ""
    echo "=== Disk Cleanup PARTIAL: Usage is still ${USAGE_PERCENT}% - Manual intervention needed ==="
    exit 1
fi

アラート疲労(Alert Fatigue)の解消

アラート疲労とは、オンコールエンジニアが過剰なアラートにさらされ、認知的な過負荷に陥る現象だ。アラートが多すぎると肝心な重要アラートを見落とし、対応時間が遅くなり、最終的にはエンジニアのバーンアウトにつながる。Google SRE Workbookは、1シフトあたり最大2〜3件のアクション可能な(actionable)インシデントを持続可能な基準線として提示している。もし1シフトあたり8〜10件以上なら、それはオンコールの問題ではなくアラート設計の問題だ。

アラート疲労を解消する戦略

1. アラート監査(Alert Audit)

毎月、直近30日間のすべてのアラートを分析する。エンジニアが2回以上、何の対処もせずに無視したアラートは、設定し直すか削除すべきだ。対処の要らないアラートはアラートではなくノイズである。

2. 重要度に応じた通知の使い分け

すべてのアラートを同じチャンネル・同じ方法で送ってはいけない。重要度に応じて通知方式を使い分ける。

重要度通知方法時間帯の制限エスカレーション待機
P0 (Critical)電話 + SMS + Slack24時間3分
P1 (High)SMS + Slack24時間5分
P2 (Medium)Slack + メール業務時間のみ30分
P3 (Low)メール + Jira チケット業務時間のみ翌営業日

3. アラートのグルーピングと重複排除

同じ根本原因から発生する複数のアラートを1つにまとめる。Alertmanagerのgroup_by設定を活用し、Grafana OnCallのルーティングルールで重複アラートをフィルタリングする。

4. 自動解決(Auto-Resolve)

一時的なスパイク型のアラートには自動解決を設定する。例えばCPU使用率が90%を超えたあと5分以内に80%を下回ったら、アラートを自動解決扱いにする。

5. メンテナンスウィンドウ

計画的なデプロイ、パッチ適用、インフラ作業の際はメンテナンスウィンドウを設定し、関連するアラートを一時的にミュートする。

6. 定期的なレビューとフィードバックループ

四半期ごとにアラート有効性のレトロスペクティブを実施する。MTTA、MTTR、アラート棄却率(dismiss rate)、重複アラート比率などのメトリクスを追跡し、メンバーからのフィードバックを反映してアラートポリシーを継続的に改善する。

アラート品質メトリクスのダッシュボード

アラート疲労を定量的に測定・追跡するための中核メトリクスは以下のとおりだ。

インシデント管理ツールの比較

現在の市場で広く使われているインシデント管理ツール4種を比較する。2025年時点では、AtlassianがOpsGenieの新規販売を停止し(2025年6月)、Grafana OnCall OSSがメンテナンスモードに入ったことで、市場の構図が変わりつつある。

機能/特性Grafana OnCall/IRMPagerDutyOpsGenie (Atlassian)Splunk On-Call (VictorOps)
料金(50名基準)~$11,500/年(Cloud IRM)~$25,200/年(Business)~$11,970/年(Standard)~$24,900/年(Growth)
オープンソースOSS 版あり(メンテナンスモード)なしなしなし
Grafana 統合ネイティブプラグインプラグインプラグイン
Slack 統合双方向(ボタン操作)双方向双方向双方向
オンコールスケジューリングWeb, iCal, TerraformWeb, APIWeb, APIWeb, API
エスカレーションポリシー多段チェーン多段 + ラウンドロビン多段多段
Terraform 対応公式 Providerコミュニティ Provider限定的限定的
AI/ML 機能Sift (IRM)AIOps(イベントインテリジェンス)限定的限定的
Runbook 統合Outgoing WebhookRunbook Automation (PD)限定的限定的
モバイルアプリGrafana Cloud アプリ専用アプリ(充実)専用アプリ専用アプリ
SSO/SAMLGrafana Cloud 連携対応(Enterprise)Atlassian SSO対応
SLA99.9%(Cloud)99.9%99.9%99.9%
学習コスト中(Grafana 経験者が有利)高(機能が豊富)
現在の状況(2026)Cloud IRM 統合完了市場リーダー新規販売終了(2025.06)Cisco 買収後に統合中

ツール選定ガイド

トラブルシューティング

問題 1:Slack 通知が届かない

Slack統合で最も多い原因は、ボットトークンの期限切れ、チャンネル権限の不足、イベント購読URLの不一致だ。

# Slack 統合の診断チェックリスト
# 1. OnCall エンジンのログを確認
docker-compose logs engine | grep -i slack

# 2. Slack アプリのイベント購読 URL を検証
# https://api.slack.com/apps -> アプリを選択 -> Event Subscriptions
# Request URL が https://oncall.example.com/slack/event_api_endpoint/ かを確認

# 3. ボットトークンの有効性を検証
curl -X POST https://slack.com/api/auth.test \
  -H "Authorization: Bearer xoxb-your-bot-token" \
  -H "Content-Type: application/json"

# 4. チャンネルのアクセス権限を確認
curl -X POST https://slack.com/api/conversations.info \
  -H "Authorization: Bearer xoxb-your-bot-token" \
  -H "Content-Type: application/json" \
  -d '{"channel": "C0XXXXXXX"}'

# 5. ユーザーの Slack アカウント連携を確認
# Grafana OnCall -> Users -> 対象ユーザー -> Slack アカウントの連携有無を確認

問題 2:エスカレーションが動かない

エスカレーション失敗の最も多い原因は、スケジュール上に現在のオンコール担当者がいないことだ。

問題 3:Webhook の失敗

Outgoing Webhookが失敗すると、Runbook自動化が動作しない。

本番チェックリスト

Grafana OnCall/IRMを本番へデプロイする前に、必ず確認しておきたい項目だ。

インフラ

オンコールスケジュール

エスカレーション

通知チャンネル

自動化

モニタリング(メタモニタリング)

障害事例と復旧

事例 1:スケジュールのギャップによるアラートの欠落

状況:金曜の夜9時に本番データベース障害が発生した。エンジニアAのオンコールシフトは金曜午後6時に終了しており、エンジニアBのシフトは土曜午前9時に始まる設定になっていた。15時間のスケジュールギャップのあいだ、アラートはエスカレーション先を見つけられず欠落した。

根本原因:スケジュール作成時に業務時間だけを考慮し、24/7カバレッジを確認していなかった。スケジュールギャップ検知のアラートも設定していなかった。

復旧と再発防止

事例 2:アラートストームによる Celery キューの飽和

状況:ネットワークパーティションが発生し、数百のサービスから同時にアラートが押し寄せた。Celeryワーカーが処理できる容量を超えてキューが飽和し、その後に発生した本当に重要なアラートまで遅延した。

根本原因:Alertmanagerのgroup_byとgroup_waitの設定が十分に積極的でなく、OnCallのルーティングルールに重複アラートのフィルタリングがなかった。Celeryワーカー数も不足していた。

復旧と再発防止

事例 3:Webhook 認証の欠如による誤検知 Runbook の実行

状況:Runbook自動実行エンドポイントに認証がなく、外部から偽造されたWebhookリクエストが送られてディスク整理Runbookが実行された。幸い整理対象が一時ファイルと古いログに限られていたためデータ損失はなかったが、潜在的なセキュリティリスクがあった。

根本原因:Outgoing WebhookエンドポイントにHMAC署名検証を実装していなかった。エンドポイントが公開インターネットに露出していた。

復旧と再発防止

参考資料

コメント

まだコメントはありません。

ログインするとコメントできます