OpenTelemetry Trace Context 전파와 분산 추적

· 약 13분 읽기 · 조회 0 #OpenTelemetry#분산 추적#Observability#Spring Boot#Kafka

분산 추적은 서비스마다 span을 남기는 것만으로 완성되지 않는다. 요청이 HTTP, Kafka, 스레드 풀을 건널 때마다 같은 trace-id가 넘어가야 하고, 한 곳에서 빠지면 trace가 둘로 끊긴다. 이 글은 W3C Trace Context 형식, Propagator와 Baggage, 메시징·비동기 구간의 전파, Head·Tail 샘플링, 로그 연결까지 OpenTelemetry Java를 기준으로 정리한다.

붉은 실 한 가닥을 따라 이어진 우편 분류실들을 지나가는 탐정

한눈에 보기

구성요소 하는 일 빠지면 생기는 일
traceparent 헤더 trace-id, 부모 span-id, 샘플링 여부 전달 다음 서비스가 새 trace를 시작한다
Propagator 헤더를 넣고(inject) 꺼낸다(extract) 계측은 됐는데 서비스 사이가 이어지지 않는다
Context 현재 스레드의 활성 span 보관 스레드 풀로 넘어간 작업이 부모를 잃는다
Sampler 남길 trace를 고른다 저장 비용이 폭증하거나 오류 trace를 놓친다
Collector 받아서 거르고 내보낸다 내보낼 곳·샘플링 규칙이 서비스마다 흩어진다

게이트웨이에서 주문 서비스, Kafka, 결제 서비스로 traceparent가 넘어가며 trace-id는 그대로이고 parent-id만 바뀌는 흐름

traceparent 한 줄에 담긴 것

W3C Trace Context는 HTTP 헤더 두 개를 정의한다. 핵심은 traceparent다.

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  │                                │                └ trace-flags (01 = sampled)
             │  │                                └ parent-id: 호출한 쪽 span-id, 16진수 16자
             │  └ trace-id: 요청 전체의 ID, 16진수 32자
             └ version
  • trace-id는 끝까지 그대로고, 서비스를 지날 때마다 parent-id가 바뀐다.
  • 받는 쪽은 parent-id를 자기 span의 부모로 삼고, 새 span-id를 만들어 다음 호출의 parent-id로 보낸다.
  • 모두 0인 ID나 형식이 틀린 헤더를 받으면 새 trace를 시작한다.
  • 마지막 01은 "앞에서 이 trace를 남기기로 했다"는 표시다. 뒤쪽 샘플러가 이 값을 따를지 정한다.

tracestate와 baggage는 무엇이 다른가

tracestate는 추적 벤더가 자기 정보를 덧붙이는 칸(vendor=값 목록)으로, 애플리케이션이 직접 쓸 일은 드물다.

baggage는 별도의 W3C Baggage 표준으로, 애플리케이션이 다음 서비스에 넘기고 싶은 키·값을 싣는다.

baggage: tenant=gov-a,channel=mobile
  • baggage는 span 속성에 자동으로 들어가지 않는다. 보려면 받은 쪽에서 span에 직접 넣는다.
  • 모든 하위 호출로 퍼지고 외부 API까지 따라갈 수 있다. 주민번호·토큰·이메일은 넣지 않는다.

Propagator: 헤더를 넣고 꺼내는 쪽

Propagator는 Context를 헤더에 넣고(inject) 헤더에서 꺼낸다(extract). OpenTelemetry Java agent를 붙이면 Spring MVC, WebClient, RestTemplate, Kafka 클라이언트에서 이 일을 대신한다.

java -javaagent:/opt/otel/opentelemetry-javaagent.jar \
  -Dotel.service.name=order-service \
  -Dotel.exporter.otlp.endpoint=http://otel-collector:4318 \
  -Dotel.propagators=tracecontext,baggage \
  -Dotel.traces.sampler=parentbased_traceidratio \
  -Dotel.traces.sampler.arg=1.0 \
  -jar order-service.jar
  • otel.propagators 기본값이 tracecontext,baggage다. Zipkin 계열 서비스와 섞여 있으면 b3multi를 더해 두 형식을 모두 읽게 한다.
  • 4318은 OTLP/HTTP, 4317은 OTLP/gRPC 포트다. 기본 프로토콜은 agent 버전마다 다를 수 있어 otel.exporter.otlp.protocol을 명시한다.
  • 중간의 Gateway·Nginx·WAF가 헤더를 지우지 않는지 먼저 본다.

HTTP를 넘어: Kafka 헤더로 전파하기

agent가 Kafka producer·consumer를 계측하면 레코드 헤더에 traceparent가 자동으로 붙는다. 사내 큐 라이브러리처럼 계측이 안 되는 경로는 같은 일을 직접 한다.

TextMapPropagator propagator = GlobalOpenTelemetry.getPropagators().getTextMapPropagator();

// 보내는 쪽: 현재 Context를 레코드 헤더에 넣는다
TextMapSetter<Headers> setter = (headers, key, value) ->
        headers.add(key, value.getBytes(StandardCharsets.UTF_8));
ProducerRecord<String, String> record = new ProducerRecord<>("payment.requested", orderId, payload);
propagator.inject(Context.current(), record.headers(), setter);

// 받는 쪽: 헤더에서 부모 Context를 꺼내 CONSUMER span을 시작한다
TextMapGetter<Headers> getter = new TextMapGetter<>() {
    public Iterable<String> keys(Headers h) {
        return StreamSupport.stream(h.spliterator(), false).map(Header::key).toList();
    }
    public String get(Headers h, String key) {
        Header last = h == null ? null : h.lastHeader(key);
        return last == null ? null : new String(last.value(), StandardCharsets.UTF_8);
    }
};
Context parent = propagator.extract(Context.current(), consumerRecord.headers(), getter);
Span span = tracer.spanBuilder("payment.requested process")
        .setParent(parent).setSpanKind(SpanKind.CONSUMER).startSpan();
try (Scope ignored = span.makeCurrent()) {
    handle(consumerRecord);
} catch (RuntimeException e) {
    span.recordException(e);
    span.setStatus(StatusCode.ERROR);
    throw e;
} finally {
    span.end();
}

배치 consumer는 부모가 여럿이므로 각 메시지의 Context를 span link로 붙인다. Outbox로 늦게 보내는 구조라면 Outbox 행에 traceparent를 같이 저장해야 trace가 이어진다(관련 글: Outbox·Inbox 패턴으로 연계 데이터 정합성 확보하기).

비동기 스레드에서 컨텍스트가 사라지는 이유

Context는 ThreadLocal에 있다. 작업이 다른 스레드로 넘어가면 Context가 없어 새 trace가 시작된다.

  • Java agent는 ExecutorService, CompletableFuture, Spring @Async, Reactor를 계측해 Context를 넘긴다.
  • agent 없이 SDK만 쓰면 직접 감싼다: Context.taskWrapping(executor)로 실행기를 감싸거나 Context.current().wrap(runnable)로 작업을 감싼다.
  • 직접 만든 스레드나 스케줄러 콜백은 agent도 놓칠 수 있다. trace 하나의 span 수가 예상과 맞는지 본다.

샘플링: Head, Tail, ParentBased

모든 trace를 저장하면 비용을 감당할 수 없다. 고르는 시점에 따라 두 방식이 있다.

방식 결정 시점 장점 한계
Head (traceidratio) 첫 span 시작 때 싸고 단순, 서비스 부담 적음 오류·지연 trace인지 모르고 버린다
Tail (Collector tail_sampling) trace가 끝난 뒤 오류·느린 trace를 골라 남긴다 Collector 메모리, 같은 trace를 한 인스턴스로 모아야 함

parentbased_* 샘플러는 부모의 trace-flags를 따른다. 루트 서비스만 비율을 정하고 나머지는 결정을 이어받으므로 trace 중간이 비는 일이 없다. Tail 샘플링을 쓸 때는 서비스 쪽 비율을 1.0으로 두어야 한다. 앞에서 버린 trace는 Collector가 볼 수 없다.

서비스들이 OTLP로 보낸 span을 Collector가 받아 메모리 제한, Tail 샘플링, 배치를 거쳐 트레이스 저장소로 내보내는 파이프라인

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: 1500 }
  tail_sampling:
    decision_wait: 10s
    policies:
      - { name: errors, type: status_code, status_code: { status_codes: [ERROR] } }
      - { name: slow,   type: latency,     latency: { threshold_ms: 1000 } }
      - { name: base,   type: probabilistic, probabilistic: { sampling_percentage: 5 } }
  batch: {}
exporters:
  otlp/traces:
    endpoint: tempo:4317
    tls: { insecure: true }   # 예시용 평문 전송, 운영은 TLS 인증서 사용
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, tail_sampling, batch]
      exporters: [otlp/traces]

tail_sampling은 contrib 배포판에 있다. Collector를 여러 대 띄우면 앞단에 loadbalancing exporter를 두고 trace-id 기준으로 나눠야 한 trace의 span이 한 인스턴스에 모인다. 위 숫자(10초, 1000ms, 5%)는 예시이며 서비스의 P99와 저장 예산으로 정한다(관련 글: P95와 P99가 평균 응답 시간보다 중요한 이유).

로그와 트레이스 잇기

trace는 어디서 느렸는지, 로그는 왜 그랬는지를 알려 준다. 둘은 trace-id로 잇는다.

  • Java agent는 Logback·Log4j MDC에 trace_id, span_id, trace_flags를 넣는다.
<pattern>%d{HH:mm:ss.SSS} %-5level [%X{trace_id},%X{span_id}] %logger{36} - %msg%n</pattern>

정리

구간 전파 수단 누가 처리 확인 방법
HTTP 호출 traceparent, baggage 헤더 agent 자동 게이트웨이 앞뒤 헤더 덤프
Kafka·MQ 레코드 헤더 agent 또는 직접 inject/extract consumer span의 부모가 producer span인지
스레드 풀 ThreadLocal Context agent 또는 Context.taskWrapping trace당 span 수

자주 하는 실수

  • 프록시가 헤더를 지운다. 계측은 정상인데 trace가 게이트웨이에서 끊긴다.
  • 서비스마다 샘플링 비율을 따로 정한다. 부모는 남고 자식은 버려져 trace 중간이 빈다.
  • Head 10%에 Tail 샘플링을 겹친다. 오류 trace의 90%는 Collector에 닿기 전에 사라진다.
  • baggage에 개인정보를 넣는다. 외부 API 호출 헤더로 그대로 나간다.
  • 배치 consumer에서 첫 메시지만 부모로 잡는다. 나머지 trace는 거기서 끊긴다.

점검표

  • 모든 서비스의 otel.propagators가 같은 형식을 포함한다.
  • API Gateway·프록시를 지난 뒤에도 traceparent 헤더가 남아 있다.
  • Kafka consumer span의 부모가 producer span으로 보인다.
  • @Async·스레드 풀 작업이 같은 trace-id로 기록된다.
  • 루트 서비스만 샘플링 비율을 정하고 나머지는 parentbased다.
  • Tail 샘플링을 쓰면 서비스 쪽 비율이 1.0이다.
  • Collector를 여러 대 두면 trace-id 기준 로드밸런싱을 한다.
  • 로그 한 줄에 trace_id·span_id가 찍힌다.

분산 추적의 품질은 span을 얼마나 많이 남기느냐가 아니라 trace-id가 모든 경계를 빠짐없이 건너느냐로 정해진다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기