OpenTelemetry로 구현하는 Kubernetes 분산 트레이싱
트레이스와 스팬의 개념, 컨텍스트 전파 원리, OpenTelemetry Collector의 receivers·processors·exporters 파이프라인, OTel Operator의 자동 계측, Tempo 백엔드 연동, 샘플링 전략까지 Kubernetes 분산 트레이싱의 전체 그림을 다룹니다.
지난 글에서 로그 파이프라인을 완성했다. 그런데 마이크로서비스 환경에서는 메트릭과 로그만으로 답하기 어려운 질문이 있다. “이 주문 요청은 왜 3초나 걸렸는가?” 요청이 게이트웨이 → 주문 → 결제 → DB를 거치는 동안 각 서비스의 로그는 따로 흩어져 있고, 메트릭은 평균만 말해준다. 이 질문에 답하는 도구가 관측성의 세 번째 기둥, 분산 트레이싱이고, 그 표준이 OpenTelemetry(OTel) 다.
트레이스와 스팬
분산 트레이싱의 데이터 모델은 두 개념으로 끝난다.
- 트레이스(Trace): 하나의 요청이 시스템을 통과하는 전체 여정. 고유한
trace_id로 식별된다 - 스팬(Span): 여정 속 개별 작업 구간(HTTP 핸들러, DB 쿼리 등). 시작/종료 시각과 부모 스팬 ID를 가진다
트레이싱 UI에서 보는 워터폴은 스팬들의 부모-자식 트리를 시간축에 펼친 것이다. 위 그림처럼 전체 320ms 중 payment 스팬이 230ms를 차지한다면 병목이 어디인지 즉시 보인다. 메트릭이 “느리다”를, 로그가 “에러 메시지”를 알려준다면, 트레이스는 “어느 구간에서 왜” 를 알려준다.
여기서 핵심 기술이 컨텍스트 전파(context propagation) 다. 서비스 A가 B를 호출할 때 trace_id와 부모 span_id를 HTTP 헤더(W3C 표준 traceparent)에 실어 보내야 B의 스팬이 같은 트레이스에 연결된다.
GET /api/payment HTTP/1.1
traceparent: 00-a1b2c3d4e5f6...-00f067aa0ba902b7-01
이 헤더를 모든 서비스가 일관되게 읽고·만들고·전달하게 하는 것이 계측(instrumentation)의 본질이고, 그 표준 도구가 OpenTelemetry다.
OpenTelemetry — 계측의 사실상 표준
OTel 이전에는 Jaeger 클라이언트, Zipkin 클라이언트 등 백엔드마다 SDK가 달랐다. OpenTelemetry는 계측 API/SDK, 전송 프로토콜(OTLP), 수집기(Collector) 를 벤더 중립으로 통일했다. 앱은 OTel SDK로 한 번만 계측하면, 백엔드는 Tempo든 Jaeger든 상용 APM이든 설정만 바꿔 갈아탈 수 있다.
Kubernetes에서는 OTel Operator를 쓰면 코드 수정 없이 자동 계측까지 가능하다.
# OTel Operator 설치 (cert-manager 필요)
helm repo add open-telemetry \
https://open-telemetry.github.io/opentelemetry-helm-charts
helm install otel-operator open-telemetry/opentelemetry-operator \
-n otel --create-namespace
# 자동 계측 정의 — 언어 런타임에 에이전트 주입
apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
name: default-instrumentation
namespace: prod
spec:
exporter:
endpoint: http://otel-collector.otel:4317
propagators:
- tracecontext
- baggage
sampler:
type: parentbased_traceidratio
argument: "0.1"
그다음 파드에 어노테이션 하나만 붙이면 Operator가 init 컨테이너로 자바 에이전트(또는 Python/Node.js 계측기)를 주입한다.
# Deployment의 파드 템플릿에 추가
metadata:
annotations:
instrumentation.opentelemetry.io/inject-java: "true"
Spring Boot 앱이라면 이 한 줄로 HTTP 서버/클라이언트, JDBC, Kafka 호출에 스팬이 자동으로 생기고 traceparent 전파까지 처리된다. 자동 계측으로 시작하고, 비즈니스적으로 중요한 구간(예: “쿠폰 적용 로직”)만 수동 스팬을 추가하는 것이 실무 순서다.
OTel Collector — 텔레메트리의 허브
앱이 백엔드로 데이터를 직접 쏘게 할 수도 있지만, 중간에 Collector를 두는 것이 표준 아키텍처다.
Collector 파이프라인은 receivers → processors → exporters 세 단계로 구성된다.
apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
name: otel-collector
namespace: otel
spec:
mode: deployment # 또는 daemonset (agent 모드)
config:
receivers:
otlp:
protocols:
grpc: {}
http: {}
processors:
batch: {}
k8sattributes: {} # 파드명·네임스페이스 등 자동 부착
memory_limiter:
limit_percentage: 80
check_interval: 1s
exporters:
otlp/tempo:
endpoint: tempo.tracing:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, batch]
exporters: [otlp/tempo]
k8sattributes 프로세서가 특히 중요하다. 스팬에 k8s.pod.name, k8s.namespace.name 같은 리소스 속성을 자동으로 붙여, 트레이스에서 “이 느린 스팬이 어느 파드에서 나왔는가”를 바로 알 수 있게 한다. 배포 모드는 노드마다 두는 DaemonSet(agent)과 중앙 Deployment(gateway)를 조합하는 2계층 구성이 대규모 클러스터의 정석이다.
백엔드: Tempo
트레이스 저장소로는 Jaeger와 Grafana Tempo가 대표적이다. 앞서 다룬 것처럼 Grafana/Loki 스택을 쓰고 있다면 Tempo가 자연스럽다 — Loki와 같은 철학(최소 인덱스 + 오브젝트 스토리지)이라 운영 비용이 낮고, Grafana에서 메트릭·로그·트레이스가 한 화면에 모인다.
helm install tempo grafana/tempo -n tracing --create-namespace \
--set tempo.storage.trace.backend=s3 \
--set tempo.storage.trace.s3.bucket=my-tempo-traces
진짜 강력한 것은 세 신호의 연결이다. 지난 글에서 구조화 로그에 trace_id 필드를 남기라고 한 이유가 여기 있다.
- 로그 → 트레이스: Grafana에서 Loki 로그의
trace_id를 클릭하면 Tempo의 해당 트레이스로 점프 - 트레이스 → 로그: 스팬을 보다가 “이 시점 이 파드의 로그”로 점프
- 메트릭 → 트레이스: 히스토그램에서 느린 요청의 예시 트레이스(exemplar)로 점프
장애 분석 동선이 “대시보드에서 이상 감지 → 느린 트레이스 확인 → 해당 스팬의 로그 확인”으로 클릭 몇 번에 끝난다.
샘플링 — 전부 저장하지 않는다
초당 수만 요청을 모두 트레이싱하면 저장 비용이 감당이 안 된다. 그래서 샘플링이 필수다.
- Head 샘플링: 요청 시작 시점에 확률로 결정(위 예시의
traceidratio 0.1= 10%). 단순하고 저렴하지만 “에러 난 요청”을 놓칠 수 있다 - Tail 샘플링: Collector가 트레이스를 끝까지 모아본 뒤 “에러가 있거나 1초 이상 걸린 것만 저장” 같은 정책으로 결정. 가치 있는 트레이스만 남지만 Collector 메모리가 필요하다
# tail_sampling 프로세서 — 에러와 느린 요청은 전부 보존
processors:
tail_sampling:
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow
type: latency
latency: { threshold_ms: 1000 }
- name: baseline
type: probabilistic
probabilistic: { sampling_percentage: 5 }
시작은 head 샘플링 10%면 충분하다. 트래픽이 커지고 “장애 때 트레이스가 없네”를 한 번 겪으면 그때 tail 샘플링을 도입하자.
마무리
이로써 관측성의 세 기둥 — 메트릭(Prometheus/Grafana), 로그(Loki/EFK), 트레이스(OTel/Tempo) — 가 모두 갖춰졌다. 핵심은 도구가 아니라 연결이다. trace_id를 고리로 세 신호를 잇는 순간, 장애 분석 시간이 한 자릿수로 줄어든다. 그런데 Kubernetes에는 외부 도구 없이도 클러스터가 스스로 남기는 1차 관측 데이터가 있다. 다음 글에서는 종종 잊히지만 트러블슈팅의 첫 단서가 되는 Kubernetes Events를 파헤친다.
지난 글: EFK 스택과 Loki — 중앙집중식 로깅 구축
다음 글: Kubernetes Events — 클러스터의 블랙박스 기록
읽어주셔서 감사합니다. 😊