Prometheus 라벨 카디널리티가 왜 죽음인가 — TSDB 내부 관점에서
Prometheus를 운영하다 보면 어느 순간부터 “카디널리티(cardinality) 조심하세요”라는 말이 유령처럼 따라붙는다. 개발자가 라벨 하나 추가했을 뿐인데 다음 날 Prometheus 인스턴스가 OOMKilled로 재기동되고, prometheus_tsdb_head_series가 400만을 찍고 있는 걸 보게 된다. 사실 나도 예전에는 “라벨 값 종류 많아지면 메모리 좀 쓰겠지” 정도로만 생각했다. 근데 TSDB 내부를 좀 파고 들어가 보니 이건 “좀 쓰는” 수준이 아니라, 아예 저장 엔진의 구조 자체가 라벨 조합 하나하나에 비용을 부과하도록 설계돼 있다.
이 글에서는 Prometheus의 로컬 TSDB가 시계열(series)을 어떻게 저장하고 인덱싱하는지, 그래서 왜 user_id, request_id, pod uid 같은 라벨이 인스턴스 하나를 통째로 죽여버리는지를 정리한다. 최근 2026년에 나온 여러 운영 노트들도 결국 같은 얘기를 다른 각도로 하고 있다.
TSDB가 시계열을 저장하는 방식
Prometheus의 TSDB는 크게 두 가지 저장 영역을 가진다. 하나는 메모리에 있는 head block, 다른 하나는 디스크에 있는 persistent block이다. 스크레이프가 들어오면 샘플은 head에 쌓이다가, 기본 2시간 단위로 잘려서 디스크 블록으로 flush 된다.
여기서 “시계열”은 라벨셋 하나 = 시계열 하나다. http_requests_total{method="GET", status="200", handler="/api/users"}와 http_requests_total{method="GET", status="200", handler="/api/orders"}는 완전히 다른 두 개의 시계열이다. TSDB 입장에서 이 둘은 서로 아무 관계가 없다.
각 시계열은 내부적으로 정수 ID(reference)를 부여받는다. 그리고 이 ID를 키로 해서 다음 자료구조들이 만들어진다. 첫째, series ID → 라벨셋 매핑. 둘째, 각 라벨 이름과 값을 series ID들의 posting list로 역인덱싱한 postings 인덱스. 셋째, series ID → 실제 샘플 chunk를 가리키는 포인터. 여기서 문제의 핵심은 두 번째, postings 인덱스다.
Postings 인덱스가 만드는 비용
PromQL에서 http_requests_total{status="500"}를 실행하면, Prometheus는 __name__="http_requests_total"에 해당하는 postings list와 status="500"에 해당하는 postings list를 꺼내서 교집합을 취한다. postings list는 series ID를 정렬해 저장한 정수 배열이다.
문제는 이 postings 인덱스가 “라벨 이름=라벨 값” 쌍마다 하나씩 존재한다는 점이다. user_id="a3f1c..." 처럼 값이 유일하다시피 한 라벨을 붙이면, 각각의 값마다 postings list가 새로 만들어지고, 그 list의 길이는 대부분 1이다. 즉, 실질적으로 postings 인덱스가 시계열 개수만큼 늘어난다.
내부적으로 이 인덱스는 mmap 된 파일에 저장되지만, head block에서는 상당 부분이 메모리에 상주한다. 라벨 값 문자열, series ID 배열, 그리고 각 시계열별 chunk 메타데이터까지 합치면 시계열 하나당 대략 3~5KB 정도의 오버헤드가 든다. 300만 시계열이면 그것만으로 10GB에 육박한다. 실제 샘플 데이터는 아직 계산도 안 했다. 이래서 카디널리티가 “좀 늘어난다”는 표현이 통하지 않는다.
Head block과 WAL의 재생 문제
Prometheus를 재시작해본 사람이라면 알겠지만, 시계열이 많을수록 재시작이 무섭게 느려진다. 왜냐하면 head block은 메모리 위에 있고, 재시작 시 WAL(Write-Ahead Log)을 처음부터 재생해서 head를 복원해야 하기 때문이다.
WAL 재생 과정은 대략 이렇다. WAL 세그먼트를 순차적으로 읽으면서, 각 라벨셋에 대해 hash를 계산하고, stripedLock으로 나뉜 series map에 등록하고, postings 인덱스를 다시 채우고, 각 샘플을 원래 있어야 할 chunk에 밀어넣는다.
시계열 수가 500만이 넘어가면 WAL 재생만 15~20분이 걸리는 상황이 어렵지 않게 나온다. 그 시간 동안은 metric ingestion도 멈춰 있고, alerting도 blind 상태가 된다. 사실 내부적으로 이 재생 자체는 goroutine으로 병렬화돼 있긴 한데, series 등록 부분은 stripe별 락을 잡기 때문에 라벨셋 hash 분포가 편향되면 병렬성이 잘 안 산다. 이걸 처음 겪었을 때 “왜 CPU는 20%밖에 안 쓰는데 이렇게 느리지” 하고 한참 프로파일링 돌렸던 기억이 있다.
Compaction과 블록 크기
head block이 2시간마다 디스크로 flush 되면, 여러 시점의 블록들이 계층적으로 compaction 된다. 여기서도 카디널리티는 직접적으로 비용에 반영된다.
compaction 자체가 “여러 블록의 postings 인덱스를 병합”하는 작업이기 때문이다. 시계열이 많으면 병합해야 할 postings list가 많고, 최종 인덱스 파일도 커진다. 그리고 대부분의 라벨셋은 짧게 살다가 없어진다. 예를 들어 pod uid가 라벨에 들어가면 pod 재기동마다 새로운 시계열이 생기는데, 이건 시간이 지나면 stale이 되지만, 그 라벨을 가진 postings 엔트리는 해당 블록이 삭제되기 전까지 인덱스에 남는다. 이걸 “churn”이라고 부르는데, 근데 churn이 높은 시스템은 실제 활성 시계열보다 훨씬 큰 인덱스를 계속 끌고 다니게 된다.
Prometheus 2.51 이후 몇 차례의 릴리스에서 postings 인덱스 구조 개선과 memory-snapshot on shutdown 기능이 들어와서 예전보단 훨씬 나아졌다. 그런데 이 개선들은 “관리를 잘 하는 시스템”에서의 상수를 낮춰준 거지, 카디널리티 폭발 자체를 막아주진 않는다.
실전에서 뭐가 카디널리티를 죽이는가
경험상 카디널리티 사고는 대부분 몇 가지 패턴을 반복한다.
첫째, Kubernetes 자동 라벨. kube-state-metrics와 cadvisor가 붙여주는 pod uid, container id, image sha 같은 라벨이 그대로 살아 있으면, pod 재기동 한 번에 시계열이 통째로 바뀐다. 이걸 그대로 두고 며칠 지나면 head는 좀 늘어난 정도지만 디스크 블록의 인덱스가 폭탄이 된다.
둘째, 애플리케이션 코드에서 실수로 넣는 unbounded 라벨. request_id, trace_id, user_email 같은 걸 라벨로 붙이는 경우다. “로그 대신 메트릭에 넣으면 대시보드에서 필터링하기 편할 것 같아서” 시작한 실험이 다음 주에 온콜을 깨운다.
셋째, HTTP 상태코드나 라우팅 경로처럼 얼핏 bounded 해 보이는데 실제로는 아닌 경우. /api/users/{id} 같은 경로가 실제 값 그대로 라벨에 들어가 있다면 사용자 수만큼 시계열이 늘어난다. Prometheus 클라이언트 라이브러리가 alert 없이 이런 값을 그대로 받아들이는 게 함정이다.
그래서 어떻게 다뤄야 하는가
내부 동작을 알면 대응 전략이 좀 더 자연스럽게 이해된다. 우선 스크레이프 단에서 잘라내는 게 가장 저렴하다. metric_relabel_configs에서 문제 라벨을 replace 규칙으로 지우거나, 화이트리스트 접근으로 필요한 라벨만 남기는 방식이다. 이렇게 하면 애초에 postings 인덱스가 만들어지지 않는다.
recording rule로 미리 aggregation 해두는 것도 정석이다. sum by (service, method) (rate(http_requests_total[5m])) 같은 걸 recording rule로 만들어 두면, 대시보드와 alert이 원본 대신 aggregated 시계열을 쿼리해서 CPU 부담을 크게 낮춘다. 다만 원본 시계열의 저장 비용은 그대로다. 그 부분은 어차피 별도로 정리해야 한다.
수집 자체를 막고 싶으면 sample_limit를 endpoint 별로 걸어두는 방법이 있다. 잘못된 exporter가 갑자기 100만 시계열을 뿌려도 스크레이프가 통째로 실패하고 metric으로 감지된다. 실무에서 이건 상당히 유용한 안전장치다. 라벨을 실수로 폭발시켰을 때 그게 사고 나기 전에 알람으로 잡힌다.
모니터링 관점에서는 prometheus_tsdb_head_series가 급격히 증가할 때 alert을 걸어두는 게 필수다. 시간당 1000개 이상 늘어나면 뭔가 잘못되고 있다는 신호로 잡는다는 가이드가 최근 여러 곳에서 반복되고 있다. 이건 임계값이라기보단 “적어도 이 정도는 걸어둬라”에 가깝다.
마무리
Prometheus의 카디널리티 문제는 “메모리를 좀 더 준다”로 해결되지 않는 경우가 많다. postings 인덱스, WAL 재생, compaction, 저장 블록 크기 — 이 모든 계층이 라벨셋 개수를 비용 함수로 삼아 돌아가기 때문이다. 그래서 처음 라벨을 설계할 때 “이 라벨 값이 유한한가”를 묻는 습관이 결국 가장 큰 절약이 된다.
우리 팀에서는 최근에 kube-state-metrics 스크레이프 config에 relabel 규칙 대여섯 개를 추가했더니 head series가 20% 정도 줄었다. 극적인 수치는 아니지만, 이건 앞으로 몇 달간 인덱스 churn이 그만큼 덜 쌓인다는 뜻이라 장기적으로 더 크다. 다음 글에서는 Thanos나 Mimir처럼 원격 저장을 도입했을 때 이 계산이 어떻게 달라지는지도 정리해보려고 한다.
혹시 다른 팀에서 카디널리티 사고 겪은 사례 있으면 댓글로 공유해주세요. 사실 이건 겪어봐야 각인이 되는 종류라서.