Skip to content

Split View: 새벽 3시에 깨우지 않는 알림 설계 — 증상 기반 알림과 번 레이트 실전

✨ Learn with Quiz
|

새벽 3시에 깨우지 않는 알림 설계 — 증상 기반 알림과 번 레이트 실전

들어가며 — 어젯밤 울린 페이지 중 몇 개가 조치로 이어졌는가

이 질문에 즉시 답할 수 없다면 알림 시스템이 이미 고장 난 상태입니다. 답이 "거의 없다"라면 고장은 심각합니다.

온콜이 무너지는 전형적인 경로는 이렇습니다. 사고가 하나 나면 회고에서 "이걸 미리 알았어야 했다"는 결론이 나오고 알림이 하나 추가됩니다. 이 과정이 2년 반복되면 알림 규칙이 300개가 되고, 슬랙 채널에는 하루 400건이 쌓이고, 온콜은 밤에 세 번 깨어나서 세 번 다 아무것도 하지 않고 다시 잡니다.

이 글은 그 300개를 줄이는 방법이 아니라, 애초에 무엇에 호출을 걸어야 하는지를 다시 정하는 방법을 다룹니다.

알림 피로가 진짜 사고를 놓치게 만드는 메커니즘

알림 피로는 게으름의 문제가 아니라 확률의 문제입니다.

하루 400건의 알림 중 실제 조치가 필요한 것이 4건이라면 정밀도는 1%입니다. 이 상태에서 사람이 학습하는 최적 전략은 "일단 무시하고 나중에 확인"입니다. 그것이 합리적이기 때문에 더 위험합니다. 훈련이나 각오로는 되돌릴 수 없습니다.

구체적으로 세 가지가 망가집니다.

첫째, 반응 시간이 늘어납니다. 알림을 열어보기 전에 "또 그거겠지"라는 사전 판단이 붙습니다. 진짜 사고의 초기 10분이 여기서 사라집니다.

둘째, 신호가 소음에 묻힙니다. 400건 중 진짜가 4건이면, 그 4건은 시각적으로 나머지 396건과 구별되지 않습니다. 심각도 레이블이 있어도 마찬가지입니다. 모든 것이 critical이면 아무것도 critical이 아닙니다.

셋째, 사람이 마모됩니다. 야간 호출은 다음 날의 판단력을 떨어뜨립니다. 조치가 필요 없는 호출로 온콜을 깨우는 것은 다음 사고의 대응 품질을 미리 깎아 먹는 행위입니다.

여기서 실무 목표가 나옵니다. 12시간 교대 기준으로 페이지는 평균 2건 이하, 그리고 페이지의 조치 전환율은 최소 70% 이상을 목표로 둡니다. 이 두 숫자를 넘어가면 알림을 추가할 것이 아니라 삭제할 때입니다.

증상에 페이지를 걸고 원인은 대시보드에 둔다

가장 강력한 규칙 하나를 고르라면 이것입니다. 사용자가 겪는 증상에만 호출을 겁니다.

CPU 사용률 90%는 증상이 아닙니다. 사용자는 CPU를 느끼지 않습니다. CPU가 90%인데 응답 시간이 정상이면 아무 일도 일어나지 않은 것이고, CPU가 40%인데 응답이 5초씩 걸리면 심각한 사고입니다. 원인 지표에 호출을 걸면 두 경우 모두 틀립니다.

호출을 걸 대상은 서비스가 사용자에게 약속한 것들입니다.

  • 가용성: 요청 중 서버 오류로 실패한 비율
  • 지연: 임계값 안에 완료되지 않은 요청의 비율
  • 처리량 급감: 정상 대비 트래픽이 사라진 경우(장애의 흔한 증상입니다)
  • 신선도: 배치나 스트림 파이프라인에서 데이터가 얼마나 밀렸는가
  • 정확성: 조정 작업에서 발견된 불일치 건수

호출을 걸지 않을 대상은 원인 지표들입니다. CPU, 메모리, 디스크 IOPS, 파드 재시작 횟수, 스레드 풀 사용률, GC 시간, 레플리카 수. 이것들은 조사용 대시보드에 두고, 사고가 났을 때 원인을 좁히는 데 씁니다.

예외는 두 부류입니다. 첫째, 되돌릴 수 없고 선행 시간이 필요한 것 — 디스크 여유 공간, 인증서 만료, 쿼터 소진처럼 도달하면 회복이 어렵고 미리 알면 예방 가능한 항목은 예측 기반으로 호출을 겁니다.

# 4시간 안에 디스크가 가득 찰 추세인가 — 도달하고 나서는 늦다
predict_linear(node_filesystem_avail_bytes{mountpoint="/data"}[6h], 4 * 3600) < 0
and
node_filesystem_avail_bytes{mountpoint="/data"} / node_filesystem_size_bytes{mountpoint="/data"} < 0.2

둘째, 관측 체계 자체의 고장입니다. 메트릭이 안 들어오는 상태는 모든 알림을 무력화하므로 반드시 호출 대상입니다.

# 스크레이프가 끊긴 잡 — 이 알림이 없으면 나머지 알림이 조용히 죽는다
up{job="checkout-api"} == 0
or
absent(up{job="checkout-api"})

SLO와 에러 버짓 — 임계값을 방어할 수 있게 정하는 법

"오류율 5% 넘으면 알림"의 5%는 어디서 왔습니까. 대부분 아무 데서도 오지 않았습니다. 과거 최대치의 1.2배 같은 방식으로 정한 숫자는 왜 그 값인지 설명할 수 없고, 설명할 수 없는 임계값은 사고가 날 때마다 조금씩 올라갑니다.

SLO에서 역산하면 임계값에 근거가 생깁니다.

목표가 30일 기준 99.9%라면 에러 버짓은 0.1%입니다. 30일은 43,200분이므로 예산은 43.2분입니다.

python3 - <<'PY'
slo = 0.999
days = 30
budget_ratio = 1 - slo
minutes = days * 24 * 60 * budget_ratio
print(f"에러 버짓: {budget_ratio*100:.2f}% = {minutes:.1f}분 / {days}일")
for br in (14.4, 6, 3, 1):
    # 번 레이트 br 로 계속 태울 때 예산이 바닥나기까지
    hours = days * 24 / br
    print(f"  번 레이트 {br:>4}: {hours:6.1f}시간 만에 소진")
PY
# 에러 버짓: 0.10% = 43.2분 / 30일
#   번 레이트 14.4:   50.0시간 만에 소진
#   번 레이트    6:  120.0시간 만에 소진
#   번 레이트    3:  240.0시간 만에 소진
#   번 레이트    1:  720.0시간 만에 소진

번 레이트는 실제 오류율을 예산 비율로 나눈 값입니다. 목표가 99.9%일 때 오류율이 1.44%면 번 레이트는 14.4입니다. 이 속도가 1시간 지속되면 30일 예산의 2%가 사라집니다.

SLI는 레코딩 규칙으로 미리 계산해 둡니다. 3일 창의 rate를 알림 평가 때마다 계산하면 프로메테우스가 먼저 죽습니다.

# rules/slo.yml
groups:
  - name: checkout-slo-sli
    interval: 30s
    rules:
      - record: job:slo_errors:ratio_rate5m
        expr: |
          sum by (job) (rate(http_requests_total{job="checkout-api", code=~"5.."}[5m]))
          /
          sum by (job) (rate(http_requests_total{job="checkout-api"}[5m]))

      - record: job:slo_errors:ratio_rate1h
        expr: |
          sum by (job) (rate(http_requests_total{job="checkout-api", code=~"5.."}[1h]))
          /
          sum by (job) (rate(http_requests_total{job="checkout-api"}[1h]))

      - record: job:slo_errors:ratio_rate30m
        expr: |
          sum by (job) (rate(http_requests_total{job="checkout-api", code=~"5.."}[30m]))
          /
          sum by (job) (rate(http_requests_total{job="checkout-api"}[30m]))

      - record: job:slo_errors:ratio_rate6h
        expr: |
          sum by (job) (rate(http_requests_total{job="checkout-api", code=~"5.."}[6h]))
          /
          sum by (job) (rate(http_requests_total{job="checkout-api"}[6h]))

      # 저트래픽 구간에서 비율이 요동치는 것을 막기 위한 최소 트래픽 게이트
      - record: job:http_requests:rate5m
        expr: sum by (job) (rate(http_requests_total{job="checkout-api"}[5m]))

마지막 레코딩 규칙이 중요합니다. 5분에 요청이 3건 들어오는 시간대에 1건이 실패하면 오류율은 33%가 되고, 모든 번 레이트 임계값을 한 번에 넘습니다. 최소 트래픽 조건을 AND로 걸어야 새벽의 유령 알림이 사라집니다. 트래픽이 아예 0이면 비율은 0을 0으로 나눈 NaN이 되어 알림이 조용히 사라지는데, 이 경우는 처리량 급감 알림이 따로 잡아야 합니다.

다중 윈도우 번 레이트 알림 — 민감도와 오탐을 동시에 잡기

단일 창 알림은 항상 한쪽을 포기합니다. 창이 짧으면 순간적인 변동에 반응해 오탐이 나고, 창이 길면 큰 사고를 늦게 잡습니다.

해법은 창 두 개를 AND로 묶는 것입니다. 긴 창은 "이 정도로 태우고 있는가"를 판정하고, 짧은 창은 "지금도 진행 중인가"를 판정합니다. 짧은 창의 역할은 민감도가 아니라 해소 속도라는 점이 핵심입니다. 짧은 창이 없으면 사고가 끝난 뒤에도 긴 창이 창 길이만큼 알림을 계속 유지합니다.

예산 소진량긴 창짧은 창번 레이트 임계값대응
2%1시간5분14.4즉시 호출
5%6시간30분6즉시 호출
10%1일2시간3티켓
10%3일6시간1티켓

짧은 창은 긴 창의 12분의 1로 잡는 것이 관례입니다. 이 비율이면 사고 종료 후 대략 짧은 창 길이 안에 알림이 내려갑니다.

# rules/slo.yml (이어서)
  - name: checkout-slo-alerts
    rules:
      - alert: CheckoutErrorBudgetBurnFast
        expr: |
          job:slo_errors:ratio_rate1h{job="checkout-api"} > (14.4 * 0.001)
          and
          job:slo_errors:ratio_rate5m{job="checkout-api"} > (14.4 * 0.001)
          and
          job:http_requests:rate5m{job="checkout-api"} > 1
        for: 2m
        labels:
          severity: page
          slo: checkout-availability
        annotations:
          summary: 'checkout-api 에러 버짓을 시간당 2% 속도로 소진 중'
          description: '현재 1시간 오류율 {{ printf "%.2f" $value }} — 남은 예산은 대시보드 확인'
          runbook_url: 'https://runbooks.internal/checkout/error-budget-burn'
          dashboard_url: 'https://grafana.internal/d/checkout-slo'

      - alert: CheckoutErrorBudgetBurnSlow
        expr: |
          job:slo_errors:ratio_rate6h{job="checkout-api"} > (6 * 0.001)
          and
          job:slo_errors:ratio_rate30m{job="checkout-api"} > (6 * 0.001)
        for: 15m
        labels:
          severity: ticket
          slo: checkout-availability
        annotations:
          summary: 'checkout-api 에러 버짓이 6시간 창에서 지속 소진 중'
          runbook_url: 'https://runbooks.internal/checkout/error-budget-burn'

for 절이 실제로 하는 일

for는 표현식이 참인 상태가 그 시간만큼 연속으로 유지되어야 발화한다는 뜻입니다. 중간에 한 번이라도 거짓이 되면 타이머가 0으로 초기화됩니다. 이것이 플래핑을 막는 원리입니다.

여기서 흔한 잘못된 조언이 있습니다. "오탐이 많으니 for를 30분으로 늘리자." 이 방법은 작동하지만 대가가 큽니다. 탐지가 30분 늦어지고, 사고가 25분 만에 끝나면 알림이 아예 울리지 않습니다. 그리고 진짜 문제는 여전히 남습니다 — 표현식 자체가 노이즈를 보고 있다는 사실입니다.

올바른 순서는 이렇습니다. 먼저 긴 창의 rate로 노이즈를 평활화합니다. 그다음 짧은 창을 AND로 걸어 진행 중임을 확인합니다. for는 마지막에 2분에서 5분 정도만 걸어 스크레이프 실패 같은 한두 번의 결측을 흡수하는 용도로 씁니다. 긴 창을 이미 쓰고 있는데 for를 15분 넘게 잡고 있다면 창 설계가 잘못된 것입니다.

규칙은 반드시 테스트합니다. 알림 규칙은 프로덕션 코드이고, 프로덕션 코드는 테스트 없이 배포하지 않습니다.

# rules/slo_test.yml
rule_files:
  - slo.yml
evaluation_interval: 30s
tests:
  - interval: 30s
    input_series:
      # 초당 95건 성공, 5건 실패 = 오류율 5% (임계값 1.44% 초과)
      - series: 'http_requests_total{job="checkout-api", code="200"}'
        values: '0+2850x240'
      - series: 'http_requests_total{job="checkout-api", code="500"}'
        values: '0+150x240'
    alert_rule_test:
      - eval_time: 70m
        alertname: CheckoutErrorBudgetBurnFast
        exp_alerts:
          - exp_labels:
              severity: page
              slo: checkout-availability
              job: checkout-api
promtool check rules rules/slo.yml
# Checking rules/slo.yml
#   SUCCESS: 7 rules found

promtool test rules rules/slo_test.yml
# Unit Testing:  rules/slo_test.yml
#   SUCCESS

페이지, 티켓, 대시보드 — 3단 분류와 런북 강제

모든 알림은 셋 중 하나여야 합니다. 네 번째는 없습니다.

  • 페이지: 사람이 지금 깨어나야 합니다. 사용자 영향이 진행 중이고, 자동 복구되지 않으며, 사람의 조치로 상황이 달라집니다. 목표는 12시간 교대당 2건 이하입니다.
  • 티켓: 업무 시간에 처리하면 됩니다. 예산은 새고 있지만 속도가 느립니다. 자동으로 이슈가 생성되고 담당자가 배정됩니다.
  • 대시보드: 알림을 만들지 않습니다. 조사할 때 보는 지표입니다. 여기에 알림을 붙이려는 충동이 300개 알림의 출발점입니다.

세 번째 분류를 명시적으로 두는 것이 중요합니다. "일단 슬랙 채널에만 보내자"는 절충안은 최악입니다. 알림이 어디에도 소속되지 않은 채 무한히 늘어나고, 그 채널은 아무도 보지 않게 되고, 나중에 그 채널에 진짜 신호가 흘러가도 놓칩니다.

페이지 알림에는 런북 링크를 강제합니다. 새벽 3시에 깨어난 사람은 창의력을 발휘할 상태가 아닙니다. 필요한 것은 확인할 것 세 가지와 조치 두 가지가 적힌 문서입니다.

# CI 게이트: severity=page 인데 runbook_url 이 없는 알림이 있으면 빌드를 깬다
missing=$(yq -r '
  .groups[].rules[]
  | select(.labels.severity == "page")
  | select(.annotations.runbook_url == null)
  | .alert
' rules/*.yml)

if [ -n "$missing" ]; then
  echo "런북 링크가 없는 페이지 알림:"
  echo "$missing"
  exit 1
fi
echo "모든 페이지 알림에 런북이 연결되어 있습니다."

런북에 반드시 들어가야 할 내용은 네 가지입니다. 이 알림이 의미하는 사용자 영향, 바로 확인할 대시보드와 쿼리, 알려진 원인과 각각의 조치, 그리고 에스컬레이션 대상. 링크만 있고 내용이 비어 있는 런북은 링크가 없는 것보다 나쁩니다.

알림을 회고하고 삭제하는 절차

알림은 추가하는 절차만 있고 삭제하는 절차가 없어서 늘어납니다. 삭제를 기본 동작으로 만드는 정기 리뷰가 필요합니다.

월 1회, 다음 데이터를 뽑아 놓고 시작합니다.

# 알림별 총 발화 시간(분). 평가 주기가 30초이므로 샘플 수에 0.5를 곱한다.
sort_desc(
  sum by (alertname) (
    count_over_time(ALERTS{alertstate="firing", severity="page"}[30d])
  ) * 0.5
)
# 지금 발화 중인 것과, 조용히 억제되어 있는 것
amtool alert query --alertmanager.url=http://alertmanager:9093 --output=extended
amtool silence query --alertmanager.url=http://alertmanager:9093 --expired=false

# 만료 없이 몇 달째 걸려 있는 사일런스가 있다면 그것은 삭제되어야 할 알림이다

알림마다 네 가지를 채웁니다. 30일 발화 횟수, 그중 실제 조치로 이어진 비율, 중앙값 해소 시간, 야간 발화 비율. 그리고 다음 규칙을 기계적으로 적용합니다.

  • 3개월 연속 조치로 이어지지 않은 페이지는 티켓으로 강등하거나 삭제합니다. 유지하려면 유지 사유를 문서에 적어야 합니다.
  • 사일런스가 30일 이상 유지된 알림은 삭제합니다. 사일런스는 알림이 틀렸다는 가장 정직한 신호입니다.
  • 같은 사고에서 항상 함께 울리는 알림 묶음은 하나로 합치거나 억제 규칙으로 묶습니다. 하나의 사고에 12개의 페이지가 날아가는 구조는 대응을 방해합니다.
  • 사고 회고에서 알림을 추가할 때는 반드시 하나를 지우거나, 왜 지울 것이 없는지 적습니다.

억제 규칙은 알림 수를 줄이는 가장 저렴한 도구입니다. 클러스터 전체가 죽었을 때 개별 서비스 알림 40개를 날려 보내는 대신 하나만 보냅니다.

# alertmanager.yml
inhibit_rules:
  - source_matchers: ['severity="page"', 'alertname="ClusterDown"']
    target_matchers: ['severity=~"page|ticket"']
    equal: ['cluster']

route:
  group_by: ['alertname', 'cluster', 'slo']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: 'ticket-queue'
  routes:
    - matchers: ['severity="page"']
      receiver: 'oncall-pager'
      repeat_interval: 1h

group_by에 무엇을 넣느냐가 알림 개수를 결정합니다. 인스턴스 단위로 그룹핑하면 파드 수만큼 알림이 옵니다. 서비스나 SLO 단위로 묶어야 사람이 읽을 수 있는 개수가 됩니다.

마치며 — 알림의 목적은 감지가 아니라 조치다

기억할 것은 한 문장입니다. 알림의 성공 기준은 "문제를 감지했는가"가 아니라 "사람이 지금 무언가를 해야 하는가"입니다.

이 기준을 통과하지 못하는 알림은 정확해도 해롭습니다. 정확한 알림 400개가 온콜의 반응 속도를 무너뜨리면 정확도는 아무 의미가 없습니다. 그래서 순서는 이렇게 됩니다. 사용자 증상으로 SLO를 정의하고, 예산에서 임계값을 역산하고, 긴 창과 짧은 창을 AND로 묶고, 페이지에는 런북을 강제하고, 매달 조치로 이어지지 않은 알림을 삭제합니다.

내일 할 수 있는 일이 하나 있다면 지난 30일간 발화 시간이 가장 긴 페이지 알림 세 개를 뽑아, 각각이 조치로 이어진 적이 있는지 확인하는 것입니다. 대부분의 팀에서 그 세 개가 온콜 피로의 절반을 차지합니다.

더 파고들 자료입니다.

Alerting That Does Not Wake You at 3AM — Symptom-Based Alerts and Burn Rate in Practice

Introduction — How Many of Last Night's Pages Led to an Action

If you cannot answer that question immediately, the alerting system is already broken. If the answer is "almost none", the breakage is severe.

The typical path by which on-call collapses looks like this. An incident happens, the retrospective concludes "we should have known about this in advance", and one alert gets added. Repeat for two years and you have 300 alert rules, 400 messages a day piling into a Slack channel, and an on-call engineer who wakes up three times a night, does nothing all three times, and goes back to sleep.

This post is not about reducing those 300. It is about re-deciding what should have been paged on in the first place.

The Mechanism by Which Alert Fatigue Makes You Miss Real Incidents

Alert fatigue is not a problem of laziness. It is a problem of probability.

If 4 out of 400 daily alerts genuinely need action, the precision is 1 percent. In that state, the optimal strategy a human learns is "ignore it for now and check later". It is more dangerous precisely because it is rational. No amount of training or resolve can reverse it.

Concretely, three things break.

First, reaction time grows. Before you even open the alert, a prior judgement of "probably that thing again" attaches itself. The first ten minutes of a real incident disappear here.

Second, the signal drowns in noise. If 4 out of 400 are real, those 4 are visually indistinguishable from the other 396. Severity labels do not change that. If everything is critical, nothing is critical.

Third, people wear out. Night-time pages degrade the next day's judgement. Waking on-call for something that requires no action is pre-emptively eating into the quality of the response to the next incident.

From this comes a practical target. Aim for an average of at most 2 pages per 12-hour shift, and an action conversion rate of at least 70 percent for pages. Cross either of those numbers and it is time to delete alerts, not add them.

Page on Symptoms, Put Causes on Dashboards

If you had to pick the single most powerful rule, it would be this. Page only on symptoms the user experiences.

CPU utilization at 90 percent is not a symptom. Users do not feel CPU. If CPU is at 90 percent and response times are normal, nothing has happened; if CPU is at 40 percent and responses take 5 seconds, that is a serious incident. Page on a cause metric and you are wrong in both cases.

What you page on is what the service promised its users.

  • Availability: the fraction of requests that failed with a server error
  • Latency: the fraction of requests not completed within the threshold
  • Throughput collapse: traffic disappearing relative to normal (a common symptom of an outage)
  • Freshness: how far behind data has fallen in a batch or stream pipeline
  • Correctness: the number of discrepancies found by a reconciliation job

What you do not page on is the cause metrics. CPU, memory, disk IOPS, pod restart count, thread pool utilization, GC time, replica count. Keep these on investigation dashboards and use them to narrow down the cause once an incident is underway.

There are two categories of exception. First, things that are irreversible and need lead time — disk free space, certificate expiry, quota exhaustion. Items that are hard to recover from once reached but preventable if known in advance get a prediction-based page.

# Is the disk on a trajectory to fill within 4 hours — after it fills it is too late
predict_linear(node_filesystem_avail_bytes{mountpoint="/data"}[6h], 4 * 3600) < 0
and
node_filesystem_avail_bytes{mountpoint="/data"} / node_filesystem_size_bytes{mountpoint="/data"} < 0.2

Second, breakage of the observability system itself. A state where metrics stop arriving disables every alert, so it must be a paging target.

# A job whose scrapes have stopped — without this alert the rest die silently
up{job="checkout-api"} == 0
or
absent(up{job="checkout-api"})

SLOs and Error Budgets — Setting Thresholds You Can Defend

Where did the 5 percent in "alert if the error rate exceeds 5 percent" come from? For most teams, from nowhere at all. A number set as, say, 1.2 times the historical maximum cannot be explained, and a threshold you cannot explain creeps upward a little with every incident.

Derive it from the SLO and the threshold acquires a justification.

If the target is 99.9 percent over 30 days, the error budget is 0.1 percent. Thirty days is 43,200 minutes, so the budget is 43.2 minutes.

python3 - <<'PY'
slo = 0.999
days = 30
budget_ratio = 1 - slo
minutes = days * 24 * 60 * budget_ratio
print(f"error budget: {budget_ratio*100:.2f}% = {minutes:.1f} min / {days} days")
for br in (14.4, 6, 3, 1):
    # how long until the budget is gone if you keep burning at burn rate br
    hours = days * 24 / br
    print(f"  burn rate {br:>4}: exhausted in {hours:6.1f} hours")
PY
# error budget: 0.10% = 43.2 min / 30 days
#   burn rate 14.4: exhausted in   50.0 hours
#   burn rate    6: exhausted in  120.0 hours
#   burn rate    3: exhausted in  240.0 hours
#   burn rate    1: exhausted in  720.0 hours

The burn rate is the actual error rate divided by the budget ratio. With a 99.9 percent target, an error rate of 1.44 percent is a burn rate of 14.4. Sustain that speed for an hour and 2 percent of the 30-day budget is gone.

Precompute the SLI with recording rules. Compute a 3-day-window rate on every alert evaluation and Prometheus dies first.

# rules/slo.yml
groups:
  - name: checkout-slo-sli
    interval: 30s
    rules:
      - record: job:slo_errors:ratio_rate5m
        expr: |
          sum by (job) (rate(http_requests_total{job="checkout-api", code=~"5.."}[5m]))
          /
          sum by (job) (rate(http_requests_total{job="checkout-api"}[5m]))

      - record: job:slo_errors:ratio_rate1h
        expr: |
          sum by (job) (rate(http_requests_total{job="checkout-api", code=~"5.."}[1h]))
          /
          sum by (job) (rate(http_requests_total{job="checkout-api"}[1h]))

      - record: job:slo_errors:ratio_rate30m
        expr: |
          sum by (job) (rate(http_requests_total{job="checkout-api", code=~"5.."}[30m]))
          /
          sum by (job) (rate(http_requests_total{job="checkout-api"}[30m]))

      - record: job:slo_errors:ratio_rate6h
        expr: |
          sum by (job) (rate(http_requests_total{job="checkout-api", code=~"5.."}[6h]))
          /
          sum by (job) (rate(http_requests_total{job="checkout-api"}[6h]))

      # Minimum traffic gate to stop the ratio swinging wildly in low-traffic periods
      - record: job:http_requests:rate5m
        expr: sum by (job) (rate(http_requests_total{job="checkout-api"}[5m]))

That last recording rule matters. In a period taking 3 requests every five minutes, one failure makes the error rate 33 percent and crosses every burn rate threshold at once. AND in a minimum traffic condition and the ghost alerts at dawn disappear. If traffic is exactly zero the ratio becomes 0 divided by 0, a NaN, and the alert quietly vanishes — that case has to be caught separately by a throughput collapse alert.

Multi-Window Burn Rate Alerts — Catching Sensitivity and False Positives Together

A single-window alert always gives up one side. A short window reacts to momentary fluctuation and produces false positives; a long window catches big incidents late.

The solution is to join two windows with AND. The long window decides "are we burning at this rate", and the short window decides "is it still happening right now". The key point is that the short window's role is not sensitivity but resolution speed. Without the short window, the long window keeps the alert up for its own window length even after the incident ends.

Budget consumedLong windowShort windowBurn rate thresholdResponse
2%1 hour5 min14.4Page immediately
5%6 hours30 min6Page immediately
10%1 day2 hours3Ticket
10%3 days6 hours1Ticket

The convention is to set the short window at one twelfth of the long window. At that ratio the alert clears roughly within one short-window length after the incident ends.

# rules/slo.yml (continued)
  - name: checkout-slo-alerts
    rules:
      - alert: CheckoutErrorBudgetBurnFast
        expr: |
          job:slo_errors:ratio_rate1h{job="checkout-api"} > (14.4 * 0.001)
          and
          job:slo_errors:ratio_rate5m{job="checkout-api"} > (14.4 * 0.001)
          and
          job:http_requests:rate5m{job="checkout-api"} > 1
        for: 2m
        labels:
          severity: page
          slo: checkout-availability
        annotations:
          summary: 'checkout-api is burning error budget at 2% per hour'
          description: 'current 1h error rate {{ printf "%.2f" $value }} — check the dashboard for remaining budget'
          runbook_url: 'https://runbooks.internal/checkout/error-budget-burn'
          dashboard_url: 'https://grafana.internal/d/checkout-slo'

      - alert: CheckoutErrorBudgetBurnSlow
        expr: |
          job:slo_errors:ratio_rate6h{job="checkout-api"} > (6 * 0.001)
          and
          job:slo_errors:ratio_rate30m{job="checkout-api"} > (6 * 0.001)
        for: 15m
        labels:
          severity: ticket
          slo: checkout-availability
        annotations:
          summary: 'checkout-api error budget is burning continuously over a 6h window'
          runbook_url: 'https://runbooks.internal/checkout/error-budget-burn'

What the for Clause Actually Does

for means the expression must stay true continuously for that duration before it fires. If it becomes false even once in between, the timer resets to zero. That is the principle by which it prevents flapping.

There is a common piece of bad advice here. "We get too many false positives, let us raise for to 30 minutes." That works, but the price is high. Detection is delayed by 30 minutes, and if the incident ends in 25 minutes the alert never fires at all. And the real problem remains — the fact that the expression itself is looking at noise.

The correct order is this. First smooth the noise with a long-window rate. Then AND in a short window to confirm it is ongoing. Use for last, at only 2 to 5 minutes, to absorb one or two missing samples from something like a failed scrape. If you are already using a long window and still holding for above 15 minutes, the window design is wrong.

Always test the rules. Alert rules are production code, and production code does not ship untested.

# rules/slo_test.yml
rule_files:
  - slo.yml
evaluation_interval: 30s
tests:
  - interval: 30s
    input_series:
      # 95 successes and 5 failures per second = 5% error rate (above the 1.44% threshold)
      - series: 'http_requests_total{job="checkout-api", code="200"}'
        values: '0+2850x240'
      - series: 'http_requests_total{job="checkout-api", code="500"}'
        values: '0+150x240'
    alert_rule_test:
      - eval_time: 70m
        alertname: CheckoutErrorBudgetBurnFast
        exp_alerts:
          - exp_labels:
              severity: page
              slo: checkout-availability
              job: checkout-api
promtool check rules rules/slo.yml
# Checking rules/slo.yml
#   SUCCESS: 7 rules found

promtool test rules rules/slo_test.yml
# Unit Testing:  rules/slo_test.yml
#   SUCCESS

Page, Ticket, Dashboard — Three Tiers and Mandatory Runbooks

Every alert must be one of three things. There is no fourth.

  • Page: a human has to wake up now. User impact is ongoing, it will not self-heal, and human action changes the situation. The target is at most 2 per 12-hour shift.
  • Ticket: it can be handled during business hours. The budget is leaking but slowly. An issue is created automatically and an owner is assigned.
  • Dashboard: do not create an alert. It is a metric you look at while investigating. The urge to attach an alert here is where 300 alerts begin.

Making that third category explicit matters. The compromise of "let us just send it to a Slack channel for now" is the worst option. Alerts grow without limit while belonging nowhere, nobody watches that channel any more, and later when a real signal flows through it, it gets missed.

Enforce runbook links on page alerts. Someone woken at 3AM is in no state to be creative. What they need is a document listing three things to check and two things to do.

# CI gate: break the build if any alert has severity=page but no runbook_url
missing=$(yq -r '
  .groups[].rules[]
  | select(.labels.severity == "page")
  | select(.annotations.runbook_url == null)
  | .alert
' rules/*.yml)

if [ -n "$missing" ]; then
  echo "page alerts with no runbook link:"
  echo "$missing"
  exit 1
fi
echo "every page alert has a runbook attached."

Four things must appear in a runbook. The user impact this alert signifies, the dashboards and queries to check right away, the known causes and the action for each, and the escalation target. A runbook that is only a link with nothing inside is worse than no link at all.

The Procedure for Reviewing and Deleting Alerts

Alerts multiply because there is a procedure for adding them and none for deleting them. You need a regular review that makes deletion the default action.

Once a month, start by pulling out the following data.

# Total firing time per alert (minutes). The evaluation interval is 30s, so multiply the sample count by 0.5.
sort_desc(
  sum by (alertname) (
    count_over_time(ALERTS{alertstate="firing", severity="page"}[30d])
  ) * 0.5
)
# What is firing now, and what is being quietly suppressed
amtool alert query --alertmanager.url=http://alertmanager:9093 --output=extended
amtool silence query --alertmanager.url=http://alertmanager:9093 --expired=false

# If a silence has been in place for months with no expiry, that is an alert that should be deleted

Fill in four things per alert. Firing count over 30 days, the fraction of those that led to an actual action, median time to resolution, and the fraction that fired at night. Then apply the following rules mechanically.

  • A page that has led to no action for three consecutive months gets demoted to a ticket or deleted. To keep it, the reason for keeping it has to be written down.
  • An alert that has been silenced for more than 30 days gets deleted. A silence is the most honest signal that an alert is wrong.
  • A bundle of alerts that always fires together in the same incident gets merged into one or tied together with inhibition rules. A structure that sends 12 pages for one incident obstructs the response.
  • When you add an alert in an incident retrospective, you must either delete one or write down why there is nothing to delete.

Inhibition rules are the cheapest tool for cutting alert count. When an entire cluster goes down, send one alert instead of firing off 40 individual service alerts.

# alertmanager.yml
inhibit_rules:
  - source_matchers: ['severity="page"', 'alertname="ClusterDown"']
    target_matchers: ['severity=~"page|ticket"']
    equal: ['cluster']

route:
  group_by: ['alertname', 'cluster', 'slo']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: 'ticket-queue'
  routes:
    - matchers: ['severity="page"']
      receiver: 'oncall-pager'
      repeat_interval: 1h

What you put in group_by determines the alert count. Group by instance and you get as many alerts as you have pods. Group by service or by SLO and the count becomes something a human can read.

Closing — The Purpose of an Alert Is Action, Not Detection

There is one sentence to remember. The success criterion for an alert is not "did it detect the problem" but "does a human have to do something right now".

An alert that fails that criterion is harmful even when it is accurate. If 400 accurate alerts destroy the reaction speed of your on-call, accuracy means nothing. So the order becomes this. Define SLOs from user symptoms, derive thresholds backwards from the budget, join long and short windows with AND, enforce runbooks on pages, and every month delete the alerts that led to no action.

If there is one thing you can do tomorrow, it is to pull the three page alerts with the longest firing time over the last 30 days and check whether each has ever led to an action. In most teams, those three account for half of all on-call fatigue.

Further reading.