OpenTelemetry 컨텍스트 전파 정리: traceparent 헤더로 추적 잇기

OpenTelemetry 추적 정리에서 만든 트리는 깔끔했지만 한계가 있었습니다. 프로세스 하나 안에서만 이어진다는 것이요.

주문 서비스가 결제 서비스를 HTTP로 부르면 어떻게 될까요? 결제 서비스 입장에서는 그냥 요청 하나가 들어온 겁니다. 자기가 누구의 자식인지 알 도리가 없으니 새 추적을 시작하죠. 결과적으로 사용자 눈에는 한 번의 주문인데 추적은 두 개로 쪼개집니다. 😅 서비스가 늘어날수록 이 조각은 많아지고, 정작 알고 싶은 “이 주문 요청이 어디서 시간을 썼나”는 답할 수 없게 돼요.

이 글에서는 경계를 넘어 추적을 이어 붙이는 방법을 다루겠습니다.

끊기는 건 컨텍스트다

앞 글에서 부모 자식이 저절로 이어진 건 startActiveSpan이 현재 컨텍스트(context)에 스팬을 올려놓고, 안쪽 스팬이 그걸 읽어 부모를 정했기 때문입니다. 그런데 이 컨텍스트는 프로세스 안의 메모리예요. HTTP 요청은 메모리를 들고 가지 않죠.

그래서 할 일이 정해집니다. 보내는 쪽에서 컨텍스트를 요청에 실어 보내고, 받는 쪽에서 꺼내 쓰면 됩니다. 문제는 무엇에 어떤 형식으로 싣느냐인데, 이게 제각각이면 서로 다른 언어와 프레임워크로 만든 서비스끼리는 못 잇습니다.

여기서 W3C Trace Context 표준이 등장합니다. HTTP 헤더 이름과 값의 형식을 못 박아 둔 규격이고, 2021년에 W3C 권고안이 됐어요. OpenTelemetry는 이 표준을 기본으로 씁니다.

traceparent 헤더 뜯어보기

실제로 나가는 헤더는 이렇게 생겼습니다.

traceparent
00-503b5388fc1c7552eef4f9198c5a874f-29daf2ca450a62ac-01

하이픈으로 나뉜 네 조각입니다. 맨 앞 00은 형식 버전이고, 두 번째 32자리가 추적 전체를 가리키는 traceId, 세 번째 16자리가 이 요청을 보낸 스팬의 spanId입니다. 마지막 01은 플래그인데, 최하위 비트가 1이면 이 추적은 샘플링됐다는 뜻이에요.

받는 쪽이 하는 일이 여기서 드러납니다. 헤더의 traceId를 자기 스팬의 traceId로 쓰고, 헤더의 spanId를 자기 부모로 삼습니다. 그러면 두 프로세스의 스팬이 같은 추적에 속하게 되죠.

플래그가 따라오는 이유도 분명합니다. 앞단에서 이 요청을 기록하지 않기로 정했다면 뒷단도 같은 결정을 따라야 추적이 반쪽만 남지 않거든요. 샘플링 결정을 요청마다 한 번만 내리고 그 결과를 헤더로 흘려보내는 구조입니다.

inject와 extract

코드에서 쓰는 함수는 두 개뿐입니다. 보내는 쪽이 inject, 받는 쪽이 extract예요. 둘 다 운반체(carrier)라고 부르는 평범한 키 값 객체를 주고받습니다.

먼저 주문 서비스입니다. 외부 호출을 감싸는 스팬을 만들고, 그 안에서 헤더 객체에 컨텍스트를 채워 넣습니다.

orders.ts
import "./instrument";
import { context, propagation, trace } from "@opentelemetry/api";

const tracer = trace.getTracer("orders");

async function charge() {
  return tracer.startActiveSpan("charge 호출", async (span) => {
    const headers: Record<string, string> = {};
    propagation.inject(context.active(), headers); // 현재 컨텍스트를 헤더로
    const res = await fetch("http://localhost:3001/charge", {
      method: "POST",
      headers,
    });
    span.end();
    return res.json();
  });
}

inject에 넘긴 headers는 그냥 빈 객체입니다. 호출이 끝나면 traceparent가 채워져 있고, 그대로 fetch에 넘기면 됩니다.

받는 쪽은 반대로 합니다.

payments.ts
import "./instrument";
import { context, propagation, trace } from "@opentelemetry/api";

const tracer = trace.getTracer("payments");

Bun.serve({
  port: 3001,
  fetch(request) {
    const carrier = Object.fromEntries(request.headers.entries());
    const parent = propagation.extract(context.active(), carrier);

    // 세 번째 인자로 넘긴 컨텍스트가 이 스팬의 부모가 됩니다
    return tracer.startActiveSpan("POST /charge", {}, parent, (span) => {
      span.end();
      return Response.json({ ok: true });
    });
  },
});

extract가 돌려준 컨텍스트를 startActiveSpan의 세 번째 인자로 넘기는 게 핵심입니다. 이 인자를 빼먹으면 헤더를 잘 받아 놓고도 현재 컨텍스트를 쓰게 되어 추적이 그대로 끊깁니다. 전파가 안 된다는 제보의 상당수가 여기예요.

이어진 추적 확인하기

앞 글에서 띄운 Jaeger를 그대로 두고 두 서비스를 각각 실행합니다. 서비스 이름만 다르게 주면 됩니다.

SERVICE_NAME=payments bun run payments.ts
SERVICE_NAME=orders bun run orders.ts
curl -X POST http://localhost:3000/orders

나가는 헤더를 찍어 보면 규격대로 채워져 있습니다.

결과
보낸 헤더: {"traceparent":"00-503b5388fc1c7552eef4f9198c5a874f-29daf2ca450a62ac-01","baggage":"tenant.id=acme"}

Jaeger에서 이 추적을 열면 서비스 두 개가 한 트리에 들어옵니다.

Jaeger 화면에서 orders 서비스의 두 스팬 아래에 payments 서비스의 POST /charge 스팬이 다른 색으로 중첩된 추적

색이 다른 마지막 줄이 결제 서비스의 스팬입니다. 주문 서비스의 charge 호출 안에 들여쓰기돼 있죠. 이제 “결제 호출에 5.93밀리초가 걸렸는데 그중 결제 서비스가 쓴 건 286마이크로초”라는 걸 읽을 수 있습니다. 나머지는 네트워크와 연결 설정에서 쓴 시간이고요. 추적을 잇지 않았다면 이 둘을 나눠 볼 방법이 없습니다.

flowchart TB
    accTitle: traceparent 헤더로 두 서비스의 스팬이 한 추적에 묶이는 과정
    accDescr: 주문 서비스가 inject로 현재 컨텍스트를 traceparent 헤더에 담아 HTTP 요청으로 보내면, 결제 서비스가 extract로 꺼내 그 컨텍스트를 부모 삼아 스팬을 만든다. 두 스팬은 같은 traceId를 갖는다.

    subgraph orders ["주문 서비스"]
        direction TB
        o1["charge 호출 스팬"] --> o2["inject<br/>컨텍스트를 헤더로"]
    end

    o2 -- "traceparent 헤더" --> p1

    subgraph payments ["결제 서비스"]
        direction TB
        p1["extract<br/>헤더에서 컨텍스트로"] --> p2["POST /charge 스팬<br/>부모가 정해짐"]
    end

    style orders fill:none,stroke:#888,stroke-width:1px
    style payments fill:none,stroke:#888,stroke-width:1px

HTTP 말고 다른 경계

inject와 extract가 평범한 키 값 객체를 받는다는 점이 여기서 힘을 발휘합니다. HTTP 헤더만 되는 게 아니거든요.

메시지 큐로 일을 넘길 때는 메시지 속성에 넣으면 됩니다. Kafka라면 레코드 헤더, SQS라면 메시지 속성이죠. gRPC는 메타데이터를 운반체로 씁니다. 심지어 cron으로 도는 작업에 앞선 요청의 컨텍스트를 물려주고 싶다면 작업 레코드에 문자열로 저장해 뒀다가 꺼내 써도 됩니다.

다만 비동기 경계에서는 한 가지를 정해야 해요. 메시지를 넣은 요청과 나중에 꺼내 처리하는 작업을 같은 추적으로 볼 것인지입니다. 같은 추적으로 묶으면 큐에 머문 시간까지 한눈에 보이지만, 며칠 뒤에 처리되는 작업이라면 추적 하나가 비정상적으로 길어집니다. 이럴 때는 별도 추적으로 시작하되 스팬 링크(span link)로 둘을 연결하는 쪽이 낫습니다.

배기지는 추적이 아니다

전파되는 게 하나 더 있습니다. 배기지(baggage)인데, 이름처럼 요청에 딸려 다니는 짐입니다.

const bag = propagation.createBaggage({ "tenant.id": { value: "acme" } });
const ctx = propagation.setBaggage(context.active(), bag);
await context.with(ctx, charge);

이렇게 넣어 두면 baggage 헤더로 함께 나가고, 받는 쪽 어디서든 꺼낼 수 있습니다.

const tenant = propagation.getBaggage(context.active())?.getEntry("tenant.id");
span.setAttribute("tenant.id", tenant?.value ?? "unknown");

쓸모는 분명합니다. 앞단만 아는 정보를 뒷단 스팬에도 붙이고 싶을 때요. 어느 고객사의 요청인지, 어떤 실험군에 속하는지 같은 것들이죠. 이게 없으면 결제 서비스 스팬만 보고는 어느 고객사 때문에 느린지 알 수 없습니다.

대신 주의할 게 둘 있어요. 우선 배기지는 자동으로 스팬 속성이 되지 않습니다. 위 코드처럼 직접 꺼내 붙여야 해요. 그리고 배기지는 모든 하위 요청의 헤더에 실려 나갑니다. 민감한 값을 넣으면 신뢰하지 않는 서비스까지 흘러가고, 큰 값을 넣으면 요청마다 그만큼의 대역폭을 씁니다. 짧은 식별자 몇 개로 제한하는 게 맞습니다.

손으로 안 해도 되는 경우

여기까지 inject와 extract를 직접 불렀지만, 실제 프로젝트에서 이 코드를 쓸 일은 생각보다 적습니다. HTTP 클라이언트와 서버용 계측 라이브러리를 붙이면 들어오는 요청에서 꺼내고 나가는 요청에 싣는 걸 알아서 해 주거든요. 앞 글에서 이야기한, 라이브러리가 API에만 의존해 계측을 품을 수 있게 된 구조가 여기서 열매를 맺는 셈입니다.

그래도 직접 만져야 하는 자리가 남습니다. 우선 메시지 큐나 배치 작업처럼 표준 계측이 없는 경계요. 그리고 직접 만든 프로토콜로 통신하는 내부 서비스도 그렇고요. 자동 계측이 붙지 않는 런타임에서도 이 방법이 유일한 선택지입니다.

무엇보다, 전파가 안 될 때 어디를 봐야 할지 알려면 이 흐름을 이해하고 있어야 합니다. 추적이 끊겼다면 원인은 셋 중 하나예요. 보내는 쪽이 헤더를 안 실었거나, 중간의 프록시나 게이트웨이가 모르는 헤더라며 지워 버렸거나, 받는 쪽이 꺼낸 컨텍스트를 스팬 생성에 안 넘긴 겁니다. 요청 헤더를 한 번 찍어 보면 첫 두 가지는 바로 갈립니다.

마치며

서비스를 넘을 때 추적이 끊기는 건 컨텍스트가 프로세스 안의 메모리이기 때문이고, W3C Trace Context의 traceparent 헤더가 이걸 실어 나릅니다. 코드에서 할 일은 보낼 때 inject, 받을 때 extract, 그리고 꺼낸 컨텍스트를 스팬 생성에 넘기는 것까지 세 가지예요. 배기지는 같은 경로로 흐르지만 추적 구조와는 무관한 별도의 짐이고요.

지금까지는 애플리케이션이 백엔드로 직접 데이터를 보냈습니다. 서비스가 둘일 때는 괜찮지만 스무 개가 되면 스무 곳의 설정을 따로 관리해야 하죠. 다음 글에서는 그 사이에 두는 수집기를 다루겠습니다.

헤더 형식의 정확한 규격은 W3C Trace Context 권고안에 정의돼 있습니다.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord