OpenTelemetry Collector 수평 확장: Agent-Gateway 구조와 loadbalancingexporter로 Tail Sampling 정확도 유지하기
tail_sampling 프로세서를 이미 사용 중이고, Collector 인스턴스를 2대 이상으로 늘리는 것을 검토 중이라면 이 글이 바로 여러분의 상황을 다루고 있습니다. 초당 수천 건 이상의 Span을 처리하는 환경에서 Collector 한 대로는 감당이 안 되는 시점이 반드시 옵니다. 그런데 단순히 인스턴스를 추가하는 것만으로는 해결되지 않는 문제가 생깁니다. 바로 Tail Sampling의 정확도 저하입니다. 같은 Trace에 속한 Span들이 서로 다른 Collector 인스턴스에 흩어지면, 에러가 발생한 요청임에도 어느 인스턴스에서도 보존되지 않는 상황이 발생할 수 있습니다.
이 글은 Kubernetes를 운영 중이고 OpenTelemetry를 막 도입한 백엔드·DevOps 엔지니어를 대상으로 합니다. Agent-Gateway 2계층 아키텍처와 loadbalancingexporter의 조합으로 수평 확장 환경에서도 Tail Sampling 집계 정확도를 유지하는 구체적인 방법을 손에 넣을 수 있습니다.
핵심 개념
Tail Sampling이 수평 확장에서 깨지는 이유
Head Sampling은 요청이 시작되는 시점에 "이 요청을 기록할지" 즉시 결정합니다. 반면 Tail Sampling은 Trace의 모든 Span이 수집된 이후에 전체 그림을 보고 결정합니다. 에러 여부, 전체 지연 시간, 특정 속성 값 등 실제 결과를 기준으로 샘플링할 수 있어 훨씬 정교한 정책 구성이 가능합니다.
문제는 이 방식이 "모든 Span이 동일한 인스턴스에 있어야 한다"는 전제를 요구한다는 점입니다. Collector를 세 대로 늘렸을 때 로드밸런서가 Span을 무작위로 분산시키면, 하나의 Trace를 구성하는 Span이 각기 다른 인스턴스에 나뉘어 들어갑니다.
# 단순 수평 확장 시 발생하는 문제
Trace ABC의 Span 1,4,7 → Gateway-1 (에러 없음 → 드롭 결정)
Trace ABC의 Span 2,3,5 → Gateway-2 (불완전한 Trace → 드롭 결정)
Trace ABC의 Span 6 (ERROR) → Gateway-3 (Span 1개만 있음 → 드롭 결정)
결과: 에러가 있었던 Trace ABC가 어느 인스턴스에서도 보존되지 않음Agent-Gateway 2계층 구조
이 문제를 해결하는 표준 아키텍처가 Agent-Gateway 2계층 구조입니다.
애플리케이션
│ OTLP
▼
[Agent Collector] ─── loadbalancingexporter (routing_key: traceID) ───▶ [Gateway-1] ── tailsamplingprocessor
(DaemonSet) ├──▶ [Gateway-2] ── tailsamplingprocessor
└──▶ [Gateway-3] ── tailsamplingprocessor| 계층 | 배포 방식 | 역할 |
|---|---|---|
| 1계층 (Agent) | DaemonSet / 사이드카 | 리소스 감지, 기본 필터링, 배치 처리 후 Gateway로 포워딩 |
| 2계층 (Gateway) | Deployment (수평 확장) | Tail Sampling, 메트릭 집계, 민감 데이터 삭제, 멀티 백엔드 내보내기 |
Agent는 애플리케이션 가까이 배치되어 경량 처리만 담당합니다. 무거운 처리는 모두 Gateway 계층으로 위임하고, loadbalancingexporter가 어느 Gateway 인스턴스로 보낼지를 결정합니다.
loadbalancingexporter의 Consistent Hashing
loadbalancingexporter는 Trace ID를 입력으로 받아 **일관된 해싱(consistent hashing)**을 통해 항상 동일한 Gateway 인스턴스로 라우팅합니다. 동일한 Trace ID는 어느 Agent에서 처리되든 항상 같은 Gateway로 향하므로, Tail Sampling이 완전한 Trace를 볼 수 있게 됩니다.
Consistent Hashing(일관된 해싱): 노드가 추가/제거될 때 재배분되는 키를 최소화하는 해싱 방식입니다. 일반 해싱은 노드 수가 바뀌면 대부분의 키가 다른 노드로 이동하지만, consistent hashing은 변경된 노드에 연결된 키만 재배분됩니다. Gateway Pod가 하나 추가되어도 전체 Trace 중 일부만 새 인스턴스로 이동합니다.
리졸버는 Gateway 인스턴스 목록을 어떻게 가져올지를 결정합니다.
| 리졸버 | 사용 시나리오 | 비고 |
|---|---|---|
static |
소규모 고정 환경, 테스트용 | 추가 권한 불필요 |
dns |
Kubernetes Headless Service 기반 자동 Pod 디스커버리 | 추가 RBAC 불필요 |
k8s |
Kubernetes API 직접 조회, 더 빠른 Pod 변경 감지 | Endpoints list/watch RBAC 필요 |
이 글의 예시에서는 dns 리졸버를 사용합니다. 대부분의 Kubernetes 환경에서 추가 RBAC 설정 없이 바로 적용할 수 있어 초기 도입 장벽이 낮기 때문입니다. k8s 리졸버는 Pod 변경을 더 빠르게 감지하지만, 별도의 ClusterRole과 ServiceAccount 설정이 필요합니다.
실전 적용
Agent DaemonSet 설정
Agent는 각 노드에 하나씩 배치되어 해당 노드의 애플리케이션 Span을 수집하고 Gateway로 전달합니다. loadbalancingexporter를 통해 Trace ID 기반 라우팅이 이루어집니다.
# agent-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: "0.0.0.0:4317"
http:
endpoint: "0.0.0.0:4318"
processors:
memory_limiter:
check_interval: 1s
limit_mib: 512
spike_limit_mib: 128
batch:
timeout: 5s
send_batch_size: 1000
exporters:
loadbalancing:
routing_key: "traceID"
protocol:
otlp:
tls:
insecure: true
resolver:
dns:
hostname: "gateway-collector-headless.observability.svc.cluster.local"
port: 4317
interval: 5s # Gateway Pod 추가/제거 반영 지연
timeout: 1s # DNS 조회 실패 허용 시간
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [loadbalancing]| 설정 항목 | 설명 |
|---|---|
routing_key: traceID |
Trace ID 기반 라우팅 (Service Name도 선택 가능) |
resolver.dns.hostname |
Kubernetes Headless Service 도메인 |
interval: 5s |
DNS 재조회 주기. 짧을수록 Pod 변경을 빠르게 반영하지만 DNS 부하가 증가합니다 |
timeout: 1s |
DNS 조회 타임아웃. 이 시간 내에 응답이 없으면 이전 목록을 유지합니다 |
processors: [memory_limiter, batch] |
Agent는 경량 처리만 수행, tail_sampling 없음 |
Agent 쪽에는 tail_sampling 프로세서를 두지 않는 것이 핵심입니다. 모든 샘플링 결정은 Gateway에서 이루어집니다.
Gateway Deployment와 Headless Service 설정
Gateway는 Tail Sampling을 포함한 무거운 처리를 담당합니다. Kubernetes Headless Service를 통해 각 Pod의 IP가 직접 노출되어 loadbalancingexporter가 consistent hashing 링을 구성할 수 있습니다.
# gateway-headless-svc.yaml
apiVersion: v1
kind: Service
metadata:
name: gateway-collector-headless
namespace: observability
spec:
clusterIP: None # Headless: DNS 쿼리 시 개별 Pod IP 목록 반환
selector:
app: gateway-collector
ports:
- port: 4317
name: otlp-grpc# gateway-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: gateway-collector
namespace: observability
spec:
replicas: 3
selector:
matchLabels:
app: gateway-collector
template:
metadata:
labels:
app: gateway-collector
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values: [gateway-collector]
topologyKey: kubernetes.io/hostname
containers:
- name: collector
image: otel/opentelemetry-collector-contrib:0.115.0
resources:
requests:
memory: "2Gi"
cpu: "500m"
limits:
memory: "8Gi"
cpu: "2000m"# gateway-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: "0.0.0.0:4317"
processors:
memory_limiter:
check_interval: 1s
limit_mib: 6144 # Deployment limits.memory의 약 75%
spike_limit_mib: 1024
tail_sampling:
decision_wait: 30s
num_traces: 50000
expected_new_traces_per_sec: 1000
sampled_cache_size: 100000
non_sampled_cache_size: 100000
policies:
- name: errors-policy
type: status_code
status_code: {status_codes: [ERROR]}
- name: slow-traces-policy
type: latency
latency: {threshold_ms: 1000}
- name: probabilistic-policy
type: probabilistic
probabilistic: {sampling_percentage: 10}
batch:
timeout: 5s
send_batch_size: 1000
exporters:
otlp/backend:
endpoint: "backend:4317"
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, tail_sampling, batch]
exporters: [otlp/backend]| 설정 항목 | 설명 |
|---|---|
decision_wait: 30s |
Trace 완성을 기다리는 최대 시간. 서비스 메시(Envoy/Linkerd 사이드카) 환경에서는 프록시 지연이 누적되므로 서비스 P99 응답 시간 + 10~15초 여유를 더한 값으로 조정하는 것을 권장합니다 |
num_traces: 50000 |
동시 처리 Trace 수. Span 평균 크기를 |
sampled_cache_size |
LRU 캐시로, 메모리에서 해제된 후 늦게 도착하는 Span에도 이전 결정을 적용합니다 |
policies 순서 |
위에서 아래로 평가되며, 하나라도 sampled이면 해당 Trace는 보존됩니다 |
PodDisruptionBudget(PDB): Kubernetes에서 Pod를 강제로 종료할 때 최소 가용 Pod 수를 보장하는 정책입니다. Gateway 계층에 PDB를 설정하면 노드 유지보수나 업그레이드 중에도 Trace 유실을 방지할 수 있습니다.
minAvailable: 2로 시작하는 것을 권장합니다.
Span Metrics 파이프라인 설정
Trace 기반 RED 메트릭(Request rate, Error rate, Duration)을 파생할 경우, Tail Sampling으로 드롭된 Span이 메트릭에 빠지지 않도록 주의가 필요합니다. spanmetricsconnector는 processors가 아닌 connectors 섹션에 선언하며, Tail Sampling 파이프라인과 완전히 분리된 별도 파이프라인에서 전체 Span을 먼저 계산해야 합니다.
# Gateway config: spanmetrics 올바른 구성
connectors:
spanmetrics:
histogram:
explicit:
buckets: [100ms, 250ms, 500ms, 1s, 5s]
dimensions:
- name: http.method
- name: http.status_code
service:
pipelines:
# 파이프라인 1: 전체 Trace에서 메트릭 계산 (샘플링 前)
traces/metrics:
receivers: [otlp]
processors: [memory_limiter]
exporters: [spanmetrics] # connector를 exporter로 사용
# 파이프라인 2: Tail Sampling (독립 실행, 동일 receiver 공유)
traces/sampling:
receivers: [otlp]
processors: [memory_limiter, tail_sampling, batch]
exporters: [otlp/backend]
# 파이프라인 3: 메트릭 출력
metrics:
receivers: [spanmetrics] # connector를 receiver로 사용
exporters: [prometheus]otlp receiver는 두 파이프라인(traces/metrics, traces/sampling)에서 공유되므로, 동일한 Span이 양쪽으로 전달됩니다. traces/metrics 파이프라인은 드롭 여부와 무관하게 전체 Span을 집계하고, traces/sampling 파이프라인은 독립적으로 Tail Sampling을 수행합니다.
spanmetricsconnector: Trace Span 데이터에서 지연 시간 히스토그램과 호출 횟수, 에러율 등 RED 메트릭을 자동으로 생성하는 OpenTelemetry Collector 컴포넌트입니다.
processors섹션이 아닌connectors섹션에 선언하며, 파이프라인에서 exporter(출력)와 receiver(입력) 양쪽으로 동시에 사용됩니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| Tail Sampling 정확도 | Trace ID 기반 라우팅으로 완전한 Trace 집계가 보장됩니다 |
| 수평 확장성 | Gateway 계층만 독립적으로 스케일 아웃할 수 있습니다 |
| 장애 격리 | Agent 장애가 Gateway에 전파되지 않으며, 반대 방향도 마찬가지입니다 |
| 유연한 정책 관리 | 샘플링 정책을 Gateway에서 중앙화하여 일관되게 관리할 수 있습니다 |
| 자동 서비스 디스커버리 | DNS/k8s 리졸버로 Gateway Pod 추가/제거가 자동 반영됩니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 메모리 압력 | decision_wait 동안 모든 Span을 메모리에 보관해야 합니다. Span 평균 1KB, Trace당 20개 Span 기준 50,000 Trace ≈ 2~3GB(오버헤드 포함) |
memory_limiter의 limit_mib를 컨테이너 메모리 한도의 75% 이하로 설정하고, num_traces를 실제 트래픽에 맞게 조정하는 것을 권장합니다 |
| 재조정(Rehashing) 문제 | Gateway Pod 수 변경 시 일부 Trace가 다른 인스턴스로 재배분되어 불완전해질 수 있습니다 | 롤링 업데이트보다 블루/그린 전환을 권장하며, 변경은 최소 단위로 진행하는 것이 좋습니다 |
| 단일 장애 지점 위험 | Gateway 계층 전체 장애 시 Trace 유실이 발생합니다 | PodDisruptionBudget(minAvailable: 2)와 Anti-affinity 룰 적용을 권장합니다 |
| 늦은 Span 도착 | decision_wait 이후 도착한 Span은 드롭됩니다 |
서비스 P99 응답 시간 + 여유분(10~15초)으로 decision_wait을 조정하는 것을 권장합니다 |
| 운영 복잡도 | 두 계층을 별도 모니터링·알림 체계로 관리해야 합니다 | 아래 지표를 기반으로 각 계층별 알림을 구성하는 것이 좋습니다 |
# Gateway 계층 핵심 모니터링 지표
otelcol_exporter_queue_size # 전송 큐 사용률 (60~70% 초과 시 스케일 아웃 신호)
otelcol_processor_dropped_spans_total # 드롭된 Span 수 (급증 시 즉시 확인 필요)
otelcol_processor_tail_sampling_* # Tail Sampling 결정 통계
otelcol_receiver_accepted_spans_total # 수신 처리량각 Gateway 인스턴스의 otelcol_receiver_accepted_spans_total 값이 고르지 않게 분포된다면 DNS TTL을 단축하거나 interval 값을 줄이는 것을 검토해볼 수 있습니다. 불균형이 지속된다면 dns 리졸버 대신 k8s 리졸버로 전환하면 Pod 변경 감지 속도가 개선됩니다(단, Endpoints list/watch RBAC 설정이 필요합니다).
실무에서 가장 흔한 실수
-
Agent에도
tail_sampling을 추가하는 경우: Agent는 전체 Trace의 일부만 보기 때문에 정확한 샘플링 결정이 불가능합니다.tail_sampling은 반드시 Gateway 계층에만 배치해야 합니다. -
Headless Service 대신 일반 ClusterIP Service를 사용하는 경우: 일반 Service는 DNS 쿼리 시 단일 가상 IP를 반환합니다.
loadbalancingexporter가 개별 Pod IP 목록을 받아야 consistent hashing을 구성할 수 있으므로,clusterIP: None설정의 Headless Service가 반드시 필요합니다. -
spanmetricsconnector를processors섹션에 선언하는 경우:spanmetricsconnector는connectors섹션에 선언하고 별도 파이프라인으로 분기해야 합니다.processors에 넣으면 Collector가 시작 자체를 거부하며, 동일 파이프라인 내에서는 Tail Sampling 이전에 동작시키는 것도 불가능합니다. 반드시 파이프라인을traces/metrics와traces/sampling으로 분리해야 합니다.
마치며
Agent-Gateway 2계층 구조와 loadbalancingexporter의 조합은 CNCF가 공식 권장하며, 초당 수만 건의 Span을 처리하는 프로덕션 환경에서도 검증된 OpenTelemetry Collector 수평 확장 패턴입니다.
지금 바로 시작해볼 수 있는 3단계:
-
현재 아키텍처 점검:
kubectl get pods -n observability로 현재 Collector 구성을 확인하고, Tail Sampling이 단일 인스턴스에서 동작 중인지, 아니면 이미 분산 환경인지 파악해볼 수 있습니다. -
Headless Service를 먼저 배포한 뒤 Gateway Deployment 적용:
kubectl apply -f gateway-headless-svc.yaml && kubectl apply -f gateway-deployment.yaml순서로 배포하는 것을 권장합니다. Service를 먼저 올려야loadbalancingexporter가 시작 시점에 DNS 조회를 성공할 수 있습니다. -
Agent에
loadbalancingexporter적용 및 검증: Agent 설정에loadbalancingexporter를 추가한 뒤,otelcol_receiver_accepted_spans_total메트릭을 인스턴스별로 비교하여 각 Gateway가 고르게 Trace를 수신하고 있는지 확인해보시면 좋습니다.
다음 글: OpenTelemetry Operator의
TargetAllocator를 활용하여 Gateway 계층에 HPA(Horizontal Pod Autoscaler)를 연동하고, 트래픽 급증 시 자동으로 대응하면서도 Tail Sampling 정확도를 유지하는 방법을 살펴볼 예정입니다.
참고 자료
- Scaling the Collector | OpenTelemetry 공식 문서
- Gateway Deployment Pattern | OpenTelemetry 공식 문서
- loadbalancingexporter README | GitHub opentelemetry-collector-contrib
- tailsamplingprocessor README | GitHub opentelemetry-collector-contrib
- Tail Sampling with OpenTelemetry: Why it's useful | OpenTelemetry Blog
- Sampling Concepts | OpenTelemetry 공식 문서
- Scale Alloy Tail Sampling | Grafana 공식 문서
- otelcol.exporter.loadbalancing | Grafana Alloy 공식 문서
- Composing OpenTelemetry Reference Architectures | Elastic Observability Labs
- Patterns for Deploying OpenTelemetry Collector at Scale | SigNoz