soobook
KUBERNETES

Grafana Mimir

Prometheus 메트릭을 수평 확장 가능한 장기 저장소로 받아내는 TSDB

Grafana Mimir는 Prometheus가 감당하지 못하는 규모의 메트릭을 받아내는 수평 확장형 시계열 데이터베이스다.

Intro

Prometheus의 저장 모델부터 짚자. Prometheus는 scrape한 샘플을 자기 local disk의 TSDB(Time Series Database)에 쌓는다. 이 구조는 한 대 안에서는 빠르고 단순하지만, local disk가 다 차는 경우, 노드가 죽는 경우, 여러 팀의 메트릭을 한 백엔드에서 어떻게 격리하느냐에 대해서 해결해야한다.

인류는 이 공백을 remote write로 메운다. Prometheus는 scrape한 샘플을 local disk에 쓰는 동시에 remote_write API로 원격 백엔드에 밀어 보낼 수 있다(scrape와 remote write가 무엇인지는 Prometheus 글에서 다룬다).

이 원격 백엔드 자리에 들어가 장기 저장, 수평 확장, 고가용성, 멀티테넌시를 한꺼번에 제공하는 것이 Mimir다.

Mimir는 이 문제를 단일 서버를 키우는 방식이 아니라 역할별로 쪼갠 stateless 컴포넌트의 집합으로 푼다. 쓰기를 받는 컴포넌트, 최근 데이터를 들고 있는 컴포넌트, object storage의 과거 블록을 읽는 컴포넌트, 블록을 정리하는 컴포넌트가 따로 있고, 각자 독립적으로 수평 확장된다.

실제 데이터는 S3 같은 object storage에 있으므로 컴포넌트 자체는 대부분 상태를 갖지 않는다. 그래서 10억 개 규모의 active series까지 검증된 확장성이 나온다.

Prometheus가 대상의 /metrics를 scrape해 로컬 TSDB에 쌓고, remote write로 Grafana Mimir에 보내 장기 저장하고 수평 확장하는 관측 파이프라인

Single Binary, Many Roles

Mimir의 모든 컴포넌트는 하나의 바이너리로 컴파일된다. 그 바이너리가 어떤 컴포넌트로 동작할지는 -target 플래그가 정한다. -target=all이면 모든 컴포넌트가 한 프로세스 안에서 함께 도는 monolithic 모드이고, 이건 개발이나 소규모 환경용이다.

프로덕션에서는 -target=distributor, -target=ingester처럼 컴포넌트마다 프로세스를 따로 띄우는 microservices 모드를 쓴다. 이렇게 해야 쓰기 부하가 몰리는 컴포넌트와 읽기 부하가 몰리는 컴포넌트를 서로 다른 배율로 늘릴 수 있다. 쿠버네티스에서는 mimir-distributed Helm 차트가 이 microservices 배치를 표준으로 깔아 준다.

역할을 쓰기 경로와 읽기 경로로 나눠 보면 컴포넌트 지도가 드러난다.

컴포넌트경로상태역할
Distributor쓰기stateless쓰기 요청의 입구, 검증, 샤딩, 복제
Ingester쓰기+읽기stateful최근 샘플을 메모리에 들고 블록으로 내보냄
Query-frontend읽기stateless쿼리 분할, 캐시, query sharding
Query-scheduler읽기stateless대기 중인 쿼리를 큐에 담아 querier에 분배
Querier읽기statelessingester와 store-gateway에서 데이터를 모아 실행
Store-gateway읽기statefulobject storage의 장기 블록을 조회
Compactor백그라운드stateless블록을 병합, 중복 제거, 오래된 블록 삭제

여기에 선택 컴포넌트로 recording rule과 alerting rule을 돌리는 Ruler, 알림을 다루는 Alertmanager가 붙는다.

The Write Path

쓰기 경로부터 따라가자. Prometheus나 Grafana Alloy, OpenTelemetry Collector가 remote_write로 보낸 샘플은 Distributor가 받는다. Distributor는 상태가 없는 입구다.

먼저 요청을 검증한다. 라벨 개수, 라벨 이름과 값의 길이, 샘플 타임스탬프, exemplar 형식이 규칙에 맞는지 보고, 한 요청 안에 유효한 샘플과 무효한 샘플이 섞여 있으면 유효한 것만 받고 무효한 것은 거른다.

여기에 테넌트별 rate limiting이 더해진다. 초당 요청 수와 초당 샘플 수를 테넌트마다 제한하고, 넘으면 HTTP 429로 되돌린다.

마지막은 HA 중복 제거다. Prometheus를 이중화해 같은 메트릭을 두 replica가 동시에 밀어 보내는 구성(HA pair)에서, Distributor의 HA tracker가 clusterreplica 라벨을 보고 한쪽 replica의 데이터만 받아들여 중복을 지운다.

검증을 통과한 시계열은 ingester로 샤딩되고 복제된다. 각 series는 hash ring 위에서 자기 위치가 정해지고, 그 series는 replication factor(기본 3)만큼의 서로 다른 ingester에 쓰인다. 쓰기는 quorum, 즉 RF가 3이면 최소 2개의 ingester에 성공적으로 써졌을 때 성공으로 친다. 이 복제 덕분에 ingester 한 대가 죽어도 그 series의 최근 데이터는 남는다.

Ingester가 10대일 때 series A는 (1,4,7)번에, series B는 (2,5,9)번에 쓰이는 식.

Ingester는 이 경로에서 유일하게 상태를 갖는 컴포넌트다. 받은 샘플을 메모리 안의 TSDB head에 쌓으면서, 동시에 WAL(Write-Ahead Log)에 기록한다. WAL은 ingester가 크래시하거나 재시작할 때 메모리 상태를 복구하는 로그다. 순서가 뒤바뀐 샘플을 받는 옵션을 켜면 그 경로용으로 WBL(Write-Behind Log)이 따로 쓰인다.

메모리에만 두면 유실 위험도 크고 메모리도 무한정 늘어난다. 그래서 ingester는 기본 2시간마다 메모리의 head를 디스크 블록으로 압축하고, 그 블록을 object storage에 업로드한다. 업로드가 끝나고 로컬 보존 기간이 지나면 로컬 사본을 지운다. 이 시점부터 그 데이터는 object storage에 있고, 읽기는 store-gateway를 통해 이뤄진다.

flowchart LR
  P[Prometheus<br/>remote_write] --> D[Distributor]
  D -->|validate<br/>HA dedup<br/>RF=3 shard| I1[Ingester]
  D --> I2[Ingester]
  D --> I3[Ingester]
  I1 -->|2h block| S[(Object Storage<br/>S3 / GCS / Azure)]
  I2 --> S
  I3 --> S

Blocks and Long-term Storage

Mimir의 저장 포맷은 Prometheus TSDB 포맷을 그대로 따른다. 테넌트마다 자기 TSDB를 갖고, 그 series들이 디스크 블록으로 떨어진다. 하나의 블록은 기본 2시간 범위를 담으며, 블록 디렉터리 안에는 세 가지가 들어 있다.

  • 메트릭 이름과 라벨을 그 블록 안의 series로 이어 주는 index
  • 블록의 메타데이터를 담은 meta.json, 그리고
  • 실제 샘플을 시간 구간별로 묶은 chunk, chunk 하나는 대략 120개 샘플을 담는다.

블록 파일이 놓이는 object storage는 Amazon S3, Google Cloud Storage, Microsoft Azure Storage, OpenStack Swift, 그리고 단일 노드 한정으로 로컬 파일시스템을 지원한다. 스토리지를 이렇게 외부 object storage에 맡긴 것이 Mimir 확장성의 뿌리다. 데이터가 컴포넌트 바깥에 있으므로 ingester, querier, store-gateway는 필요한 만큼 늘렸다 줄였다 할 수 있다.

The Read Path

읽기 경로의 입구는 Query-frontend다. PromQL 쿼리가 들어오면 곧장 querier로 넘기지 않고 세 가지 가속을 먼저 건다.

Splitting

분할(splitting)은 긴 시간 범위의 쿼리를 기본 24시간 단위로 쪼갠다. 여러 날짜에 걸친 쿼리를 한 querier가 통째로 처리하다 out-of-memory로 죽는 것을 막고, 쪼갠 조각을 여러 querier에서 병렬로 돌려 속도를 올린다.

Caching

캐시(caching)는 쿼리 결과를 Memcached에 저장해 두고 다음 쿼리에서 재사용한다. 캐시가 일부만 맞으면 모자란 구간만 부분 쿼리로 실행한다.

Query sharding

세 번째는 query sharding으로, 하나의 쿼리를 여러 querier에 나눠 실행한다.

Query Scheduler

이렇게 가공된 쿼리는 Query-scheduler로 들어가 인메모리 큐에 담긴다. Querier들은 이 큐에서 쿼리를 하나씩 꺼내 실행한다. query-frontend가 직접 querier에게 밀어 넣지 않고 scheduler의 큐를 거치게 한 덕분에, querier를 늘리고 줄이는 것과 무관하게 쿼리가 안정적으로 분배된다.

Querier

querier가 쿼리를 실행할 때 데이터는 두 곳에서 온다. 아직 object storage로 안 내려간 최근 데이터는 ingester에서, 이미 블록으로 떨어진 과거 데이터는 store-gateway에서 가져온다. querier는 이 둘을 fan-out으로 동시에 조회하고, 겹치는 구간을 병합하고 중복을 지워 하나의 결과로 만든다. 결과는 다시 query-frontend를 거쳐 클라이언트로 돌아간다.

flowchart LR
  Q[PromQL query] --> QF[Query-frontend<br/>split / cache / shard]
  QF --> QS[Query-scheduler<br/>queue]
  QS --> QR[Querier]
  QR -->|recent| I[Ingester]
  QR -->|historical| SG[Store-gateway]
  SG --> OS[(Object Storage)]

Store-gateway가 object storage의 방대한 블록에서 필요한 것만 집어내는 방식이 읽기 성능의 관건이다. store-gateway는 스토리지 전체를 훑지 않는다. 대신 compactor가 유지하는 bucket index를 주기적으로 내려받아 어떤 블록이 있는지 목록을 얻고, 각 블록의 index-header만 local disk에 내려받는다. index-header는 블록 index에서 조회에 필요한 부분만 뽑은 축약본이다. 쿼리가 실제로 실행될 때도 블록 전체가 아니라 그 쿼리에 필요한 index와 chunk 조각만 object storage에서 가져온다. 블록은 store-gateway들 사이에서도 hash ring으로 샤딩되고 기본 3배로 복제되어, 한 인스턴스가 빠져도 조회가 이어진다.

compactor는 store-gateway 내부 모듈이 아니라 object storage의 블록 정리를 전담하는 별도의 백그라운드 컴포넌트다. 그 과정에서 bucket index도 함께 유지하며, 아래 The Compactor and Split-and-Merge 섹션에서 자세히 다룬다.

Query Sharding

query-frontend의 세 번째 가속인 query sharding은 하나의 쿼리를 여러 머신에서 나눠 실행하는 기법이다. 데이터셋을 여러 shard로 쪼개고, 각 shard를 부분 쿼리로 만들어 여러 querier에서 병렬로 돌린 뒤, query-frontend가 그 결과들을 다시 합친다. 앞서 본 분할이 시간 축을 쪼갠다면, sharding은 series 축을 쪼갠다.

모든 쿼리를 쪼갤 수 있는 건 아니다. sum, min, max, count, avg 같은 결합 가능한(associative) 집계는 부분 결과를 나중에 다시 합쳐도 값이 같으므로 쪼갤 수 있다. 반면 histogram_quantile이나 sort 같은 함수는 전체 데이터를 봐야 하므로 그 자체로는 쪼갤 수 없고, 안쪽의 집계 부분만 쪼갠다. 쪼개진 부분 쿼리에는 __query_shard__라는 라벨 셀렉터가 붙어 각자 데이터의 한 조각만 본다.

# 원본 쿼리
sum(rate(metric[1m]))

# shard 수 3으로 실행되는 모습
sum(
  concat(
    sum(rate(metric{__query_shard__="1_of_3"}[1m]))
    sum(rate(metric{__query_shard__="2_of_3"}[1m]))
    sum(rate(metric{__query_shard__="3_of_3"}[1m]))
  )
)

query sharding은 -query-frontend.parallelize-shardable-queries로 켜며, 부분 쿼리 폭발을 막기 위해 한 입력 쿼리가 만들 수 있는 부분 쿼리 수에 기본 128개의 상한이 걸려 있다.

The Compactor and Split-and-Merge

여기까지 보면 문제가 하나 남는다. ingester들이 2시간마다 각자 블록을 올리는데, RF가 3이면 같은 2시간 구간의 같은 series가 여러 ingester의 블록에 중복으로 존재한다. 블록 수도 계속 늘고, 중복 샘플도 쌓인다. 이걸 정리하는 것이 Compactor다.

Compactor가 같은 구간의 중복 블록을 하나로 병합하고, 인접 구간을 더 큰 블록으로 넓히고, 거대 테넌트의 series를 shard로 갈라 블록 크기를 한도 안에 묶는 3단계

Compactor는 두 방향으로 블록을 합친다. vertical compaction은 같은 2시간 구간에 대해 여러 ingester가 올린 블록들을 하나로 병합하면서, 복제 때문에 생긴 중복 샘플을 제거한다. ingester 수만큼 있던 블록이 구간당 하나로 준다. horizontal compaction은 그다음에 인접한 시간 구간의 블록들을 더 큰 블록으로 합친다. 예를 들어 2시간 블록들을 12시간, 24시간 블록으로 키운다. 블록 수가 줄면 조회할 블록이 줄어 쿼리가 빨라지고, store-gateway가 메모리에 들고 있어야 할 index-header도 작아진다.

그런데 테넌트가 아주 커지면 한 블록이 무한정 커지는 문제가 생긴다. TSDB index에는 크기 한계가 있어서, 한 구간의 모든 series를 한 블록에 밀어 넣을 수 없다. Mimir가 이 문제를 푸는 알고리즘이 split-and-merge다.

split-and-merge는 두 단계다. split 단계에서 compactor는 소스 블록들을 N개(-compactor.split-groups) 그룹으로 나누고, 각 그룹을 압축하되 결과를 하나가 아니라 M개(-compactor.split-and-merge-shards)의 split block으로 쪼갠다. 각 split block은 M개 shard 중 한 shard에 속하는 series만 담는다. split 단계가 끝나면 N × M개의 블록이 생긴다. merge 단계에서는 같은 shard에 속하는 split block들을 합친다. 그러면 shard마다 하나씩, 총 M개의 블록으로 준다. 한 블록에 전부 밀어 넣는 대신 series를 shard로 갈라 두었으므로, 테넌트가 아무리 커져도 블록 하나의 크기가 관리 범위 안에 머문다. 권장 기준은 active series 800만 개당 shard 1개다.

Compactor 자체도 hash ring으로 샤딩된다. compactor는 시작할 때 링에 자기 토큰을 등록하고, 주기적으로 스토리지를 스캔해 자기 토큰 범위에 해당하는 테넌트의 블록만 압축한다. compactor 수를 늘리면 압축 작업이 자동으로 재분배되어 대규모 테넌트의 압축이 병렬화된다. 이 과정에서 compactor는 querier, store-gateway, ruler가 참조하는 bucket index도 함께 갱신한다.

How Components Find Each Other

지금까지 hash ring이라는 말이 반복해 나왔다. Mimir 컴포넌트들이 서로를 발견하고 부하를 나누는 바탕이 이 hash ring이다. 같은 종류의 컴포넌트 인스턴스들이 링 위에 랜덤 토큰으로 자기 위치를 등록하면, 데이터(series, block)는 자기 hash 값이 떨어지는 토큰 범위의 인스턴스로 배정된다. 인스턴스가 추가되거나 빠지면 그 토큰 범위만 재배정되므로, 전체를 멈추지 않고도 확장과 축소가 이뤄진다.

인스턴스들이 hash ring에 랜덤 토큰으로 자리를 등록하고 memberlist gossip으로 링 상태를 공유하며, series는 hash가 떨어진 지점에서 시계방향으로 다음 토큰의 주인 인스턴스에 배정되는 구조

이 링 상태를 공유하는 기본 백엔드가 memberlist를 통한 gossip 프로토콜이다. 각 인스턴스가 주변 인스턴스에 자기 상태를 퍼뜨리며 링 전체가 서로의 존재를 알게 된다. 별도의 조정 서비스 없이 클러스터가 자율적으로 멤버십을 유지한다는 뜻이다. 원한다면 Consul이나 etcd를 KV 저장소로 대신 쓸 수도 있다.

여기에 두 겹의 격리가 더 얹힌다. zone-aware replication은 한 series의 복제본들을 서로 다른 zone(가용 영역 같은 장애 도메인)에 분산해, zone 하나가 통째로 죽어도 데이터가 남게 한다. shuffle sharding은 각 테넌트의 데이터를 전체 인스턴스가 아니라 그 일부에만 배정해, 한 테넌트의 문제가 다른 테넌트로 번지는 반경을 좁힌다. 그리고 모든 쓰기 요청은 X-Scope-OrgID 헤더로 테넌트를 지정해야 하며, 이 테넌트 ID가 저장부터 조회까지 데이터를 격리하는 축이 된다.

Classic vs Ingest Storage

지금까지 설명한 쓰기 경로, 즉 distributor가 ingester로 직접 복제하고 ingester가 write path와 read path에 모두 관여하는 구조를 Mimir는 classic architecture라 부른다. 이 구조에는 약점이 하나 있다. ingester가 상태를 많이 짊어지고 쓰기와 읽기를 겸하다 보니, 무거운 쿼리가 라이브 쓰기를 방해할 수 있고, ingester 두 대가 동시에 빠지면 quorum이 깨져 장애로 번질 수 있다.

이 약점을 겨냥해 새로 stable이 된 것이 ingest storage architecture다. distributor와 ingester 사이에 Kafka(또는 호환 시스템)를 끼워 넣어 읽기와 쓰기를 완전히 떼어 놓는 구조다. distributor는 검증한 쓰기 요청을 Kafka 파티션에 샤딩해 넣고, Kafka가 복제와 영속화를 확인하면 그 시점에 클라이언트에 성공을 응답한다. 쓰기 경로가 Kafka에서 끝난다는 점이 classic과 다르다. ingester는 더 이상 쓰기 경로에 없다. 대신 각 ingester가 하나의 Kafka 파티션을 구독해 그 데이터를 소비하고 조회용으로 들고 있는, 사실상 읽기 전용 컴포넌트가 된다.

이 분리가 주는 이점이 뚜렷하다. 쓰기 성공이 ingester가 아니라 Kafka의 영속화에 달려 있으므로, 무거운 쿼리가 쓰기를 흔들지 못한다. ingester가 죽어도 재시작 후 Kafka에 쌓인 백로그를 빠르게 따라잡아 빈틈 없는 데이터를 복구한다. 그래서 파티션당 ingester 하나만 살아 있어도 읽기 일관성이 보장되고, 권장 복제 수도 classic의 3에서 2로 낮아진다. 낮은 복제 수로도 랜덤 장애에 더 강한 구조가 된다.

In Practice

실제 클러스터 운영에서 이 스택은 역할 분담이 뚜렷하다. 각 클러스터의 Prometheus는 scrape와 remote_write만 맡는 얇은 수집기로 두고, 로컬 보존은 짧게 잡는다. 장기 저장과 전 클러스터 집계는 중앙 Mimir가 맡는다. 클러스터나 팀 단위로 X-Scope-OrgID 테넌트를 부여하면 저장부터 조회까지 그 축으로 격리된다.

여러 클러스터의 Prometheus가 각자 테넌트를 지정해 중앙 Mimir로 remote_write하고, Grafana가 Mimir를 단일 데이터소스로 등록해 PromQL로 전 클러스터를 조회하는 운영 구성

읽는 쪽의 주체는 Prometheus가 아니라 Grafana다. Mimir의 query-frontend가 Prometheus 호환 API를 노출하므로, Grafana에는 데이터소스 타입을 Prometheus로 고르되 URL만 Mimir를 가리키는 데이터소스 하나를 등록한다. 대시보드가 클러스터 10개의 메트릭을 PromQL 한 번으로 묶어 보고, recording rule과 alerting rule 평가도 개별 Prometheus가 아니라 Mimir의 ruler로 중앙화할 수 있다. 개별 Prometheus 한 대가 죽어도 대시보드와 알림은 Mimir에 쌓인 데이터로 계속 동작한다.

References