Jaehong Jung

Grafana Mimir에 Kafka를 도입하기 전에 미리 알았다면 좋았을 것들

mimirkafkamonitoringdevops

이 글의 원문은 채널톡 테크블로그에서 확인할 수 있습니다.

안녕하세요, 채널코퍼레이션 DevOps팀의 재티(정재홍)입니다.

채널팀은 모니터링 스택의 ingestion pipeline을 더 안정적으로 만들기 위해 Grafana Mimir에 Kafka 기반 ingest-storage architecture를 도입했습니다. 이유는 크게 두 가지였습니다.

  1. Distributor와 ingester 사이에 asynchronous buffer를 두어, ingester 장애나 일시적인 처리 지연이 곧바로 distributor backpressure와 metrics 유실로 번지지 않게 만들기 위해서였습니다.
  2. Mimir 3.0에서 Kafka 기반 ingest-storage architecture가 stable이자 preferred architecture가 되었고, 장기적으로는 어차피 넘어가야 할 방향이라고 판단했기 때문입니다.

하지만 실제로 도입해보니, “Mimir 앞단에 Kafka를 하나 넣는다” 정도로 이해하면 놓치기 쉬운 지점들이 많았습니다. 특히 Mimir가 Kafka를 사용하는 방식은 우리가 흔히 떠올리는 Kafka producer/consumer 모델과 꽤 달랐습니다. 보통은 여러 consumer가 같은 consumer group 안에서 partition을 나눠 가지고, consumer가 늘거나 줄면 Kafka가 rebalance를 통해 partition assignment를 다시 계산하는 방식을 떠올립니다.

Mimir ingest-storage에서는 조금 달랐습니다. ingester pod의 instance ID, 더 정확히는 이름 끝의 ordinal이 Kafka partition number와 직접 연결됩니다. mimir-ingester-zone-a-3은 partition 3을 consume하고, mimir-ingester-zone-b-3도 같은 partition 3을 consume합니다. 하나의 ingester는 정확히 하나의 partition만 읽고, 하나의 partition은 고가용성을 위해 여러 zone의 ingester가 함께 읽습니다.

이 글에서는 Mimir에 Kafka를 도입하게 되면서 배운 점들을 정리해보려 합니다.


1. 왜 Kafka를 넣었나

먼저 배경부터 짧게 이야기하겠습니다. Kafka 도입의 이유는 크게 두 가지였습니다.

1-1. asynchronous buffer를 이용한 안정성

첫 번째는 asynchronous buffer를 추가해 monitoring pipeline의 가용성과 안정성을 높이는 것이었습니다.

기존 Mimir classic pipeline에는 distributor와 ingester 사이의 backpressure를 흡수할 durable buffer가 없었습니다. Ingester 계층에 문제가 생기면 write path의 성공률과 지연에 직접 영향을 줍니다. 저희는 ingester가 실제로 버틸 수 있는 capacity를 기준으로 distributor rate-limit을 설정했습니다. Ingester가 감당하기 어려운 양을 계속 받으면 metrics 인프라 전체로 장애가 전파될 수 있기 때문에, 일정 수준을 넘는 write 요청은 distributor에서 429로 거절하고 discard하는 방식이었습니다. 이 설정 덕분에 최악의 상황을 막을 수는 있었지만, 운영 중 429가 몇 차례 발생했고 실제로 그만큼 metrics 유실도 있었습니다. 반대로 rate-limit을 너무 느슨하게 잡으면 ingester 부하가 커져 ingester 자체가 흔들리고, 이 또한 장애로 이어지는 경우도 있었습니다.

저희의 metrics pipeline은 여러 Kubernetes cluster의 Prometheus replica들이 metrics를 수집하고, 중앙 monitoring cluster의 Mimir distributor로 직접 remote write하는 구조입니다.

flowchart LR
    subgraph C1["spoke cluster A"]
        P1["Prometheus replicas"]
    end
    subgraph C2["spoke cluster B"]
        P2["Prometheus replicas"]
    end
    subgraph C3["spoke cluster C"]
        P3["Prometheus replicas"]
    end
    subgraph HUB["hub monitoring cluster"]
        D["mimir-distributor"] -->|"quorum write"| I["mimir-ingesters<br/>RF=3"]
    end
    P1 -->|"remote_write"| D
    P2 -->|"remote_write"| D
    P3 -->|"remote_write"| D

여기서 write의 성공 경계를 정확히 볼 필요가 있습니다. Mimir classic architecture에서 distributor는 요청을 받았다는 이유만으로 Prometheus에 성공을 반환하지 않습니다. Distributor는 incoming series를 ingester에 Push하고, 설정된 replication factor에 따른 write quorum을 만족한 뒤에야 remote write 요청을 성공으로 처리합니다. 저희처럼 RF=3을 사용하면 같은 series가 최소 2개 ingester에 기록되어야 성공합니다. Ingester 하나에 쓰지 못해도 나머지 두 곳에 기록되면 성공하지만, 이는 한 replica에 gap이 생긴 것이지 해당 metric 전체가 drop된 것은 아닙니다.

Quorum을 만족하지 못하면 distributor는 성공 대신 5xx를 반환합니다. Prometheus remote write는 5xx를 recoverable error로 보고 local WAL에 남아 있는 samples를 재시도합니다. 따라서 일시적인 ingester 부하나 장애가 곧바로 metrics 유실로 이어지는 것은 아닙니다. 다만 Prometheus 공식 remote write 가이드의 기본 동작을 기준으로, remote endpoint 장애나 backlog가 2시간 넘게 지속되면 아직 전송하지 못한 데이터가 WAL compaction 과정에서 유실될 수 있습니다.

Kafka가 없을 때 Mimir ingester가 처리 capacity를 넘으면 유실 가능성은 두 가지 경로로 나타납니다.

  1. Ingester write quorum 실패가 장시간 이어지면 Prometheus가 5xx를 재시도하는 동안 remote write backlog가 쌓이고, 2시간이 지나 WAL이 compact되면 미전송 metrics가 유실될 수 있습니다.
  2. Distributor rate-limit을 ingester가 받을 수 있는 수준으로 설정하면 pipeline을 보호할 수 있지만, limit을 넘은 요청은 distributor가 drop하고 429를 반환합니다. Prometheus는 기본적으로 429를 재시도하지 않으므로 retry_on_http_429: true를 설정하지 않았다면 해당 metrics는 즉시 유실될 수 있습니다.

비슷한 pain point는 Loki logging pipeline에도 있었습니다. 각 클러스터의 otel-collector DaemonSet이 node의 logs를 수집해 중앙 monitoring cluster의 otel-collector-gateway Deployment로 보내고, gateway가 Loki distributor로 Push하는 구조입니다.

flowchart LR
    subgraph C1["spoke cluster A"]
        O1["otel-collector<br/>DaemonSet"]
    end
    subgraph C2["spoke cluster B"]
        O2["otel-collector<br/>DaemonSet"]
    end
    subgraph HUB["hub monitoring cluster"]
        G["otel-collector-gateway<br/>Deployment"] --> Q["gateway internal<br/>in-memory sending_queue"]
        Q -->|"Push / retry"| D["loki-distributor"]
        D --> I["loki-ingester"]
    end
    O1 -->|"OTLP logs"| G
    O2 -->|"OTLP logs"| G

현재 구성처럼 gateway exporter의 sending_queue가 memory 기반이면, 각 클러스터의 otel-collector가 gateway로 전송을 성공한 시점은 logs가 Loki ingester에 저장된 시점과 다릅니다. Gateway가 logs를 자신의 pipeline과 queue에 받아들이면 upstream 요청은 성공할 수 있고, 이후 Loki ingester의 부하나 장애로 distributor Push가 느려지거나 실패하면 gateway가 retry하면서 queue가 쌓입니다. 이 queue는 Prometheus remote write WAL처럼 크고 durable한 buffer가 아니기 때문에, capacity를 넘거나 gateway가 restart 또는 OOM되면 아직 Loki에 전달하지 못한 logs가 유실될 수 있습니다.

Mimir와 Loki의 구체적인 failure mode는 달랐지만, 운영 관점에서 공통된 pain point는 downstream의 일시적인 장애와 처리 지연을 충분히 흡수할 durable asynchronous buffer가 없다는 점이었습니다.

Mimir ingest-storage를 도입하면 이 기준이 바뀝니다. Distributor는 더 이상 빠르고 유연한 scale-out이 어려운 ingester의 순간 처리량에 직접 맞춰 rate-limit을 잡을 필요가 줄어듭니다. Write path의 1차 도착지는 Kafka가 되고, Kafka가 durable buffer로 데이터를 보관합니다. 따라서 distributor가 받을 수 있는 capacity는 ingester의 즉시 처리량보다는 Kafka cluster의 produce throughput, replication, broker capacity를 기준으로 설계할 수 있습니다. Ingester는 Kafka에 쌓인 데이터를 자신의 속도로 따라잡으면 되고, 일시적인 ingester 지연은 consumer lag로 흡수됩니다.

물론 Kafka도 무한한 버퍼는 아닙니다. broker throughput, retention, disk, lag alert를 운영해야 합니다. 하지만 Mimir write path가 “Kafka에 먼저 안전하게 적재하고 ingester가 뒤따라간다”는 구조로 바뀌는 것이 핵심이었습니다.

1-2. TO-BE 아키텍처

두 번째 이유는 Mimir 3.0에서 Kafka 기반 ingest-storage architecture가 TO-BE 아키텍처가 되었기 때문입니다. Grafana Labs의 Mimir architecture blog에서도 Kafka를 ingestion과 query 사이의 asynchronous buffer로 도입해 read/write path를 분리하는 것을 중요한 아키텍처 변화로 설명합니다. 공식 문서에서도 Mimir 3.0부터 ingest-storage architecture는 stable이며 preferred architecture라고 안내합니다. 즉 Kafka 도입은 단기적인 장애 완화책이기도 했지만, 어차피 언젠가는 넘어가야 할 방향이기도 했습니다.

Mimir classic architecture에서는 write path가 다음과 같습니다.

flowchart LR
    P["Prometheus / remote_write"] --> D["Mimir distributor"]
    D -->|"gRPC Push<br/>RF=3 fan-out"| I1["ingester A"]
    D -->|"gRPC Push"| I2["ingester B"]
    D -->|"gRPC Push"| I3["ingester C"]
    I1 --> S3["Object storage"]
    I2 --> S3
    I3 --> S3

Distributor는 incoming series를 hash ring으로 shard한 뒤, 설정된 replication factor만큼 여러 ingester에 동시에 Push합니다. RF를 3으로 설정하면 같은 series가 3개의 ingester TSDB head에 들어갑니다. write request는 quorum을 만족해야 성공합니다. 따라서 ingester가 write path에 직접 들어와 있고, ingester 문제는 distributor의 write 성공률에 직접 영향을 줍니다.

반면 ingest-storage architecture에서는 Kafka가 write path의 끝이 됩니다.

flowchart LR
    P["Prometheus / remote_write"] --> D["Mimir distributor"]
    D -->|"Produce"| K["Kafka / MSK<br/>topic partitions"]
    K -->|"Consume"| I1["ingester zone-a-0"]
    K -->|"Consume"| I2["ingester zone-b-0"]
    K -->|"Consume"| I3["ingester zone-c-0"]
    I1 --> S3["Object storage"]
    I2 --> S3
    I3 --> S3

Distributor는 series를 Kafka partition으로 shard하고, Kafka가 persistence와 replication을 책임집니다. Ingester는 Kafka에서 데이터를 비동기로 읽어 in-memory TSDB와 WAL에 반영하고, query path에서 최근 데이터를 제공합니다. 즉 write path와 read path가 분리됩니다.

Grafana Mimir 3.0 ingest-storage 아키텍처

Grafana Labs의 Mimir architecture blog에서 소개한 ingest-storage architecture입니다. Kafka가 ingestion과 query 사이의 asynchronous buffer로 들어가면서 write path와 read path가 분리됩니다.

다시 한 번 정리하자면, 저희가 기대한 효과는 다음과 같습니다.

  • ingester 장애가 곧바로 write 실패로 이어지지 않게 하기
  • Kafka lag를 버퍼로 삼아 distributor ↔ ingester backpressure로 인한 429 완화
  • Mimir v3의 preferred architecture인 ingest-storage로 전환하기

2. 가장 먼저 눈에 띈 점: ingester 리소스가 크게 줄었다

PoC에서 가장 눈에 띈 변화는 ingester 리소스 사용량이었습니다. 같은 remote_write traffic을 classic stack과 ingest-storage stack에 dual-write하면서 비교했는데, distributor 입력은 거의 같았지만 ingester가 처리하는 양은 크게 달라졌습니다. 당시 중앙 monitoring cluster 기준으로 distributor ingestion rate는 classic 쪽이 약 313k samples/s, Kafka PoC 쪽이 약 308k samples/s로 거의 동일했습니다. 그런데 ingester 지표는 다음처럼 차이가 났습니다.

지표classicingest-storage PoC차이
ingester CPU total12.42 cores3.07 cores-75%
ingester memory total148.8 GB46.4 GB-69%
ingester ingested samples/s941k/s309k/s약 1/3
ingester in-memory series35.17M11.42M약 1/3
ingester PV usage213.16 GiB63.92 GiB약 1/3

원인은 RF(Replication Factor)에 있었습니다. 저희 classic stack은 Grafana Labs가 production 환경에 권장하는 구성에 따라 RF=3을 사용했습니다. 따라서 distributor는 같은 series를 3개 ingester에 Push했고, distributor 입력이 313k samples/s라면 ingester 전체 입장에서는 약 941k samples/s를 받게 됩니다. 반면 ingest-storage에서는 distributor가 series를 Kafka partition 하나에만 씁니다. Mimir 내부의 partition ring은 write path에서 ReplicationFactor() = 1처럼 동작합니다. write durability는 Mimir ingester RF가 아니라 Kafka topic replication factor가 담당합니다.

다만 여기서 주의할 점이 있습니다. Ingest-storage에서 “RF가 1이 된다”는 말은 주로 distributor write path와 ingester가 들고 있는 series 관점의 이야기입니다. Zone-aware replication을 적용하면 같은 Kafka partition을 여러 zone의 ingester owner가 함께 consume합니다. 따라서 S3에 올라가는 block 자체는 zone owner 수만큼 생길 수 있고, 이 중복은 classic과 마찬가지로 compactor가 vertical compaction으로 정리합니다.

즉 핵심은 “S3에 중복 block이 전혀 생기지 않는다”가 아닙니다. 더 중요한 변화는 ingester가 in-memory TSDB head나 local disk에 들고 있는 metric series 부담이 classic의 RF=3 fan-out 구조에서 partition 단위 1-copy 구조로 줄어든다는 점입니다.

Mimir classic과 ingest-storage의 replication 비교

Classic에서도 최종 쿼리 결과가 3배로 보이는 것은 아닙니다. read-time dedup과 compactor vertical compaction이 중복 block을 정리합니다. Ingest-storage에서도 zone-aware owner들이 올린 block은 compactor가 정리합니다. 차이는 그 전 단계에 있습니다. Classic에서는 distributor가 write path에서 같은 series를 RF=3으로 fan-out하고, ingester들이 그 데이터를 동시에 write path로 받아야 합니다. Ingest-storage에서는 distributor가 Kafka partition 하나에만 쓰고, ingester는 자신이 맡은 partition을 비동기로 따라갑니다. 이 구조 덕분에 ingester가 즉시 감당해야 하는 in-memory/local disk 부담이 크게 줄어듭니다.

이 점은 꽤 큰 장점이었습니다. 다만 그만큼 중요한 책임이 Kafka로 이동합니다. 이제 최근 데이터의 durability와 write availability는 Kafka topic replication, broker availability, producer 설정, consumer lag 운영에 크게 의존합니다.


3. Mimir의 Kafka 사용 방식은 일반적인 consumer group과는 다르다

이 글에서 가장 강조하고 싶은 부분입니다.

일반적인 Kafka 애플리케이션을 생각하면 보통 이런 그림을 떠올립니다.

flowchart LR
    K["Kafka topic<br/>partitions 0..N"] --> CG["consumer group"]
    CG --> C1["consumer-1<br/>partition 0,1"]
    CG --> C2["consumer-2<br/>partition 2,3"]
    CG --> C3["consumer-3<br/>partition 4,5"]

Consumer가 늘거나 줄면 group coordinator가 partition assignment를 다시 조정합니다. 각 consumer는 여러 partition을 읽을 수도 있고, scale-out에 따라 assignment가 바뀔 수 있습니다.

Mimir ingest-storage의 ingester는 그렇게 동작하지 않습니다. 공식 문서에도 나와 있듯이, each ingester consumes from exactly one partition입니다. 그리고 partition assignment는 instance ID에서 추출됩니다. instance ID가 ingester-zone-a-13이면 partition 13을 읽고, mimir-write-zone-b-7이면 partition 7을 읽습니다. 정규식으로 보면 instance ID 끝의 -([0-9]+)$ 부분이 partition number가 됩니다.

즉 Kubernetes StatefulSet ordinal이 사실상 Kafka partition number가 됩니다.

Mimir ingester ordinal과 Kafka partition의 매핑

여기서 낯선 점은 두 가지입니다.

  1. 첫째, 하나의 ingester가 여러 partition을 나눠 읽지 않습니다. zone-a-0은 partition 0만 읽고, zone-a-1은 partition 1만 읽습니다.

  2. 둘째, 고가용성을 위해 같은 partition을 여러 ingester가 동시에 읽습니다. 예를 들어 partition 0은 zone-a-0, zone-b-0, zone-c-0이 함께 읽습니다. Kafka consumer group 관점에서는 각 ingester가 자신만의 offset을 가지고 같은 partition을 읽는 셈입니다.

Mimir의 read path는 이 구조를 전제로 합니다. Querier는 partitions ring을 보고 어떤 partition에 필요한 series가 있는지 찾고, ingesters ring을 보고 그 partition을 소유한 healthy ingester 중 하나를 골라 읽습니다. Ingest-storage에서는 partition별 quorum이 1에 가깝게 동작합니다. 같은 partition을 소유한 ingester가 여러 개 있으면, 그중 하나만 읽어도 됩니다.

그래서 Mimir ingest-storage에서 “ingester replica 수를 늘린다”는 말은 일반적인 Kafka consumer 애플리케이션처럼 worker 수를 자유롭게 늘리는 의미가 아닙니다. partition 수, StatefulSet ordinal, zone 수, partition owner 수가 함께 맞아야 합니다.


4. partition 수와 ingester replica 수를 먼저 정해야 한다

이 구조 때문에 가장 먼저 정해야 하는 것은 Kafka topic partition 수입니다.

Mimir 문서에서는 topic partition 수가 적어도 한 zone의 ingester 수 이상이어야 한다고 설명합니다. 이유는 간단합니다. ingester ordinal이 partition number가 되기 때문에, partition 6을 읽는 ingester를 만들려면 Kafka topic에도 partition 6이 있어야 합니다.

저희 PoC에서는 Kafka topic partition을 7개로 두고, ingester를 3-zone zone-aware로 구성했습니다. Helm chart 기준으로는 ingester.replicas: 21을 설정하면 zone 3개에 나뉘어 각 StatefulSet이 replicas: 7로 렌더링되었습니다.

Kafka partitions: 7
Zones: 3

mimir-ingester-zone-a replicas: 7  -> partition 0..6
mimir-ingester-zone-b replicas: 7  -> partition 0..6
mimir-ingester-zone-c replicas: 7  -> partition 0..6

Total ingester pods: 21
Partition owners: normally 3 owners per partition

여기서 헷갈리기 쉬운 설정이 ingester.partition_ring.min_partition_owners_count였습니다. 이 값은 “partition이 ACTIVE가 되기 전에 최소 몇 owner가 붙어 있어야 하는가”를 의미합니다. partition 개수나 pod 배치를 만들어주는 옵션이 아닙니다. PENDING, ACTIVE, INACTIVE를 포함한 상태 전이에 대해서는 Grafana 공식 문서의 Partitions ring lifecycle을 참고할 수 있습니다. 이 글의 7절에서도 scale-in 관점에서 다시 설명합니다.

예를 들어 single StatefulSet에서 replicas를 12로 두고 min_partition_owners_count: 2를 설정한다고 해서 “6 partitions × 2 owners” 구조가 자동으로 만들어지지 않습니다. 같은 partition number를 가진 ingester가 여러 개 있어야 하므로, stock Helm chart 기준으로는 zone-aware StatefulSet처럼 같은 ordinal을 가진 복수 StatefulSet이 필요합니다.

또 하나 현실적인 제약은 Helm chart였습니다. 당시 사용한 mimir-distributed 6.0.6 chart에서는 zoneAwareReplication.enabled=true일 때 zone을 최소 3개 정의하도록 template validation이 걸려 있었습니다. Mimir 모델상 partition당 2 owners 구성이 가능하더라도, chart values만으로 2-zone/RF=2 형태를 깔끔하게 만들기는 어려웠습니다. 결국 chart patch를 피하기 위해 3-zone 구성으로 갔습니다.

이 지점에서 배운 점은 명확합니다.

Mimir ingest-storage의 replica 설계는 “consumer 수”가 아니라 “partition 수 × zone/owner 수”로 생각해야 합니다.


5. 왜 이런 제약을 선택했을까

여기까지 보면 한 가지 질문이 생깁니다.

왜 Mimir는 하나의 ingester가 여러 Kafka partition을 유연하게 consume하도록 만들지 않고, ingester ordinal과 partition number를 강하게 묶어두었을까?

기존에 여러 consumer가 하나의 consumer group에 들어가고, partition이 consumer들 사이에서 rebalance되며 이동하는 모델을 떠올렸지만, Mimir ingest-storage에서는 partition이 ingester들 사이를 임의로 왔다 갔다 하면 곤란한 이유가 있었습니다.

핵심은 ingester가 단순한 Kafka consumer가 아니라 read path에도 관여하는 stateful component라는 점입니다. Distributor는 write path에서 series를 Kafka partition으로 shard합니다. Ingester는 그 partition을 consume해 in-memory TSDB와 local WAL/blocks에 반영합니다. 그리고 querier는 최근 데이터를 읽을 때 object storage만 보는 것이 아니라 ingester도 함께 조회합니다. 이때 querier는 아무 ingester나 찍어서 물어보지 않습니다. Mimir의 SSoT(Source of Truth)는 hash ring에 있습니다. Querier는 partitions ring을 보고 어떤 partition에 해당 series가 있는지 판단하고, ingesters ring을 보고 그 partition을 소유한 healthy ingester를 찾아 query합니다.

flowchart LR
    Q["querier"] --> PR["partitions ring<br/>series token -> partition"]
    Q --> IR["ingesters ring<br/>partition owner -> ingester address"]
    PR --> P["partition N"]
    IR --> I["ingester zone-a-N / zone-b-N / zone-c-N"]
    Q -->|"read recent samples"| I

따라서 partition ownership은 read path에서 안정적으로 해석(위치를 특정) 가능해야 합니다. 일반 Kafka consumer group처럼 partition assignment가 rebalance 때마다 자유롭게 옮겨 다니고, 그 결과 어떤 ingester가 어떤 partition의 최신 데이터를 들고 있는지가 자주 바뀐다면, Mimir의 partitions ring과 ingesters ring이 그 상태를 정확히 반영하고 전파해야 합니다. 물론 이 사실만으로 “하나의 ingester가 반드시 single partition만 consume해야 한다”는 근거가 되어주기에는 부족합니다. 하지만 적어도 Kafka partition이 일반 consumer group처럼 투명하게 서버들 사이를 이동해도 되는 대상은 아니라는 점은 분명합니다.

Mimir는 이 복잡도를 줄이는 쪽을 선택한 것으로 보입니다. 하나의 ingester는 하나의 partition만 consume하고, 어떤 partition을 consume할지는 instance ID 끝의 ordinal에서 결정합니다. 그러면 운영 모델은 훨씬 단순해집니다. 처음 세팅할 때는 필요한 capacity를 예측해 Kafka topic partition count와 한 zone의 ingester replica 수를 정합니다. 이후 capacity가 부족하거나 scale-out이 필요해지면 partition count와 ingester count를 같은 방향으로 하나씩 늘리면 됩니다.

partition 0  <->  ingester-zone-a-0 / zone-b-0 / zone-c-0
partition 1  <->  ingester-zone-a-1 / zone-b-1 / zone-c-1
partition 2  <->  ingester-zone-a-2 / zone-b-2 / zone-c-2
...

엄밀히 말하면 partition count와 ingester count가 항상 같아야 하는 것은 아닙니다. 하지만 운영 관점에서는 동일하게 유지하는 편이 가장 단순합니다.

  • Partition count가 ingester count보다 많으면, 남는 partition에는 owner가 없습니다. 이 partition은 partitions ring에서 ACTIVE write/read 대상으로 쓰이지 않거나, 사실상 있어도 사용되지 않는 partition이 됩니다.
  • 반대로 ingester count가 partition count보다 많으면, 남는 ingester는 자신의 ordinal에 해당하는 Kafka partition을 찾지 못합니다. 이 경우 해당 ingester는 정상적으로 consume할 partition을 얻지 못하고 startup에서 대기하거나 에러를 발생시킬 수 있습니다.

결국 이 특이한 디자인은 두 가지 목적이 맞물린 결과로 이해할 수 있습니다. 하나는 기존 Grafana Mimir/Loki 계열 시스템이 state와 ownership의 SSoT로 사용해온 hash ring 모델을 계속 활용하는 것입니다. 다른 하나는 운영자가 조정해야 하는 파라미터를 단순하게 만드는 것입니다. Kafka consumer group rebalance에 partition ownership을 맡기는 대신, Mimir의 ring과 StatefulSet ordinal로 ownership을 명시하면, 운영자는 “partition count와 ingester ordinal을 맞춘다”는 단순한 규칙으로 capacity를 설계하고 확장할 수 있습니다.

정리하면 이렇습니다.

  • Mimir ingester는 단순 consumer가 아니라 read path에서 최근 데이터를 제공하는 component의 역할도 하고 있습니다.
  • Querier는 hash ring을 보고 어떤 partition과 ingester를 읽어야 하는지 결정합니다.
  • 따라서 partition ownership은 Kafka consumer group rebalance처럼 암묵적으로 움직이기보다, Mimir ring에서 명시적으로 관리되어야 합니다.
  • 이 제약 덕분에 운영자는 partition count와 ingester ordinal을 1:1로 맞추는 단순한 모델로 capacity를 설계할 수 있습니다.

6. 새 consumer group은 backfill storm을 만들 수 있다

PoC 중 가장 당황스러웠던 문제는 새 zone에 ingester를 추가했을 때 Kafka backlog를 한꺼번에 replay하면서 MSK broker와 ingester가 크게 흔들린 일이었습니다.

원인은 StatefulSet/pod 이름 변경이었습니다. zone-aware 전환 과정에서 pod 이름이 기존 mimir-ingester-* 형태에서 mimir-ingester-zone-a/b/c-* 형태로 바뀌었습니다. Mimir ingester는 partition을 읽을 때 instance ID 기준으로 consumer group을 만듭니다. 따라서 이름이 바뀌면 기존 committed offset이 없는 새로운 consumer group이 생깁니다.

문제는 startup consume position의 의미였습니다.

당시 기본값에 가까운 last-offset은 “마지막 committed offset이 있으면 그 지점부터 읽는다”는 의미로 이해할 수 있습니다. 그런데 완전히 새로운 consumer group에는 committed offset이 없습니다. 이 경우 의도치 않게 partition start부터 읽기 시작할 수 있습니다.

sequenceDiagram
    participant I as New ingester<br/>new instance ID
    participant K as Kafka partition
    participant M as MSK broker

    I->>K: consumer group offset 조회
    K-->>I: committed offset 없음
    Note over I: startup position fallback<br/>partition start부터 consume
    I->>M: 과거 backlog Fetch 폭증
    M-->>I: Fetch throttling / BytesOut 증가
    Note over I: receive delay / lag 증가<br/>readiness 지연

실제로 관찰한 증상은 다음과 같았습니다.

  • ingester log에 starting consumption from partition start because no offset has been found 류의 메시지
  • MSK broker Fetch throttling
  • BytesOut 급증
  • ingester receive delay와 consumer lag 증가
  • readiness 지연 (lag > 15s일 때, consume은 계속해서 진행하되, readiness는 false로 바뀜)

운영 목적이 과거 backlog 재처리가 아니라 zone-aware ingester를 정상 기동시키는 것이었기 때문에, PoC에서는 startup position을 end로 명시했습니다. 즉 새 consumer group은 latest offset부터 읽게 하여 backlog replay를 의도적으로 skip했습니다.

여기서의 교훈은 다음과 같습니다.

  • StatefulSet 이름, pod 이름, instance ID가 바뀌면 consumer group도 바뀌는지 확인해야 합니다.
  • 새 consumer group에 committed offset이 없을 때 어디서부터 consume할지 명시해야 합니다.
  • end는 backlog storm을 피하지만, Kafka에만 남아 있던 과거 데이터는 의도적으로 skip하는 선택입니다.
  • timestamp는 cutover 시점을 명확히 정해야 할 때 유용할 수 있습니다.

Kafka를 buffer로 넣었다고 해서 항상 안전해지는 것은 아닙니다. Buffer는 장애를 흡수해주지만, 잘못 붙은 consumer는 Kafka 클러스터에 부하를 주면서 또 다른 장애를 만들 수 있습니다.


7. scale-in은 replicas만 줄이면 끝나는 일이 아니다

Scale-out은 비교적 직관적입니다. Kafka partition 수가 충분하고 새 ordinal에 해당하는 partition이 존재한다면 ingester를 늘릴 수 있습니다. 물론 partition ring lifecycle과 Pending → Active 전파 시간을 고려해야 합니다.

Scale-in은 조금 더 고민해야 했습니다. Ingest-storage architecture에서는 partition에 lifecycle이 있습니다.

  • Pending: 아직 read/write 불가
  • Active: distributor가 write하고 querier가 read하는 정상 상태
  • Inactive: distributor는 더 이상 write하지 않지만, querier는 아직 read해야 하는 상태

Scale-in에서 중요한 것은 해당 partition으로 새 write를 멈추고, 이미 들어간 데이터가 object storage에서 query 가능해질 때까지 기다린 뒤 ingester를 제거하는 것입니다.

Grafana 문서에서는 rollout-operator가 production에서 이 과정을 조율한다고 설명합니다. 하지만 저희가 사용한 Helm chart와 rollout-operator 기본 조합을 실제로 테스트해보니, “켜면 자동으로 안전한 scale-in이 완성된다”고 보기에는 제약이 있었습니다.

먼저 chart의 prepareDownscale: true는 StatefulSet scale-in admission 시점에 prepare endpoint를 호출하도록 설정합니다. 그런데 당시 chart에서는 path가 ingester/prepare-shutdown으로 하드코딩되어 있었습니다. 반면 ingest-storage partition scale-in에서 보고 싶었던 primitive는 /ingester/prepare-partition-downscale이었습니다. 이 endpoint는 partition을 ACTIVE -> INACTIVE로 바꾸어 distributor가 해당 partition에 더 이상 write하지 않게 만듭니다.

sequenceDiagram
    participant O as Operator / Runbook
    participant I as Ingester partition owner
    participant R as Partitions ring
    participant D as Distributor
    participant Q as Querier

    O->>I: POST /ingester/prepare-partition-downscale
    I->>R: partition ACTIVE -> INACTIVE
    D->>R: active partitions 조회
    Note over D: inactive partition에는 새 write 중단
    Q->>R: read 대상 partitions 조회
    Note over Q: inactive partition은 아직 read 대상
    O->>O: 충분한 delay / 지표 확인
    O->>I: prepare shutdown / scale-in

또 하나 헷갈렸던 점은 rollout-operator의 zone-aware scale-in 순서와 12h gate였습니다. grafana.com/min-time-between-zones-downscale=12h가 설정되어 있으면, 한 zone이 줄어든 뒤 다른 zone이 바로 줄어드는 것을 admission webhook이 막습니다. 이 자체는 안전장치로 유용합니다. 다만 webhook은 “대상 zone 자신의 마지막 downscale”이 아니라 같은 rollout group의 다른 StatefulSet들의 last-downscale을 검사합니다. 또한 rollout-downscale-leader annotation 때문에 zone-a → zone-b → zone-c처럼 follower가 leader replica 수를 따라가는 chain이 만들어집니다.

즉 c만 수동으로 줄여서 테스트하면 잠깐 통과하더라도 controller가 leader 상태를 보고 다시 replica를 맞출 수 있습니다. 정상적인 테스트는 chart가 만든 leader/follower 순서를 이해하고 진행해야 했습니다.

결국 당시 결론은 이랬습니다.

Mimir ingest-storage의 scale-in 보호 primitive는 존재하지만, mimir-distributed chart + rollout-operator 기본 설정만으로 저희가 원하는 “partition write stop → 충분한 drain 대기 → shutdown” 절차가 자동 완성되지는 않았습니다.

scale-in runbook은 다음과 같이 작성했습니다.

  1. /ingester/prepare-partition-downscale 호출
  2. distributor에서 scale-in target의 partition number에 write가 멈춘 것 확인
  3. reader request/offset/lag가 멈춘 것 확인
  4. 충분한 시간 이후 replicas 감소

8. Read consistency와 lag도 운영 지표가 된다

Ingest-storage architecture에서는 distributor가 Kafka에 write한 뒤 client에게 성공을 반환합니다. Ingester는 Kafka를 비동기로 consume합니다. 따라서 write가 성공한 직후의 데이터가 query path에서 즉시 보인다고 항상 가정할 수는 없습니다.

정상 상태에서는 ingestion end-to-end latency가 보통 1초 미만이어야 하지만, Kafka broker 문제나 ingester restart, backfill 상황에서는 lag가 커질 수 있습니다. Mimir는 strong read consistency가 필요한 query에 대해 X-Read-Consistency: strong header를 지원합니다. 이 경우 query-frontend가 Kafka partition의 최신 offset을 가져와 read path로 전파하고, ingester가 해당 offset까지 consume할 때까지 기다린 뒤 query를 수행합니다.

이 기능은 ruler처럼 read-after-write 성격이 필요한 곳에 중요합니다. 다만 lag가 큰 상황에서는 query latency가 증가하거나 timeout으로 이어질 수 있습니다.

그래서 ingest-storage 도입 후에는 기존 ingester CPU/memory뿐 아니라 다음 지표들이 더 중요해졌습니다.

  • Kafka broker BytesIn / BytesOut
  • broker Fetch throttling
  • partition별 consumer lag
  • cortex_ingest_storage_reader_receive_delay_seconds
  • cortex_ingest_storage_reader_last_consumed_offset
  • ingester startup lag / readiness 지연
  • distributor Kafka produce error / latency

Classic architecture에서 “ingester가 write를 받는가”를 보던 관점에서, 이제는 “Kafka에 잘 쌓이고 있는가”와 “ingester가 각 partition을 얼마나 빨리 따라잡는가”를 함께 봐야 합니다.


9. 도입 전에 체크했으면 좋았을 목록

마지막으로, 다시 도입 전으로 돌아간다면 먼저 체크할 것들을 목록으로 정리해보겠습니다.

Kafka / MSK

  • topic partition 수를 몇 개로 시작할 것인가?
  • 향후 partition 수 증가가 필요한 시점은 언제인가?
  • topic replication factor, min ISR, broker AZ 배치는 어떻게 할 것인가?
  • Mimir default record size에 맞게 Kafka message.max.bytes / topic max.message.bytes가 충분한가?
  • 인증 방식이 현재 Mimir 버전에서 지원되는가?
  • MSK broker BytesOut과 Fetch throttling alert를 준비했는가?

Mimir ingester 배치

  • ingester instance ID가 반드시 -<partition number>로 끝나는가?
  • StatefulSet ordinal과 Kafka partition number의 대응이 명확한가?
  • zone-aware를 몇 zone으로 운영할 것인가?
  • partition당 min. owner 수를 몇 개로 둘 것인가?

Migration / rollout

  • 기존 ingester 이름에서 새 이름으로 바뀌며 consumer group이 새로 생기지 않는가?
  • 새 consumer group에 committed offset이 없을 때 startup position은 무엇인가?
  • backfill이 필요하다면 broker와 ingester capacity를 충분히 확보했는가?
  • backfill이 필요 없다면 end 또는 timestamp를 명시했는가?

Scale-in

  • /ingester/prepare-partition-downscale를 어떤 절차로 호출할 것인가?
  • partition이 INACTIVE가 된 뒤 얼마나 기다릴 것인가?
  • 어떤 지표로 drain 완료를 판단할 것인가?
  • rollout-operator의 admission webhook, leader/follower annotation, 12h gate를 이해했는가?

결론

Mimir ingest-storage architecture는 단순히 Kafka만 추가하면 되는 간단한 작업은 아니었습니다. Mimir의 write/read path 책임 분리가 바뀌고, replication의 위치가 ingester에서 Kafka로 이동하며, ingester의 의미도 “write quorum을 구성하는 stateful writer”에서 “Kafka partition을 읽어 query path에 최근 데이터를 제공하는 partition owner”에 가까워집니다.

이 변화 덕분에 얻는 이점은 분명했습니다. 저희 PoC에서는 동일한 ingestion traffic에서 ingester CPU와 memory가 크게 줄었고, distributor와 ingester 사이 backpressure를 Kafka로 흡수할 수 있는 구조가 생겼습니다. Classic의 RF=3 fan-out으로 인한 TSDB head/WAL/block 중복 비용도 크게 줄었습니다. 다만 이는 공짜로 얻은 절감은 아닙니다. Kafka/MSK broker, storage, network traffic과 이를 운영하는 비용이 새로 추가됩니다. 이 글의 PoC 수치는 ingester 계층에서 줄어든 리소스를 보여줄 뿐, Kafka를 포함한 전체 monitoring stack의 총비용이 그만큼 감소했다는 의미는 아닙니다. 실제 비용 효과는 ingester 절감분과 Kafka 운영 비용을 함께 비교해야 합니다.

그리고 운영 모델도 함께 바뀝니다. 특히 ingester pod ordinal과 Kafka partition number가 1:1로 연결된다는 점, 새 instance ID가 새 consumer group과 offset 문제를 만들 수 있다는 점, scale-in이 partition lifecycle과 rollout-operator 동작을 이해해야 하는 작업이라는 점을 알게 되었습니다.