Skip to content

Split View: 분산 트레이싱 실전 가이드: OpenTelemetry, Jaeger, Grafana Tempo

|

분산 트레이싱 실전 가이드: OpenTelemetry, Jaeger, Grafana Tempo

분산 트레이싱 OpenTelemetry

들어가며

마이크로서비스 아키텍처에서 하나의 사용자 요청은 여러 서비스를 거쳐 처리된다. API Gateway에서 시작된 요청이 인증 서비스, 주문 서비스, 결제 서비스, 알림 서비스를 순차적/병렬적으로 호출하는 구조에서, 특정 요청이 느린 이유를 파악하려면 전체 호출 체인을 추적해야 한다. 단일 서비스의 로그나 메트릭만으로는 서비스 간 인과 관계를 파악하기 어렵다.

분산 트레이싱(Distributed Tracing)은 이 문제를 해결한다. 요청이 서비스 경계를 넘을 때마다 고유한 Trace ID를 전파하고, 각 서비스에서 수행한 작업을 Span으로 기록하여 전체 요청 흐름을 하나의 트레이스로 재구성한다. Google의 Dapper 논문(2010)에서 시작된 이 개념은 이제 OpenTelemetry라는 CNCF 프로젝트로 표준화되었고, Jaeger와 Grafana Tempo 같은 백엔드를 통해 저장하고 분석할 수 있다.

이 글에서는 분산 트레이싱의 핵심 개념부터 시작하여 OpenTelemetry SDK를 활용한 Python/Go 계측, OpenTelemetry Collector 구성, Jaeger와 Grafana Tempo 백엔드 구축, 샘플링 전략, 비용 최적화, 운영 시 주의사항까지 프로덕션 환경에서 필요한 전체 파이프라인을 실전 코드와 함께 다룬다.


분산 트레이싱 핵심 개념

Trace, Span, Context Propagation

분산 트레이싱의 세 가지 핵심 개념을 정리한다.

Trace는 하나의 사용자 요청이 시스템을 통과하는 전체 경로를 나타낸다. 고유한 Trace ID로 식별되며, 여러 Span의 집합으로 구성된다.

Span은 트레이스 내에서 하나의 작업 단위를 나타낸다. 각 Span은 고유한 Span ID, 부모 Span ID, 시작/종료 시간, 속성(Attributes), 이벤트(Events), 상태(Status)를 가진다. Span의 종류(SpanKind)는 SERVER, CLIENT, PRODUCER, CONSUMER, INTERNAL 다섯 가지가 있다.

Context Propagation은 Trace ID와 Span ID를 서비스 경계를 넘어 전달하는 메커니즘이다. HTTP 헤더(W3C Trace Context의 traceparent, tracestate), gRPC 메타데이터, 메시지 큐 헤더 등을 통해 컨텍스트가 전파된다.

W3C Trace Context 표준

W3C Trace Context는 분산 트레이싱의 컨텍스트 전파 표준이다. traceparent 헤더의 형식은 다음과 같다.

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             |  |                                |                |
           버전  Trace ID (32)                  Parent Span ID   Flags

OpenTelemetry는 기본적으로 W3C Trace Context를 사용하며, B3(Zipkin), Jaeger 형식의 전파도 지원한다.

OpenTelemetry 아키텍처 개요

OpenTelemetry는 텔레메트리 데이터(트레이스, 메트릭, 로그)를 생성, 수집, 전송하기 위한 벤더 중립적 프레임워크다. 주요 구성 요소는 다음과 같다.

  • API: 계측을 위한 인터페이스 정의 (벤더 독립적)
  • SDK: API의 구현체 (샘플링, 배치, 내보내기 등)
  • Collector: 텔레메트리 데이터를 수신, 처리, 내보내는 독립 프로세스
  • Instrumentation Libraries: 라이브러리/프레임워크 자동 계측

OpenTelemetry SDK 계측 (Python/Go)

Python 계측

Python 애플리케이션에 OpenTelemetry를 적용하는 방법을 살펴본다. 먼저 필요한 패키지를 설치한다.

pip install opentelemetry-api \
  opentelemetry-sdk \
  opentelemetry-exporter-otlp \
  opentelemetry-instrumentation-flask \
  opentelemetry-instrumentation-requests \
  opentelemetry-instrumentation-sqlalchemy

다음은 Flask 애플리케이션에 OpenTelemetry를 적용하는 전체 코드다.

# tracing.py - OpenTelemetry 초기화 모듈
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.trace.propagation import TraceContextTextMapPropagator
from opentelemetry.baggage.propagation import W3CBaggagePropagator

def init_tracer(service_name: str, otlp_endpoint: str = "localhost:4317"):
    """OpenTelemetry TracerProvider를 초기화하고 글로벌 설정을 적용한다."""
    resource = Resource.create({
        SERVICE_NAME: service_name,
        SERVICE_VERSION: "1.0.0",
        "deployment.environment": "production",
    })

    provider = TracerProvider(resource=resource)

    # OTLP gRPC Exporter 설정
    otlp_exporter = OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)
    span_processor = BatchSpanProcessor(
        otlp_exporter,
        max_queue_size=2048,
        max_export_batch_size=512,
        schedule_delay_millis=5000,
    )
    provider.add_span_processor(span_processor)

    # 글로벌 TracerProvider 등록
    trace.set_tracer_provider(provider)

    # W3C Trace Context + Baggage 전파 설정
    set_global_textmap(CompositePropagator([
        TraceContextTextMapPropagator(),
        W3CBaggagePropagator(),
    ]))

    return provider
# app.py - Flask 애플리케이션에 트레이싱 적용
from flask import Flask, request, jsonify
from opentelemetry import trace
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from tracing import init_tracer
import requests

# 트레이서 초기화
provider = init_tracer("order-service", "otel-collector:4317")

app = Flask(__name__)

# 자동 계측: Flask와 requests 라이브러리
FlaskInstrumentor().instrument_app(app)
RequestsInstrumentor().instrument()

# 수동 계측을 위한 트레이서 획득
tracer = trace.get_tracer("order-service", "1.0.0")

@app.route("/orders", methods=["POST"])
def create_order():
    """주문 생성 API - 수동 Span을 추가하여 세밀한 추적 수행"""
    with tracer.start_as_current_span(
        "validate_order",
        attributes={"order.type": "standard"}
    ) as span:
        order_data = request.get_json()
        span.set_attribute("order.item_count", len(order_data.get("items", [])))

        # 재고 확인 - 외부 서비스 호출 (자동 계측됨)
        inventory_resp = requests.post(
            "http://inventory-service:8080/check",
            json=order_data["items"]
        )

        if inventory_resp.status_code != 200:
            span.set_status(trace.StatusCode.ERROR, "Inventory check failed")
            span.record_exception(Exception("재고 부족"))
            return jsonify({"error": "insufficient_inventory"}), 400

    with tracer.start_as_current_span("process_payment") as span:
        payment_resp = requests.post(
            "http://payment-service:8080/charge",
            json={"amount": order_data["total"]}
        )
        span.set_attribute("payment.method", order_data.get("payment_method", "card"))

    return jsonify({"order_id": "ORD-12345", "status": "confirmed"}), 201

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

Go 계측

Go 애플리케이션에서는 go.opentelemetry.io/otel 패키지를 사용한다. HTTP 서버와 gRPC 클라이언트에 트레이싱을 적용하는 예제를 살펴본다.

// tracing.go - OpenTelemetry 초기화
package main

import (
    "context"
    "log"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
    "go.opentelemetry.io/otel/trace"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
)

func initTracer(ctx context.Context, serviceName, endpoint string) (*sdktrace.TracerProvider, error) {
    // gRPC 연결로 OTLP Exporter 생성
    conn, err := grpc.NewClient(endpoint,
        grpc.WithTransportCredentials(insecure.NewCredentials()),
    )
    if err != nil {
        return nil, err
    }

    exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
    if err != nil {
        return nil, err
    }

    // Resource 정의 - 서비스 메타데이터
    res, err := resource.New(ctx,
        resource.WithAttributes(
            semconv.ServiceName(serviceName),
            semconv.ServiceVersion("1.0.0"),
            semconv.DeploymentEnvironment("production"),
        ),
    )
    if err != nil {
        return nil, err
    }

    // TracerProvider 생성
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter,
            sdktrace.WithMaxQueueSize(2048),
            sdktrace.WithMaxExportBatchSize(512),
        ),
        sdktrace.WithResource(res),
        sdktrace.WithSampler(sdktrace.ParentBased(
            sdktrace.TraceIDRatioBased(0.1), // 10% 샘플링
        )),
    )

    // 글로벌 설정
    otel.SetTracerProvider(tp)
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{},
        propagation.Baggage{},
    ))

    return tp, nil
}

// handleOrder - HTTP 핸들러에서 수동 Span 생성
func handleOrder(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    tracer := otel.Tracer("order-handler")

    ctx, span := tracer.Start(ctx, "process-order",
        trace.WithSpanKind(trace.SpanKindServer),
    )
    defer span.End()

    // 비즈니스 로직에 컨텍스트 전달
    orderID, err := processOrder(ctx)
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        http.Error(w, "order failed", http.StatusInternalServerError)
        return
    }

    span.SetAttributes(attribute.String("order.id", orderID))
    w.WriteHeader(http.StatusCreated)
}

자동 계측 vs 수동 계측

구분자동 계측수동 계측
적용 방식라이브러리/에이전트 사용코드에 직접 Span 생성
코드 변경최소 (초기화만)비즈니스 로직 곳곳에 삽입
커버리지HTTP, DB, gRPC 등 프레임워크 레벨커스텀 비즈니스 로직까지 가능
세밀한 제어제한적속성, 이벤트, 링크 자유롭게 추가
권장 전략기본 골격으로 사용핵심 비즈니스 로직에 보완적으로 추가

실전에서는 자동 계측으로 HTTP/gRPC/DB 호출의 기본 Span을 생성하고, 수동 계측으로 비즈니스 로직의 핵심 구간을 추가로 계측하는 하이브리드 전략을 권장한다.


OpenTelemetry Collector 구성

OpenTelemetry Collector는 텔레메트리 데이터를 수신(Receivers), 처리(Processors), 내보내기(Exporters)하는 파이프라인 역할을 한다. 애플리케이션에서 직접 백엔드로 데이터를 보내는 대신 Collector를 중간에 두면 재시도, 배치, 샘플링, 라우팅 등을 중앙에서 관리할 수 있다.

Collector 구성 파일

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 5s
    send_batch_size: 1024
    send_batch_max_size: 2048

  memory_limiter:
    check_interval: 1s
    limit_mib: 1024
    spike_limit_mib: 256

  resource:
    attributes:
      - key: environment
        value: production
        action: upsert
      - key: cluster
        value: k8s-prod-01
        action: upsert

  # Tail-based 샘플링: 에러와 느린 요청은 100% 수집
  tail_sampling:
    decision_wait: 10s
    num_traces: 100000
    expected_new_traces_per_sec: 1000
    policies:
      - name: error-policy
        type: status_code
        status_code:
          status_codes:
            - ERROR
      - name: latency-policy
        type: latency
        latency:
          threshold_ms: 1000
      - name: probabilistic-policy
        type: probabilistic
        probabilistic:
          sampling_percentage: 10

exporters:
  otlp/jaeger:
    endpoint: jaeger-collector:4317
    tls:
      insecure: true

  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true

  debug:
    verbosity: basic

extensions:
  health_check:
    endpoint: 0.0.0.0:13133
  zpages:
    endpoint: 0.0.0.0:55679

service:
  extensions: [health_check, zpages]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, resource, tail_sampling, batch]
      exporters: [otlp/jaeger, otlp/tempo, debug]
  telemetry:
    logs:
      level: info
    metrics:
      address: 0.0.0.0:8888

Collector Docker Compose 배포

# docker-compose.yaml (Collector 부분)
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.116.0
    command: ['--config=/etc/otel-collector-config.yaml']
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
    ports:
      - '4317:4317' # OTLP gRPC
      - '4318:4318' # OTLP HTTP
      - '8888:8888' # Collector 메트릭
      - '13133:13133' # Health check
      - '55679:55679' # zPages
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 2g
          cpus: '1.0'
    healthcheck:
      test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:13133']
      interval: 10s
      timeout: 5s
      retries: 3

Collector 구성에서 주의할 점은 processors 순서다. memory_limiter는 반드시 첫 번째로 배치하여 OOM을 방지해야 하며, batch는 마지막에 위치시켜 네트워크 효율을 높인다.


Jaeger 백엔드 구축과 운영

Jaeger는 Uber에서 개발한 오픈소스 분산 트레이싱 시스템으로, CNCF Graduated 프로젝트다. Jaeger v2는 OpenTelemetry Collector를 기반으로 재구축되어 네이티브 OTLP 지원을 제공한다.

Jaeger 프로덕션 배포

프로덕션 환경에서는 Jaeger all-in-one이 아닌 분리된 컴포넌트 배포를 권장한다. Elasticsearch를 스토리지 백엔드로 사용하는 구성을 살펴본다.

# docker-compose-jaeger.yaml
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - 'ES_JAVA_OPTS=-Xms1g -Xmx1g'
    ports:
      - '9200:9200'
    volumes:
      - es_data:/usr/share/elasticsearch/data
    healthcheck:
      test: ['CMD-SHELL', 'curl -f http://localhost:9200/_cluster/health || exit 1']
      interval: 10s
      timeout: 5s
      retries: 10

  jaeger:
    image: jaegertracing/jaeger:2.4.0
    environment:
      - SPAN_STORAGE_TYPE=elasticsearch
      - ES_SERVER_URLS=http://elasticsearch:9200
      - ES_NUM_SHARDS=3
      - ES_NUM_REPLICAS=1
      - ES_INDEX_PREFIX=jaeger
      - ES_TAGS_AS_FIELDS_ALL=true
      - COLLECTOR_OTLP_ENABLED=true
    ports:
      - '16686:16686' # Jaeger UI
      - '4317:4317' # OTLP gRPC
      - '4318:4318' # OTLP HTTP
      - '14250:14250' # gRPC Collector
    depends_on:
      elasticsearch:
        condition: service_healthy
    restart: unless-stopped

volumes:
  es_data:
    driver: local

Jaeger 인덱스 관리

Elasticsearch 기반 Jaeger 운영에서 인덱스 관리는 필수다. 오래된 트레이스를 자동 삭제하는 크론잡을 설정한다.

#!/bin/bash
# jaeger-index-cleaner.sh - 14일 이상 된 Jaeger 인덱스 삭제
ES_URL="http://elasticsearch:9200"
RETENTION_DAYS=14

# 삭제 대상 날짜 계산
CUTOFF_DATE=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)

echo "Deleting Jaeger indices older than ${CUTOFF_DATE}..."

# jaeger-span-* 및 jaeger-service-* 인덱스 조회 및 삭제
for INDEX_TYPE in "jaeger-span" "jaeger-service"; do
  INDICES=$(curl -s "${ES_URL}/_cat/indices/${INDEX_TYPE}-*" \
    | awk '{print $3}' \
    | sort)

  for INDEX in ${INDICES}; do
    # 인덱스명에서 날짜 추출 (jaeger-span-2026-03-01 형식)
    INDEX_DATE=$(echo "${INDEX}" | grep -oP '\d{4}-\d{2}-\d{2}')
    if [[ "${INDEX_DATE}" < "${CUTOFF_DATE}" ]]; then
      echo "Deleting index: ${INDEX}"
      curl -s -X DELETE "${ES_URL}/${INDEX}"
    fi
  done
done

echo "Cleanup completed."

Jaeger 핵심 기능

  • 트레이스 검색: 서비스명, 작업명, 태그, 시간 범위로 트레이스 검색
  • 트레이스 비교: 두 트레이스를 나란히 비교하여 성능 차이 분석
  • 의존성 그래프: 서비스 간 호출 관계를 DAG로 시각화
  • SPM (Service Performance Monitoring): RED 메트릭(Rate, Error, Duration) 자동 생성

Grafana Tempo: 대규모 트레이스 스토리지

Grafana Tempo는 대규모 분산 트레이싱을 위해 설계된 백엔드다. Jaeger와 달리 인덱스를 생성하지 않고 오브젝트 스토리지(S3, GCS, Azure Blob)에 트레이스를 직접 저장하여 비용을 획기적으로 줄인다.

Tempo 구성

# tempo-config.yaml
server:
  http_listen_port: 3200

distributor:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
        http:
          endpoint: 0.0.0.0:4318

ingester:
  max_block_duration: 5m
  max_block_bytes: 1073741824 # 1GB
  flush_check_period: 10s

compactor:
  compaction:
    block_retention: 336h # 14일
  ring:
    kvstore:
      store: memberlist

storage:
  trace:
    backend: s3
    s3:
      bucket: tempo-traces-prod
      endpoint: s3.ap-northeast-2.amazonaws.com
      region: ap-northeast-2
      # IAM Role 기반 인증 권장 (access_key 직접 입력 지양)
    wal:
      path: /var/tempo/wal
    local:
      path: /var/tempo/blocks
    pool:
      max_workers: 100
      queue_depth: 10000

querier:
  search:
    query_timeout: 30s
  frontend_worker:
    frontend_address: tempo-query-frontend:9095

metrics_generator:
  registry:
    external_labels:
      source: tempo
      cluster: k8s-prod
  storage:
    path: /var/tempo/generator/wal
    remote_write:
      - url: http://prometheus:9090/api/v1/write
        send_exemplars: true
  traces_storage:
    path: /var/tempo/generator/traces
  processor:
    service_graphs:
      dimensions:
        - service.namespace
        - deployment.environment
    span_metrics:
      dimensions:
        - http.method
        - http.status_code
        - http.route

overrides:
  defaults:
    metrics_generator:
      processors: [service-graphs, span-metrics]

Tempo + Grafana 통합

Tempo의 진정한 힘은 Grafana와의 통합에서 나온다. TraceQL을 사용하여 트레이스를 쿼리하고, 로그(Loki)와 메트릭(Prometheus/Mimir)을 트레이스와 상호 연결할 수 있다.

Grafana 데이터소스 설정에서 Tempo를 추가하고, Trace to Logs, Trace to Metrics 기능을 활성화하면 트레이스의 특정 Span에서 관련 로그와 메트릭으로 바로 점프할 수 있다. 이것이 Grafana 스택의 핵심 차별점인 "상관관계 분석(Correlation)"이다.

TraceQL 쿼리 예제

Tempo 2.0부터 도입된 TraceQL은 트레이스 전용 쿼리 언어다.

// 에러 상태인 Span을 가진 트레이스 검색
{ status = error }

// 특정 서비스에서 1초 이상 걸린 Span 검색
{ resource.service.name = "order-service" && duration > 1s }

// HTTP 500 에러를 반환한 Span 검색
{ span.http.status_code = 500 }

// 두 서비스 간의 호출 관계 검색
{ resource.service.name = "api-gateway" } >> { resource.service.name = "payment-service" }

트레이싱 백엔드 비교 (Jaeger vs Tempo vs Zipkin)

트레이싱 백엔드를 선택할 때 환경과 요구사항에 맞는 도구를 선택하는 것이 중요하다. 주요 트레이싱 백엔드를 비교한다.

항목JaegerGrafana TempoZipkinAWS X-Ray
개발 주체Uber (CNCF Graduated)Grafana LabsTwitter (현 X)AWS
스토리지Elasticsearch, Cassandra, KafkaS3, GCS, Azure BlobElasticsearch, MySQL, CassandraAWS 자체 스토리지
인덱싱전체 인덱싱인덱스 없음 (Trace ID 기반)전체 인덱싱자체 인덱싱
쿼리서비스, 태그, 시간 기반 검색TraceQL, Trace ID 검색서비스, 태그, 시간 기반 검색Filter Expressions
GB당 스토리지 비용높음 (ES 인덱싱)매우 낮음 (오브젝트 스토리지)높음 (ES 인덱싱)중간 (관리형)
OTLP 지원네이티브 (v2)네이티브별도 Collector 필요별도 SDK/Collector
스케일링수평 확장 가능수평 확장 우수제한적자동 (관리형)
UI자체 UI (우수)Grafana (탁월)자체 UI (기본)AWS 콘솔
메트릭 생성SPM (v2)Metrics Generator없음자체 메트릭
로그 연동제한적Loki 네이티브 연동없음CloudWatch 연동
적합한 환경독립 트레이싱 시스템Grafana 스택 사용 환경소규모/학습AWS 네이티브

선택 가이드

  • Jaeger: 독립적인 트레이싱 시스템이 필요하고, 풍부한 검색 기능이 중요하며, Elasticsearch/Cassandra 운영 역량이 있는 팀
  • Grafana Tempo: 이미 Grafana, Loki, Prometheus/Mimir를 사용하고 있으며, 비용 효율이 중요하고, 대규모 트레이스를 처리해야 하는 환경
  • Zipkin: 빠른 프로토타이핑이나 학습 목적, 또는 레거시 Zipkin 형식과의 호환이 필요한 경우
  • AWS X-Ray: AWS 서비스(Lambda, ECS, EKS)를 주로 사용하고, 관리형 서비스를 선호하는 환경

샘플링 전략과 비용 최적화

프로덕션 환경에서 모든 트레이스를 수집하면 스토리지 비용과 네트워크 부하가 급증한다. 효과적인 샘플링 전략은 비용과 가시성 사이의 균형을 맞추는 핵심이다.

Head-based 샘플링

요청이 시작될 때 샘플링 여부를 결정한다. 구현이 단순하지만, 에러 트레이스를 놓칠 수 있다.

# Python에서 Head-based 확률적 샘플링 설정
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased, ParentBased

# ParentBased: 부모 Span이 샘플링되었으면 자식도 샘플링
# 부모가 없는 루트 Span은 10% 확률로 샘플링
sampler = ParentBased(root=TraceIdRatioBased(0.1))

provider = TracerProvider(
    resource=resource,
    sampler=sampler,
)

Tail-based 샘플링

트레이스가 완성된 후 전체 Span을 보고 샘플링 여부를 결정한다. OpenTelemetry Collector의 tail_sampling 프로세서를 사용하며, 에러 트레이스나 지연이 큰 트레이스를 100% 수집할 수 있다. 단, Collector에 메모리 부하가 발생하므로 decision_wait 시간과 num_traces 설정을 환경에 맞게 조정해야 한다.

비용 최적화 전략

  1. 계층별 샘플링: 정상 트래픽은 5~10%, 에러/느린 요청은 100% 수집
  2. 서비스별 차등 샘플링: 핵심 서비스는 높은 비율, 내부 헬스체크는 0%
  3. 스토리지 티어링: 최근 데이터는 빠른 스토리지, 오래된 데이터는 저비용 스토리지
  4. TTL 설정: 트레이스 보존 기간을 7~14일로 제한 (대부분의 디버깅은 48시간 내 발생)
  5. Attribute 필터링: 불필요한 Span Attribute를 Collector에서 제거하여 데이터 크기 축소

운영 시 주의사항과 트러블슈팅

Context Propagation 누락

분산 트레이싱에서 가장 흔한 문제는 컨텍스트 전파 누락이다. 서비스 간 호출에서 traceparent 헤더가 전달되지 않으면 트레이스가 끊어진다.

증상: Jaeger/Tempo에서 트레이스를 보면 Span이 하나밖에 없거나, 서로 다른 Trace ID로 분리되어 있다.

원인과 해결:

  • HTTP 클라이언트 라이브러리의 자동 계측이 누락된 경우: RequestsInstrumentor().instrument() 또는 otelhttp.NewTransport() 적용
  • 메시지 큐(Kafka, RabbitMQ)를 통한 호출에서 컨텍스트가 전파되지 않는 경우: 메시지 헤더에 traceparent를 수동으로 주입/추출
  • gRPC 인터셉터가 적용되지 않은 경우: otelgrpc.UnaryClientInterceptor() 추가

Collector 병목과 백프레셔

트래픽이 급증하면 Collector가 처리량을 감당하지 못해 Span이 누락될 수 있다.

모니터링 지표:

  • otelcol_exporter_send_failed_spans: 내보내기 실패한 Span 수
  • otelcol_processor_batch_timeout_trigger_send: 타임아웃으로 인한 강제 전송
  • otelcol_receiver_refused_spans: 수신 거부된 Span 수

해결:

  • memory_limiter 프로세서를 반드시 설정하여 OOM 방지
  • Collector를 수평 확장하고 로드밸런서 뒤에 배치
  • batch 프로세서의 send_batch_sizetimeout을 조정

높은 카디널리티 Attribute

Span Attribute에 사용자 ID, 세션 ID, 요청 본문 등 카디널리티가 높은 값을 넣으면 인덱싱 비용이 폭증한다 (특히 Jaeger + Elasticsearch 환경).

가이드라인:

  • Attribute 값의 카디널리티는 수천 이하로 유지
  • 고유 식별자는 Span Attribute 대신 Span Event나 로그에 기록
  • Jaeger의 ES_TAGS_AS_FIELDS_ALL=false로 설정하고 필요한 태그만 인덱싱

실패 사례와 복구 절차

사례 1: Collector OOM으로 인한 트레이스 유실

상황: Tail-based 샘플링을 사용하는 Collector에서 트래픽 급증으로 메모리 사용량이 한계를 초과하여 프로세스가 종료되었다. 약 5분간 모든 트레이스가 유실되었다.

근본 원인: tail_sampling 프로세서의 num_traces가 50,000으로 설정되어 있었으나, 피크 시간에는 초당 10,000건의 새 트레이스가 유입되었다. decision_wait이 30초로 설정되어 최대 300,000개의 트레이스를 메모리에 보관해야 했다.

복구 절차:

  1. Collector Pod 자동 재시작 확인 (Kubernetes liveness probe)
  2. num_traces를 200,000으로 증가, decision_wait을 10초로 축소
  3. memory_limiterlimit_mib를 실제 Pod 메모리 한계의 80%로 설정
  4. Collector 인스턴스를 3개로 수평 확장

예방 조치: Collector의 메모리 사용량에 대한 알림 규칙을 설정하고, 자동 스케일링(HPA)을 적용한다.

사례 2: Elasticsearch 인덱스 폭증으로 인한 Jaeger 장애

상황: 새로운 서비스를 배포한 후 Span Attribute에 요청 바디 전체를 포함하는 계측이 추가되었다. 24시간 만에 Elasticsearch 디스크 사용량이 95%를 초과하여 인덱스 쓰기가 차단되었다.

근본 원인: 개발자가 디버깅 목적으로 span.set_attribute("request.body", json.dumps(request_body))를 추가했으나, 이 Attribute의 평균 크기가 10KB였고 초당 1,000건의 요청이 발생하여 일일 약 800GB의 추가 데이터가 생성되었다.

복구 절차:

  1. 문제가 된 서비스의 Span Attribute를 제거하는 핫픽스 배포
  2. Elasticsearch의 디스크 워터마크를 일시적으로 조정하여 쓰기 재개
  3. 문제 기간의 인덱스를 수동 삭제하여 디스크 확보
  4. Collector에 Attribute 크기 제한 프로세서 추가

예방 조치: Collector 레벨에서 attributes 프로세서를 사용하여 Attribute 값 크기를 제한하고, CI/CD 파이프라인에서 계측 코드 리뷰를 의무화한다.

사례 3: 클럭 스큐로 인한 비정상적인 Span 순서

상황: Jaeger UI에서 트레이스를 볼 때 자식 Span이 부모 Span보다 먼저 시작되거나, Span 시간이 음수로 표시되었다.

근본 원인: 컨테이너 호스트 노드 간 시간 동기화(NTP)가 정상 작동하지 않아 최대 500ms의 클럭 스큐가 발생했다.

복구 절차:

  1. 모든 노드에서 NTP 동기화 상태 확인: chronyc tracking
  2. NTP 서버 설정 수정 및 강제 동기화
  3. 클럭 스큐가 큰 기간의 트레이스 데이터는 무시

예방 조치: Kubernetes 노드에 chrony 또는 systemd-timesyncd를 설정하고, NTP 오프셋에 대한 모니터링 알림을 구성한다.


마치며

분산 트레이싱은 마이크로서비스 아키텍처에서 필수적인 관측 가능성 도구다. OpenTelemetry를 통해 벤더 중립적으로 계측하고, Jaeger 또는 Grafana Tempo를 백엔드로 사용하면 프로덕션 환경에서 요청 흐름을 투명하게 추적할 수 있다.

핵심 정리:

  1. OpenTelemetry로 계측을 표준화한다. 자동 계측으로 기본 골격을 만들고, 수동 계측으로 비즈니스 로직을 보완한다.
  2. OpenTelemetry Collector를 중간에 배치한다. 재시도, 샘플링, 라우팅을 중앙에서 관리한다.
  3. 백엔드는 환경에 맞게 선택한다. Grafana 스택이라면 Tempo, 독립 시스템이라면 Jaeger가 적합하다.
  4. Tail-based 샘플링으로 비용을 최적화한다. 에러와 지연이 큰 요청은 반드시 수집하고, 정상 요청은 낮은 비율로 샘플링한다.
  5. 운영 파이프라인을 모니터링한다. Collector와 백엔드 자체의 메트릭을 수집하고 알림을 설정한다.

분산 트레이싱을 도입할 때 가장 중요한 것은 "모든 것을 계측하려 하지 않는 것"이다. 핵심 사용자 여정(Critical User Journey)을 먼저 정의하고, 해당 경로의 서비스를 우선 계측한 후 점진적으로 확대하는 것이 현실적인 접근이다.


참고자료

Practical Guide to Distributed Tracing: OpenTelemetry, Jaeger, Grafana Tempo

Distributed Tracing OpenTelemetry

Introduction

In a microservices architecture, a single user request is processed across multiple services. When a request originating from an API Gateway sequentially or concurrently calls authentication, order, payment, and notification services, you need to trace the entire call chain to determine why a specific request is slow. Logs and metrics from a single service alone are insufficient to understand causal relationships between services.

Distributed tracing solves this problem. Every time a request crosses a service boundary, a unique Trace ID is propagated. Each service records the work it performs as a Span, allowing the entire request flow to be reconstructed as a single trace. This concept, which originated from Google's Dapper paper (2010), has now been standardized as OpenTelemetry, a CNCF project, and can be stored and analyzed through backends like Jaeger and Grafana Tempo.

This post covers the full pipeline needed for production environments with practical code examples, starting from core distributed tracing concepts, through Python/Go instrumentation with the OpenTelemetry SDK, OpenTelemetry Collector configuration, Jaeger and Grafana Tempo backend setup, sampling strategies, cost optimization, and operational considerations.


Core Concepts of Distributed Tracing

Trace, Span, Context Propagation

Let's review the three core concepts of distributed tracing.

A Trace represents the entire path of a single user request as it traverses the system. It is identified by a unique Trace ID and consists of a collection of multiple Spans.

A Span represents a single unit of work within a trace. Each Span has a unique Span ID, parent Span ID, start/end times, Attributes, Events, and Status. There are five types of SpanKind: SERVER, CLIENT, PRODUCER, CONSUMER, and INTERNAL.

Context Propagation is the mechanism for passing Trace ID and Span ID across service boundaries. Context is propagated through HTTP headers (W3C Trace Context's traceparent and tracestate), gRPC metadata, message queue headers, and more.

W3C Trace Context Standard

W3C Trace Context is the standard for context propagation in distributed tracing. The format of the traceparent header is as follows:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             |  |                                |                |
          Version  Trace ID (32 chars)           Parent Span ID   Flags

OpenTelemetry uses W3C Trace Context by default and also supports B3 (Zipkin) and Jaeger format propagation.

OpenTelemetry Architecture Overview

OpenTelemetry is a vendor-neutral framework for generating, collecting, and transmitting telemetry data (traces, metrics, logs). The main components are:

  • API: Interface definitions for instrumentation (vendor-independent)
  • SDK: Implementation of the API (sampling, batching, exporting, etc.)
  • Collector: A standalone process that receives, processes, and exports telemetry data
  • Instrumentation Libraries: Automatic instrumentation for libraries and frameworks

OpenTelemetry SDK Instrumentation (Python/Go)

Python Instrumentation

Let's look at how to apply OpenTelemetry to a Python application. First, install the required packages:

pip install opentelemetry-api \
  opentelemetry-sdk \
  opentelemetry-exporter-otlp \
  opentelemetry-instrumentation-flask \
  opentelemetry-instrumentation-requests \
  opentelemetry-instrumentation-sqlalchemy

Here is the complete code for applying OpenTelemetry to a Flask application:

# tracing.py - OpenTelemetry initialization module
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource, SERVICE_NAME, SERVICE_VERSION
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.trace.propagation import TraceContextTextMapPropagator
from opentelemetry.baggage.propagation import W3CBaggagePropagator

def init_tracer(service_name: str, otlp_endpoint: str = "localhost:4317"):
    """Initialize the OpenTelemetry TracerProvider and apply global settings."""
    resource = Resource.create({
        SERVICE_NAME: service_name,
        SERVICE_VERSION: "1.0.0",
        "deployment.environment": "production",
    })

    provider = TracerProvider(resource=resource)

    # OTLP gRPC Exporter configuration
    otlp_exporter = OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)
    span_processor = BatchSpanProcessor(
        otlp_exporter,
        max_queue_size=2048,
        max_export_batch_size=512,
        schedule_delay_millis=5000,
    )
    provider.add_span_processor(span_processor)

    # Register global TracerProvider
    trace.set_tracer_provider(provider)

    # W3C Trace Context + Baggage propagation setup
    set_global_textmap(CompositePropagator([
        TraceContextTextMapPropagator(),
        W3CBaggagePropagator(),
    ]))

    return provider
# app.py - Applying tracing to a Flask application
from flask import Flask, request, jsonify
from opentelemetry import trace
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from tracing import init_tracer
import requests

# Initialize tracer
provider = init_tracer("order-service", "otel-collector:4317")

app = Flask(__name__)

# Automatic instrumentation: Flask and requests library
FlaskInstrumentor().instrument_app(app)
RequestsInstrumentor().instrument()

# Obtain tracer for manual instrumentation
tracer = trace.get_tracer("order-service", "1.0.0")

@app.route("/orders", methods=["POST"])
def create_order():
    """Order creation API - adds manual Spans for fine-grained tracing"""
    with tracer.start_as_current_span(
        "validate_order",
        attributes={"order.type": "standard"}
    ) as span:
        order_data = request.get_json()
        span.set_attribute("order.item_count", len(order_data.get("items", [])))

        # Inventory check - external service call (auto-instrumented)
        inventory_resp = requests.post(
            "http://inventory-service:8080/check",
            json=order_data["items"]
        )

        if inventory_resp.status_code != 200:
            span.set_status(trace.StatusCode.ERROR, "Inventory check failed")
            span.record_exception(Exception("Insufficient inventory"))
            return jsonify({"error": "insufficient_inventory"}), 400

    with tracer.start_as_current_span("process_payment") as span:
        payment_resp = requests.post(
            "http://payment-service:8080/charge",
            json={"amount": order_data["total"]}
        )
        span.set_attribute("payment.method", order_data.get("payment_method", "card"))

    return jsonify({"order_id": "ORD-12345", "status": "confirmed"}), 201

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

Go Instrumentation

In Go applications, the go.opentelemetry.io/otel package is used. Let's look at an example of applying tracing to an HTTP server and gRPC client.

// tracing.go - OpenTelemetry initialization
package main

import (
    "context"
    "log"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
    "go.opentelemetry.io/otel/trace"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
)

func initTracer(ctx context.Context, serviceName, endpoint string) (*sdktrace.TracerProvider, error) {
    // Create OTLP Exporter with gRPC connection
    conn, err := grpc.NewClient(endpoint,
        grpc.WithTransportCredentials(insecure.NewCredentials()),
    )
    if err != nil {
        return nil, err
    }

    exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithGRPCConn(conn))
    if err != nil {
        return nil, err
    }

    // Define Resource - service metadata
    res, err := resource.New(ctx,
        resource.WithAttributes(
            semconv.ServiceName(serviceName),
            semconv.ServiceVersion("1.0.0"),
            semconv.DeploymentEnvironment("production"),
        ),
    )
    if err != nil {
        return nil, err
    }

    // Create TracerProvider
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter,
            sdktrace.WithMaxQueueSize(2048),
            sdktrace.WithMaxExportBatchSize(512),
        ),
        sdktrace.WithResource(res),
        sdktrace.WithSampler(sdktrace.ParentBased(
            sdktrace.TraceIDRatioBased(0.1), // 10% sampling
        )),
    )

    // Global settings
    otel.SetTracerProvider(tp)
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{},
        propagation.Baggage{},
    ))

    return tp, nil
}

// handleOrder - Create manual Span in HTTP handler
func handleOrder(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    tracer := otel.Tracer("order-handler")

    ctx, span := tracer.Start(ctx, "process-order",
        trace.WithSpanKind(trace.SpanKindServer),
    )
    defer span.End()

    // Pass context to business logic
    orderID, err := processOrder(ctx)
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        http.Error(w, "order failed", http.StatusInternalServerError)
        return
    }

    span.SetAttributes(attribute.String("order.id", orderID))
    w.WriteHeader(http.StatusCreated)
}

Automatic vs Manual Instrumentation

AspectAutomatic InstrumentationManual Instrumentation
How to applyUse libraries/agentsCreate Spans directly in code
Code changesMinimal (initialization only)Inserted throughout business logic
CoverageFramework-level: HTTP, DB, gRPC, etc.Can cover custom business logic
Fine controlLimitedFreely add attributes, events, and links
Recommended useUse as the base skeletonAdd supplementally for key business logic

In practice, a hybrid strategy is recommended: use automatic instrumentation to generate baseline Spans for HTTP/gRPC/DB calls, and add manual instrumentation for critical sections of business logic.


OpenTelemetry Collector Configuration

The OpenTelemetry Collector serves as a pipeline for receiving (Receivers), processing (Processors), and exporting (Exporters) telemetry data. By placing a Collector between applications and backends instead of sending data directly, you can centrally manage retries, batching, sampling, and routing.

Collector Configuration File

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 5s
    send_batch_size: 1024
    send_batch_max_size: 2048

  memory_limiter:
    check_interval: 1s
    limit_mib: 1024
    spike_limit_mib: 256

  resource:
    attributes:
      - key: environment
        value: production
        action: upsert
      - key: cluster
        value: k8s-prod-01
        action: upsert

  # Tail-based sampling: collect 100% of errors and slow requests
  tail_sampling:
    decision_wait: 10s
    num_traces: 100000
    expected_new_traces_per_sec: 1000
    policies:
      - name: error-policy
        type: status_code
        status_code:
          status_codes:
            - ERROR
      - name: latency-policy
        type: latency
        latency:
          threshold_ms: 1000
      - name: probabilistic-policy
        type: probabilistic
        probabilistic:
          sampling_percentage: 10

exporters:
  otlp/jaeger:
    endpoint: jaeger-collector:4317
    tls:
      insecure: true

  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true

  debug:
    verbosity: basic

extensions:
  health_check:
    endpoint: 0.0.0.0:13133
  zpages:
    endpoint: 0.0.0.0:55679

service:
  extensions: [health_check, zpages]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, resource, tail_sampling, batch]
      exporters: [otlp/jaeger, otlp/tempo, debug]
  telemetry:
    logs:
      level: info
    metrics:
      address: 0.0.0.0:8888

Collector Docker Compose Deployment

# docker-compose.yaml (Collector section)
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.116.0
    command: ['--config=/etc/otel-collector-config.yaml']
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml:ro
    ports:
      - '4317:4317' # OTLP gRPC
      - '4318:4318' # OTLP HTTP
      - '8888:8888' # Collector metrics
      - '13133:13133' # Health check
      - '55679:55679' # zPages
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 2g
          cpus: '1.0'
    healthcheck:
      test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:13133']
      interval: 10s
      timeout: 5s
      retries: 3

An important consideration in Collector configuration is the order of processors. memory_limiter must always be placed first to prevent OOM, and batch should be positioned last to improve network efficiency.


Jaeger Backend Setup and Operations

Jaeger is an open-source distributed tracing system developed by Uber and is a CNCF Graduated project. Jaeger v2 has been rebuilt on top of the OpenTelemetry Collector, providing native OTLP support.

Jaeger Production Deployment

For production environments, deploying separate components is recommended over the Jaeger all-in-one setup. Let's look at a configuration using Elasticsearch as the storage backend.

# docker-compose-jaeger.yaml
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.17.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - 'ES_JAVA_OPTS=-Xms1g -Xmx1g'
    ports:
      - '9200:9200'
    volumes:
      - es_data:/usr/share/elasticsearch/data
    healthcheck:
      test: ['CMD-SHELL', 'curl -f http://localhost:9200/_cluster/health || exit 1']
      interval: 10s
      timeout: 5s
      retries: 10

  jaeger:
    image: jaegertracing/jaeger:2.4.0
    environment:
      - SPAN_STORAGE_TYPE=elasticsearch
      - ES_SERVER_URLS=http://elasticsearch:9200
      - ES_NUM_SHARDS=3
      - ES_NUM_REPLICAS=1
      - ES_INDEX_PREFIX=jaeger
      - ES_TAGS_AS_FIELDS_ALL=true
      - COLLECTOR_OTLP_ENABLED=true
    ports:
      - '16686:16686' # Jaeger UI
      - '4317:4317' # OTLP gRPC
      - '4318:4318' # OTLP HTTP
      - '14250:14250' # gRPC Collector
    depends_on:
      elasticsearch:
        condition: service_healthy
    restart: unless-stopped

volumes:
  es_data:
    driver: local

Jaeger Index Management

Index management is essential when operating Jaeger with Elasticsearch. Set up a cron job to automatically delete old traces.

#!/bin/bash
# jaeger-index-cleaner.sh - Delete Jaeger indices older than 14 days
ES_URL="http://elasticsearch:9200"
RETENTION_DAYS=14

# Calculate cutoff date
CUTOFF_DATE=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)

echo "Deleting Jaeger indices older than ${CUTOFF_DATE}..."

# Query and delete jaeger-span-* and jaeger-service-* indices
for INDEX_TYPE in "jaeger-span" "jaeger-service"; do
  INDICES=$(curl -s "${ES_URL}/_cat/indices/${INDEX_TYPE}-*" \
    | awk '{print $3}' \
    | sort)

  for INDEX in ${INDICES}; do
    # Extract date from index name (format: jaeger-span-2026-03-01)
    INDEX_DATE=$(echo "${INDEX}" | grep -oP '\d{4}-\d{2}-\d{2}')
    if [[ "${INDEX_DATE}" < "${CUTOFF_DATE}" ]]; then
      echo "Deleting index: ${INDEX}"
      curl -s -X DELETE "${ES_URL}/${INDEX}"
    fi
  done
done

echo "Cleanup completed."

Key Jaeger Features

  • Trace search: Search traces by service name, operation name, tags, and time range
  • Trace comparison: Compare two traces side by side to analyze performance differences
  • Dependency graph: Visualize inter-service call relationships as a DAG
  • SPM (Service Performance Monitoring): Automatic generation of RED metrics (Rate, Error, Duration)

Grafana Tempo: Large-Scale Trace Storage

Grafana Tempo is a backend designed for large-scale distributed tracing. Unlike Jaeger, it does not create indices and stores traces directly in object storage (S3, GCS, Azure Blob), dramatically reducing costs.

Tempo Configuration

# tempo-config.yaml
server:
  http_listen_port: 3200

distributor:
  receivers:
    otlp:
      protocols:
        grpc:
          endpoint: 0.0.0.0:4317
        http:
          endpoint: 0.0.0.0:4318

ingester:
  max_block_duration: 5m
  max_block_bytes: 1073741824 # 1GB
  flush_check_period: 10s

compactor:
  compaction:
    block_retention: 336h # 14 days
  ring:
    kvstore:
      store: memberlist

storage:
  trace:
    backend: s3
    s3:
      bucket: tempo-traces-prod
      endpoint: s3.ap-northeast-2.amazonaws.com
      region: ap-northeast-2
      # IAM Role-based authentication recommended (avoid hardcoding access_key)
    wal:
      path: /var/tempo/wal
    local:
      path: /var/tempo/blocks
    pool:
      max_workers: 100
      queue_depth: 10000

querier:
  search:
    query_timeout: 30s
  frontend_worker:
    frontend_address: tempo-query-frontend:9095

metrics_generator:
  registry:
    external_labels:
      source: tempo
      cluster: k8s-prod
  storage:
    path: /var/tempo/generator/wal
    remote_write:
      - url: http://prometheus:9090/api/v1/write
        send_exemplars: true
  traces_storage:
    path: /var/tempo/generator/traces
  processor:
    service_graphs:
      dimensions:
        - service.namespace
        - deployment.environment
    span_metrics:
      dimensions:
        - http.method
        - http.status_code
        - http.route

overrides:
  defaults:
    metrics_generator:
      processors: [service-graphs, span-metrics]

Tempo + Grafana Integration

The true power of Tempo comes from its integration with Grafana. You can query traces using TraceQL and correlate logs (Loki) and metrics (Prometheus/Mimir) with traces.

By adding Tempo as a data source in Grafana settings and enabling the Trace to Logs and Trace to Metrics features, you can jump directly from a specific Span in a trace to related logs and metrics. This "correlation analysis" is the key differentiator of the Grafana stack.

TraceQL Query Examples

TraceQL, introduced in Tempo 2.0, is a query language dedicated to traces.

// Search for traces with Spans in error status
{ status = error }

// Search for Spans taking more than 1 second in a specific service
{ resource.service.name = "order-service" && duration > 1s }

// Search for Spans that returned HTTP 500 errors
{ span.http.status_code = 500 }

// Search for call relationships between two services
{ resource.service.name = "api-gateway" } >> { resource.service.name = "payment-service" }

Tracing Backend Comparison (Jaeger vs Tempo vs Zipkin)

When choosing a tracing backend, it is important to select the right tool for your environment and requirements. Here is a comparison of the major tracing backends.

ItemJaegerGrafana TempoZipkinAWS X-Ray
DeveloperUber (CNCF Graduated)Grafana LabsTwitter (now X)AWS
StorageElasticsearch, Cassandra, KafkaS3, GCS, Azure BlobElasticsearch, MySQL, CassandraAWS managed storage
IndexingFull indexingNo indexing (Trace ID-based)Full indexingProprietary indexing
QueryingService, tag, time-based searchTraceQL, Trace ID searchService, tag, time-based searchFilter Expressions
Storage cost/GBHigh (ES indexing)Very low (object storage)High (ES indexing)Medium (managed)
OTLP supportNative (v2)NativeRequires separate CollectorSeparate SDK/Collector
ScalingHorizontal scaling possibleExcellent horizontal scalingLimitedAutomatic (managed)
UIBuilt-in UI (good)Grafana (excellent)Built-in UI (basic)AWS Console
Metrics generationSPM (v2)Metrics GeneratorNoneProprietary metrics
Log integrationLimitedNative Loki integrationNoneCloudWatch integration
Best suited forStandalone tracing systemGrafana stack environmentsSmall-scale/learningAWS-native

Selection Guide

  • Jaeger: Teams that need a standalone tracing system, require rich search capabilities, and have Elasticsearch/Cassandra operational expertise
  • Grafana Tempo: Environments already using Grafana, Loki, and Prometheus/Mimir where cost efficiency matters and large-scale trace processing is needed
  • Zipkin: For quick prototyping or learning purposes, or when compatibility with legacy Zipkin format is required
  • AWS X-Ray: Environments primarily using AWS services (Lambda, ECS, EKS) that prefer managed services

Sampling Strategies and Cost Optimization

In production environments, collecting all traces leads to surging storage costs and network load. An effective sampling strategy is key to balancing cost and visibility.

Head-based Sampling

Sampling decisions are made when the request starts. Implementation is simple, but error traces can be missed.

# Head-based probabilistic sampling setup in Python
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased, ParentBased

# ParentBased: if the parent Span is sampled, the child is also sampled
# Root Spans without a parent are sampled at 10% probability
sampler = ParentBased(root=TraceIdRatioBased(0.1))

provider = TracerProvider(
    resource=resource,
    sampler=sampler,
)

Tail-based Sampling

Sampling decisions are made after the trace is complete, based on all the Spans. This uses the tail_sampling processor in the OpenTelemetry Collector and can collect 100% of error traces and high-latency traces. However, since it puts memory pressure on the Collector, the decision_wait time and num_traces settings need to be tuned for your environment.

Cost Optimization Strategies

  1. Tiered sampling: Collect 5-10% of normal traffic, 100% of errors and slow requests
  2. Per-service differentiated sampling: Higher rates for critical services, 0% for internal health checks
  3. Storage tiering: Fast storage for recent data, low-cost storage for older data
  4. TTL settings: Limit trace retention to 7-14 days (most debugging happens within 48 hours)
  5. Attribute filtering: Remove unnecessary Span Attributes at the Collector to reduce data size

Operational Considerations and Troubleshooting

Missing Context Propagation

The most common issue in distributed tracing is missing context propagation. If the traceparent header is not passed during inter-service calls, the trace gets broken.

Symptoms: When viewing a trace in Jaeger/Tempo, there is only one Span, or Spans are separated into different Trace IDs.

Causes and solutions:

  • Missing automatic instrumentation for HTTP client libraries: Apply RequestsInstrumentor().instrument() or otelhttp.NewTransport()
  • Context not propagated through message queues (Kafka, RabbitMQ): Manually inject/extract traceparent in message headers
  • Missing gRPC interceptor: Add otelgrpc.UnaryClientInterceptor()

Collector Bottlenecks and Backpressure

When traffic spikes, the Collector may not handle the throughput, causing Span loss.

Metrics to monitor:

  • otelcol_exporter_send_failed_spans: Number of Spans that failed to export
  • otelcol_processor_batch_timeout_trigger_send: Forced sends due to timeout
  • otelcol_receiver_refused_spans: Number of refused Spans

Solutions:

  • Always configure the memory_limiter processor to prevent OOM
  • Horizontally scale Collectors behind a load balancer
  • Tune send_batch_size and timeout in the batch processor

High Cardinality Attributes

Placing high-cardinality values such as user IDs, session IDs, or request bodies in Span Attributes causes indexing costs to skyrocket (especially in Jaeger + Elasticsearch environments).

Guidelines:

  • Keep Attribute value cardinality to thousands or fewer
  • Record unique identifiers in Span Events or logs instead of Span Attributes
  • Set Jaeger's ES_TAGS_AS_FIELDS_ALL=false and index only the necessary tags

Failure Cases and Recovery Procedures

Case 1: Trace Loss Due to Collector OOM

Situation: A Collector using tail-based sampling exceeded its memory limit due to a traffic spike, causing the process to terminate. All traces were lost for approximately 5 minutes.

Root cause: The tail_sampling processor's num_traces was set to 50,000, but during peak hours, 10,000 new traces per second were flowing in. With decision_wait set to 30 seconds, up to 300,000 traces needed to be held in memory.

Recovery procedure:

  1. Verify automatic Collector Pod restart (Kubernetes liveness probe)
  2. Increase num_traces to 200,000 and reduce decision_wait to 10 seconds
  3. Set memory_limiter's limit_mib to 80% of the actual Pod memory limit
  4. Horizontally scale to 3 Collector instances

Preventive measures: Set up alerting rules for Collector memory usage and apply autoscaling (HPA).

Case 2: Jaeger Outage Due to Elasticsearch Index Explosion

Situation: After deploying a new service, instrumentation was added that included the entire request body as a Span Attribute. Within 24 hours, Elasticsearch disk usage exceeded 95%, and index writes were blocked.

Root cause: A developer added span.set_attribute("request.body", json.dumps(request_body)) for debugging purposes. This Attribute averaged 10KB in size, and with 1,000 requests per second, approximately 800GB of additional data was generated daily.

Recovery procedure:

  1. Deploy a hotfix to remove the problematic Span Attribute from the service
  2. Temporarily adjust Elasticsearch disk watermarks to resume writes
  3. Manually delete indices from the problem period to free disk space
  4. Add an Attribute size limit processor to the Collector

Preventive measures: Use the attributes processor at the Collector level to limit Attribute value sizes, and mandate instrumentation code reviews in the CI/CD pipeline.

Case 3: Abnormal Span Ordering Due to Clock Skew

Situation: When viewing traces in the Jaeger UI, child Spans appeared to start before parent Spans, or Span durations were displayed as negative.

Root cause: Time synchronization (NTP) between container host nodes was not functioning properly, causing clock skew of up to 500ms.

Recovery procedure:

  1. Check NTP synchronization status on all nodes: chronyc tracking
  2. Correct NTP server configuration and force synchronization
  3. Disregard trace data from the period with significant clock skew

Preventive measures: Configure chrony or systemd-timesyncd on Kubernetes nodes, and set up monitoring alerts for NTP offset.


Conclusion

Distributed tracing is an essential observability tool in microservices architectures. By instrumenting in a vendor-neutral manner through OpenTelemetry and using Jaeger or Grafana Tempo as backends, you can transparently trace request flows in production environments.

Key takeaways:

  1. Standardize instrumentation with OpenTelemetry. Build the base skeleton with automatic instrumentation, and supplement with manual instrumentation for business logic.
  2. Place an OpenTelemetry Collector in the middle. Centrally manage retries, sampling, and routing.
  3. Choose a backend that fits your environment. If you use the Grafana stack, Tempo is suitable; for a standalone system, Jaeger is a good fit.
  4. Optimize costs with tail-based sampling. Always collect error and high-latency requests, and sample normal requests at a low rate.
  5. Monitor the operational pipeline. Collect metrics from the Collector and backends themselves, and set up alerts.

The most important thing when adopting distributed tracing is "not trying to instrument everything." Define your Critical User Journeys first, prioritize instrumenting services along those paths, and then gradually expand. This is the pragmatic approach.


References