Tail Sampling + KEDA: 트래픽 급증에도 트레이스를 놓치지 않는 2계층 OTel 아키텍처
분산 시스템에서 가장 아찔한 순간은 트래픽이 폭발적으로 증가하는 바로 그 순간입니다. 플래시세일이 시작되거나 바이럴 이벤트가 터질 때, 관찰 가능성(Observability) 파이프라인은 가장 많은 데이터를 처리해야 하면서도 가장 쉽게 무너집니다. 이때 아무 트레이스나 버리는 헤드 샘플링(Head Sampling)을 쓰면 정작 문제가 생긴 요청은 증거도 없이 사라지고 맙니다.
이 글은 OpenTelemetry의 Tail Sampling과 KEDA의 이벤트 드리븐 오토스케일링을 결합해, 역할을 분리한 두 단계 파이프라인(2계층 아키텍처)으로 트래픽 급증에도 의미 있는 트레이스를 정밀하게 보존하면서 인프라를 자동으로 확장하는 방법을 다룹니다. 특히 2024년 등장한 Kedify otel-add-on이 이 조합을 실무에서 훨씬 쉽게 구현할 수 있게 해준 맥락과 함께, 실제 Kubernetes 환경에 바로 적용할 수 있는 YAML 예시와 운영 팁을 살펴봅니다.
이 글을 읽고 나면 Tail Sampling의 상태 의존성이 왜 단순 수평 확장을 막는지, 그리고 그 제약을 2계층 Collector 구조와 KEDA의 Push 기반 메트릭으로 어떻게 우아하게 해결하는지 이해할 수 있습니다.
핵심 개념
Tail Sampling이란 무엇인가
분산 추적에서 샘플링은 "어떤 요청의 트레이스를 저장할 것인가"를 결정하는 과정입니다. 가장 단순한 방식인 **헤드 샘플링(Head Sampling)**은 요청이 시작되는 시점에 무작위로 저장 여부를 결정합니다. 구현이 간단하지만 치명적인 약점이 있습니다. 요청이 실제로 에러를 냈는지, 응답이 느렸는지 전혀 알 수 없는 상태에서 결정하기 때문에 가장 중요한 트레이스가 버려질 수 있습니다.
**Tail Sampling(테일 샘플링)**은 이 문제를 뒤집어 해결합니다. 하나의 트레이스를 구성하는 모든 스팬(Span)이 수집된 뒤, 트레이스 전체를 보고 저장 여부를 결정합니다. 에러가 있었는지, 특정 임계값 이상으로 느렸는지, 특정 속성이 포함되어 있는지를 기준으로 삼을 수 있어 노이즈는 줄이고 가치 있는 트레이스는 확실히 보존합니다.
용어 정리: 스팬(Span)은 하나의 작업 단위를 나타내며, 여러 스팬이 모여 하나의 트레이스(Trace)를 구성합니다. 예를 들어 "HTTP 요청 → DB 쿼리 → 캐시 조회"가 각각 스팬이고, 이 세 스팬의 묶음이 트레이스입니다.
Tail Sampling의 핵심 제약: 상태 의존성
Tail Sampling이 강력한 만큼, 운영 관점에서 중요한 제약이 하나 있습니다. 동일한 TraceID에 속하는 모든 스팬은 반드시 같은 Collector 인스턴스로 모여야 합니다. 결정을 내리려면 트레이스 전체가 한 곳에 있어야 하기 때문입니다.
이것이 단순한 수평 확장을 막는 원인입니다. Collector를 그냥 여러 개 띄우면 같은 트레이스의 스팬들이 서로 다른 인스턴스로 분산되어, 어느 인스턴스도 완전한 트레이스를 가지지 못하게 됩니다.
# ❌ 잘못된 구성: 스팬이 무작위로 분산됨
App → [Round-Robin LB] → Collector-0, Collector-1, Collector-2
(같은 TraceID의 스팬이 뿔뿔이 흩어짐)
# ✅ 올바른 구성: TraceID 기반 일관된 라우팅
App → [TraceID 일관 해시 LB] → Collector-0 (TraceID-A 전담)
→ Collector-1 (TraceID-B 전담)
→ Collector-2 (TraceID-C 전담)loadbalancing exporter는 TraceID를 입력값으로 하는 일관 해시(Consistent Hash) 알고리즘으로 라우팅 대상을 결정합니다. 덕분에 특정 TraceID의 스팬은 Collector 클러스터 크기가 변해도 항상 동일한 인스턴스로 향합니다. 단, StatefulSet을 스케일아웃·스케일인할 때는 DNS 목록이 변경되면서 짧은 리밸런싱이 발생할 수 있습니다. 이 시간 동안 일부 스팬이 다른 인스턴스로 전달될 수 있으므로, decision_wait(결정 대기 시간)을 여유 있게 설정하면 영향을 최소화할 수 있습니다.
KEDA: 이벤트 드리븐 오토스케일링의 확장
**KEDA(Kubernetes Event-Driven Autoscaling)**는 Kubernetes 기본 HPA(Horizontal Pod Autoscaler)가 CPU/메모리 메트릭만 다루는 한계를 극복합니다. Kafka 큐 길이, Prometheus 메트릭, OpenTelemetry 메트릭 등 70개 이상의 외부 이벤트 소스를 트리거로 사용할 수 있으며, 트래픽이 완전히 없을 때는 레플리카를 0까지 줄이는 Scale-to-Zero도 지원합니다.
Push vs Pull 스케일링: 기존 Prometheus 방식은 KEDA가 15~30초 간격으로 메트릭을 긁어(Pull)와서 판단합니다. OTLP Push 방식은 Collector가 메트릭을 직접 밀어보내므로 수 초 내 스케일링 반응이 가능합니다.
KEDA v2.12 내장 OTel 통합 vs Kedify otel-add-on: 무엇이 다른가
이름이 비슷해 혼동하기 쉽지만, 이 두 기능은 목적이 완전히 다릅니다.
| KEDA v2.12 내장 OTel 통합 | Kedify otel-add-on | |
|---|---|---|
| 방향 | KEDA → OTel Collector (KEDA 내부 메트릭을 OTel로 내보냄) | OTel Collector → KEDA (Collector 메트릭을 스케일 트리거로 사용) |
| 목적 | KEDA 자체 관찰 가능성 향상 | Collector 처리량 기반 오토스케일링 |
| 상태 | 실험적(Experimental), KEDA v2.12+ 내장 | 오픈소스(Apache 2.0), Kedify사 제공, 별도 설치 필요 |
| 사용 사례 | KEDA 스케일링 동작 자체를 추적하고 싶을 때 | OTel Collector를 메트릭 기반으로 스케일링하고 싶을 때 |
이 글에서 구현할 패턴은 Kedify otel-add-on 방식입니다. Kedify는 KEDA 생태계 전문 기업이며, otel-add-on은 Apache 2.0 라이선스 오픈소스 프로젝트입니다. OTel Collector 메트릭을 OTLP로 수신하고, PromQL 유사 쿼리로 집계한 값을 gRPC로 KEDA에 전달하는 브리지 역할을 합니다.
2계층 아키텍처: 제약을 설계로 해결하기
상태 의존성 문제와 오토스케일링 요구를 동시에 만족시키는 해답은 역할을 명확히 분리한 2계층 구조입니다.
[애플리케이션 Pod들]
│ OTLP/gRPC
▼
┌─────────────────────────────────────────────┐
│ 1계층: Gateway Collector (Deployment) │
│ - loadbalancing exporter (TraceID 일관 해시)│
│ - 상태 없음 → KEDA 자유 확장 가능 │
└───────────────────┬─────────────────────────┘
│ Headless Service DNS
▼
┌─────────────────────────────────────────────┐
│ 2계층: Tail Sampling Collector (StatefulSet)│
│ - tailsamplingprocessor │
│ - memory_limiter processor │
│ - 안정적 DNS로 TraceID 어핀티 보장 │
└───────────────────┬─────────────────────────┘
│
▼
[Jaeger / Grafana Tempo / Datadog]1계층(Gateway) 은 loadbalancing exporter로 TraceID를 해시하여 2계층의 특정 인스턴스에 일관되게 스팬을 전달합니다. 상태가 없으므로 KEDA나 HPA로 자유롭게 확장할 수 있습니다.
2계층(Tail Sampling Backend) 은 StatefulSet과 Headless Service로 구성됩니다. 각 Pod가 otel-tail-sampling-0, otel-tail-sampling-1처럼 안정적인 DNS 이름을 가지므로, 1계층이 항상 같은 인스턴스로 라우팅할 수 있습니다.
용어 정리: Headless Service는 Kubernetes에서
clusterIP: None으로 설정한 서비스로, 각 Pod의 DNS를 개별적으로 노출합니다. StatefulSet의 각 Pod가 고유한 주소를 가져야 할 때 필수적으로 사용됩니다.
실전 적용
전제 조건: 아래 예시를 적용하려면 Kubernetes 클러스터에
observability네임스페이스, KEDA 오퍼레이터, Kedify otel-add-on이 미리 설치되어 있어야 합니다. ConfigMap과 StatefulSet YAML은 항상 함께 적용(kubectl apply -f)해야 정상 동작합니다.
1단계: Gateway Collector 구성
1계층 Collector의 핵심은 loadbalancingexporter입니다. 수신한 스팬의 TraceID를 일관 해시하여 2계층 StatefulSet의 특정 인스턴스로 고정 라우팅합니다.
# otel-gateway-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: otel-gateway-config
namespace: observability
data:
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
exporters:
loadbalancing:
protocol:
otlp:
tls:
insecure: true
resolver:
dns:
# 2계층 StatefulSet의 Headless Service DNS
hostname: otel-tail-sampling-headless.observability.svc.cluster.local
port: 4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter]
exporters: [loadbalancing]| 설정 항목 | 역할 |
|---|---|
loadbalancing.resolver.dns |
Headless Service를 통해 2계층 Pod 목록을 동적으로 조회 |
memory_limiter |
트래픽 급증 시 OOM 방지를 위한 메모리 상한 설정 |
tls.insecure: true |
클러스터 내부 통신이므로 TLS 생략 (프로덕션에서는 별도 구성 권장) |
2단계: Tail Sampling StatefulSet 구성
아래 두 파일(otel-tail-sampling-statefulset.yaml과 otel-tail-sampling-config.yaml)을 함께 적용합니다.
# otel-tail-sampling-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: otel-tail-sampling
namespace: observability
spec:
serviceName: otel-tail-sampling-headless # Headless Service 연결
replicas: 3
selector:
matchLabels:
app: otel-tail-sampling
template:
metadata:
labels:
app: otel-tail-sampling
spec:
containers:
- name: collector
# latest 태그 대신 특정 버전을 고정해 재현 가능한 빌드를 유지합니다.
# 최신 버전: https://github.com/open-telemetry/opentelemetry-collector-contrib/releases
image: otel/opentelemetry-collector-contrib:v0.120.0
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1"
volumeMounts:
- name: config
mountPath: /conf
volumes:
- name: config
configMap:
name: otel-tail-sampling-config
---
# Headless Service: 각 Pod에 개별 DNS 제공
apiVersion: v1
kind: Service
metadata:
name: otel-tail-sampling-headless
namespace: observability
spec:
clusterIP: None # Headless 핵심 설정
selector:
app: otel-tail-sampling
ports:
- name: otlp-grpc
port: 4317
targetPort: 4317 # 명시적으로 지정해 혼란을 방지합니다
---
# PodDisruptionBudget: 롤링 업데이트 중 최소 인스턴스 보장
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: otel-tail-sampling-pdb
namespace: observability
spec:
minAvailable: 2
selector:
matchLabels:
app: otel-tail-sampling2계층 Collector의 샘플링 정책 ConfigMap입니다.
# otel-tail-sampling-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: otel-tail-sampling-config
namespace: observability
data:
config.yaml: |
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
memory_limiter:
check_interval: 1s
limit_mib: 800
spike_limit_mib: 200
tail_sampling:
# decision_wait(30초) × expected_new_traces_per_sec(1,000개/초)
# = 결정 대기 중인 최대 트레이스 약 30,000개
# num_traces(100,000)는 이 예상량의 3배 이상 여유를 두는 값입니다.
# 메모리 limits도 (트레이스 평균 크기 × num_traces)로 함께 조정하세요.
decision_wait: 30s
num_traces: 100000
expected_new_traces_per_sec: 1000
policies:
# 에러가 있는 트레이스는 무조건 보존
- name: error-policy
type: status_code
status_code: {status_codes: [ERROR]}
# 2초 이상 걸린 느린 요청 보존
- name: slow-traces-policy
type: latency
latency: {threshold_ms: 2000}
# 나머지는 10%만 샘플링
- name: probabilistic-policy
type: probabilistic
probabilistic: {sampling_percentage: 10}
exporters:
otlp:
endpoint: jaeger-collector.observability.svc:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, tail_sampling]
exporters: [otlp]| 정책 항목 | 조건 | 보존율 |
|---|---|---|
error-policy |
HTTP 5xx, gRPC Error 스테이터스 | 100% |
slow-traces-policy |
응답 시간 2초 초과 | 100% |
probabilistic-policy |
위 조건 미해당 정상 요청 | 10% |
decision_wait와 메모리 관계:decision_wait(기본 30초) ×expected_new_traces_per_sec(초당 1,000개) = 결정 대기 중인 최대 트레이스 약 30,000개입니다.num_traces는 이보다 충분히 크게 설정해야 하며, 메모리limits도 (트레이스 평균 크기 ×num_traces)를 기준으로 함께 조정하는 것이 좋습니다. 트래픽 응답이 빠른 서비스라면decision_wait를 10~15초로 줄여 메모리 점유를 낮출 수 있습니다.
3단계: KEDA ScaledObject + Kedify otel-add-on 연동
1계층 Gateway Collector를 수신 스팬 수 기반으로, 2계층 Tail Sampling을 버퍼 트레이스 수 기반으로 오토스케일링하는 ScaledObject입니다.
# keda-gateway-scaledobject.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: otel-gateway-scaler
namespace: observability
spec:
scaleTargetRef:
name: otel-gateway-collector # 1계층 Deployment 이름
minReplicaCount: 2 # 콜드 스타트 방지: 최소 2개 유지
maxReplicaCount: 20
pollingInterval: 5 # 5초마다 메트릭 확인
cooldownPeriod: 60 # 스케일인 전 60초 대기
triggers:
- type: external
metadata:
scalerAddress: kedify-otel-add-on.observability.svc:4318
# rate()로 1분 평균 순간 수신량(초당 스팬 수)을 계산합니다.
# KEDA는 (반환값 / targetValue)를 올림해 레플리카 수를 결정합니다.
metricQuery: |
sum(rate(otelcol_receiver_accepted_spans{receiver="otlp"}[1m]))
targetValue: "10000" # 인스턴스당 목표 초당 스팬 수
---
# 2계층 Tail Sampling: 처리 중인 트레이스 수로 스케일링
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: otel-tail-sampling-scaler
namespace: observability
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: StatefulSet
name: otel-tail-sampling
minReplicaCount: 2
maxReplicaCount: 10
triggers:
- type: external
metadata:
scalerAddress: kedify-otel-add-on.observability.svc:4318
# 히스토그램 버킷(_bucket) 대신 카운트 메트릭을 사용해
# 의미 있는 단일 집계값을 얻습니다.
# 메트릭이 누적 카운터인지 게이지인지 환경에 따라 확인 후 사용하세요.
metricQuery: |
sum(otelcol_processor_tail_sampling_num_traces_sampled)
targetValue: "80000" # 전체 클러스터 기준 처리 트레이스 임계값Kedify otel-add-on 데이터 흐름:
OTel Collector (메트릭 OTLP Push)
│ 포트 4317
▼
┌─────────────────────────────┐
│ kedify-otel-add-on │
│ - 내부 TSDB (순환 버퍼) │
│ - PromQL 유사 집계 엔진 │
└──────────────┬──────────────┘
│ gRPC External Scaler (포트 4318)
▼
KEDA Operator
│
▼
레플리카 수 결정 및 적용장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 정밀한 샘플링 | 에러·지연·속성 기반으로 의미 있는 트레이스만 보존, 스토리지 비용 절감 |
| 초 단위 스케일링 반응 | OTLP Push 방식으로 폴링 지연 제거, 기존 Pull 방식 대비 반응 속도 10배 이상 향상 |
| 비용 최적화 | 저부하 시 레플리카 수 최소화 가능 (minReplicaCount 설정에 따라 조절) |
| 유연한 트리거 | Collector 자체 메트릭(큐 사이즈, 수신 스팬 수, 메모리 사용률)으로 정교한 기준 설정 |
| 표준 기반 | OpenTelemetry + KEDA 조합으로 벤더 종속 없이 Jaeger, Tempo, Datadog 등 교체 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 2계층 StatefulSet 확장 제약 | 자유로운 HPA 적용 불가, 스케일 중 트레이스 윈도우(30초) 내 스팬 유실 위험 | PodDisruptionBudget(minAvailable: 2) 설정, decision_wait를 10~15초로 단축해 영향 최소화 |
| 메모리 스파이크 | 트래픽 급증 시 버퍼링 증가로 OOM 위험 | memory_limiter 필수 설정, bytes_limiting 정책으로 대형 트레이스 제한 |
| 콜드 스타트 지연 | Scale-to-Zero 후 첫 요청 처리까지 수 초~수십 초 지연, 그 사이 스팬 유실 | 프로덕션에서는 minReplicaCount: 0 사용 금지. Gateway 최소 2개, Tail Sampling 최소 2개 유지 권장 |
| DNS 동기화 지연 | StatefulSet 변경 후 loadbalancing exporter가 DNS를 재조회하기까지 수 초 소요 | Kubernetes DNS 기본 TTL은 30초. Gateway의 dns_refresh_delay를 10~15초로 조정하고 dnsPolicy: ClusterFirst 확인 |
| KEDA OTel 통합 실험적 상태 | KEDA 내장 OTel 메트릭 출력 기능은 Experimental | Kedify otel-add-on(안정 버전)을 대안으로 활용, 프로덕션 전 충분한 부하 테스트 권장 |
용어 보충:
decision_wait은 Tail Sampling Processor가 하나의 트레이스에 대한 샘플링 결정을 내리기 전까지 모든 스팬이 도착하길 기다리는 최대 시간입니다. 기본값은 30초이며, 트래픽 패턴이 빠른 서비스는 10~15초로 줄여 메모리 점유를 낮출 수 있습니다.
실무에서 가장 흔한 실수
-
2계층 Collector에 단순 Deployment 사용: StatefulSet이 아닌 Deployment로 구성하면 Pod가 교체될 때 안정적인 DNS가 유지되지 않아 트레이스가 분산됩니다. 반드시 StatefulSet + Headless Service 조합을 사용하는 것을 권장합니다.
-
minReplicaCount: 0으로 프로덕션 운영: Scale-to-Zero는 비용 절감 측면에서 매력적이지만, Collector가 완전히 꺼진 상태에서 트래픽이 들어오면 콜드 스타트 동안 스팬이 회복 불가능하게 유실됩니다. 특히 Tail Sampling 2계층이 꺼져 있으면 그 사이에 들어온 스팬은 결정 자체가 이뤄지지 않습니다. Gateway 1계층과 Tail Sampling 2계층 모두 최소 2개 이상을 유지하는 것을 권장합니다. -
memory_limiter없이 운영: 트래픽 급증 시 Collector가 버퍼에 트레이스를 쌓다가 OOM으로 재시작되면, 그 순간의 모든 트레이스가 유실됩니다.memory_limiter설정은 선택이 아닌 필수입니다.
마치며
Tail Sampling과 KEDA의 결합은 단순한 기술 조합이 아닙니다. "언제 어떤 데이터가 가치 있는가"를 정밀하게 판단하고, 동시에 "그 데이터를 처리할 인프라를 어떻게 탄력적으로 운영하는가"라는 두 문제를 하나의 아키텍처로 해결하는 패턴입니다. 이 글에서 살펴본 2계층 구조, KEDA ScaledObject, Kedify otel-add-on의 조합을 통해 트래픽 급증 상황에서도 중요한 트레이스를 놓치지 않는 파이프라인을 구성할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
-
로컬 환경에서 2계층 구조 실습해보기:
kind나minikube로 로컬 Kubernetes 클러스터를 띄운 뒤, KubeCon EU 2024 샘플링 튜토리얼의 매니페스트를 적용해 보시면 좋습니다.kubectl apply -f몇 줄로 동작하는 2계층 파이프라인을 직접 확인할 수 있습니다. -
1단계에서 동작을 확인했다면, 운영 중인 KEDA ScaledObject에 OTel 메트릭 트리거를 추가해보시면 좋습니다. Kedify otel-add-on GitHub의 Helm 차트로 10분 내에 브리지 컴포넌트를 배포할 수 있습니다.
-
샘플링 정책을 서비스 특성에 맞게 점진적으로 고도화하기: 처음에는 에러(
status_code: ERROR)와 느린 요청(latency) 두 가지 정책만 적용하고, 실제 트레이스 저장 비율과 스토리지 비용을 관찰하면서probabilistic정책의 샘플링 비율을 조정해 나가는 방식이 운영 부담을 낮추는 데 도움이 됩니다.
다음 글: OpenTelemetry Collector의
spanmetrics커넥터로 트레이스에서 RED 메트릭(Rate, Errors, Duration)을 자동 생성하고 Grafana 대시보드에 연결하는 파이프라인 구성
참고 자료
공식 문서
- Scaling the Collector | OpenTelemetry
- Sampling 개념 | OpenTelemetry
- tailsamplingprocessor README | opentelemetry-collector-contrib
- OpenTelemetry Collector 통합 (Experimental) | KEDA
- ScaledObject 명세 | KEDA
- otel-add-on | Kedify GitHub
실습 가이드
- OpenTelemetry Kubernetes Tracing Tutorial - Sampling | KubeCon EU 2024
- OTel Collector 메트릭으로 KEDA 스케일링 | Kedify 블로그
- OpenTelemetry 스케일러 문서 | Kedify
- KEDA + OTel 커스텀 메트릭 오토스케일링 설정 | oneuptime 블로그
- KEDA + Amazon Managed Prometheus로 Kubernetes 오토스케일링 | AWS 블로그