OpenTelemetry Operator + HPA: Tail Sampling 정확도를 지키는 2-레이어 Gateway 패턴
Kubernetes 환경에서 관측 가능성(Observability) 파이프라인을 운영하다 보면 반드시 한 번은 부딪히는 딜레마가 있습니다. 트래픽이 급증할 때 OpenTelemetry Collector를 수평 확장하고 싶은데, 막상 늘리면 Tail Sampling이 망가질 것 같아 손이 잘 안 가는 상황입니다. "Collector를 늘리면 같은 트레이스의 스팬이 흩어지지 않나?"라는 걱정은 근거 있는 우려이지만, 이 문제는 이미 잘 정의된 패턴으로 해결할 수 있습니다.
이 글을 끝까지 읽으면 2-레이어 Gateway 아키텍처로 HPA와 Tail Sampling을 동시에 만족시키는 구체적인 YAML 구성을 직접 적용할 수 있게 됩니다. OpenTelemetry Operator의 autoscaler 블록 활용법, KEDA를 이용한 큐 기반 스케일링, 그리고 운영 현장에서 흔히 저지르는 실수까지 다룹니다. 참고로 OpenTelemetry Operator의 TargetAllocator는 이 아키텍처에서 Prometheus 메트릭 수집 타깃을 분배하는 역할을 담당하며, 트레이스 라우팅과는 별개입니다(자세한 내용은 후반부 부록 섹션에서 설명합니다).
이커머스 플래시 세일처럼 예측하기 어려운 트래픽 급증 상황에서도 에러 트레이스와 느린 요청을 빠짐없이 수집하고 싶은 분들께 실질적인 인사이트가 될 것입니다.
핵심 개념
Tail Sampling이 수평 확장과 충돌하는 이유
Tail Sampling(꼬리 기반 샘플링)은 트레이스의 모든 스팬이 메모리에 쌓인 뒤 에러·지연·속성 등 기준으로 보존 여부를 사후 결정하는 방식입니다. Head Sampling(앞단에서 확률적으로 즉시 결정)과 달리, 트레이스 전체 컨텍스트를 보고 판단하므로 품질이 훨씬 높습니다.
문제는 같은 Trace ID에 속한 스팬이 반드시 동일한 Collector 인스턴스에 모여야 한다는 점입니다. Collector를 단순히 Deployment + HPA로 수평 확장하면, 라운드로빈 방식의 로드밸런서가 스팬을 여러 인스턴스에 흩뿌려 Tail Sampling 결정 자체가 불가능해집니다.
2-레이어 아키텍처: 표준 해결 패턴
이 충돌을 해결하는 방법은 역할에 따라 두 계층을 분리하는 것입니다.
| 레이어 | 역할 | 배포 방식 | 확장 방식 |
|---|---|---|---|
| 레이어 1 (로드밸런싱) | traceID 해싱으로 스팬 라우팅 | Deployment | HPA로 자유롭게 확장 |
| 레이어 2 (Tail Sampling) | 스팬 집계 후 샘플링 결정 | StatefulSet | 신중하게, 수동 또는 VPA 권장 |
레이어 1은 상태(state)를 갖지 않아 HPA로 자유롭게 확장·축소할 수 있습니다. loadbalancingexporter가 Trace ID를 해싱하여 레이어 2의 특정 StatefulSet Pod로 항상 동일하게 라우팅해주므로, 레이어 2의 Tail Sampling 정확도가 유지됩니다.
loadbalancingexporter: OpenTelemetry Collector Contrib에 포함된 익스포터로,
traceID또는service.name기준으로 스팬을 해싱하여 일관된 엔드포인트로 전달합니다. DNS 기반 리졸버와 함께 사용하면 StatefulSet의 Headless Service Pod 목록을 동적으로 인식합니다.
HPA(Horizontal Pod Autoscaler): Kubernetes 기본 내장 리소스로, CPU·메모리 사용률 또는 커스텀 메트릭을 기준으로 Pod 수를 자동으로 늘리거나 줄입니다.
spec.autoscaler블록을 OpenTelemetryCollector CRD에 선언하면 Operator가 HPA를 자동으로 생성·관리합니다.
StatefulSet + Headless Service: StatefulSet은 각 Pod에 안정적이고 고유한 네트워크 식별자(예:
pod-0,pod-1)를 부여하는 Kubernetes 워크로드 리소스입니다. Headless Service와 함께 사용하면pod-0.service-name.namespace.svc.cluster.local형식의 DNS로 각 Pod를 직접 주소 지정할 수 있어, Tail Sampling Collector처럼 특정 Pod로의 일관된 라우팅이 필요한 경우에 적합합니다.
중요한 역할 구분: OpenTelemetry Operator의 TargetAllocator는 Prometheus 메트릭 수집(스크레이프) 타깃을 여러 Collector 인스턴스에 분배하는 도구입니다. 트레이스의 Tail Sampling 라우팅과는 직접적인 관련이 없습니다. 두 기능을 혼동하면 아키텍처 설계가 꼬이기 쉽습니다.
실전 적용
예시 1: 이커머스 플래시 세일 대응 — 2-레이어 구성
세일 이벤트 시 트래픽이 수십~수백 배 급증하는 환경을 가정합니다. 레이어 1은 HPA로 자동 확장하고, 레이어 2는 StatefulSet으로 고정 운영합니다.
레이어 1: 로드밸런싱 Gateway (HPA 적용)
# v1alpha1은 deprecated 상태입니다. OpenTelemetry Operator v0.80+ 환경에서는 v1beta1 사용을 권장합니다.
apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
name: gateway-lb
namespace: observability
spec:
mode: deployment
autoscaler:
minReplicas: 3
maxReplicas: 20
targetCPUUtilization: 60
config: |
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 1s
send_batch_size: 1024
exporters:
loadbalancing:
routing_key: traceID
protocol:
otlp:
tls:
insecure: true
resolver:
dns:
hostname: tail-sampling-collector-headless
port: 4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [loadbalancing]레이어 2: Tail Sampling Collector (StatefulSet)
apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
name: tail-sampling-collector
namespace: observability
spec:
mode: statefulset
replicas: 5
config: |
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
tail_sampling:
decision_wait: 30s
num_traces: 100000
policies:
- name: error-policy
type: status_code
status_code: {status_codes: [ERROR]}
- name: slow-traces
type: latency
latency: {threshold_ms: 1000}
- name: probabilistic-baseline
type: probabilistic
probabilistic: {sampling_percentage: 10}
exporters:
otlp:
# 최신 Jaeger는 OTLP 직수신 포트(4317)를 권장합니다.
# 구형 gRPC 포트(14250)는 최신 Jaeger 환경에서 동작하지 않을 수 있습니다.
endpoint: jaeger-collector:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling]
exporters: [otlp]아래 표는 주요 설정 값의 의미와 조정 기준을 정리한 것입니다.
| 설정 키 | 예시 값 | 의미 |
|---|---|---|
targetCPUUtilization |
60 |
CPU 60% 초과 시 스케일 아웃. 보수적으로 낮게 설정 권장 |
decision_wait |
30s |
스팬 수집 대기 시간. 길수록 정확하나 메모리 소비 증가. 스팬 ~1KB 가정 시 10만 트레이스 × 평균 10스팬 = 약 1GB+ 수준 |
num_traces |
100000 |
메모리에 보관할 최대 트레이스 수. OOM 방지를 위해 Pod 메모리 리소스 설정과 함께 조율 필요 |
routing_key |
traceID |
레이어 1의 해싱 기준. traceID가 Tail Sampling에 필수 |
hostname |
*-headless |
Headless Service로 StatefulSet Pod 목록을 DNS로 조회 |
예시 2: KEDA로 큐 기반 오토스케일링 적용
CPU/메모리 기반 HPA는 Collector 내부 상태를 반영하지 못한다는 한계가 있습니다. 예를 들어 Collector가 데이터를 드랍하고 있어도 CPU 사용률은 낮게 유지될 수 있습니다. Collector 내부 메트릭을 KEDA ScaledObject의 트리거로 연결하면 더 정밀한 스케일링이 가능합니다.
이 메트릭들은 Collector의 메트릭 포트(기본 8888)에서 Prometheus 형식으로 노출됩니다.
http://<collector-svc>:8888/metrics에서 직접 확인하거나 Prometheus 스크레이프 설정으로 수집할 수 있습니다.
KEDA(Kubernetes Event-Driven Autoscaling): Kubernetes의 기본 HPA가 지원하지 않는 외부 메트릭 소스(Prometheus, Kafka, SQS 등)를 기반으로 스케일링을 제어하는 오픈소스 프레임워크입니다. ScaledObject 리소스로 트리거 조건을 선언하면 내부적으로 HPA를 생성·관리합니다.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: gateway-collector-scaler
namespace: observability
spec:
scaleTargetRef:
name: gateway-lb-collector
minReplicaCount: 2
maxReplicaCount: 30
cooldownPeriod: 300 # 단위: 초(seconds). 스케일 인 전 대기 시간
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring:9090
metricName: otelcol_exporter_queue_size
threshold: "1000"
query: >
sum(otelcol_exporter_queue_size{
job="gateway-collector"
})
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring:9090
# OTel Collector 버전에 따라 메트릭 이름이 다를 수 있습니다.
# v0.90 미만: otelcol_processor_refused_spans
# v0.90 이상: 메트릭 이름 체계가 변경되었으므로 :8888/metrics에서 실제 이름을 확인하세요.
metricName: otelcol_processor_refused_spans
threshold: "100"
query: >
rate(otelcol_processor_refused_spans_total{
job="gateway-collector"
}[1m])흔한 실수 3가지
-
레이어 2(Tail Sampling)에 HPA를 그대로 적용하는 경우 — StatefulSet 레이어에 스케일 아웃이 발생하면,
loadbalancingexporter의 DNS 기반 리졸버가 새로운 Pod 목록을 재조회하기 전까지(DNS TTL + 갱신 지연) 일부 스팬이 잘못된 Pod로 라우팅될 수 있습니다. 이 구간에 메모리에 있던 in-flight 스팬이 유실될 수 있으므로, 레이어 2는 VPA(Vertical Pod Autoscaler)나 수동 스케일을 활용하고 스케일 전에 반드시decision_wait대기 시간을 확보하는 것이 좋습니다. -
TargetAllocator를 트레이스 라우팅 도구로 오해하는 경우 — TargetAllocator는 Prometheus 스크레이프 타깃 분배 전용입니다. 트레이스 Tail Sampling의 Trace Affinity는
loadbalancingexporter가 담당합니다. 두 기능의 역할을 혼동하면 불필요한 복잡도가 생깁니다. -
decision_wait과 HPAstabilizationWindowSeconds를 독립적으로 설정하는 경우 — 스케일 인이decision_wait(기본 30초) 완료 전에 시작되면 미결정 스팬이 유실됩니다.stabilizationWindowSeconds는decision_wait값보다 충분히 크게, 최소 300초 이상으로 설정하는 것을 권장합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 자동 용량 대응 | HPA/KEDA가 트래픽 급증 시 Collector 수를 자동 조절하여 데이터 손실을 최소화합니다 |
| Tail Sampling 정확도 유지 | loadbalancingexporter의 traceID 해싱으로 같은 트레이스의 스팬이 항상 동일 Collector에 도달합니다 |
| 비용 효율 | 평상시에는 최소 레플리카로 유지하고 피크 타임에만 확장됩니다 |
| 메트릭 수집 일관성 | TargetAllocator의 Consistent Hashing으로 스크레이프 중복·누락이 방지됩니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Tail Sampling 계층 스케일 인 위험 | 스케일 다운 시 메모리의 미결정 스팬이 유실될 수 있습니다 | stabilizationWindowSeconds를 300초 이상으로 설정하고, decision_wait 이후에 스케일 인이 시작되도록 조율하는 것을 권장합니다 |
| DNS 업데이트 지연 | 레이어 2 스케일 아웃 시 레이어 1이 새 Pod를 인식하기까지 수초~수십 초 지연이 발생합니다 | DNS TTL을 낮추고 dns_refresh_delay 설정을 조정하면 지연을 줄일 수 있습니다 |
| 메모리 압박 | decision_wait 동안 모든 스팬이 메모리에 유지되어 트래픽 급증 시 OOM 위험이 있습니다 |
HPA 임계값을 60% 이하로 보수적으로 설정하고 num_traces를 메모리 리소스에 맞게 조율하는 것이 좋습니다 |
| CPU 기반 HPA의 한계 | Collector가 데이터를 드랍하는 동안에도 CPU는 낮게 유지될 수 있습니다 | otelcol_exporter_queue_size 기반 KEDA 사용을 검토해보시면 좋습니다 |
| TargetAllocator 역할 오해 | TargetAllocator를 트레이스 라우팅에도 쓰려는 시도가 있으나, 이는 메트릭 스크레이프 전용입니다 | 아키텍처 문서에 역할 경계를 명확히 기술하는 것을 권장합니다 |
부록: TargetAllocator로 Prometheus 스크레이프 타깃 분산
레이어 1 Gateway가 HPA로 3→10개로 확장될 때, Prometheus 메트릭 수집 타깃도 자동으로 재분배되어야 합니다. TargetAllocator를 활성화하면 Collector 인스턴스 수 변화에 따라 타깃이 자동으로 재할당됩니다. 이 기능은 트레이스 파이프라인과 완전히 독립적으로 동작합니다.
apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
name: gateway-lb
namespace: observability
spec:
mode: deployment
targetAllocator:
enabled: true
serviceAccount: opentelemetry-targetallocator-sa
prometheusCR:
enabled: true
podMonitorSelector: {}
serviceMonitorSelector: {}
allocationStrategy: consistent-hashing
config: |
receivers:
prometheus:
config:
scrape_configs: [] # TargetAllocator가 동적으로 주입
target_allocator:
endpoint: http://${MY_POD_NAMESPACE}-gateway-lb-targetallocator
interval: 30s
collector_id: ${MY_POD_NAME}
exporters:
prometheusremotewrite:
endpoint: http://thanos-receive:19291/api/v1/receive
service:
pipelines:
metrics:
receivers: [prometheus]
exporters: [prometheusremotewrite]마치며
2-레이어 아키텍처(HPA가 적용된 로드밸런싱 레이어 + StatefulSet 기반 Tail Sampling 레이어)는 수평 확장과 샘플링 정확도를 동시에 만족시키는 현재의 표준 패턴입니다.
아직 이 구성을 적용하지 않은 팀이라면 아래 순서로 단계적으로 도입해보시면 좋습니다.
-
현재 Collector 아키텍처 점검 — 단일 Deployment로 Tail Sampling을 운영 중이라면
kubectl get otelcol -A로 현황을 파악하고,loadbalancingexporter설정이 없는지 확인해보시면 좋습니다. 없다면 레이어 분리가 필요한 상황입니다. -
레이어 2 StatefulSet 먼저 배포 —
loadbalancingexporter의 DNS 라우팅 타깃이 되는 레이어 2를 먼저 올려두어야 레이어 1 배포 후 즉시 트레이스가 정상 흐릅니다. 위 예시의tail-sampling-collectorYAML을 환경에 맞게 조정한 뒤kubectl apply -f tail-sampling.yaml로 적용해보시면 됩니다. -
레이어 1 로드밸런서 배포 후 KEDA 도입 검토 — 레이어 2가 준비된 뒤
gateway-lbYAML을 적용하고kubectl get hpa -n observability로 HPA 생성 여부를 확인해보시면 됩니다. 이후 Prometheus에서otelcol_exporter_queue_size메트릭이 수집되고 있다면, 위 ScaledObject YAML을 참고하여 CPU 기반 HPA 대신 큐 크기 기반 스케일링으로 전환해보시는 것을 권장합니다.
다음 글: Tail Sampling의
decision_wait튜닝과num_traces조정으로 메모리 사용량을 최적화하는 방법 — 실제 OOM 사례 분석과 프로파일링 결과를 바탕으로 고트래픽에서도 안정적으로 운영하는 Collector 설정 가이드
참고 자료
공식 문서
- Horizontal Pod Autoscaling | OpenTelemetry
- Scaling the Collector | OpenTelemetry
- Target Allocator | OpenTelemetry
- Sampling 개념 | OpenTelemetry
- Sampling Milestones 2025 | OpenTelemetry Blog
- tailsamplingprocessor README | GitHub
- TargetAllocator README | GitHub
- Scale Alloy tail sampling | Grafana Docs
- OpenTelemetry Collector Integration | KEDA
- Target Allocator | AWS ADOT
- HPA for OpenTelemetry Collector | IBM Docs
커뮤니티