ress 의 기술블로그
monitoring2026년 3월 31일

Loki/Tempo OOM과의 전쟁: Kafka Consumer Throttling과 GOMEMLIMIT

재시작 시 Kafka backlog 폭주로 반복 OOM이 발생하는 악순환을 3가지 축으로 끊은 기록

🎯 한 줄 요약#

Loki/Tempo OOM → crash → 재시작 → Kafka backlog 폭주 → 또 OOM. 이 악순환을 Kafka Consumer Throttling + 청크/블록 튜닝 + GOMEMLIMIT 3가지 축으로 끊었습니다.


🤔 문제 분석#

악순환 사이클#

모니터링 백엔드가 계속 죽었습니다. 한 번 OOM으로 죽으면 끝이 아니었습니다.

┌─────────────────────────────────────────────────────────────┐
│                    OOM 악순환 사이클                          │
│                                                             │
│         ┌──────────────┐                                    │
│         │ Loki / Tempo │                                    │
│         │   OOM 발생   │                                    │
│         └──────┬───────┘                                    │
│                │                                            │
│                ▼                                            │
│         ┌──────────────┐       ┌──────────────────┐         │
│         │   Pod Crash  │──────▶│  재시작 (restart) │         │
│         │   & Restart  │       └────────┬─────────┘         │
│         └──────────────┘                │                   │
│                ▲                        ▼                   │
│                │               ┌────────────────┐           │
│                │               │  Kafka backlog  │           │
│                │               │  한꺼번에 유입   │           │
│                │               └────────┬───────┘           │
│                │                        │                   │
│                │                        ▼                   │
│                │               ┌────────────────┐           │
│                │               │  메모리 급증     │           │
│                └───────────────│  → 또 OOM!      │           │
│                                └────────────────┘           │
│                                                             │
│   ※ 이 사이클이 무한 반복. 수동 개입 없이는 복구 불가        │
└─────────────────────────────────────────────────────────────┘

Loki든 Tempo든, OOM으로 죽으면 재시작됩니다. 그런데 죽어 있던 동안 Kafka에 데이터가 쌓입니다. 재시작하자마자 이 backlog를 한꺼번에 소비하면서 메모리가 폭증합니다. 그리고 또 OOM.

뭐지? 왜 매번 같은 패턴으로 죽는 거야?

원인을 파고들어 보니 3가지 축에서 동시에 문제가 있었습니다.

환경#

  • Kind 단일 클러스터 (5노드, 32GB RAM)
  • Loki: SingleBinary 모드, Dev 1536Mi / Prod 1Gi memory limit
  • Tempo: SingleBinary 모드, Dev 4Gi / Prod 2Gi memory limit
  • OTel Collector Back: Kafka consumer → Tempo/Loki 전송

SingleBinary 모드라서 ingestion, compaction, query가 한 Pod에서 돌아갑니다. 리소스 경합이 심할 수밖에 없는 구조입니다.

문제 축 1: Kafka Consumer — 재시작 폭주#

OTel Collector Back의 Kafka receiver 설정을 살펴봤습니다.

항목현재 값문제
fetch_max1MB (기본)재시작 시 backlog를 빠르게 소비하며 메모리 급증
max_processing_time100ms (기본)tail_sampling 처리 시간 부족
Loki exporter retry/queue미설정Loki 장애 시 데이터 유실, back pressure 없음

기본 max_fetch_size는 1MB지만, Kafka에 backlog가 쌓인 상태에서 consumer가 빠르게 소비하면서 메모리가 급증하는 문제는 여전히 발생합니다. 명시적으로 더 큰 값(5-10MB)을 설정하고 max_processing_time을 늘려 처리 여유를 주는 것이 핵심입니다.

아! fetch 크기 자체보다, 처리 속도 조절이 관건이었습니다.

문제 축 2: Loki 청크 — 메모리 과점유#

항목현재 (기본값)문제
chunk_idle_period30midle 청크가 30분간 메모리 점유
max_chunk_age2h최대 2시간 메모리 체류
GOMEMLIMIT미설정Go GC가 컨테이너 limit을 모름
Prod memory limit1GiSingleBinary에 부족
쿼리 parallelismTSDB 기준 128대량 쿼리 시 OOM

chunk_idle_period 30분이면, 데이터가 안 들어오는 스트림의 청크도 30분간 메모리에 남습니다. max_chunk_age 2시간은 활성 스트림의 청크가 최대 2시간 동안 메모리를 점유한다는 뜻입니다.

쿼리 parallelism도 문제였습니다. TSDB 스키마 기준 tsdb_max_query_parallelism이 128입니다 (일반 max_query_parallelism은 32). Grafana에서 대시보드 하나 열면 쿼리가 동시에 쏟아지는데, 128개 병렬 처리는 SingleBinary 모드에서 감당이 안 됩니다.

문제 축 3: Tempo Prod — Dev와 설정 불일치#

항목DevProd문제
max_block_duration5m30m (기본)Prod에서 블록이 6배 오래 메모리 점유
trace_idle_period10s25s (기본)idle trace 정리 지연
ingestion rate50MB/s무제한spike 시 보호 없음
GOMEMLIMIT미설정미설정양쪽 모두 GC 문제

Dev에서는 max_block_duration 5분으로 튜닝해놓고, Prod는 기본값 30분 그대로였습니다. 같은 SingleBinary 모드인데 Prod가 6배 더 오래 메모리를 점유합니다.


🔍 검토한 대안#

A. Distributed Mode 전환 — 기각#

Loki를 SimpleScalable/Distributed 모드로, Tempo도 ingester/compactor/querier를 분리하는 방안입니다.

  • 장점: 컴포넌트별 독립 스케일링, 장애 격리
  • 단점: Kind 32GB 환경에 과도한 리소스. 최소 Loki 3pod + Tempo 5pod 필요
  • 판정: 기각. 모니터링만으로 8pod 추가는 현재 환경에 맞지 않음. EKS prod 전환 시 재검토

B. 현재 아키텍처 유지 + 설정 최적화 — 채택#

3가지 축을 동시에 강화하는 방안입니다.

  • 장점: 코드 변경 없음, values 수정만으로 해결, 즉시 적용 가능
  • 단점: SingleBinary 한계는 존재 (query와 ingestion 경합)
  • 판정: 채택

C. Kafka retention 단축 — 부분 채택 가능#

traces retention을 1h에서 15m으로, logs를 2h에서 30m으로 줄이는 방안입니다.

  • 장점: 재시작 시 backlog 물량 자체가 적어짐
  • 단점: Loki/Tempo가 30분 이상 죽으면 데이터 영구 유실
  • 판정: consumer throttling이 더 근본적 해결. 보조적으로 병행 가능

✅ 3가지 축 동시 강화#

이것이 핵심입니다. 한 축만 고쳐서는 악순환이 끊어지지 않습니다.

축 1: Kafka Consumer Throttling#

재시작 시 한 번에 가져오는 데이터를 제한합니다. Kafka retention(1h/2h) 내에 모두 소화되므로 데이터 유실은 없습니다.

# OTel Collector Back — Kafka receiver 설정
kafka/traces:
  consumer:
    fetch_max: 5242880       # 5MB/fetch (명시적 제한)
    max_processing_time: 1s  # tail_sampling 처리 여유 확보

kafka/logs:
  consumer:
    fetch_max: 10485760      # 10MB/fetch
    max_processing_time: 1s

max_processing_time을 100ms에서 1s로 늘린 것이 중요합니다. tail_sampling processor가 trace를 모아서 판단하는데, 100ms로는 처리가 밀리면서 메모리가 쌓였습니다. 1초의 여유를 주면 처리와 소비가 균형을 이룹니다.

축 2: Loki Exporter retry + sending_queue#

Loki가 일시적으로 장애 상태일 때, OTel Collector가 데이터를 버리지 않도록 버퍼를 둡니다.

# OTel Collector Back — Loki exporter 설정
otlphttp/loki:
  retry_on_failure:
    enabled: true
    max_elapsed_time: 300s   # 5분간 재시도
  sending_queue:
    enabled: true
    queue_size: 500          # 500 batches 버퍼링

queue가 가득 차면 Kafka consumer에 자연스러운 back pressure가 걸립니다. Tempo exporter에는 이미 retry + queue_size 2000이 설정되어 있었기 때문에 Loki 쪽만 추가하면 됩니다.

이것이 핵심 포인트입니다. Loki OOM → Collector가 데이터 유실 → 재시작 후 다시 보내려고 Kafka 재소비 → 또 OOM. 이 고리를 sending_queue가 끊어줍니다.

축 3: Loki 청크/GOMEMLIMIT + Tempo ingester 튜닝#

Loki Before/After:

설정BeforeAfter근거
chunk_idle_period30m (기본)5midle 청크 빠른 flush
max_chunk_age2h (기본)30m메모리 체류 시간 4배 단축
GOMEMLIMIT미설정limit의 90%OOM 전 GC 적극 개입
Prod memory limit1Gi2GiSingleBinary 최소 요구
쿼리 parallelism128 (TSDB 기본)8쿼리 시 OOM 방지

Tempo Before/After (Prod):

설정BeforeAfter근거
max_block_duration30m (기본)5mDev와 통일, 메모리 6배 절감
trace_idle_period25s (기본)10sidle trace 빠른 정리
ingestion rate무제한15MB/s + 30MB burstspike 보호
GOMEMLIMIT미설정limit의 90%OOM 전 GC 적극 개입

GOMEMLIMIT에 대해 짚고 넘어갈 부분이 있습니다.

GOMEMLIMIT은 soft limit입니다. Go 런타임이 이 한도에 가까워지면 GC를 더 적극적으로 수행하지만, 절대적 보장은 아닙니다. 그래도 미설정 시 Go GC가 컨테이너 메모리 limit을 모르고 동작하는 것보다 훨씬 효과적입니다.

컨테이너 memory limit이 2Gi면, GOMEMLIMIT을 90%인 ~1.8Gi로 설정합니다. Go 런타임이 1.8Gi 근처에서 GC를 적극적으로 수행하기 때문에, limit 2Gi에 도달해서 OOM Kill되는 것을 방지할 수 있습니다.

쿼리 parallelism 128 → 8 변경도 큰 효과가 있습니다. TSDB 스키마 기준 tsdb_max_query_parallelism이 기본 128인데 (일반 max_query_parallelism은 32), SingleBinary에서 128개 병렬 쿼리는 ingestion과 리소스를 심하게 경합합니다. 8로 줄이면 쿼리 속도는 느려지지만, OOM 위험이 크게 줄어듭니다.


📊 메모리 예상 효과#

Loki (Prod)#

Before: chunk 2h 체류 x stream 수 + GC 미개입 → ~1Gi 초과 → OOM (limit: 1Gi)
After:  chunk 30m 체류 x stream 수 + GOMEMLIMIT GC → ~800Mi 안정 (limit: 2Gi)

메모리 limit을 2Gi로 올리면서 동시에 실제 사용량을 800Mi 수준으로 낮추는 것이 포인트입니다. 여유분이 충분해야 burst 트래픽도 버틸 수 있습니다.

Tempo (Prod)#

Before: block 30m 체류 + 무제한 ingestion → ~2Gi 초과 → OOM (limit: 2Gi)
After:  block 5m 체류 + 15MB/s rate limit + GOMEMLIMIT GC → ~1.2Gi 안정 (limit: 2Gi)

악순환 차단 후#

┌─────────────────────────────────────────────────────────────┐
│               악순환 차단 — 3가지 축 적용 후                  │
│                                                             │
│                  ┌──────────────────┐                        │
│                  │  Kafka backlog   │                        │
│                  │  (재시작 후 존재) │                        │
│                  └────────┬─────────┘                        │
│                           │                                 │
│                           ▼                                 │
│              ┌────────────────────────┐                      │
│              │  축1: Consumer Throttle │                      │
│              │  fetch_max 5-10MB      │                      │
│              │  processing_time 1s    │                      │
│              └────────────┬───────────┘                      │
│                           │ 점진적 소비                      │
│                           ▼                                 │
│              ┌────────────────────────┐                      │
│              │  축2: sending_queue    │                      │
│              │  500 batch 버퍼        │                      │
│              │  back pressure 전달    │                      │
│              └────────────┬───────────┘                      │
│                           │ 안정적 전송                      │
│                           ▼                                 │
│              ┌────────────────────────┐                      │
│              │  축3: 백엔드 튜닝      │                      │
│              │  청크/블록 빠른 flush   │                      │
│              │  GOMEMLIMIT GC 개입    │                      │
│              └────────────┬───────────┘                      │
│                           │                                 │
│                           ▼                                 │
│              ┌────────────────────────┐                      │
│              │  ✅ 메모리 안정 유지    │                      │
│              │  Loki ~800Mi / 2Gi     │                      │
│              │  Tempo ~1.2Gi / 2Gi    │                      │
│              │                        │                      │
│              │  ❌ OOM 사이클 차단!    │                      │
│              └────────────────────────┘                      │
│                                                             │
│   ※ backlog가 있어도 점진적으로 소화 → OOM 없이 복구          │
└─────────────────────────────────────────────────────────────┘

3가지 축이 동시에 작동하면서 악순환 고리가 끊어졌습니다.

재시작 시 Kafka backlog가 있어도 throttling으로 점진적으로 소비합니다. 백엔드가 일시 장애여도 sending_queue가 버퍼링합니다. 그리고 백엔드 자체도 청크/블록을 빠르게 flush하고 GOMEMLIMIT으로 GC가 적극 개입하기 때문에, 메모리가 limit에 도달하지 않습니다.


📚 핵심 포인트#

OOM은 메모리 부족이 아니라 유입 제어 부재가 원인입니다.

메모리를 아무리 늘려도, 유입 속도를 제어하지 않으면 결국 OOM이 발생합니다. 특히 Kafka와 같은 버퍼가 있는 파이프라인에서는 재시작 후 backlog 폭주가 치명적입니다.

3가지를 기억하겠습니다:

  1. 유입 제어가 첫 번째: Consumer throttling으로 backlog를 점진적으로 소화
  2. 장애 격리가 두 번째: sending_queue로 백엔드 장애가 전체 파이프라인을 무너뜨리지 않도록 차단
  3. 백엔드 최적화가 세 번째: 청크/블록 체류 시간 단축 + GOMEMLIMIT으로 메모리 효율화

이 세 축이 동시에 작동해야 악순환이 끊어집니다. 하나만 고치면 다른 경로로 같은 문제가 반복됩니다.

다음에 읽을 글