Skip to content
Published on

읽히는 대시보드와 울릴 만한 경보 — 질문 정의, 변수 구성, SLO, 그리고 경보 피로

공유하기
Authors

들어가며 — 아무도 열지 않는 대시보드

대시보드가 60개 있는 조직을 자주 봅니다. 그중 온콜이 실제로 여는 것은 두세 개입니다. 나머지는 만들 때 한 번 열리고 그 뒤로는 열리지 않습니다.

경보도 비슷합니다. 규칙이 200개인데 온콜은 알림 채널을 음소거해 두었고, 정말 중요한 것 하나가 그 안에 묻혀 있습니다.

두 문제의 원인은 같습니다. 대시보드와 경보를 "무엇을 보여 줄까"에서 출발해서 만들었기 때문입니다. 출발점은 "누가 언제 무슨 결정을 내려야 하는가"여야 합니다.

이 글은 그 출발점에서 다시 시작합니다. Grafana 12 계열과 Prometheus 3.13.0 LTS 기준으로 확인했습니다. Grafana는 UI 경로가 자주 바뀌므로 화면 조작 대신 프로비저닝 파일과 쿼리로 설명합니다.

대시보드가 답해야 할 질문을 먼저 적는다

패널을 만들기 전에 질문을 적습니다. 질문이 없으면 패널을 만들지 않습니다.

대시보드는 종류에 따라 답해야 할 질문이 다르고, 하나에 다 넣으려는 시도가 실패의 시작입니다.

종류보는 사람답해야 할 질문패널 수
서비스 개요온콜, 첫 5분지금 사용자가 영향받고 있는가, 어느 라우트인가6~8
서비스 상세그 서비스 담당자원인이 코드인가 의존성인가 자원인가15~25
의존성온콜우리가 부르는 쪽 중 문제가 있는 곳이 있는가6~10
용량주간 리뷰다음 분기에 무엇이 먼저 한계에 닿는가10~15
SLO팀 리드, 월간에러 버짓이 얼마나 남았는가4~6

서비스 개요 대시보드의 질문은 다섯 개면 충분합니다.

  1. 요청이 실패하고 있는가, 몇 퍼센트인가
  2. 느려졌는가, 어느 라우트가 느려졌는가
  3. 언제부터인가, 배포와 겹치는가
  4. 트래픽 자체가 변했는가
  5. 우리가 의존하는 것 중 나빠진 곳이 있는가

이 다섯 개에 답하는 패널만 남기면 대시보드는 8개 패널을 넘지 않습니다. 그리고 순서가 중요합니다. 위에서 아래로 읽었을 때 "영향이 있는가 → 어디인가 → 왜인가"의 흐름이 되어야 합니다. CPU 그래프가 맨 위에 있으면 그 대시보드는 이미 순서가 틀렸습니다.

데이터소스와 변수 — 하나의 대시보드를 여러 대상에 쓰기

같은 대시보드를 환경마다 복사하기 시작하면 관리가 무너집니다. 스테이징에서 고친 것이 프로덕션에 반영되지 않고, 세 달 뒤에는 서로 다른 여섯 개의 사본이 생깁니다.

해법은 데이터소스 자체를 변수로 만드는 것입니다.

# provisioning/datasources/prometheus.yaml
apiVersion: 1
datasources:
  - name: Prometheus-prod
    uid: prom-prod
    type: prometheus
    access: proxy
    url: http://prometheus.observability.svc:9090
    jsonData:
      httpMethod: POST
      timeInterval: 15s          # 스크레이프 간격. rate 간격 계산의 기준이 된다
      prometheusType: Prometheus
      prometheusVersion: 3.13.0
      exemplarTraceIdDestinations:
        - name: trace_id
          datasourceUid: tempo-prod
    isDefault: true

  - name: Prometheus-staging
    uid: prom-staging
    type: prometheus
    access: proxy
    url: http://prometheus.staging.svc:9090
    jsonData:
      httpMethod: POST
      timeInterval: 15s

timeInterval 을 스크레이프 간격과 맞추는 것이 중요합니다. Grafana의 rate 간격 변수는 이 값과 패널 폭을 근거로 안전한 윈도우를 계산합니다. 값이 비어 있으면 기본값으로 계산해서, 화면을 좁게 보거나 시간 범위를 짧게 잡았을 때 rate 결과가 비어 버립니다.

변수는 계단식으로 구성합니다. 앞 변수의 선택이 뒤 변수의 후보를 좁힙니다.

{
  "templating": {
    "list": [
      {
        "name": "datasource",
        "type": "datasource",
        "query": "prometheus",
        "current": { "text": "Prometheus-prod", "value": "prom-prod" }
      },
      {
        "name": "namespace",
        "type": "query",
        "datasource": { "type": "prometheus", "uid": "${datasource}" },
        "query": "label_values(kube_namespace_status_phase, namespace)",
        "refresh": 1,
        "sort": 1
      },
      {
        "name": "service",
        "type": "query",
        "datasource": { "type": "prometheus", "uid": "${datasource}" },
        "query": "label_values(http_requests_total{namespace=\"$namespace\"}, service)",
        "refresh": 2,
        "includeAll": true,
        "multi": true
      },
      {
        "name": "route",
        "type": "query",
        "datasource": { "type": "prometheus", "uid": "${datasource}" },
        "query": "label_values(http_requests_total{namespace=\"$namespace\", service=~\"$service\"}, route)",
        "refresh": 2,
        "includeAll": true,
        "multi": true
      }
    ]
  }
}

refresh 값의 의미를 자주 혼동합니다. 1은 대시보드를 열 때, 2는 시간 범위가 바뀔 때 갱신합니다. 롤링 배포로 파드 이름이 계속 바뀌는 환경에서는 2가 필요합니다. 1로 두면 어제 열어 둔 탭에서 이미 사라진 파드를 계속 조회합니다.

다중 선택 변수를 쿼리에 넣을 때는 정규식 매칭을 씁니다.

# 다중 선택 변수는 정규식으로 받는다. 등호로 받으면 값이 하나일 때만 동작한다
sum by (route) (
  rate(http_requests_total{namespace="$namespace", service=~"$service", route=~"$route"}[$__rate_interval])
)

# 대시보드 패널에서는 rate 간격 변수를 쓴다.
# 고정 [5m] 을 쓰면 시간 범위를 넓게 볼 때 해상도가 낭비되고,
# 좁게 볼 때는 샘플이 부족해 그래프에 구멍이 생긴다
histogram_quantile(0.99,
  sum by (le, route) (
    rate(http_request_duration_seconds_bucket{namespace="$namespace", service=~"$service"}[$__rate_interval])
  )
)

전체 선택을 허용했다면 includeAll 의 커스텀 값도 확인합니다. 기본값이 정규식으로 동작하지 않는 형태면 전체 선택 시 결과가 비어 버립니다.

패널 설계 — 읽히는 배치와 읽히지 않는 배치

같은 데이터로도 읽히는 배치와 읽히지 않는 배치가 있습니다.

행 단위로 질문을 묶습니다. 첫 행은 "영향이 있는가"입니다. 에러율, p99, 트래픽 세 개면 됩니다. 둘째 행은 "어디인가"입니다. 라우트별 분해와 상위 오류 유형. 셋째 행부터가 원인 후보입니다.

축을 고정합니다. 에러율 패널의 최댓값을 자동으로 두면, 에러가 0.01%일 때도 그래프가 요동쳐서 심각해 보입니다. 반대로 진짜 사고 때는 이전과 비교가 안 됩니다. 백분율 축은 0에서 시작하고 임계선을 함께 그립니다.

단위를 지정합니다. 초 단위 지연 시간을 unit 없이 두면 1.4가 1.4초인지 1.4밀리초인지 읽는 사람이 모릅니다. 새벽 3시에 그 판단을 하게 만들면 안 됩니다.

패널 하나에 시계열 20개를 넘기지 않습니다. 라우트가 120개인 서비스에서 전체를 그리면 아무것도 안 보입니다. 상위 N개만 그리고 나머지는 합칩니다.

# 상위 10개 라우트만. 나머지가 궁금하면 변수로 좁혀서 본다
topk(10,
  sum by (route) (
    rate(http_requests_total{service=~"$service", status_class="5xx"}[$__rate_interval])
  )
)

# 배포 시각을 주석으로 겹쳐 그리면 "언제부터인가" 질문이 즉시 해결된다
changes(kube_deployment_status_observed_generation{namespace="$namespace"}[$__rate_interval]) > 0

배포 주석은 투자 대비 효과가 가장 큰 항목입니다. 그래프가 꺾인 지점에 세로선이 있고 그것이 배포라면, 조사는 그 자리에서 끝납니다.

경보는 원인이 아니라 증상에 건다

경보 설계의 핵심 원칙 하나만 고르라면 이것입니다. 사용자가 체감하는 것에 경보를 겁니다.

원인 기반 경보의 문제를 예로 봅니다.

# 원인 기반 — 이런 규칙이 40개쯤 쌓이면 경보 피로가 시작된다
- alert: HighCPU
  expr: rate(process_cpu_seconds_total[5m]) > 0.8
  for: 5m
- alert: HighMemory
  expr: process_resident_memory_bytes / container_spec_memory_limit_bytes > 0.9
  for: 5m
- alert: ManyGoroutines
  expr: go_goroutines > 10000
  for: 5m
- alert: DBConnectionsHigh
  expr: pg_stat_activity_count > 80
  for: 5m

이 네 개의 문제는 셋입니다. 첫째, 사용자에게 아무 영향이 없어도 울립니다. CPU가 85%인 것은 자원을 잘 쓰고 있다는 뜻일 수도 있습니다. 둘째, 진짜 장애 때는 네 개가 동시에 울려서 무엇이 원인인지 오히려 흐려집니다. 셋째, 사용자가 실패를 겪고 있는데 이 네 지표가 모두 정상인 상황을 놓칩니다.

# 증상 기반 — 사용자가 겪는 것에 건다
groups:
  - name: symptom_alerts
    rules:
      - alert: HighErrorRate
        expr: |
          sum by (service) (rate(http_requests_total{status_class="5xx"}[5m]))
            /
          sum by (service) (rate(http_requests_total[5m]))
            > 0.02
        for: 5m
        labels:
          severity: page
        annotations:
          summary: '5xx 비율이 2%를 넘었습니다'
          runbook_url: https://wiki.internal/runbook/high-error-rate

      - alert: HighLatency
        expr: |
          histogram_quantile(0.99,
            sum by (le, service) (rate(http_request_duration_seconds_bucket[5m]))
          ) > 1.5
        for: 10m
        labels:
          severity: page
        annotations:
          summary: 'p99 지연이 1.5초를 넘었습니다'

      - alert: RequestsStopped
        expr: |
          sum by (service) (rate(http_requests_total[5m])) == 0
            and
          sum by (service) (rate(http_requests_total[5m] offset 1h)) > 1
        for: 5m
        labels:
          severity: page
        annotations:
          summary: '평소 트래픽이 있던 서비스에 요청이 없습니다'

세 번째 규칙이 자주 빠집니다. 에러율은 분모가 0이면 계산되지 않으므로, 서비스가 완전히 죽어 요청을 아예 받지 못하면 에러율 경보는 울리지 않습니다. 트래픽 실종을 별도로 감시해야 합니다.

원인 지표는 경보가 아니라 대시보드에 둡니다. 온콜이 증상 경보로 깨어난 뒤 원인을 좁히는 데 쓰는 재료입니다.

절대 발화하지 않는 경보를 잡아내기

가장 위험한 경보는 시끄러운 경보가 아니라 조용히 작동하지 않는 경보입니다.

# 문제 있는 규칙 — 5분 평균이 임계값을 넘는 일은 사실상 없다
avg_over_time(http_request_duration_seconds_sum[5m])
  / avg_over_time(http_request_duration_seconds_count[5m]) > 5

# 왜 안 울리는가: 평균은 꼬리를 숨긴다. p99 가 8초여도 평균은 0.2초일 수 있다.
# 이 규칙은 서비스 전체가 8초씩 걸릴 때만 발화한다.

이런 규칙은 배포 후 몇 달이 지나도 아무도 모릅니다. 정기적으로 발화 이력이 0인 규칙을 뽑아 봅니다.

# 지난 30일 동안 한 번도 발화하지 않은 규칙 찾기
# ALERTS_FOR_STATE 는 활성 알림에만 존재하므로, 규칙 목록과 대조해야 한다
count by (alertname) (max_over_time(ALERTS[30d]))

# 규칙 평가 자체가 실패하고 있는지도 확인한다
increase(prometheus_rule_evaluation_failures_total[1h]) > 0

규칙 단위 테스트를 CI에 넣으면 이 문제를 배포 전에 잡습니다.

# tests/alerts_test.yml
rule_files:
  - ../rules/symptom_alerts.yml

evaluation_interval: 30s

tests:
  - interval: 15s
    input_series:
      - series: 'http_requests_total{service="checkout", status_class="2xx"}'
        values: '0+90x60'
      - series: 'http_requests_total{service="checkout", status_class="5xx"}'
        values: '0+10x60'
    alert_rule_test:
      - eval_time: 10m
        alertname: HighErrorRate
        exp_alerts:
          - exp_labels:
              service: checkout
              severity: page
            exp_annotations:
              summary: '5xx 비율이 2%를 넘었습니다'
              runbook_url: https://wiki.internal/runbook/high-error-rate

SLO와 에러 버짓 — 임계값을 정하는 근거

"에러율 2%"라는 숫자는 어디서 왔을까요. 대개 아무 데서도 오지 않았습니다. 누군가 감으로 정했고 아무도 다시 묻지 않았습니다.

SLO는 그 임계값에 근거를 줍니다. 절차는 셋입니다.

  1. SLI를 정의합니다. "좋은 이벤트의 비율"로 표현합니다. 5xx가 아닌 응답의 비율, 300ms 안에 끝난 요청의 비율.
  2. SLO를 정합니다. 30일 동안 SLI가 몇 퍼센트 이상이어야 하는가.
  3. 에러 버짓을 계산합니다. SLO가 99.9%면 버짓은 0.1%입니다. 30일 기준으로 43.2분입니다.
# rules/slo.yml
groups:
  - name: slo_sli
    interval: 30s
    rules:
      # 좋은 요청의 비율 — 헬스체크는 제외한다
      - record: service:sli_availability:ratio_rate5m
        expr: |
          sum by (service) (
            rate(http_requests_total{status_class!="5xx", route!~"/healthz|/readyz"}[5m])
          )
          /
          sum by (service) (
            rate(http_requests_total{route!~"/healthz|/readyz"}[5m])
          )

      # 각 윈도우의 에러 비율 — 번레이트 경보가 참조한다
      - record: service:http_error_ratio:rate5m
        expr: |
          1 - service:sli_availability:ratio_rate5m

      - record: service:http_error_ratio:rate1h
        expr: |
          sum by (service) (rate(http_requests_total{status_class="5xx", route!~"/healthz|/readyz"}[1h]))
          /
          sum by (service) (rate(http_requests_total{route!~"/healthz|/readyz"}[1h]))

      - record: service:http_error_ratio:rate6h
        expr: |
          sum by (service) (rate(http_requests_total{status_class="5xx", route!~"/healthz|/readyz"}[6h]))
          /
          sum by (service) (rate(http_requests_total{route!~"/healthz|/readyz"}[6h]))

      - record: service:http_error_ratio:rate30m
        expr: |
          sum by (service) (rate(http_requests_total{status_class="5xx", route!~"/healthz|/readyz"}[30m]))
          /
          sum by (service) (rate(http_requests_total{route!~"/healthz|/readyz"}[30m]))

      # 남은 에러 버짓 비율 — 대시보드의 핵심 패널
      - record: service:error_budget_remaining:ratio30d
        expr: |
          1 - (
            (
              sum by (service) (rate(http_requests_total{status_class="5xx", route!~"/healthz|/readyz"}[30d]))
              /
              sum by (service) (rate(http_requests_total{route!~"/healthz|/readyz"}[30d]))
            )
            / 0.001
          )

번레이트는 "지금 속도로 계속되면 버짓이 얼마 만에 소진되는가"의 배수입니다. 소진율 1은 30일에 정확히 다 쓰는 속도이고, 14.4는 약 2일 만에 다 쓰는 속도입니다.

소진율30일 버짓 소진 시간긴 윈도우짧은 윈도우대응
14.4약 2일1시간5분즉시 호출
65일6시간30분업무 시간 내 대응
310일1일2시간티켓 생성
130일3일6시간주간 리뷰

긴 윈도우와 짧은 윈도우를 함께 쓰는 이유는 두 가지 오탐을 각각 막기 위해서입니다. 긴 윈도우만 쓰면 이미 끝난 장애에 대해 몇 시간 동안 계속 울립니다. 짧은 윈도우만 쓰면 순간적인 스파이크에 울립니다.

groups:
  - name: slo_burn_rate
    rules:
      - alert: ErrorBudgetBurnFast
        expr: |
          service:http_error_ratio:rate1h > (14.4 * 0.001)
            and
          service:http_error_ratio:rate5m > (14.4 * 0.001)
        for: 2m
        labels:
          severity: page
          slo: availability
        annotations:
          summary: '에러 버짓을 14.4배 속도로 소진 중입니다. 약 2일이면 전량 소진됩니다'
          runbook_url: https://wiki.internal/runbook/slo-burn

      - alert: ErrorBudgetBurnSlow
        expr: |
          service:http_error_ratio:rate6h > (6 * 0.001)
            and
          service:http_error_ratio:rate30m > (6 * 0.001)
        for: 15m
        labels:
          severity: ticket
          slo: availability
        annotations:
          summary: '에러 버짓을 6배 속도로 소진 중입니다'

SLO 대시보드는 패널 네 개면 충분합니다. 남은 버짓 비율, 30일 SLI 추이, 현재 소진율, 그리고 버짓을 가장 많이 쓴 라우트 상위 목록. 이 넷이 "지금 위험을 감수해도 되는가"라는 질문에 답합니다.

경보 피로를 줄이는 규칙

경보가 작동하지 않게 되는 방식은 시끄러워지는 것입니다. 아래 규칙들이 효과가 큽니다.

모든 경보에 러북 링크를 답니다. 링크가 없는 경보는 만들지 않습니다. 이 규칙 하나가 경보 수를 자연히 줄입니다. 러북을 쓰는 과정에서 "이건 사람이 볼 필요가 없구나"를 깨닫게 되기 때문입니다.

호출과 티켓을 구분합니다. 지금 당장 사람이 일어나야 하는 것만 호출입니다. 나머지는 티켓이나 채널 알림입니다. 새벽에 울린 경보 중 아침까지 기다려도 됐던 것이 있다면 그 경보의 등급을 내립니다.

억제 규칙을 씁니다. 상위 장애가 진행 중일 때 그로 인한 하위 경보를 묶습니다.

# alertmanager.yml
route:
  group_by: ['alertname', 'service']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: slack-default
  routes:
    - matchers: [severity = page]
      receiver: pagerduty
      group_wait: 10s
      repeat_interval: 1h
      continue: true
    - matchers: [severity = ticket]
      receiver: jira
      repeat_interval: 24h

inhibit_rules:
  # 클러스터 전체 장애 중에는 개별 서비스 경보를 억제한다
  - source_matchers: [alertname = ClusterUnreachable]
    target_matchers: [severity =~ 'page|ticket']
    equal: [cluster]
  # 같은 서비스에서 page 가 떠 있으면 ticket 은 억제한다
  - source_matchers: [severity = page]
    target_matchers: [severity = ticket]
    equal: [service]

receivers:
  - name: pagerduty
    pagerduty_configs:
      - routing_key_file: /etc/alertmanager/pd_key
  - name: jira
    webhook_configs:
      - url: http://alert-to-jira.internal/hook
  - name: slack-default
    slack_configs:
      - channel: '#alerts'
        send_resolved: true

정기적으로 경보를 검토합니다. 월 1회, 지난달에 발화한 경보를 두 축으로 분류합니다.

발화했는가행동을 유발했는가조치
유지
아니오임계값 조정 또는 등급 하향, 반복되면 삭제
아니오해당 없음규칙이 작동하는지 테스트, 안 되면 수정하거나 삭제
발화 이력이 아예 없음해당 없음단위 테스트로 발화 가능성 검증

두 번째 줄이 가장 중요합니다. 울렸지만 아무도 아무것도 하지 않은 경보는 다음 달에도 같은 일을 반복합니다. 행동을 유발하지 않는 경보는 정보이지 경보가 아닙니다.

대시보드와 경보를 코드로 관리하기

UI에서 만든 대시보드는 리뷰도 롤백도 되지 않습니다. Grafana 12는 프로비저닝과 관측 가능성 코드화가 정리되어 있어, 파일로 관리하는 편이 확실히 낫습니다.

# provisioning/dashboards/dashboards.yaml
apiVersion: 1
providers:
  - name: gitops
    orgId: 1
    folder: Services
    type: file
    disableDeletion: true
    updateIntervalSeconds: 60
    allowUiUpdates: false        # UI 수정 금지. 변경은 저장소를 거친다
    options:
      path: /etc/grafana/dashboards
      foldersFromFilesStructure: true
# provisioning/alerting/rules.yaml — Grafana 관리 경보를 파일로 정의한다
apiVersion: 1
groups:
  - orgId: 1
    name: checkout_slo
    folder: Alerts
    interval: 1m
    rules:
      - uid: checkout-burn-fast
        title: Checkout error budget burn (fast)
        condition: THRESHOLD
        data:
          - refId: BURN
            relativeTimeRange:
              from: 3600
              to: 0
            datasourceUid: prom-prod
            model:
              expr: service:http_error_ratio:rate1h{service="checkout"}
              instant: true
              refId: BURN
          - refId: THRESHOLD
            datasourceUid: __expr__
            model:
              type: threshold
              expression: BURN
              conditions:
                - evaluator:
                    type: gt
                    params: [0.0144]
              refId: THRESHOLD
        for: 2m
        noDataState: NoData
        execErrState: Alerting
        labels:
          severity: page
          slo: availability
        annotations:
          summary: Checkout 에러 버짓이 14.4배 속도로 소진 중입니다
          runbook_url: https://wiki.internal/runbook/slo-burn

noDataState 를 무엇으로 둘지가 실무에서 갈립니다. Alerting 으로 두면 스크레이프가 잠깐 끊겨도 울립니다. OK 로 두면 데이터 수집이 통째로 멈춰도 조용합니다. 대개 NoData 로 두고, 수집 중단 자체를 감시하는 별도 경보를 하나 만드는 편이 낫습니다.

- alert: ScrapeTargetDown
  expr: up{job=~"checkout.*|payment.*"} == 0
  for: 5m
  labels:
    severity: page
  annotations:
    summary: '스크레이프 대상이 5분간 응답하지 않습니다'

대시보드 JSON을 저장소에 넣을 때 주의할 점이 둘 있습니다. 첫째, UI에서 저장하면 패널 좌표와 버전 번호 같은 잡음이 잔뜩 섞인 diff가 생깁니다. 저장 전에 불필요한 필드를 걷어내는 정규화 스크립트를 CI에 두면 리뷰가 가능해집니다. 둘째, 데이터소스를 UID로 하드코딩하면 다른 환경에서 깨집니다. 데이터소스 변수를 쓰고 그 변수를 참조하게 만듭니다.

마치며 — 대시보드와 경보는 사람의 행동을 설계하는 일이다

패널과 규칙은 결과물이고, 진짜 설계 대상은 새벽 3시에 깨어난 사람이 무엇을 보고 무엇을 하는가입니다. 그 관점에서 보면 판단이 쉬워집니다. 열었을 때 다음 행동이 정해지지 않는 패널은 지웁니다. 울렸을 때 할 일이 없는 경보는 등급을 내리거나 없앱니다.

지금 할 수 있는 점검 두 가지를 남깁니다. 첫째, 지난 30일 동안 한 번도 열리지 않은 대시보드 목록을 뽑습니다. 열지 않는데도 유지 비용은 계속 나가고 있습니다. 둘째, 지난 30일 동안 발화한 경보를 세고, 그중 실제로 조치가 따른 비율을 계산합니다. 그 비율이 절반을 밑돈다면 경보 시스템은 이미 신뢰를 잃은 상태입니다.

더 파고들 자료입니다.