IT/모니터링

새벽에 Prometheus가 죽었다 — 카디널리티 폭발 삽질기

gfrog 2026. 7. 1. 12:16
SMALL

지난주 화요일 새벽 2시 반쯤이었다. 온콜 알림이 왔고, Prometheus 인스턴스 두 대가 연달아 OOM으로 죽었다는 내용이었다. 눈을 비비고 노트북을 열었는데, Grafana는 이미 죽어 있었다. 데이터 소스가 죽었으니 당연한 일이었다. 그 다음 두 시간 반 동안 내가 겪은 얘기다.

처음엔 그냥 재시작하면 될 줄 알았다

당시 우리 스택은 이랬다. 노드 24대 EKS 클러스터, kube-prometheus-stack으로 배포한 Prometheus 두 대 HA 구성, 메모리 리소스 리밋은 각각 16GB. 평상시 series 개수는 대략 480만 정도. 리텐션 15일. remote_write로 장기 저장소에 밀어넣고 있었다.

일단 재시작. WAL replay가 시작됐는데, 이게 도무지 끝나지가 않았다. 평소엔 3~4분이면 끝나던 replay가 20분이 지나도 진행률 표시가 안 나왔다. 로그를 보니 head chunks 로딩에서 계속 메모리를 먹더니 또 OOM. 이게 반복됐다.

새벽 3시 15분쯤, 이건 그냥 재시작으로 해결될 게 아니란 걸 깨달았다.

진범을 찾아서

메모리 리밋을 임시로 32GB로 올려서 겨우 기동시켰다. 그리고 /api/v1/status/tsdb 엔드포인트를 두들겨봤다. 이게 죽기 전에는 되게 유용한데 죽고 나서는 죽어서 못 쓴다는 게 함정이다. 어쨌든 살아난 상태에서 확인해보니 series 개수가 4,800만이었다. 평소의 10배.

topByCardinality를 뽑아봤다.

# TopK metric names by series
http_request_duration_seconds_bucket   38,240,113
http_request_size_bytes_bucket            412,003
...

http_request_duration_seconds_bucket 하나가 3,800만 series를 잡아먹고 있었다. 라벨을 까봤다.

{
  method="POST",
  path="/api/v1/orders/...",
  status="200",
  le="0.005",
  user_id="u_8f2a3b1c...",
  ...
}

user_id. 이게 왜 여기 있냐.

원인은 사흘 전의 배포였다

git log를 뒤졌다. 사흘 전 화요일에 백엔드 팀에서 결제 API에 관측성 개선을 추가한 PR이 있었다. 그 PR에서 RED metrics를 좀 더 상세히 뽑겠다며 histogram에 user_id를 라벨로 추가했다. PR 리뷰에는 "관측성 강화 👍"라고 승인 코멘트가 달려 있었다.

user_id는 카디널리티가 폭발하는 대표적인 라벨이다. 이건 실무자면 다 아는 얘기고 Prometheus 공식 문서에도 굵은 글씨로 경고가 되어 있다. 그런데 왜 통과됐을까? 리뷰어도 관측성 팀도 이걸 못 본 이유가 있었다.

첫째, 우리 회사에서 결제 API는 트래픽이 그렇게 많지 않았다. 하루 활성 사용자가 수천 명 수준이라 리뷰어 머릿속에서 "user_id 라벨 = 수천 개 시리즈" 정도로 계산됐던 것 같다. 근데 histogram이라는 걸 놓쳤다. bucket 개수가 15개, method 3개, status 5개, path 20개 정도. 사용자 5,000명이라 치면 5,000 × 15 × 3 × 5 × 20 = 2,250만. 그리고 이게 매 요청마다 새로운 사용자로 늘어난다. 15일 리텐션 안에서 계속 축적됐다.

둘째, 우리에겐 카디널리티 리미트가 없었다. Prometheus scrape 설정에 sample_limit은 있었지만 label_limit이나 series_limit_per_metric은 명시적으로 안 걸어놨다.

응급 처치

일단 살려야 했다. 세 가지를 순서대로 했다.

먼저 문제 metric을 scrape 단계에서 잘라냈다. Prometheus 설정에 metric_relabel_configs로 임시 필터를 추가했다.

metric_relabel_configs:
  - source_labels: [__name__, user_id]
    regex: 'http_request_duration_seconds_bucket;.+'
    action: drop

이렇게 하면 user_id가 있는 해당 metric의 series는 아예 저장 안 된다. 재시작하니까 새 series는 안 만들어졌다. 하지만 기존에 쌓인 4천만 series는 그대로 남아 있어서 여전히 메모리를 잡아먹었다.

두 번째, WAL과 head chunks를 정리해야 했다. curl -X POST http://prom:9090/api/v1/admin/tsdb/clean_tombstones로 툼스톤 정리하고, --storage.tsdb.retention.time=3d로 임시로 리텐션을 줄여서 재기동. 이러니까 head가 3일치만 유지되면서 메모리가 확 내려갔다.

세 번째, 백엔드 팀에 연락해서 다음 배포에서 그 라벨을 빼달라고 요청했다.

새벽 5시쯤 안정화됐다. 커피가 필요했다.

재발 방지로 걸어놓은 것들

이 사건 이후에 몇 가지를 걸어놨다.

Prometheus 설정에 글로벌 리미트를 추가했다.

scrape_configs:
  - job_name: 'kubernetes-pods'
    sample_limit: 50000
    label_limit: 30
    label_name_length_limit: 200
    label_value_length_limit: 200

sample_limit은 한 번의 scrape에서 받아들일 sample 개수 상한이다. 이걸 넘으면 그 scrape 자체가 실패한다. 즉 어떤 앱이 갑자기 카디널리티를 뿌려대도 저장소를 죽이진 못한다. 실패한 scrape는 up=0으로 잡히니까 알림이 뜬다.

그리고 라벨 카디널리티 감시 알림을 걸었다.

- alert: HighCardinalityMetric
  expr: |
    topk(5, count by (__name__)({__name__=~".+"})) > 500000
  for: 10m
  labels:
    severity: warning
  annotations:
    summary: "Metric {{ $labels.__name__ }} has {{ $value }} series"

이건 특정 metric의 series 수가 50만을 넘으면 경고를 준다. 임계값은 서비스마다 다를 텐데, 우리는 대충 이 정도로 잡았다.

마지막으로 PR 리뷰 체크리스트에 항목을 추가했다. "새로 추가하는 metric에 user_id, request_id, session_id, email, ip 같은 unbounded 라벨이 없는가?" 이거 하나만 물어봐도 대부분의 사고는 막을 수 있다.

곁다리로 배운 것들

이번 일을 겪으면서 VictoriaMetrics로 갈아탈까 하는 얘기가 팀 안에서 나왔다. VictoriaMetrics는 카디널리티 처리에 훨씬 강하고, 최근 v1.139 계열이 나오면서 프로덕션 하드닝도 많이 됐다. 실제로 고카디널리티 상황에서 VictoriaMetrics가 Prometheus보다 메모리를 훨씬 덜 먹는다는 벤치마크가 여러 개 있다.

Grafana Mimir도 옵션이다. 이쪽은 멀티테넌트에 per-tenant 카디널리티 리미트를 distributor 레이어에서 강제할 수 있어서, 팀별로 예산을 나눠주는 조직이면 매력적이다.

근데 아직 결정 못했다. 저장소를 바꾸는 건 큰 결정이고, 이번 사건은 저장소가 문제가 아니라 라벨링 컨벤션이 없었던 게 문제였다는 게 팀 결론이었다. 저장소를 바꿔도 카디널리티 리미트를 안 걸면 똑같이 터진다.

그래서 결론은

새벽 2시 반에 알림 받고 싶지 않으면 sample_limit이랑 label_limit을 걸어두자. 별 거 아닌 두 줄이다. 그리고 metric에 user_id 넣지 말자. 진짜로.

혹시 비슷한 사고 겪으신 분 있으면 어떻게 해결하셨는지 궁금하다. 우리는 아직 카디널리티 감시 알림의 임계값을 어떻게 튜닝할지 감을 잡는 중이다.

BIG