Prometheus는 16바이트 샘플을 어떻게 평균 1.37바이트로 저장하는가

seonwoo_jung·2026년 7월 22일

1. 왜 이걸 파고들었나

메트릭이 초당 수만 개씩 쏟아지는데, Prometheus 한 노드가 이걸 다 받아서 몇 주씩 보관한다. 처음엔 막연히 "샘플 하나당 timestamp 8B + value 8B = 16B씩 쌓겠지" 생각했다. 그런데 그렇게 저장한다면 활성 시리즈 수십만 개짜리 환경에서 디스크가 진작 터졌어야 한다.

실제로 Gorilla 논문(Pelkonen et al., VLDB 2015)은 실운영 데이터에서 샘플 하나가 평균 약 1.37바이트까지 줄어든다고 보고한다. 16바이트가 1.37바이트면 대략 12배다. 이 숫자가 어떤 자료구조에서 나오는지, 그리고 압축이 정확히 언제 일어나는지 Prometheus tsdb 소스와 공식 Storage 문서를 따라가며 정리했다.

2. 저장의 최소 단위: 시리즈와 청크

하나의 시리즈(series) 는 라벨 집합으로 유일하게 식별된다. 메트릭 이름조차 __name__ 라벨일 뿐이다. 즉 up{job="api"}{__name__="up", job="api"} 라는 라벨 집합이고, 이 시리즈는 (timestamp int64 ms, value float64) 샘플의 시퀀스를 갖는다.

실제 저장 단위는 시리즈별 청크(chunk) 다. 기본 XORChunk 하나는 최대 120 샘플을 담거나 청크 시간 범위를 넘으면 잘린다(cut). 이 "120 샘플에서 자른다"는 규칙이 뒤에서 메모리를 억제하는 핵심 장치가 된다.

3. 쓰기 경로 — 압축은 디스크가 아니라 메모리에서 일어난다

가장 크게 오해했던 부분이다. 스크레이프된 샘플이 들어오면 경로는 둘로 갈라진다.

scrape → Appender
   │
   ├─(1) WAL append  ── wal/ 세그먼트, record: series/samples/tombstones/exemplars
   │        · 비압축, 순차 write. 크래시 복구 전용.
   │
   └─(2) Head 메모리 청크에 append (Gorilla 압축 인코딩)
            · headChunk 가 꽉 참(120샘플/범위) → chunks_head/ 로 flush 후 mmap
            · append 중인 활성 청크만 힙에 상주, 나머지는 mmap 참조만 유지

포인트는 디스크로 먼저 내려가는 건 비압축 WAL이고, Gorilla 압축은 메모리 Head 청크에 append하는 시점에 일어난다는 것이다. WAL은 순차 append라 빠르고 오직 내구성(크래시 복구)을 위한 로그다. 진짜 저장 표현은 압축된 in-memory 청크다.

압축 시점(메모리 append)과 디스크 내구성 시점(WAL write)은 완전히 분리돼 있다.

청크를 120 샘플에서 자르고 mmap으로 넘기는 이유는 힙 관리다. append 중인 활성 청크만 GC가 훑는 힙에 두고, 꽉 찬 청크는 chunks_head/ 파일로 밀어 OS 페이지 캐시에 맡긴다. 그러면 활성 시리즈가 수십만 개여도 상주 힙이 "시리즈 헤더 + 마지막 청크" 수준으로 억제된다.

4. Gorilla 압축: 1.37바이트가 나오는 곳

chunkenc/xor.go가 하는 일은 두 축의 압축이다.

타임스탬프 — delta-of-delta. 스크레이프 간격은 거의 일정하다(예: 15s). 그래서 DoD = (tₙ − tₙ₋₁) − (tₙ₋₁ − tₙ₋₂) 는 대부분 0이다. Prometheus 구현은 제어비트로 DoD 크기를 분기한다.

조건제어비트데이터 비트
DoD == 000
∈ 14비트 범위1014
∈ 17비트 범위11017
∈ 20비트 범위111020
그 외111164

간격이 흔들리지 않으면 타임스탬프 하나가 단 1비트로 줄어든다. (첫 샘플은 raw, 둘째는 delta 그대로 저장한다.)

값 — 이전 값과 XOR. 값이 그대로면 XOR == 0 이라 0 한 비트다. 다르면 1 뒤에 다음 분기가 붙는다.

  • 이번 XOR의 leading/trailing 0 개수가 직전 "의미 블록" 창 안에 들어가면 → 10 + 이전 창 재사용, 의미 비트만 기록.
  • 벗어나면 → 11 + leading-zero 5비트 + 의미블록 길이 6비트 + 의미 비트.

게이지처럼 조금씩 변하는 float은 XOR 결과의 상·하위 비트가 0으로 몰려서, 실제 기록되는 의미 비트가 얼마 안 된다. 이 두 축이 합쳐져 논문의 평균 1.37바이트/샘플이 나온다.

5. 직접 따라가 본 인코딩

xor.go append 로직을, 15s(=15000ms) 간격에 값 고정 42.0으로 손으로 따라가면 이렇게 수렴한다.

t0=1000,  v0=42.0 → t0 raw(varint) + v0 raw(64bit)          (첫 샘플, 원본)
t1=16000, v1=42.0 → delta=15000 기록 + (v XOR==0 → '0' 1비트)
t2=31000, v2=42.0 → DoD=(31000-16000)-(16000-1000)=0 → '0' 1비트
                    + 값 XOR==0 → '0' 1비트   ⇒ 이 샘플 총 2비트

카운터/게이지가 일정 간격으로 같은(혹은 규칙적으로 증가하는) 값을 뱉으면 샘플당 몇 비트로 수렴한다. 반대로 값이 매 스크레이프 크게 요동치면 XOR 의미 비트가 커져 압축률이 뚝 떨어진다. "고카디널리티·고엔트로피가 저장을 키운다"는 통념이 여기서 정량적으로 드러난다.

6. 재시작·복구와 불변 블록

재시작. 크래시/셧다운 후 기동 시 chunks_head/의 mmap 청크를 참조로 되살린 뒤 WAL을 리플레이한다. 단, 이미 mmap 청크에 반영된 구간은 건너뛰고 그 이후 샘플만 메모리로 올린다. WAL이 무한정 자라지 않도록 checkpoint로 잘린다 — 오래된 세그먼트를 순회하며 아직 필요한 series/샘플 레코드만 새 checkpoint로 옮기고 원본을 삭제한다.

Compaction. Head 청크 범위(기본 2h)를 벗어난 데이터는 백그라운드로 디스크의 영구 블록으로 잘려 나가고, 그만큼 Head와 WAL이 truncate된다. 이후 인접 블록들은 더 큰 블록으로 병합된다. 공식 문서에 따르면 블록 크기는 보존 기간의 10% 또는 31일 중 작은 값까지 커진다. 블록 하나의 구조는 이렇다.

01ABC.../
  ├─ chunks/000001   압축된 청크 바이트 (세그먼트 파일)
  ├─ index           역색인(postings) + 심볼테이블 + 시리즈→청크 위치
  ├─ meta.json       min/maxTime, 샘플/시리즈 수, compaction level
  └─ tombstones      삭제 마커

블록은 불변(immutable) 이다. 그래서 delete API는 데이터를 즉시 지우지 않고 tombstone에 (시리즈, 시간범위)만 기록한다. 실제 물리 삭제는 다음 compaction이 블록을 재기록할 때 반영된다.

7. 역색인으로 쿼리 풀기

index 파일은 라벨 매칭을 위한 inverted index다. label=value → 그 라벨을 가진 시리즈 ID의 오름차순 리스트(postings list) 를 담는다. 모든 라벨 문자열은 심볼 테이블에 중복 제거되어 offset으로만 참조된다(index 크기 절감).

up{job="api"} 쿼리는 __name__="up" postings와 job="api" postings를 정렬 리스트 교집합으로 좁힌 뒤, 각 시리즈 레코드의 청크 위치(min/maxTime + chunks 파일 offset)로 실제 데이터를 읽는다. postings가 오름차순이라 교집합이 두 커서를 앞으로만 밀며 진행하는 선형 merge(또는 큰 쪽을 건너뛰는 galloping)로 끝나는 게 포인트다. 정규식 =~ 은 해당 라벨의 값들을 훑어 postings를 합집합한다.

8. 정리

핵심 한 줄: Prometheus TSDB는 "① WAL 비압축 append로 내구성 → ② 메모리 Head 청크에 Gorilla 압축 → ③ 청크가 차면 mmap → ④ 2시간 경과분을 불변 블록으로 compaction" 하는, 쓰기 경로와 압축 시점이 분리된 append-only 엔진이다.

파고들며 바로잡은 오해 세 가지:

  • "샘플을 받자마자 디스크에 압축 저장한다" → 디스크로 먼저 가는 건 비압축 WAL이고, 압축은 메모리 append 시점이다.
  • "샘플 하나는 16바이트다" → 원시 표현일 뿐, delta-of-delta + XOR로 평균 1~2바이트까지 줄어든다.
  • "delete 하면 바로 사라진다" → 블록이 불변이라 tombstone 마커만 남고, 실제 제거는 다음 compaction에서 이뤄진다.

다음에 더 파볼 만한 것: Head append의 isolation(append id 기반 MVCC 유사 메커니즘), index v2 포맷의 postings offset table, 그리고 Thanos·Mimir가 이 블록 포맷을 오브젝트 스토리지로 확장하는 방식.

참고 자료

  • Prometheus Docs — Storage (on-disk layout, block/WAL 설명)
  • prometheus/prometheus 소스: tsdb/chunkenc/xor.go, tsdb/head.go, tsdb/wlog/, tsdb/index/
  • Pelkonen et al., "Gorilla: A Fast, Scalable, In-Memory Time Series Database", VLDB 2015
  • Fabian Reinartz, "Writing a Time Series Database from Scratch" (2017)

0개의 댓글