Skip to content
Published on

Prometheus TSDB 내부 구조: WAL, Chunks, Blocks, Compaction

Authors

1. 개요

Prometheus의 TSDB(Time Series Database)는 시계열 데이터에 최적화된 로컬 스토리지 엔진입니다. Facebook의 Gorilla 논문에서 영감을 받은 압축 기법과 LSM-tree에서 영감을 받은 블록 기반 구조를 결합하여 높은 쓰기 성능과 효율적인 스토리지를 제공합니다.

이 글에서는 TSDB의 디렉토리 구조부터 WAL, Head Block, 영속 블록, 컴팩션까지 전체 스토리지 계층을 분석합니다.

2. TSDB 디렉토리 구조

Prometheus의 데이터 디렉토리는 다음과 같은 구조를 갖습니다:

data/
  |-- wal/
  |     |-- 00000001
  |     |-- 00000002
  |     +-- 00000003
  |
  |-- chunks_head/
  |     |-- 000001
  |     +-- 000002
  |
  |-- 01BKGV7JBM69T2G1BGBGM6KB12/   (Block ULID)
  |     |-- meta.json
  |     |-- index
  |     |-- chunks/
  |     |     |-- 000001
  |     |     +-- 000002
  |     +-- tombstones
  |
  |-- 01BKGTZQ1SYQJTR4PB43C8PD98/   (Block ULID)
  |     |-- meta.json
  |     |-- index
  |     |-- chunks/
  |     +-- tombstones
  |
  |-- lock
  +-- queries.active

각 디렉토리와 파일의 역할:

  • wal/: Write-Ahead Log 세그먼트 파일들
  • chunks_head/: Head Block의 메모리 매핑된 청크 파일들
  • ULID 디렉토리/: 각 영속 블록 (ULID는 시간 기반 고유 ID)
  • lock: 프로세스 단독 접근 보장용 잠금 파일
  • queries.active: 현재 활성 쿼리 추적

3. Write-Ahead Log (WAL)

3.1 WAL의 목적

WAL은 데이터 내구성을 보장하는 핵심 메커니즘입니다. 모든 수신 데이터는 먼저 WAL에 기록된 후 메모리(Head Block)에 적용됩니다. Prometheus가 비정상 종료되면 WAL을 리플레이하여 Head Block을 복구합니다.

3.2 세그먼트 파일 구조

WAL은 고정 크기(기본 128MB)의 세그먼트 파일로 구성됩니다:

세그먼트 파일 내부 구조:
+----------+----------+----------+-----+
| Record 1 | Record 2 | Record 3 | ... |
+----------+----------+----------+-----+

각 Record:
+--------+--------+---------+------+
| Type   | Length | CRC32   | Data |
| 1 byte | varint | 4 bytes | ...  |
+--------+--------+---------+------+

3.3 레코드 타입

WAL에는 4가지 주요 레코드 타입이 있습니다:

Series Record (타입 1): 새로운 시계열 등록

Series Record:
+----------+-------------------+
| Series ID| Labels (name/value pairs) |
+----------+-------------------+

새로운 시계열이 처음 발견되면 Series Record가 기록됩니다. Series ID는 Head Block 내에서 고유한 참조 번호입니다.

Samples Record (타입 2): 샘플 데이터

Samples Record:
+----------+-----------+-------+
| Series ID| Timestamp | Value |
+----------+-----------+-------+

스크래핑된 각 샘플은 해당하는 Series ID와 함께 기록됩니다.

Tombstones Record (타입 3): 삭제 마킹

시계열 데이터의 특정 시간 범위 삭제를 요청할 때 기록됩니다. 실제 삭제는 컴팩션 시점에 수행됩니다.

Exemplars Record (타입 4): Exemplar 데이터

트레이스 ID 등 추가 메타데이터가 포함된 Exemplar 샘플입니다.

3.4 WAL 관리

  • 세그먼트 순환: 세그먼트가 128MB에 도달하면 새 세그먼트가 생성됩니다
  • 세그먼트 정리: Head Block이 컴팩트되어 영속 블록이 생성되면, 해당 시간 범위의 WAL 세그먼트는 삭제됩니다
  • 체크포인트: WAL의 빠른 리플레이를 위해 주기적으로 체크포인트가 생성됩니다. 체크포인트에는 활성 시계열의 최신 상태만 포함됩니다
WAL 체크포인트 프로세스:
1. 현재 활성 시계열 목록 수집
2. 체크포인트 디렉토리 생성 (checkpoint.NNNNN)
3. 활성 시계열의 Series Record 기록
4. 이전 체크포인트 및 해당 WAL 세그먼트 삭제

3.5 WAL 압축

--storage.tsdb.wal-compression 플래그로 WAL 압축을 활성화할 수 있습니다. Snappy 압축을 사용하며, 일반적으로 WAL 크기를 절반 정도로 줄입니다. CPU 오버헤드는 미미합니다.

4. Head Block

4.1 Head Block 구조

Head Block은 TSDB에서 가장 최근의 데이터를 보관하는 인메모리 구조입니다:

Head Block:
+------------------------------------------+
| memSeries Map                             |
|   series_id_1 -> memSeries_1             |
|   series_id_2 -> memSeries_2             |
|   ...                                     |
+------------------------------------------+
| Posting Lists (in-memory index)          |
+------------------------------------------+
| Stripe Lock Pool                         |
+------------------------------------------+

4.2 memSeries 구조

각 시계열은 memSeries 구조체로 표현됩니다:

memSeries:
+------------+
| ref (ID)   |
| labels     |
| chunks     | --> [chunk_0] -> [chunk_1] -> [chunk_current]
| headChunk  | --> 현재 활성 청크 (쓰기 가능)
| firstTs    |
| lastTs     |
| lastValue  |
+------------+

주요 필드:

  • ref: 시계열의 고유 참조 번호
  • labels: 메트릭 이름과 레이블의 정렬된 목록
  • chunks: 완성된 청크들의 연결 리스트
  • headChunk: 현재 데이터가 추가되고 있는 활성 청크

4.3 청크 인코딩

Prometheus는 Gorilla 논문의 압축 기법을 사용합니다:

타임스탬프 인코딩 (Delta-of-Delta):

Sample 1: t1 (원본 값 저장)
Sample 2: d1 = t2 - t1 (첫 번째 delta)
Sample 3: dd = (t3 - t2) - (t2 - t1) (delta-of-delta)

스크래핑이 규칙적이면 dd는 대부분 0에 가까움
-> 매우 적은 비트로 표현 가능

비트 인코딩:
dd == 0: 1 bit ('0')
-63 <= dd <= 64: 2 + 7 = 9 bits
-255 <= dd <= 256: 2 + 9 = 11 bits
-2047 <= dd <= 2048: 2 + 12 = 14 bits
그 외: 4 + 32 = 36 bits

값 인코딩 (XOR):

Sample 1: v1 (원본 float64 저장, 64 bits)
Sample 2: xor = v2 XOR v1
Sample 3: xor = v3 XOR v2

연속된 값이 유사하면 XOR 결과의 대부분이 0
-> leading zeros와 trailing zeros를 이용한 압축

비트 인코딩:
xor == 0: 1 bit ('0')
leading/trailing이 이전과 같으면: 2 + significant bits
그 외: 2 + 5(leading) + 6(significant_length) + significant bits

이 압축 방식으로 샘플당 평균 1.37바이트라는 놀라운 압축률을 달성합니다.

4.4 청크 수명주기

1. 새 샘플 도착
2. headChunk에 추가
3. headChunk가 120개 샘플 또는 chunkRange(2시간)에 도달
4. headChunk 완성 처리 (immutable)
5. chunks_head/ 디렉토리에 mmap 파일로 기록
6. 새 headChunk 생성
7. 이전 청크는 mmap으로 접근 (메모리에서 해제 가능)

4.5 Memory Mapped Chunks

Prometheus 2.19부터 Head Block의 완성된 청크는 chunks_head/ 디렉토리에 mmap 파일로 기록됩니다. 이를 통해:

  • 메모리 사용량을 크게 줄일 수 있습니다 (OS가 페이지 캐시로 관리)
  • 크래시 복구 시 WAL 리플레이 시간이 단축됩니다
  • 청크 데이터는 필요할 때만 메모리에 로드됩니다

5. 영속 블록 구조

5.1 블록 개요

Head Block의 데이터가 일정 시간(기본 2시간)이 지나면 영속 블록으로 컴팩트됩니다:

Block Directory:
01BKGV7JBM69T2G1BGBGM6KB12/
  |-- meta.json      (블록 메타데이터)
  |-- index          (시계열 인덱스)
  |-- chunks/
  |     |-- 000001   (청크 데이터)
  |     +-- 000002
  +-- tombstones      (삭제 마킹)

5.2 meta.json

블록의 메타데이터를 포함합니다:

{
  "ulid": "01BKGV7JBM69T2G1BGBGM6KB12",
  "minTime": 1602547200000,
  "maxTime": 1602554400000,
  "stats": {
    "numSamples": 1234567,
    "numSeries": 5678,
    "numChunks": 9012
  },
  "compaction": {
    "level": 1,
    "sources": ["01BKGV7JBM69T2G1BGBGM6KB12"]
  },
  "version": 1
}

5.3 인덱스 구조

인덱스 파일은 시계열을 빠르게 검색하기 위한 구조를 포함합니다:

Index File Structure:
+------------------+
| Symbol Table     |  (모든 레이블 이름/값의 사전)
+------------------+
| Series           |  (시계열별 레이블과 청크 참조)
+------------------+
| Label Index      |  (레이블 이름 -> 가능한 값들)
+------------------+
| Postings         |  (레이블 쌍 -> 시계열 ID 목록)
+------------------+
| Postings Offset  |  (포스팅 목록의 오프셋 테이블)
+------------------+
| TOC              |  (Table of Contents)
+------------------+

5.4 Posting List

Posting List는 역인덱스의 핵심입니다. 각 레이블 이름-값 쌍에서 해당하는 시계열 ID 목록으로의 매핑입니다:

예시:
job="prometheus" -> [1, 3, 5, 7, 9]
job="node"       -> [2, 4, 6, 8, 10]
instance="localhost:9090" -> [1, 2]
instance="localhost:9100" -> [3, 4]

쿼리: job="prometheus" AND instance="localhost:9090"
  -> [1, 3, 5, 7, 9] INTERSECT [1, 2]
  -> [1]

Posting List는 정렬된 상태로 저장되어 교집합/합집합 연산이 O(n) 시간에 수행됩니다.

5.5 청크 파일

청크 파일에는 실제 시계열 샘플 데이터가 압축 저장됩니다:

Chunk File Format:
+--------+--------+--------+-----+
| Chunk 1| Chunk 2| Chunk 3| ... |
+--------+--------+--------+-----+

각 Chunk:
+----------+----------+--------+------+
| Length   | Encoding | Data   | CRC  |
| uvarint  | 1 byte   | ...    | 4B   |
+----------+----------+--------+------+

인코딩 타입:

  • 0: Raw (미압축)
  • 1: XOR (float 샘플용, 기본값)
  • 2: Histogram
  • 3: Float Histogram

6. 컴팩션

6.1 컴팩션 개요

컴팩션은 작은 블록을 큰 블록으로 병합하는 프로세스입니다:

Level 0: [2h] [2h] [2h] [2h] [2h] [2h]
                     |
                     v (compaction)
Level 1: [  6h  ] [  6h  ] [  6h  ]
                     |
                     v (compaction)
Level 2: [      18h       ] [  6h  ]

6.2 Level-based 컴팩션

Prometheus는 레벨 기반 컴팩션 전략을 사용합니다:

컴팩션 결정 기준:
1. 시간 범위가 같은 레벨의 블록 3개 이상이 있을 때
2. 병합 결과의 시간 범위가 retention 기간의 10%를 초과하지 않을 때
3. 병합 결과가 max-block-duration을 초과하지 않을 때

컴팩션 프로세스:
1. 병합할 블록 선택
2. 새 블록 디렉토리 생성 (임시)
3. 모든 소스 블록의 시계열 병합
4. tombstone 적용 (삭제된 데이터 제거)
5. 새 인덱스 생성
6. 새 청크 파일 생성
7. meta.json 업데이트 (level 증가, sources 기록)
8. 원자적으로 새 블록을 활성화
9. 이전 블록 삭제 (지연)

6.3 Vertical 컴팩션

시간 범위가 겹치는 블록이 존재할 때 수행되는 특수 컴팩션입니다:

발생 상황:
- Backfill로 과거 데이터 삽입
- out-of-order 샘플 허용 설정
- 블록 복원 후 중복 발생

처리 방식:
1. 겹치는 블록 감지
2. 동일 시계열의 샘플 병합 (타임스탬프 기반 정렬)
3. 중복 샘플 제거
4. 단일 블록으로 통합

6.4 삭제 처리

데이터 삭제는 두 단계로 처리됩니다:

1단계 - 삭제 요청 (API 호출):
  - tombstones 파일에 삭제 범위 기록
  - 실제 데이터는 아직 존재

2단계 - 컴팩션 시:
  - tombstones를 적용하여 삭제된 범위의 데이터 제외
  - 새 블록에는 삭제된 데이터가 포함되지 않음

7. 리텐션 관리

7.1 시간 기반 리텐션

--storage.tsdb.retention.time=15d (기본값)

동작 방식:
1. 블록의 maxTime이 현재 시간 - retention보다 이전이면 삭제 대상
2. 컴팩션 주기마다 확인
3. 블록 단위로 삭제 (부분 삭제 불가)

7.2 크기 기반 리텐션

--storage.tsdb.retention.size=10GB

동작 방식:
1. 모든 블록의 총 디스크 사용량 계산
2. 제한을 초과하면 가장 오래된 블록부터 삭제
3. WAL과 chunks_head는 크기 계산에서 제외

7.3 복합 리텐션

두 조건을 동시에 설정하면 먼저 도달하는 조건이 적용됩니다:

--storage.tsdb.retention.time=30d
--storage.tsdb.retention.size=50GB

- 30일이 지난 블록은 크기에 관계없이 삭제
- 50GB를 초과하면 30일 이내라도 오래된 블록부터 삭제

8. mmap과 메모리 관리

8.1 mmap 활용

Prometheus TSDB는 mmap(memory-mapped files)을 적극 활용합니다:

mmap 사용 영역:
1. 영속 블록의 인덱스 파일
2. 영속 블록의 청크 파일
3. Head Block의 완성된 청크 (chunks_head/)

mmap의 장점:
- OS가 페이지 캐시를 통해 자동으로 메모리 관리
- 필요한 부분만 메모리에 로드 (lazy loading)
- 메모리 부족 시 OS가 자동으로 페이지 해제
- Go 프로세스의 힙 메모리 부담 감소

8.2 메모리 사용 분석

주요 메모리 소비 영역:

1. Head Block memSeries (가장 큰 비중):
   - 시계열당 약 500바이트~1KB
   - 활성 시계열 수에 비례

2. Head Block headChunks:
   - 시계열당 하나의 활성 청크
   - 청크당 약 100~200바이트

3. Posting Lists (인메모리 인덱스):
   - 레이블 카디널리티에 비례

4. WAL 리플레이 버퍼:
   - 시작 시 일시적으로 큰 메모리 사용
   - 리플레이 완료 후 해제

5. 쿼리 실행 메모리:
   - 동시 쿼리 수와 결과 크기에 비례

9. Out-of-Order 샘플 처리

9.1 Out-of-Order 지원

Prometheus 2.39부터 out-of-order 샘플을 지원합니다:

--storage.tsdb.out-of-order-time-window=30m

동작 방식:
1. 현재 Head Block의 최신 타임스탬프보다 과거 샘플 도착
2. out-of-order 윈도우 내이면 별도의 WBL(Write-Behind Log)에 기록
3. Head Block에 out-of-order 청크로 저장
4. 컴팩션 시 in-order 청크와 병합

9.2 WBL (Write-Behind Log)

Out-of-order 샘플 전용 WAL입니다:

WBL vs WAL:
- WAL: in-order 샘플 전용
- WBL: out-of-order 샘플 전용
- 두 로그 모두 크래시 복구에 사용
- WBL은 OOO 윈도우가 설정된 경우에만 활성화

10. 성능 특성

10.1 쓰기 성능

쓰기 경로:
WAL Write (순차 I/O) -> Head Block Update (메모리)

특성:
- WAL은 순차 쓰기만 수행하여 디스크 I/O 최적화
- Head Block은 메모리 연산으로 매우 빠름
- 초당 수백만 샘플 수집 가능

10.2 읽기 성능

읽기 경로:
1. 쿼리 파싱 및 최적화
2. 포스팅 리스트로 관련 시계열 검색
3. 해당 시간 범위의 청크 로드
4. 청크 디코딩 및 결과 계산

최적화:
- 포스팅 리스트 교집합으로 빠른 시계열 필터링
- mmap으로 필요한 청크만 로드
- 시간 범위로 불필요한 블록 스킵

11. 정리

Prometheus TSDB는 시계열 데이터의 특성을 최대한 활용한 설계를 보여줍니다. delta-of-delta와 XOR 인코딩으로 샘플당 1.37바이트의 압축률을 달성하고, WAL로 데이터 내구성을 보장하며, 레벨 기반 컴팩션으로 쿼리 효율성을 유지합니다.

다음 글에서는 PromQL 엔진의 내부 구조를 분석합니다. 렉서와 파서, AST 구조, 쿼리 평가 엔진의 동작 방식을 살펴볼 예정입니다.