- はじめに
- インシデント管理自動化の必要性
- Grafana OnCall のアーキテクチャ
- インストールと初期構成
- オンコールスケジューリング
- エスカレーションポリシーの設計
- Slack/Teams 統合
- PagerDuty 連携
- Runbook 自動化
- アラート疲労(Alert Fatigue)の解消
- インシデント管理ツールの比較
- トラブルシューティング
- 本番チェックリスト
- 障害事例と復旧
- 参考資料

はじめに
午前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 │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
中核となる構成要素は以下のとおりだ。
- Integration: 外部モニタリングシステムからアラートを受信するエンドポイント。Alertmanager、Grafana Alerting、Datadog、Zabbixなど多様なソースをサポートする。
- Route Engine: 受信したアラートをラベルや条件に応じて適切なエスカレーションチェーンへルーティングする。
- Escalation Chain: アラートに対する段階的な対応手順を定義する。まず誰に通知し、応答がなければ次に誰へエスカレーションするかを決める。
- Schedule: オンコールのローテーションスケジュールを管理する。いま誰がオンコールかを決定する中核コンポーネントだ。
- Outgoing Webhook: インシデント発生時に外部システムと連携し、自動化された処理を実行する。
インストールと初期構成
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公式ドキュメントが推奨するオンコールスケジュール設計の原則は以下のとおりだ。
- チーム規模に合ったローテーション周期の選択:4〜6名のチームには週次ローテーション、8名以上のチームには2日ローテーションが適する。
- Follow-the-Sun パターン:3つ以上のタイムゾーンに分散したチームは、各地域が業務時間だけオンコールを担当するように設計する。このモデルはエンジニアあたりのオンコール時間を最大67%まで削減できる。
- オーバーライドの仕組み:計画された不在(休暇、会議)にはシフトスワップを、緊急の不在にはオーバーライドを使う。
- バックアップスケジュール:プライマリスケジュールとは別に、必ずバックアップスケジュールを構成する。
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公式ドキュメントが推奨する基本のエスカレーションパターンは以下のとおりだ。
- オンコールスケジュールの担当者へ基本の通知を送る
- 5分待機(応答時間を確保する)
- 応答がなければ重要(Important)チャンネルへ再通知
- 10分待機
- バックアップスケジュールの担当者へエスカレーション
- 15分待機
- チーム全体へ通知(最後の手段)
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チャンネルへ自動的に送信される。
- アラートのタイトルと詳細
- 現在のオンコール担当者のメンション
- Acknowledge / Resolve / Escalate のアクションボタン
- 関連するGrafanaダッシュボードへのリンク
- Runbookへのリンク(設定されている場合)
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 + Slack | 24時間 | 3分 |
| P1 (High) | SMS + Slack | 24時間 | 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)、重複アラート比率などのメトリクスを追跡し、メンバーからのフィードバックを反映してアラートポリシーを継続的に改善する。
アラート品質メトリクスのダッシュボード
アラート疲労を定量的に測定・追跡するための中核メトリクスは以下のとおりだ。
- Signal-to-Noise Ratio (SNR): 全アラートのうち実際に対処が必要だったアラートの比率。目標は80%以上。
- MTTA (Mean Time To Acknowledge): アラート受信から確認までの平均時間。5分以内が理想。
- MTTR (Mean Time To Resolve): アラート受信から解決までの平均時間。
- Alerts per On-Call Shift: 1シフトあたりのアラート数。Google SREの基準では2〜3件が持続可能。
- After-Hours Alert Rate: 業務時間外のアラート比率。低いほど健全なシステム。
- Alert Dismiss Rate: 何の対処もなく無視されたアラートの比率。高ければノイズが多いという合図。
インシデント管理ツールの比較
現在の市場で広く使われているインシデント管理ツール4種を比較する。2025年時点では、AtlassianがOpsGenieの新規販売を停止し(2025年6月)、Grafana OnCall OSSがメンテナンスモードに入ったことで、市場の構図が変わりつつある。
| 機能/特性 | Grafana OnCall/IRM | PagerDuty | OpsGenie (Atlassian) | Splunk On-Call (VictorOps) |
|---|---|---|---|---|
| 料金(50名基準) | ~$11,500/年(Cloud IRM) | ~$25,200/年(Business) | ~$11,970/年(Standard) | ~$24,900/年(Growth) |
| オープンソース | OSS 版あり(メンテナンスモード) | なし | なし | なし |
| Grafana 統合 | ネイティブ | プラグイン | プラグイン | プラグイン |
| Slack 統合 | 双方向(ボタン操作) | 双方向 | 双方向 | 双方向 |
| オンコールスケジューリング | Web, iCal, Terraform | Web, API | Web, API | Web, API |
| エスカレーションポリシー | 多段チェーン | 多段 + ラウンドロビン | 多段 | 多段 |
| Terraform 対応 | 公式 Provider | コミュニティ Provider | 限定的 | 限定的 |
| AI/ML 機能 | Sift (IRM) | AIOps(イベントインテリジェンス) | 限定的 | 限定的 |
| Runbook 統合 | Outgoing Webhook | Runbook Automation (PD) | 限定的 | 限定的 |
| モバイルアプリ | Grafana Cloud アプリ | 専用アプリ(充実) | 専用アプリ | 専用アプリ |
| SSO/SAML | Grafana Cloud 連携 | 対応(Enterprise) | Atlassian SSO | 対応 |
| SLA | 99.9%(Cloud) | 99.9% | 99.9% | 99.9% |
| 学習コスト | 中(Grafana 経験者が有利) | 高(機能が豊富) | 低 | 中 |
| 現在の状況(2026) | Cloud IRM 統合完了 | 市場リーダー | 新規販売終了(2025.06) | Cisco 買収後に統合中 |
ツール選定ガイド
- Grafana エコシステム中心の組織: Grafana Cloud IRMが最適だ。Prometheus、Loki、Tempoとネイティブに統合されるため、コンテキストスイッチなしでアラートからインシデント対応までワンストップで処理できる。コストもPagerDutyの半分以下だ。
- 大規模エンタープライズ: PagerDutyが依然として市場リーダーだ。最も豊富な統合エコシステム、成熟したAIOps機能、実績のある安定性を提供する。コストは高いが、複雑なサービスアーキテクチャを抱える大規模組織にはそれだけの価値がある。
- Atlassian エコシステムの組織: OpsGenieの新規販売が停止されたため、Jira Service Managementへ移行するか、別のツールを検討する必要がある。
- コスト最優先: Grafana OnCall OSSを自前で運用するか、Grafana Cloud IRMのFreeティアを出発点として活用する。
トラブルシューティング
問題 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:エスカレーションが動かない
エスカレーション失敗の最も多い原因は、スケジュール上に現在のオンコール担当者がいないことだ。
- スケジュールにギャップ(Gap)がないかを確認する。シフトの間に空き時間があるとエスカレーション先が存在せず、アラートが欠落する。
- タイムゾーン設定が正しいかを確認する。分散チームではタイムゾーンの不一致が頻出する問題だ。
- エスカレーションチェーンの各段階が正しいスケジュールまたはユーザーを参照しているかを確認する。
問題 3:Webhook の失敗
Outgoing Webhookが失敗すると、Runbook自動化が動作しない。
- 対象サーバーへのネットワーク到達性を確認する(ファイアウォール、セキュリティグループ)。
- HTTPS証明書が有効かを確認する。自己署名証明書を使うとTLS検証に失敗する。
- Webhookのタイムアウトを確認する。デフォルトのタイムアウトが短いため、長いスクリプトはタイムアウトしうる。
- OnCallのOutgoing WebhookログでHTTPステータスコードとレスポンスボディを確認する。
本番チェックリスト
Grafana OnCall/IRMを本番へデプロイする前に、必ず確認しておきたい項目だ。
インフラ
- OnCall エンジンの高可用性構成(最小2レプリカ)
- MySQL/PostgreSQL データベースのバックアップとレプリケーション構成
- Redis クラスタまたは Sentinel の構成
- RabbitMQ クラスタの構成(メッセージ欠落の防止)
- TLS/HTTPS の適用(Slack 統合には必須)
- ネットワークポリシーとファイアウォールルールの設定
オンコールスケジュール
- すべてのスケジュールにギャップ(Gap)がないことを検証
- バックアップスケジュールが構成されているかを確認
- タイムゾーン設定が正しいかを確認
- オーバーライドのプロセスが文書化されているかを確認
- 月1回のテスト通知でスケジュールを検証
エスカレーション
- すべての Integration にエスカレーションチェーンが接続されているかを確認
- 最終段のエスカレーションが存在するかを確認(キャッチオール)
- 重要度別のエスカレーションポリシーが使い分けられているかを確認
- エスカレーションチェーンを月1回テスト
通知チャンネル
- Slack/Teams 統合が正常に動作するかを確認
- SMS/電話の通知チャンネルをテスト
- メール通知の到達を確認
- ユーザーごとの通知設定(Notification Preferences)を完了
自動化
- Outgoing Webhook エンドポイントの可用性を確認
- Runbook スクリプトの権限と実行環境を検証
- 自動解決(Auto-Resolve)ポリシーの設定
- Webhook 認証(署名検証)の適用
モニタリング(メタモニタリング)
- OnCall システム自体のヘルスチェックアラートを構成
- Celery ワーカーのキュー遅延を監視
- Webhook 失敗率を監視
- 通知の配信遅延を追跡
障害事例と復旧
事例 1:スケジュールのギャップによるアラートの欠落
状況:金曜の夜9時に本番データベース障害が発生した。エンジニアAのオンコールシフトは金曜午後6時に終了しており、エンジニアBのシフトは土曜午前9時に始まる設定になっていた。15時間のスケジュールギャップのあいだ、アラートはエスカレーション先を見つけられず欠落した。
根本原因:スケジュール作成時に業務時間だけを考慮し、24/7カバレッジを確認していなかった。スケジュールギャップ検知のアラートも設定していなかった。
復旧と再発防止:
- 24/7カバレッジを保証するスケジュールへ再設計
- エスカレーションチェーンの最終段に「チーム全体へ通知」をキャッチオールとして追加
- Terraformでスケジュールを管理し、CIパイプラインにギャップ検証テストを追加
- OnCallシステム自体に「スケジュールギャップ検知」アラートを構成
事例 2:アラートストームによる Celery キューの飽和
状況:ネットワークパーティションが発生し、数百のサービスから同時にアラートが押し寄せた。Celeryワーカーが処理できる容量を超えてキューが飽和し、その後に発生した本当に重要なアラートまで遅延した。
根本原因:Alertmanagerのgroup_byとgroup_waitの設定が十分に積極的でなく、OnCallのルーティングルールに重複アラートのフィルタリングがなかった。Celeryワーカー数も不足していた。
復旧と再発防止:
- Alertmanagerのgroup_waitを30秒から2分へ引き上げ
- group_byにcluster、namespaceを追加して関連アラートをグルーピング
- Celeryワーカーを4個から8個へ増やし、優先度キュー(critical, default, low)を分離
- OnCallのルーティングにrate limitingルールを追加:同一ソースから5分以内に10件以上のアラートが来たら自動でグルーピング
事例 3:Webhook 認証の欠如による誤検知 Runbook の実行
状況:Runbook自動実行エンドポイントに認証がなく、外部から偽造されたWebhookリクエストが送られてディスク整理Runbookが実行された。幸い整理対象が一時ファイルと古いログに限られていたためデータ損失はなかったが、潜在的なセキュリティリスクがあった。
根本原因:Outgoing WebhookエンドポイントにHMAC署名検証を実装していなかった。エンドポイントが公開インターネットに露出していた。
復旧と再発防止:
- すべてのWebhookエンドポイントにHMAC-SHA256署名検証を適用
- Webhook受信サーバーを内部ネットワークへ移し、VPNまたはIPホワイトリストを適用
- Runbook実行前に二次確認(dry-run)の段階を追加
- 重要なRunbook(DB関連)はauto_executeを無効化し、手動承認を必須化
参考資料
- Grafana OnCall OSS 公式ドキュメント
- Grafana Cloud IRM の紹介
- Grafana OnCall Terraform Provider
- Grafana IRM エスカレーションチェーンのベストプラクティス
- Grafana IRM オンコールスケジュールのベストプラクティス
- PagerDuty vs OpsGenie vs Grafana OnCall の比較
- Prezi の PagerDuty から Grafana OnCall への移行事例
- incident.io - 2025 アラート疲労を防ぐガイド
- Grafana OnCall GitHub Repository
- Grafana PagerDuty 統合ドキュメント