loadbalancingexporter 완전 가이드: 2-티어 아키텍처로 Tail Sampling 정확도 보장하기
Kubernetes 기반 Observability 파이프라인을 운영하다 보면 어느 날 이런 상황을 맞닥뜨리게 됩니다. Tier 2 Collector Pod가 4개인데 그 중 1개에 전체 트래픽의 80%가 집중되어 있고, 나머지 3개는 거의 유휴 상태입니다. 더 심각한 문제는 지연 시간 임계값을 초과한 요청도, 에러가 발생한 트레이스도 tail sampling policy에 걸리지 않아 그냥 통과되고 있다는 점입니다. 원인은 단 하나입니다. 표준 Kubernetes Service의 라운드로빈이 동일 트레이스에 속한 스팬들을 여러 인스턴스에 분산시켜, 어떤 인스턴스도 트레이스 전체를 볼 수 없게 만들기 때문입니다.
loadbalancingexporter는 이 문제를 consistent hash ring으로 해결합니다. traceID를 기준으로 해싱하면 동일 트레이스의 모든 스팬은 항상 같은 Tier 2 인스턴스로 라우팅됩니다. 이 글을 읽고 나면 DNS resolver와 k8s resolver를 상황에 맞게 선택·설정할 수 있고, 2레벨 Resiliency로 Pod 재시작 중 데이터 유실을 최소화하며, PromQL 쿼리로 인스턴스 간 트래픽 편향(load skew, uneven distribution, 부하 불균형)을 실시간으로 감지할 수 있게 됩니다.
이 글은 Kubernetes 환경에서 OpenTelemetry Collector를 운영하거나 수평 확장을 준비 중인 팀을 주 대상으로 합니다. 글 말미에는 AWS ECS/Fargate 환경을 위한 부록도 포함되어 있습니다.
핵심 개념
Consistent Hashing과 loadbalancingexporter
loadbalancingexporter는 OpenTelemetry Collector Contrib에 포함된 exporter 컴포넌트입니다. traceID, service, streamID 세 가지 라우팅 키(routing_key) 중 하나를 기준으로 consistent hash ring을 통해 데이터를 항상 동일한 다운스트림 Collector 인스턴스로 전달하는 것이 핵심 역할입니다.
Consistent Hashing이란? 노드가 추가·삭제될 때 해시 결과가 최소한으로 변경되도록 설계된 분산 알고리즘입니다. 일반 모듈러 해싱과 달리, 노드 증감 시 전체 키가 재배분되지 않아 토폴로지 변화에 강건합니다.
해시 링 동작 예시: 아래는 노드가 3개일 때 각 traceID가 가장 가까운 노드로 배정되고, 노드-D가 추가되면 일부 키만 재배분되는 흐름을 나타낸 것입니다.
해시 링 (노드 3개 → 노드-D 추가 시)
node-A
/ \
traceID-1 traceID-3 ─── (node-D 추가 후 → node-D로 이전)
| |
node-C node-B
\ /
traceID-2
노드-D 추가 → traceID-3만 재배분, traceID-1·traceID-2는 기존 노드 유지| routing_key | 주요 용도 | 기본 적용 신호 |
|---|---|---|
traceID |
트레이스 기반 tail sampling | traces |
service |
서비스별 부하 격리 | metrics |
streamID |
gRPC 스트림 고정 | - |
streamID는 gRPC 스트리밍으로 메트릭을 수집하는 시나리오에서 활용됩니다. 동일한 gRPC 스트림을 항상 같은 Collector 인스턴스로 고정해야 할 때 사용하며, traceID·service와 달리 특정 신호 유형에 기본 적용되지는 않습니다.
왜 표준 K8s Service로는 부족한가
Kubernetes Service의 기본 동작(라운드로빈 또는 IPVS 로드밸런싱)은 tail sampling 파이프라인에 치명적입니다. 동일 트레이스의 스팬들이 여러 Collector 인스턴스에 분산되면, 각 인스턴스는 트레이스의 일부만 가지고 있으므로 "이 트레이스의 전체 지연 시간이 500ms를 초과하는가?"와 같은 정책 판단이 불가능해집니다.
2-티어 아키텍처 전체 구조
[애플리케이션 / 에이전트]
│
[Tier 1 Collector] ← loadbalancingexporter (traceID 기준 consistent hash)
│
[Tier 2 Collector] ← tailsamplingprocessor (동일 traceID → 동일 인스턴스 보장)
│
[백엔드 (Jaeger / Grafana Tempo 등)]- Tier 1: 수신한 스팬을 traceID 기준으로 해시하여 특정 Tier 2 인스턴스에 전달합니다. 수평 확장이 자유롭고, 상태(state)를 거의 보유하지 않습니다.
- Tier 2: 동일 traceID의 모든 스팬이 동일 인스턴스에 집결되므로 tail sampling policy를 정확하게 적용할 수 있습니다.
decision_wait동안 스팬을 메모리에 보관합니다.
핵심: Tier 1과 Tier 2를 반드시 별도 Deployment로 분리해야 합니다. 같은 Pod에 합치면 Tier 2 재배포 시 데이터 수신 자체가 중단됩니다.
실전 적용
예시 1: DNS Resolver로 Pod IP 동적 조회하기
Kubernetes 헤드리스 서비스(headless service)를 이용하면 DNS A 레코드로 각 Pod의 IP를 직접 반환받을 수 있습니다. loadbalancingexporter의 dns resolver는 이 IP 목록을 주기적으로 재조회하여 해시 링을 갱신합니다.
먼저 Tier 2 서비스를 헤드리스로 선언합니다.
# tier2-headless-service.yaml
apiVersion: v1
kind: Service
metadata:
name: otelcol-tier2-headless
namespace: observability
spec:
clusterIP: None # 헤드리스 서비스 핵심 설정
selector:
app: otelcol-tier2
ports:
- name: otlp-grpc
port: 4317
targetPort: 4317그 다음, Tier 1 Collector에 dns resolver를 설정합니다.
# tier1-collector-config.yaml
exporters:
loadbalancing:
routing_key: traceID
protocol:
otlp:
timeout: 1s
tls:
insecure: true
resolver:
dns:
hostname: otelcol-tier2-headless.observability.svc.cluster.local
port: 4317
interval: 5s # DNS 재조회 주기 (Pod 증감 반영 시간)
timeout: 1s
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [loadbalancing] # exporter 이름 'loadbalancing'으로 연결주의:
clusterIP: None이 없으면 DNS가 단일 ClusterIP를 반환해 로드밸런싱이 전혀 되지 않습니다. 설정 후kubectl exec -it <pod> -n observability -- nslookup otelcol-tier2-headless.observability.svc.cluster.local을 실행해 여러 A 레코드가 반환되는지 확인해보시기를 권장합니다.
| 설정 항목 | 역할 | 권장값 |
|---|---|---|
hostname |
헤드리스 서비스 FQDN | 네임스페이스 포함 전체 경로 권장 |
interval |
DNS 재조회 주기 | 5~30s (토폴로지 변화 속도에 따라 조정) |
timeout |
DNS 쿼리 타임아웃 | 1~3s |
clusterIP: None |
헤드리스 선언 | 반드시 None이어야 Pod IP 반환 |
예시 2: k8s Resolver로 토폴로지 즉시 반영하기
k8s resolver는 Kubernetes EndpointSlice를 watch 방식으로 감시하여 DNS 폴링보다 훨씬 빠르게 Pod 추가·삭제를 반영합니다. Kubernetes 1.33+에서 기존 Endpoints 리소스가 deprecated됨에 따라 EndpointSlice 기반 k8s resolver가 권장됩니다.
# tier1-collector-config.yaml (k8s resolver 버전)
exporters:
loadbalancing:
routing_key: traceID
protocol:
otlp:
timeout: 1s
tls:
insecure: true
resolver:
k8s:
service: otelcol-tier2.observability
ports:
- 4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [loadbalancing]k8s resolver를 사용하려면 Collector ServiceAccount에 RBAC 권한이 필요합니다. ClusterRole을 사용하는 이유는 EndpointSlice watch가 네임스페이스 경계를 넘나들 수 있어야 하기 때문입니다. 네임스페이스 범위 Role로는 다른 네임스페이스의 엔드포인트를 감시할 수 없습니다.
# rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: otelcol-tier1-role
rules:
- apiGroups: ["discovery.k8s.io"]
resources: ["endpointslices"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: otelcol-tier1-rolebinding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: otelcol-tier1-role
subjects:
- kind: ServiceAccount
name: otelcol-tier1
namespace: observability| 비교 항목 | dns resolver | k8s resolver |
|---|---|---|
| 반영 방식 | 주기적 DNS 폴링 | EndpointSlice watch (이벤트 기반) |
| 반영 속도 | interval 설정에 따라 수 초~수십 초 | 거의 즉시 (수 초 이내) |
| 추가 설정 | headless service 필수 | RBAC 필수 |
| K8s 버전 의존 | 없음 | 1.21+ (EndpointSlice GA) |
예시 3: 2레벨 Resiliency와 헬스체크 연동하기
기본 설정에서 retry와 queue는 비활성화 상태입니다. Tier 2 Pod가 재시작되는 동안 라우팅된 스팬은 즉시 유실됩니다. 프로덕션 환경에서는 반드시 2레벨 Resiliency를 명시적으로 활성화해야 합니다.
num_consumers는 큐에서 동시에 스팬을 소비하는 goroutine 수로, Tier 2 Pod 수의 2~3배를 시작점으로 설정하는 것을 권장합니다. queue_size는 초당 유입 배치 수 × 예상 최대 재시도 시간(초)으로 추정할 수 있습니다. 예를 들어 초당 100개 배치, 최대 10초 재시도 환경이라면 1000이 적절한 기준이 됩니다.
# tier1-collector-config.yaml (Resiliency 설정 포함)
exporters:
loadbalancing:
routing_key: traceID
resolver:
dns:
hostname: otelcol-tier2-headless.observability.svc.cluster.local
interval: 5s
# Resiliency Level 1: Loadbalancer 레벨 재시도
queue:
enabled: true
num_consumers: 10 # Tier 2 Pod 수 × 2~3배를 시작점으로 설정
queue_size: 1000 # 초당 배치 수 × 최대 재시도 시간(초)으로 추정
retry_on_failure:
enabled: true
initial_interval: 5s
max_interval: 30s
max_elapsed_time: 300s
protocol:
otlp:
timeout: 5s
# Resiliency Level 2: 서브 exporter(개별 연결) 레벨 재시도
retry_on_failure:
enabled: true
initial_interval: 1s
max_interval: 10s
sending_queue:
enabled: true
queue_size: 100
extensions:
health_check:
endpoint: 0.0.0.0:13133 # /health 엔드포인트 노출주의: 이 설정만으로는 Collector 재시작 시 큐가 초기화됩니다.
loadbalancingexporter는 Persistent Queue를 지원하지 않으므로, 모든 서브 exporter가 동일한 인메모리 큐를 공유합니다. Collector 재시작 중 유입된 스팬의 유실 창(window)을 최소화하려면 Tier 2의 빠른 복구 전략(RollingUpdate 배포, preStop 훅 등)을 병행하는 것을 권장합니다.
2레벨 Resiliency 모델: Level 2(서브 exporter)는 개별 Tier 2 인스턴스와의 연결 문제를 처리하고, Level 1(loadbalancer)은 전체 파이프라인 관점에서 재시도를 담당합니다. 두 레벨을 함께 설정해야 장애 격리 효과가 극대화됩니다.
헬스체크 엔드포인트를 Kubernetes liveness/readiness probe에 연동하는 설정입니다.
# tier1-deployment.yaml (probe 부분)
livenessProbe:
httpGet:
path: /health
port: 13133
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /health
port: 13133
initialDelaySeconds: 5
periodSeconds: 10예시 4: Tier 2 Collector 설정 완성하기
Tier 1 설정만으로는 전체 파이프라인이 완성되지 않습니다. Tier 2에서 실제 tail sampling이 이루어지려면 tailsamplingprocessor가 포함된 설정이 필요합니다.
Tail Sampling이란? 트레이스의 모든 스팬이 수집된 후 전체 트레이스 데이터를 보고 샘플링 여부를 결정하는 방식입니다. 지연 시간 초과, 에러 발생 등 조건 기반 정책이 가능하지만, 모든 스팬이 동일 인스턴스에 모여야 한다는 전제가 있습니다.
# tier2-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
tail_sampling:
# decision_wait: 마지막 스팬 수신 후 샘플링 결정까지 대기 시간
# 이 시간 동안 해당 트레이스의 모든 스팬을 메모리에 보관합니다
decision_wait: 30s
num_traces: 50000 # 동시에 메모리에 보관할 최대 트레이스 수
policies:
- name: errors-policy
type: status_code
status_code:
status_codes: [ERROR]
- name: latency-policy
type: latency
latency:
threshold_ms: 500
- name: probabilistic-policy
type: probabilistic
probabilistic:
sampling_percentage: 10 # 나머지 트레이스는 10% 샘플링
exporters:
otlp:
endpoint: jaeger-collector:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling]
exporters: [otlp]
decision_wait와 메모리 트레이드오프:decision_wait값을 높이면 스팬 수집 완결성이 높아지지만, 해당 시간 동안 모든 트레이스가 메모리에 보관됩니다. 초당 트레이스 수 ×decision_wait(초) × 평균 스팬 크기만큼 메모리가 소비되므로,num_traces한도를 초과하면 오래된 트레이스부터 제거됩니다.tailsamplingprocessor심화 튜닝은 다음 글에서 자세히 다룹니다.
예시 5: 트래픽 편향(Skew) 실시간 모니터링하기
Tier 2 인스턴스 간 수신 스팬 수의 편차를 Prometheus 메트릭으로 모니터링할 수 있습니다.
# Tier 2 인스턴스별 스팬 전송률 (5분 기준)
sum by (exporter) (
rate(otelcol_exporter_sent_spans_total[5m])
)레이블 확인 방법: 실제 메트릭에서
exporter레이블 값은 환경에 따라loadbalancing/0,loadbalancing/1형태이거나 다를 수 있습니다.label_values(otelcol_exporter_sent_spans_total, exporter)로 실제 레이블 키·값을 먼저 확인한 뒤 쿼리를 조정해보시기를 권장합니다.
인스턴스 간 편차가 크게 나타난다면 아래 두 가지를 먼저 확인해보시기를 권장합니다.
- traceID 생성 방식 확인: 순차적(sequential) ID를 생성하는 비표준 방식은 해시 링에서 특정 노드로 집중될 수 있습니다. W3C Trace Context 표준을 따르는 랜덤 128비트 ID가 권장됩니다.
- routing_key 설정 확인: 의도와 다른 routing_key가 설정되어 있지 않은지 검토합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| Tail sampling 정확성 | 동일 traceID의 모든 스팬이 동일 Collector에 집결되어 sampling policy 판단이 정확합니다 |
| 동적 토폴로지 지원 | dns/k8s/aws_cloud_map resolver가 Pod 증감을 자동 반영합니다 |
| 결정론적 라우팅 | 동일 설정의 여러 Tier 1 인스턴스가 항상 동일한 라우팅 결과를 만들어냅니다 |
| 다중 신호 지원 | traces, metrics, logs 모두 처리 가능하며 routing_key를 신호 유형별로 다르게 설정할 수 있습니다 |
| 유연한 라우팅 기준 | traceID / service / streamID 세 가지 routing_key를 선택적으로 사용할 수 있습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 기본값 비안전 | retry/queue가 기본 비활성화 상태로 장애 시 즉각 데이터 유실 | 2레벨 retry/queue 명시적 활성화 |
| Persistent Queue 미지원 | 모든 서브 exporter가 동일 인메모리 큐를 공유하여 개별 영속 큐 불가 | 큐 크기 충분히 설정, Tier 2 빠른 복구 전략 병행 |
| DNS TTL 지연 | dns resolver는 topology 변화 반영에 수 초~수십 초 지연 발생 | interval 값 최적화 또는 k8s resolver로 전환 고려 |
| k8s resolver RBAC | EndpointSlice watch 권한 누락 시 backend 목록 갱신 불가 | ClusterRole에 endpointslices 권한 명시 |
| 해시 편향 가능성 | 비표준 traceID 생성(순차 ID 등) 사용 시 특정 Tier 2로 트래픽 집중 | W3C 표준 랜덤 128비트 traceID 사용 |
| v0.141.0 dns:/// 버그 | gRPC 레벨 DNS 라운드로빈 설정이 무력화되는 regression 존재 | v0.145.0 이상 버전 사용 권장 |
실무에서 가장 흔한 실수
-
headless service 미설정 후 원인 파악 실패:
clusterIP: None없이 dns resolver를 사용하면 단일 ClusterIP만 반환되어 로드밸런싱이 전혀 이루어지지 않습니다. 설정 적용 후 Pod 내부에서nslookup otelcol-tier2-headless.observability.svc.cluster.local을 실행했을 때 단일 IP만 반환된다면 headless 설정이 누락된 것입니다.kubectl get svc -n observability로CLUSTER-IP컬럼이None인지 확인해보시기를 권장합니다. -
retry/queue 기본값 신뢰로 인한 조용한 데이터 유실: 기본 설정 상태에서는 Tier 2 Pod 재시작만으로도 수 초~수십 초 분량의 스팬이 아무 오류 없이 유실됩니다. Collector 로그에서
"Dropping data because sending_queue is full"또는 연결 실패 메시지를 확인할 수 있다면 Resiliency 설정이 누락된 신호입니다. 프로덕션 배포 전 2레벨 Resiliency 설정이 포함되어 있는지 체크리스트로 검토해보시기를 권장합니다. -
k8s resolver RBAC 미적용 후 증상 추적 어려움: ClusterRole에
endpointslices권한이 없으면 resolver가 backend 목록을 갱신하지 못하고 기존 Pod IP에만 계속 라우팅합니다. Pod가 교체되어도 트래픽이 구 IP로 향하는 증상이 나타날 수 있습니다.kubectl logs <tier1-pod> -n observability | grep -i "endpoint\|resolver\|backend"로 resolver 관련 로그를 확인해보시기를 권장합니다.
마치며
loadbalancingexporter의 2-티어 아키텍처는 OpenTelemetry 공식 문서와 Grafana에서 권장하는, tail sampling 정확성과 수평 확장성을 동시에 확보하는 패턴입니다.
지금 바로 시작할 수 있는 3단계:
-
현재 설정 점검:
kubectl exec -it <tier1-pod> -n observability -- cat /conf/config.yaml로 resolver 방식을 확인하고,kubectl get svc -n observability로 Tier 2 서비스의CLUSTER-IP컬럼이None인지 점검해봅니다. headless 서비스가 맞다면 Pod 내부에서nslookup을 실행해 여러 A 레코드가 반환되는지까지 확인해봅니다. -
버전 업그레이드 및 Resiliency 활성화: 현재 Collector 버전이 v0.141.0 부근이라면 dns:/// 회귀 버그 영향을 받을 수 있으므로 v0.145.0 이상으로 업그레이드하는 것을 권장합니다. 이후
retry_on_failure와queue설정을 Tier 1 설정 파일에 명시적으로 추가하고 배포해봅니다. 배포 후 Collector 로그에서"backend <IP>:4317 added"형태의 메시지로 resolver가 올바르게 backend를 감지하는지 확인할 수 있습니다. -
트래픽 편향 모니터링 추가: Prometheus에
sum by (exporter) (rate(otelcol_exporter_sent_spans_total[5m]))쿼리를 추가하고 Grafana 대시보드에 시각화합니다. 먼저label_values(otelcol_exporter_sent_spans_total, exporter)로 실제 레이블 값을 확인한 뒤 쿼리를 조정해봅니다. 인스턴스 간 편차가 2배 이상 지속된다면 traceID 생성 방식 또는 routing_key 설정을 재점검하는 것을 권장합니다.
다음 글:
tailsamplingprocessor정책 심화 — 지연 시간, 에러율, 속성 기반 복합 정책 설계와decision_wait튜닝으로 메모리 사용량을 최적화하는 방법
부록: AWS ECS/Fargate 환경의 Cloud Map Resolver
Kubernetes 외 환경인 AWS ECS/Fargate에서는 aws_cloud_map resolver를 활용할 수 있습니다.
resolver:
aws_cloud_map:
namespace: "otel-collectors"
service_name: "tier2-collector"
port: 4317
interval: 30s
timeout: 5s참고 자료
- loadbalancingexporter README | OpenTelemetry Collector Contrib
- Collector Scaling 가이드 | OpenTelemetry 공식
- Gateway 배포 패턴 | OpenTelemetry 공식
- Tail Sampling 개념 및 2-tier 아키텍처 | OTel 공식 블로그
- Scale Alloy tail sampling | Grafana 공식 문서
- OpenTelemetry Resiliency 가이드 | OpenTelemetry 공식
- k8s resolver EndpointSlice 전환 이슈 #40871 | GitHub
- dns:/// 스킴 regression 이슈 #14372 | GitHub
- k8s resolver 예제 | OpenTelemetry Collector Contrib GitHub
- OpenTelemetry Collector Reference Architectures | Elastic