Skip to content
Published on

Grafana OnCall과 인시던트 관리 자동화: PagerDuty 통합부터 Runbook 자동화까지

Authors
  • Name
    Twitter
Grafana OnCall

들어가며

새벽 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 공식 문서가 권장하는 온콜 스케줄 설계 원칙은 다음과 같다.

  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 채널에 자동으로 전송된다.

  • 알림 제목과 상세 내용
  • 현재 온콜 담당자 멘션
  • 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 + Slack24시간3분
P1 (High)SMS + Slack24시간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/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 인수 후 통합 중

도구 선택 가이드

  • 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를 비활성화하고 수동 승인 필수화

참고자료