- 들어가며 — 어젯밤 울린 페이지 중 몇 개가 조치로 이어졌는가
- 알림 피로가 진짜 사고를 놓치게 만드는 메커니즘
- 증상에 페이지를 걸고 원인은 대시보드에 둔다
- SLO와 에러 버짓 — 임계값을 방어할 수 있게 정하는 법
- 다중 윈도우 번 레이트 알림 — 민감도와 오탐을 동시에 잡기
- 페이지, 티켓, 대시보드 — 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일간 발화 시간이 가장 긴 페이지 알림 세 개를 뽑아, 각각이 조치로 이어진 적이 있는지 확인하는 것입니다. 대부분의 팀에서 그 세 개가 온콜 피로의 절반을 차지합니다.
더 파고들 자료입니다.
현재 단락 (1/189)
이 질문에 즉시 답할 수 없다면 알림 시스템이 이미 고장 난 상태입니다. 답이 "거의 없다"라면 고장은 심각합니다.