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

Alloy에서 OTel Collector로 전환한 이유

River 문법의 한계와 Kafka 버퍼링 확장을 위해 CNCF 표준 수집기로 전환한 결정

🎯 한 줄 요약#

Grafana Alloy의 River 문법과 제한된 커뮤니티 자료 때문에 CNCF 표준 OTel Collector로 전환했습니다. Kafka 버퍼링을 logs/traces 전체로 확장해 30만 동시 접속 스파이크에 대비합니다.


🤔 배경: Alloy의 한계#

현재 아키텍처#

Goti 프로젝트의 관측성 파이프라인은 이렇게 구성되어 있었습니다.

{/* TODO: Draw.io로 교체 */}

┌──────────────────┐
│  Spring Boot     │
│  (OTel SDK)      │
└────────┬─────────┘
         │ OTLP (gRPC/HTTP)
         ▼
┌──────────────────────────────────────┐
│  Grafana Alloy (DaemonSet, v1.6.1)  │
│  ┌─────────┐ ┌─────────┐ ┌───────┐ │
│  │ Metrics  │ │  Logs   │ │Traces │ │
│  └────┬─────┘ └────┬────┘ └───┬───┘ │
└───────┼─────────────┼─────────┼──────┘
        │             │         │
        ▼             ▼         ▼
   ┌─────────┐  ┌─────────┐  ┌───────────┐
   │  Mimir   │  │  Loki   │  │   Kafka   │
   │ (분산 7P)│  │ (직접)  │  │(traces만) │
   └─────────┘  └─────────┘  └─────┬─────┘
                                    ▼
                              ┌───────────┐
                              │   Tempo    │
                              └───────────┘

핵심 문제는 logs가 Kafka 없이 Loki로 직접 전송된다는 점입니다. Traces만 Kafka로 버퍼링하고 있었습니다.

문제점 4가지#

이 구조에서 운영하면서 4가지 문제를 느꼈습니다.

1. 커뮤니티 자료 부족

Alloy는 River라는 독자적인 문법을 사용합니다. Kafka 연동에서 문제가 생기면, 검색해도 OTel Collector YAML 기반 자료만 나옵니다. River 문법으로 변환하는 건 결국 팀원이 직접 해야 했습니다.

2. Kafka 연동 자료 부족

otelcol.exporter.kafka 블록의 세부 설정을 찾기 어려웠습니다. OTel Collector contrib의 kafkaexporter는 문서화가 잘 되어 있지만, Alloy에서 래핑한 버전은 예제가 부족합니다.

3. 스파이크 대응 불완전

30만 동시 접속 목표인 Goti에서, 초기 폭주 시 logs가 Loki로 직접 쏟아지면 OOM 위험이 있었습니다. Traces는 이미 Kafka로 버퍼링하고 있는데, logs만 빠져 있는 구조였습니다.

결국 logs도 Kafka 버퍼링이 필요했습니다.

4. 벤더 종속

Alloy에는 Grafana 전용 블록이 꽤 있습니다.

  • loki.process: Loki 전용 로그 처리
  • prometheus.remote_write: Prometheus 전용 전송
  • mimir.rules.kubernetes: Mimir 전용 rule sync

이런 블록에 의존하면 나중에 다른 백엔드로 전환하기 어렵습니다.


🔍 대안 비교#

수집기: 3가지 옵션#

항목Alloy 유지OTel CollectorPrometheus Agent + OTel
Kafka 연동River 문법 (자료 적음)공식 contrib (자료 풍부)OTel만 Kafka, Agent는 별도
OTLP 수신otelcol.* 래핑네이티브OTel에서 처리
K8s metrics내장 prometheus.operator.*Preset (kubernetesAttributes)Agent가 담당
Loki 연동loki.process (전용)otlphttp → /otlp (표준)동일
커뮤니티Grafana 중심CNCF 전체분산
River 의존있음없음 (표준 YAML)없음
커스텀 빌드불가ocb로 경량 빌드불가
파이프라인 UI12345 포트없음 (대시보드 대체)없음

OTel Collector가 커뮤니티, Kafka 연동, 표준 YAML 면에서 압도적이었습니다. 유일한 단점은 Alloy의 파이프라인 시각화 UI를 잃는 것인데, Grafana 대시보드로 대체할 수 있었습니다.

메트릭 저장소: Mimir 분산 유지#

"활성 시리즈가 약 50만인데 분산 모드가 과잉 아닌가?"라는 의문이 있었습니다.

항목Mimir 분산 유지Mimir 싱글바이너리Prometheus 단독
Pod 수7 + Kafka + MinIO11
활성 시리즈 한계수천만~100만~50만
OTLP native지원지원미지원
장기 보관S3S3/filesystem로컬만
HA다중 replica단일단일

싱글바이너리로 가고 싶었지만, 현실적으로 불가능했습니다.

공식 mimir-distributed Helm chart에 SingleBinary 모드가 없습니다 (Grafana Discussion #6211). SimpleScalable 모드도 Mimir 3.0에서 제거되었습니다 (PR #12584). 커스텀 StatefulSet을 직접 만드는 것보다 공식 chart를 유지하는 것이 운영 부담이 적다고 판단했습니다.

분산 모드가 과잉인 건 맞지만, 공식 chart의 한계 때문에 현상 유지를 선택한 것입니다.

스파이크 버퍼: Kafka vs Redis Streams vs 내부 큐#

항목Kafka (기존 공유)Redis StreamsCollector 내부 큐
추가 Pod01+0
버퍼 용량디스크 기반 (대용량)메모리 기반메모리 기반
내구성디스크 + replicationAOF/RDB프로세스 종료 시 유실
이미 운영 중Yes (traces용)No-
logs 버퍼링topic 추가만구현 필요제한적

백엔드팀이 이미 Strimzi Kafka를 운영하고 있었습니다. Traces 버퍼링도 Kafka topic으로 하고 있었습니다.

추가 인프라 비용 0으로 logs topic만 추가하면 되는 Kafka가 가장 현실적이었습니다.

30만 스파이크 시 초당 수만 건의 로그가 쏟아져도, Kafka의 디스크 기반 버퍼링은 OOM 걱정이 없습니다. Redis Streams나 Collector 내부 큐는 메모리 기반이라 대용량 스파이크에 취약합니다.


✅ 결정: 목표 아키텍처#

Before / After#

Before: Alloy 기반

{/* TODO: Draw.io로 교체 */}

┌──────────────────┐
│  Spring Boot     │
│  (OTel SDK)      │
└────────┬─────────┘
         │ OTLP
         ▼
┌──────────────────────────────────────┐
│  Grafana Alloy (DaemonSet)           │
│  metrics ──→ Mimir 분산 (직접)       │
│  logs    ──→ Loki (직접) ⚠️ 버퍼 없음│
│  traces  ──→ Kafka → Alloy → Tempo  │
└──────────────────────────────────────┘

After: OTel Collector + Kafka 전면 버퍼링

{/* TODO: Draw.io로 교체 */}

┌──────────────────┐
│  Spring Boot     │
│  (OTel SDK)      │
└────────┬─────────┘
         │ OTLP (4317/4318)
         ▼
┌──────────────────────────────────────────┐
│  OTel Collector Front (Deployment, 1-2r) │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│  │ Metrics   │ │  Logs    │ │  Traces  │ │
│  └────┬──────┘ └────┬─────┘ └────┬─────┘ │
└───────┼──────────────┼────────────┼───────┘
        │              │            │
        ▼              ▼            ▼
   ┌─────────┐   ┌──────────┐ ┌──────────┐
   │  Mimir   │   │  Kafka   │ │  Kafka   │
   │ (분산)   │   │ logs.v1  │ │traces.v1 │
   │ (직접)   │   └────┬─────┘ └────┬─────┘
   └─────────┘        │            │
                       ▼            ▼
              ┌────────────────────────────┐
              │ OTel Collector Back (1-2r)  │
              │ tail sampling, PII masking  │
              │ logs  ──→ Loki OTLP        │
              │ traces ──→ Tempo           │
              └────────────────────────────┘

┌──────────────────────────────────────────┐
│ K8s ServiceMonitor / PodMonitor          │
│ → Prometheus (kps) → remoteWrite → Mimir │
└──────────────────────────────────────────┘

핵심 변화를 정리하면 다음과 같습니다.

Metrics: OTel Collector Front에서 prometheusremotewrite exporter로 Mimir에 직접 전송합니다. Kafka를 거치지 않는 이유는, 메트릭은 실시간성이 중요하고 볼륨도 상대적으로 적기 때문입니다.

Logs: 기존에 Loki 직접 전송이던 것을 Kafka topic(observability.logs.v1)으로 전환했습니다. Collector Back이 Kafka에서 소비해 Loki OTLP로 전달합니다.

Traces: 기존과 동일하게 Kafka 버퍼링을 유지합니다. 다만 Alloy consumer 대신 OTel Collector Back이 소비합니다.

결정 근거#

1. OTel Collector: 커뮤니티와 Kafka 연동

kafkaexporter/kafkareceiver가 공식 contrib으로 문서화가 잘 되어 있습니다. 팀원이 트러블슈팅할 때 검색하면 바로 답을 찾을 수 있습니다.

Alloy의 otelcol.* 블록이 OTel Collector의 컴포넌트와 개념적으로 대응하기 때문에 어떤 컴포넌트가 필요한지는 동일합니다. 하지만 파이프라인 배선 구문은 완전히 다르기 때문에 마이그레이션 시 YAML 재작성이 필요합니다.

OTel Collector는 YAML 기반 pipeline 정의를 사용합니다:

service:
  pipelines:
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [kafka]

반면 Alloy는 River 문법으로 output 블록을 명시적으로 연결합니다:

otelcol.receiver.otlp "default" {
  output {
    logs = [otelcol.exporter.kafka.default.input]
  }
}

컴포넌트 이름은 비슷하지만, 배선 방식이 근본적으로 다릅니다. 전환 리스크가 낮다고 오해하면 안 됩니다. YAML 재작성은 필수입니다.

2. Mimir 분산 유지: 공식 chart 한계

공식 mimir-distributed Helm chart에 SingleBinary 모드가 없습니다 (Discussion #6211). SimpleScalable도 Mimir 3.0에서 제거되었습니다. 커스텀 StatefulSet을 만드는 것보다 공식 chart를 유지하는 것이 장기적으로 안전합니다.

3. Kafka 공유: 추가 비용 0

백엔드팀이 이미 Strimzi Kafka를 운영하고 있고, traces도 이미 Kafka 버퍼링 중입니다. observability.logs.v1 topic 하나만 추가하면 됩니다.


📊 트레이드오프#

모든 전환에는 잃는 것이 있습니다. 솔직하게 정리합니다.

긍정적 영향#

영향정량적 효과
Pod 구성 변화Alloy DaemonSet 제거 → OTel Collector Front/Back 2개 + Prometheus 1개 추가
스파이크 대응logs + traces 모두 Kafka 버퍼링
학습 비용 절감표준 YAML, CNCF 전체 커뮤니티 자료 활용
벤더 중립Grafana 전용 블록 의존 제거

부정적 영향 / 리스크#

리스크확률영향대응
Mimir 분산 모드 리소스 과잉확정dev 수용, chart에 SingleBinary 추가 시 재검토
대시보드 40개 중 일부 깨짐otelcol_* 호환, alloy_* 1개 패널만 교체
log_type 라벨 동작 변경Loki OTLP native + distributor otlp_config 사용 중
PrometheusRule sync 방식 변경확정mimirtool CronJob으로 대체

상실하는 기능 3가지를 구체적으로 보면:

Alloy Web UI 상실: Alloy는 :12345 포트에서 파이프라인 DAG를 시각적으로 보여줬습니다. OTel Collector에는 이 기능이 없습니다. 대신 Grafana 대시보드(infra-otel-pipeline.json)로 파이프라인 상태를 모니터링할 계획입니다.

mimir.rules.kubernetes 자동 sync 상실: Alloy가 Kubernetes의 PrometheusRule CR을 감시해서 Mimir ruler에 자동으로 sync하던 기능입니다. OTel Collector에는 이 기능이 없으므로, mimirtool CronJob 또는 수동 sync로 대체해야 합니다.

Alloy 클러스터링 상실: DaemonSet 간 타겟 분배 기능입니다. Prometheus(kps)가 scrape + remoteWrite to Mimir 하는 구조로 대체합니다.


📚 핵심 포인트#

이번 결정에서 가장 중요한 교훈을 정리합니다.

도구보다 생태계를 봐야 합니다. Alloy 자체는 훌륭한 도구입니다. 하지만 River 문법이라는 독자적 생태계가 팀의 학습과 트러블슈팅을 느리게 만들었습니다. CNCF 표준(OTel Collector YAML)은 검색 한 번에 답이 나옵니다.

버퍼링은 전부 아니면 전무입니다. Traces만 Kafka로 버퍼링하고 logs는 직접 전송하는 반쪽짜리 구조는 위험했습니다. 30만 스파이크에서 logs가 Loki를 OOM시키면, traces만 살아남아도 의미가 없습니다.

공식 chart의 한계도 수용해야 합니다. Mimir 싱글바이너리가 이상적이지만, 공식 Helm chart가 지원하지 않으면 무리하게 커스텀하지 않는 것이 장기적으로 낫습니다. Helm chart에 SingleBinary 모드가 추가되면 그때 전환하면 됩니다.

향후 과제#

  • prod(EKS) 배포 시 Mimir 리소스 최적화 검토
  • OTel Collector Operator 도입 검토 (auto-instrumentation, sidecar injection)
  • Kafka SCRAM-SHA-512 인증 설정 (prod 전용)

다음에 읽을 글