← all posts
DEV 2026.05.02 · 16 min read Intermediate

Prometheus는 왜 Pull 방식인가

Pull 스크레이프가 서비스 생존 감지를 내장하는 원리부터 카디널리티 폭발, Gorilla 압축, PromQL 계산 원리까지 — Prometheus 설계 철학을 추적한다.


Prometheus는 “내가 서비스에서 데이터를 가져온다”는 Pull 모델을 선택했다. 대부분의 모니터링 도구가 “서비스가 데이터를 밀어 넣는” Push 방식을 쓰는데, 왜 반대 방향을 택했는가? 그리고 이 선택 하나가 TSDB 구조, 압축 알고리즘, PromQL 설계, Kubernetes 연동 방식까지 어떻게 관통하는가?

Pull 모델 — 서비스 생존 감지가 공짜로 따라온다

Pull의 핵심은 단순하다. Prometheus가 15초마다 GET http://service:8080/metrics를 날린다. 응답이 오면 up=1, 안 오면 up=0. 별도의 헬스체크 로직이 필요 없다. 서비스가 죽는 순간 스크레이프가 실패하고, 그 실패 자체가 알림의 트리거가 된다.

Push 방식에서는 이게 공짜가 아니다. 서비스가 죽으면 Push가 멈추지만, 수집기 입장에서 “Push가 안 온 것”과 “서비스가 죽은 것”을 구분하려면 별도 heartbeat 메커니즘이 필요하다. Pushgateway를 상시 서비스에 쓰면 안 되는 이유가 여기 있다. 서비스가 죽어도 Pushgateway에 마지막 값이 남아 up=1처럼 보인다 — 좀비 메트릭이다.

트레이드오프

Pull은 Prometheus가 서비스에 직접 접근할 수 있어야 한다. 방화벽 뒤 서비스, IP가 변동하는 이동 장치, 실행 후 즉시 종료하는 배치 잡은 Pull이 어렵다. 이때만 Pushgateway(배치 잡 전용) 또는 OTel Collector 브리지를 사용한다. Pushgateway는 배치 잡의 마지막 실행 결과를 보존하는 용도이지, 상시 서비스의 Push 채널이 아니다.

데이터 모델 — 레이블이 곧 시계열이다

Prometheus의 모든 데이터는 시계열(Time Series) 로 저장된다. 시계열은 레이블 집합으로 유일하게 식별된다.

{__name__="http_requests_total", method="GET", status="200", job="order-service", instance="10.0.1.1:8080"}

이 레이블 집합을 XXHash로 해시하면 64비트 시계열 ID가 나온다. 레이블 순서는 무관하다 — 집합이기 때문이다.

문제는 레이블 값의 종류 수, 즉 카디널리티다. 총 시계열 수는 메트릭 수와 모든 레이블 카디널리티의 곱이다. method(5) × status(3) × service(10) = 150 시계열은 안전하다. 여기에 user_id(100만)를 레이블로 추가하는 순간 1억 5천만 시계열이 된다. 시계열 하나당 메모리 약 2~5KB — Prometheus가 OOM으로 죽는다.

user_id, order_id, request_id는 레이블이 아니라 Trace Span attribute다. 역할을 헷갈리면 TSDB가 죽는다.

메트릭 4가지 타입의 선택 기준도 명확하다. “지금까지 몇 건?”이면 Counter, “지금 몇 건?”이면 Gauge다. Counter는 절대 감소하지 않고 재시작 시 0으로 초기화된다. rate()가 이 초기화를 자동으로 처리한다. Gauge에 rate()를 쓰면 틀린 값이 나온다.

TSDB 구조 — WAL이 먼저, 메모리가 두 번째

Prometheus가 샘플을 받으면 두 곳에 동시에 쓴다. 먼저 WAL(Write-Ahead Log) 에 순차적으로 기록하고, 그 다음 메모리의 Head Block에 추가한다. Head Block은 최근 2시간치 데이터를 메모리에 유지한다. 크래시가 나면 WAL을 재생해 Head Block을 복구한다 — 최대 손실은 마지막 fsync 이후 2초치다.

2시간이 쌓이면 Head Block을 디스크에 플러시해 불변(immutable) Block으로 만든다. 이 Block들이 쌓이면 Compaction이 작은 Block들을 큰 Block으로 병합한다: 2시간 → 6시간 → 18시간 순서로. Compaction이 쿼리 성능을 높이는 이유는 간단하다 — 열어야 할 Block 수가 줄어들기 때문이다.

/prometheus/data/
  ├── wal/              ← 쓰기 지속성 (크래시 복구용)
  ├── chunks_head/      ← Head Block (메모리, 최근 2시간)
  └── 01BKGV7J.../     ← 완성된 Block (불변, 디스크)
      ├── chunks/       ← Gorilla 압축된 샘플
      ├── index         ← 레이블 → 시계열 역인덱스
      └── meta.json     ← 시간 범위, 통계

--storage.tsdb.retention.time=30d로 설정해도 만료 데이터가 즉시 삭제되지 않는다. Block 단위로 삭제하기 때문에 Block의 maxTime이 retention 기준을 넘어서야 다음 Compaction 때 삭제된다. 실제로는 최대 1일 초과해 유지될 수 있다.

Gorilla 압축 — 타임스탬프는 1비트, 값은 XOR

Prometheus는 원시 데이터의 1/11 수준 디스크만 쓴다. Facebook의 Gorilla 논문에서 가져온 알고리즘 덕분이다.

타임스탬프 압축: 15초 간격 스크레이프에서 연속 타임스탬프의 2차 차분(Delta-of-Delta)은 대부분 0이다. Gorilla 인코딩에서 0은 1비트로 표현된다. 120개 샘플(30분)의 타임스탬프를 원시로 저장하면 960 bytes지만, Delta-of-Delta 후에는 약 25 bytes다.

값 압축(XOR): 연속된 float64 값이 비슷할수록 XOR 결과에 0이 많아진다. Counter처럼 균일하게 증가하는 값은 샘플당 약 5비트만 쓴다. 무작위 float64는 55비트까지 올라가 압축 효과가 없다.

실측 압축률1.37 bytes/샘플(원시 16 bytes 대비 11.7)\text{실측 압축률} \approx 1.37 \text{ bytes/샘플} \quad (\text{원시 16 bytes 대비 } {\approx}11.7\text{배})

시계열 100만 개, 15초 간격, 30일 보존 기준으로 압축 후 약 240GB다. 압축률을 무시하고 계산하면 2.76TB가 나온다 — 용량 계획에서 흔히 틀리는 지점이다.

PromQL — rate()와 histogram_quantile()의 계산

rate(http_requests_total[5m])는 5분 범위의 첫 샘플과 마지막 샘플 사이의 증가량을 경과 시간으로 나눈다. Counter 재시작(값이 갑자기 감소)은 자동으로 보정한다. irate()는 마지막 두 샘플만 쓰기 때문에 반응이 빠르지만 불안정하다 — 알림 규칙에는 rate(), 순간 디버깅에는 irate()를 쓴다.

histogram_quantile(0.99, rate(x_bucket[5m]))에서 빠뜨리면 안 되는 게 rate()다. 이게 없으면 “지금까지 전체” 기간의 누적 분포에서 p99를 계산한다 — 최근 상황이 반영되지 않는다. 버킷 사이는 선형 보간으로 근사하기 때문에 버킷 경계가 실제 응답 시간 분포와 맞지 않으면 부정확해진다. 서비스 응답 시간이 50~200ms 범위라면 버킷도 그 범위에 집중해야 한다.

반복적으로 쓰는 복잡한 쿼리는 Recording Rule로 미리 계산해 저장한다. histogram_quantile 같은 비용 큰 집계는 30초마다 결과를 새 시계열로 기록해 두면 대시보드 로드가 1/10 이하로 빨라진다.

Kubernetes SD — 어노테이션이 스크레이프를 켠다

Kubernetes 환경에서 Pod IP는 재시작마다 바뀐다. Static 설정으로 IP를 직접 관리하면 배포마다 Prometheus 설정을 수정해야 한다. Kubernetes SD는 API Server를 Watch해 Pod/Service/Endpoints 변경을 실시간으로 추적한다.

SD가 발견한 모든 Pod를 스크레이프하면 안 된다. relabeling으로 필터링해야 한다. 실무 패턴은 어노테이션 기반 opt-in이다:

# Pod 어노테이션으로 스크레이프 선언
annotations:
  prometheus.io/scrape: "true"
  prometheus.io/port: "8080"
  prometheus.io/path: "/actuator/prometheus"
# relabeling: 어노테이션 없는 Pod 제외
relabel_configs:
  - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
    action: keep
    regex: 'true'

Prometheus Operator를 쓰면 ServiceMonitor CRD로 관리한다. 개발팀이 서비스와 함께 ServiceMonitor를 배포하면 Operator가 Prometheus 설정을 자동으로 갱신한다 — Prometheus 설정 파일을 직접 수정할 필요가 없어진다.

정리

  • Pull 모델은 스크레이프 실패를 서비스 생존 감지로 자동 전환한다. Pushgateway는 배치 잡 전용이다.
  • 카디널리티는 레이블 카디널리티의 곱으로 계산된다. user_id는 레이블이 아니라 Trace attribute다.
  • TSDB는 WAL → Head Block(메모리) → Block(디스크) → Compaction 순으로 데이터를 이동시킨다.
  • Gorilla 압축은 균일 간격 타임스탬프와 완만한 값 변화를 활용해 평균 11.7배 압축한다.
  • rate()는 Counter 전용, histogram_quantile()에는 반드시 rate()를 먼저 적용한다.
  • Kubernetes에서는 어노테이션 기반 opt-in + ServiceMonitor CRD로 스크레이프를 자율적으로 관리한다.

다음 글에서는 Prometheus가 처리하기 어려운 로그 데이터를 구조화하는 방법, 그리고 Loki가 레이블 기반 인덱싱으로 어떻게 로그를 효율적으로 저장하는지 추적한다.