- Published on
Grafana OnCall과 인시던트 관리 자동화: PagerDuty 통합부터 Runbook 자동화까지
- Authors
- Name
- 들어가며
- 인시던트 관리 자동화의 필요성
- Grafana OnCall 아키텍처
- 설치와 초기 구성
- 온콜 스케줄링
- 에스컬레이션 정책 설계
- Slack/Teams 통합
- PagerDuty 연동
- Runbook 자동화
- 알림 피로(Alert Fatigue) 해소
- 인시던트 관리 도구 비교
- 트러블슈팅
- 프로덕션 체크리스트
- 실패 사례와 복구
- 참고자료

들어가며
새벽 3시, 잠을 깨우는 알림음이 울린다. 프로덕션 데이터베이스의 커넥션 풀이 고갈되었다는 경고다. 담당 엔지니어를 찾느라 Slack 채널을 뒤지고, 누가 온콜인지 확인하고, 대응 절차를 기억해내려 애쓰는 사이 장애 시간은 분 단위로 늘어난다. 이것이 인시던트 관리 자동화가 없는 조직의 현실이다.
2025년 글로벌 인시던트 관리 시장은 급격히 성장하고 있다. 마이크로서비스 아키텍처와 클라우드 네이티브 환경이 확산되면서, 단일 장애가 수십 개의 서비스에 연쇄적으로 영향을 미치는 상황이 일상이 되었다. Google SRE Workbook에 따르면 온콜 엔지니어가 시프트당 감당할 수 있는 지속 가능한 인시던트 수는 최대 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 세 가지 방식으로 스케줄을 관리할 수 있다.
스케줄 설계 원칙
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로 마이그레이션하거나, 두 시스템을 병행 운영하는 경우가 있다. 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 # 먼저 시뮬레이션으로 확인
양방향 웹훅 연동
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:
"""웹훅 서명 검증"""
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 사용량 수집",
"Top 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": [
"유휴 커넥션 강제 종료",
"커넥션 풀 크기 동적 확장",
"슬로우 쿼리 식별 및 킬",
],
},
"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은 시프트당 최대 23건의 액션 가능한(actionable) 인시던트를 지속 가능한 기준선으로 제시한다. 만약 시프트당 810건 이상이라면, 그것은 온콜 문제가 아니라 알림 설계 문제다.
알림 피로 해소 전략
1. 알림 감사(Alert Audit)
매월 지난 30일간의 모든 알림을 분석한다. 엔지니어가 두 번 이상 아무 조치 없이 무시한 알림은 재설정하거나 제거해야 한다. 조치가 필요 없는 알림은 알림이 아니라 노이즈다.
2. 심각도 기반 차등 알림
모든 알림을 동일한 채널과 방법으로 전송하면 안 된다. 심각도에 따라 알림 방식을 차등 적용한다.
| 심각도 | 알림 방법 | 시간대 제한 | 에스컬레이션 대기 |
|---|---|---|---|
| P0 (Critical) | 전화 + SMS + Slack | 24시간 | 3분 |
| P1 (High) | SMS + Slack | 24시간 | 5분 |
| P2 (Medium) | Slack + 이메일 | 업무시간만 | 30분 |
| P3 (Low) | 이메일 + Jira 티켓 | 업무시간만 | 다음 업무일 |
3. 알림 그룹핑과 중복 제거
동일한 근본 원인에서 발생하는 여러 알림을 하나로 묶는다. 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: 시프트당 알림 수. 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 검증 실패가 발생한다.
- 웹훅 타임아웃을 확인한다. 기본 타임아웃이 짧아 긴 스크립트는 타임아웃될 수 있다.
- OnCall의 Outgoing Webhook 로그에서 HTTP 상태 코드와 응답 본문을 확인한다.
프로덕션 체크리스트
Grafana OnCall/IRM을 프로덕션에 배포하기 전에 반드시 확인해야 할 항목들이다.
인프라
- OnCall 엔진 고가용성 구성 (최소 2 replica)
- MySQL/PostgreSQL 데이터베이스 백업 및 복제 구성
- Redis 클러스터 또는 Sentinel 구성
- RabbitMQ 클러스터 구성 (메시지 유실 방지)
- TLS/HTTPS 적용 (Slack 통합 필수)
- 네트워크 정책 및 방화벽 규칙 설정
온콜 스케줄
- 모든 스케줄에 갭(Gap)이 없는지 검증
- 백업 스케줄이 구성되어 있는지 확인
- 타임존 설정이 올바른지 확인
- 오버라이드 프로세스가 문서화되어 있는지 확인
- 월 1회 테스트 알림으로 스케줄 검증
에스컬레이션
- 모든 Integration에 에스컬레이션 체인이 연결되어 있는지 확인
- 최후의 에스컬레이션 단계가 존재하는지 확인 (캐치올)
- 심각도별 에스컬레이션 정책이 차등 설정되어 있는지 확인
- 에스컬레이션 체인을 월 1회 테스트
알림 채널
- Slack/Teams 통합 정상 작동 확인
- SMS/전화 알림 채널 테스트
- 이메일 알림 전달 확인
- 사용자별 알림 설정(Notification Preferences) 완료
자동화
- Outgoing Webhook 엔드포인트 가용성 확인
- Runbook 스크립트 권한 및 실행 환경 검증
- 자동 해결(Auto-Resolve) 정책 설정
- 웹훅 인증(서명 검증) 적용
모니터링 (메타 모니터링)
- OnCall 시스템 자체의 헬스체크 알림 구성
- Celery 워커 큐 지연 모니터링
- 웹훅 실패율 모니터링
- 알림 전달 지연 시간 추적
실패 사례와 복구
사례 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 자동 실행 엔드포인트에 인증이 없어, 외부에서 위조된 웹훅 요청을 보내 디스크 정리 Runbook이 실행되었다. 다행히 정리 대상이 임시 파일과 오래된 로그에 한정되어 데이터 손실은 없었으나, 잠재적 보안 위험이 있었다.
근본 원인: Outgoing Webhook 엔드포인트에 HMAC 서명 검증을 구현하지 않았다. 엔드포인트가 공개 인터넷에 노출되어 있었다.
복구 및 재발 방지:
- 모든 웹훅 엔드포인트에 HMAC-SHA256 서명 검증 적용
- 웹훅 수신 서버를 내부 네트워크로 이동, VPN 또는 IP 화이트리스트 적용
- Runbook 실행 전 2차 확인(dry-run) 단계 추가
- 중요 Runbook(DB 관련)은 auto_execute를 비활성화하고 수동 승인 필수화