채널코퍼레이션 DevOps팀이 Grafana Mimir에 Kafka 기반 ingest-storage architecture를 도입하며 겪은 운영 포인트를 정리했다. distributor와 ingester 사이에 durable buffer가 없어 ingester 부하가 곧장 429와 metrics 유실로 번지던 구조를 Kafka로 흡수하려 했고, Mimir 3.0에서 이 구조가 stable·preferred가 된 것도 이유였다. 그런데 Mimir가 Kafka를 쓰는 방식이 일반적인 consumer group 모델과 달라, ingester pod의 ordinal이 곧 Kafka partition number가 되는 1:1 결합에서 예상 못 한 함정이 나왔다.
핵심 포인트- Mimir ingester는 정확히 하나의 partition만 consume하고, 어떤 partition을 읽을지는 instance ID 끝의 -숫자에서 결정된다. StatefulSet ordinal이 사실상 partition number다.
- 같은 partition을 여러 zone의 ingester가 각자 offset을 갖고 동시에 읽는다. replica 설계는 consumer 수가 아니라 partition 수 × zone/owner 수로 생각해야 한다.
- PoC에서 ingester 리소스가 크게 줄었는데 원인은 RF다. classic은 distributor가 RF=3으로 fan-out하지만 ingest-storage는 partition 하나에만 쓰고 durability는 Kafka topic replication이 담당한다.
- zone-aware 전환으로 pod 이름이 바뀌자 consumer group이 새로 생겨 committed offset이 없었고, partition 처음부터 replay하며 MSK broker와 ingester가 함께 흔들렸다.
- scale-in은 replicas만 줄이면 되는 일이 아니다. partition을 ACTIVE에서 INACTIVE로 바꿔 write를 멈추고 drain을 기다린 뒤 내려야 한다.
상세 정리- 도입 배경 1: classic pipeline에는 distributor와 ingester 사이 backpressure를 흡수할 durable buffer가 없어 ingester capacity 기준으로 distributor rate-limit을 잡았다. 운영 중 429가 몇 차례 발생해 그만큼 metrics 유실이 있었고, 반대로 limit을 느슨히 잡으면 ingester 자체가 흔들렸다.
- 유실 경로 두 가지: write quorum 실패가 길어지면 Prometheus가 5xx를 재시도하는 동안 backlog가 쌓이고 2시간 뒤 WAL compaction에서 미전송분이 사라질 수 있다. 또 rate-limit을 넘긴 요청은 distributor가 429로 drop하는데 Prometheus는 기본적으로 429를 재시도하지 않아 retry_on_http_429를 켜지 않았다면 즉시 유실된다.
- Loki도 유사한 문제를 안고 있었다. otel-collector-gateway의 sending_queue가 memory 기반이라 upstream 전송 성공 시점과 Loki 저장 시점이 다르고, gateway가 restart나 OOM되면 미전달 logs가 사라진다.
- 구조 변화: write path의 1차 도착지가 Kafka가 되면서 distributor capacity를 ingester 즉시 처리량이 아니라 Kafka produce throughput·replication·broker capacity 기준으로 설계할 수 있게 됐고, 일시적 ingester 지연은 consumer lag로 흡수된다.
- 리소스 측정: 같은 remote_write 트래픽을 dual-write해 비교했을 때 distributor ingestion rate는 classic 약 313k samples/s, Kafka PoC 약 308k samples/s로 거의 같았지만 ingester가 처리하는 양이 크게 달랐다. classic은 RF=3이라 ingester 전체 입장에서 약 941k samples/s를 받는다.
- 다만 RF=1은 distributor write path와 ingester가 들고 있는 series 관점의 이야기다. zone-aware replication을 쓰면 같은 partition을 여러 zone owner가 consume해 S3 block은 owner 수만큼 생기고, 이 중복은 compactor의 vertical compaction이 정리한다. 진짜 이득은 ingester가 in-memory TSDB head와 local disk에 즉시 들고 있어야 할 부담이 줄어드는 데 있다.
- partition 수 산정: topic partition 수가 최소 한 zone의 ingester 수 이상이어야 한다. ordinal이 partition number가 되므로 partition 6을 읽는 ingester를 만들려면 topic에도 partition 6이 있어야 한다.
- PoC 구성: Kafka partition 7개에 3-zone zone-aware. Helm에서 ingester.replicas 21을 주면 zone별 StatefulSet이 replicas 7로 렌더링돼 총 21 pod, partition당 통상 owner 3이 된다.
- 헷갈리기 쉬운 설정: ingester.partition_ring.min_partition_owners_count는 partition이 ACTIVE가 되기 전 최소 owner 수를 뜻할 뿐, partition 개수나 pod 배치를 만들어주지 않는다. single StatefulSet에 replicas 12와 min owners 2를 준다고 6 partition × 2 owner가 되지 않는다.
- chart 제약: mimir-distributed 6.0.6에서 zoneAwareReplication.enabled=true면 zone을 최소 3개 정의하도록 template validation이 걸려 있어, 모델상 partition당 2 owner가 가능해도 chart values만으로는 2-zone 구성이 깔끔히 안 나와 3-zone으로 갔다.
- 왜 이런 제약인가: ingester는 단순 consumer가 아니라 read path에도 관여하는 stateful component다. querier가 partitions ring으로 어느 partition에 series가 있는지 판단하고 ingesters ring으로 그 partition을 소유한 healthy ingester를 찾으므로, ownership이 rebalance로 자유롭게 옮겨 다니면 곤란하다. ownership을 ring과 ordinal로 명시해 운영 파라미터를 단순화한 선택이다.
- 개수 불일치 시: partition이 ingester보다 많으면 남는 partition에 owner가 없어 사실상 안 쓰이고, ingester가 더 많으면 남는 pod가 자기 ordinal에 해당하는 partition을 못 찾아 startup에서 대기하거나 에러를 낸다.
- backfill storm: 이름이 바뀌어 새 consumer group이 생기자 last-offset 설정에서 committed offset이 없어 partition start부터 읽기 시작했다. 로그의 starting consumption from partition start 메시지, MSK Fetch throttling, BytesOut 급증, receive delay와 lag 증가, lag 15초 초과 시 readiness false가 증상이었다. 목적이 backlog 재처리가 아니었으므로 startup position을 end로 명시해 replay를 건너뛰었다.
- scale-in 실측: chart의 prepareDownscale는 당시 path가 ingester/prepare-shutdown으로 하드코딩돼 있었는데, ingest-storage에서 필요한 primitive는 /ingester/prepare-partition-downscale이었다. 이 endpoint가 partition을 ACTIVE에서 INACTIVE로 바꿔 distributor write를 멈춘다.
- rollout-operator 함정: min-time-between-zones-downscale=12h는 대상 zone 자신의 마지막 downscale이 아니라 같은 rollout group 내 다른 StatefulSet들의 last-downscale을 본다. rollout-downscale-leader annotation 때문에 zone-a → zone-b → zone-c 체인이 생겨, zone-c만 수동으로 줄이면 controller가 leader 상태를 보고 replica를 되돌릴 수 있다.
- 작성한 scale-in runbook: prepare-partition-downscale 호출 → distributor에서 해당 partition write 중단 확인 → reader request/offset/lag 정지 확인 → 충분한 시간 후 replicas 감소.
- read consistency: distributor가 Kafka write 후 성공을 반환하고 ingester는 비동기 consume이라, write 직후 데이터가 query에서 즉시 보인다고 가정할 수 없다. read-after-write가 필요한 ruler 같은 곳은 X-Read-Consistency strong 헤더로 최신 offset까지 consume을 기다리게 할 수 있으나 lag가 크면 query latency 증가나 timeout으로 이어진다.
- 새로 봐야 하는 지표: broker BytesIn/BytesOut, Fetch throttling, partition별 consumer lag, cortex_ingest_storage_reader_receive_delay_seconds, last_consumed_offset, ingester startup lag와 readiness 지연, distributor Kafka produce error와 latency.
- 비용 관점 단서: PoC 수치는 ingester 계층 절감만 보여줄 뿐, Kafka/MSK broker·storage·network와 운영 비용이 새로 붙으므로 전체 스택 총비용 감소를 뜻하지 않는다고 명시했다.
왜 읽나Mimir나 Loki 계열 관측 스택에 Kafka ingest-storage를 도입하려는 인프라·SRE 엔지니어가 partition 설계, consumer group 전환, scale-in 절차에서 밟을 함정을 미리 확인할 수 있다.