마이크로서비스 몇 개만 넘어가면 "어디서 느려졌는지"를 로그만으로 짚기가 어려워진다. 게이트웨이 로그에는 3초가 찍혔는데 각 서비스 로그를 하나씩 열어보면 다들 자기는 빨랐다고 한다. 예전에 이 블로그에 Jaeger 설치 글을 하나 올린 적이 있는데, 그때는 v1 시절이었다. 그 사이 Jaeger는 v2로 아키텍처가 통째로 바뀌었고, 지금 새로 도입한다면 그때 글의 절차를 그대로 따라가면 안 되는 부분이 꽤 있다. 그래서 원리부터 다시 정리해 둔다.
Jaeger는 요청 하나가 여러 서비스를 지나가는 동안 각 구간의 시작과 종료 시각을 span으로 기록하고, 같은 trace ID로 묶어 하나의 타임라인으로 보여주는 오픈소스 분산 추적 플랫폼이다. 2024년 11월 공개된 v2부터는 OpenTelemetry Collector 프레임워크 위에서 동작하는 단일 jaeger 바이너리로 재설계되어, 구성 파일에 따라 all-in-one, collector, query 같은 역할로 실행한다. 로컬에서 확인만 할 목적이라면 도커 컨테이너 하나로 UI와 OTLP 수신 포트까지 전부 띄울 수 있다.
아래에서는 Jaeger가 무엇인지, span과 trace ID가 어떻게 조립되는지, v2 아키텍처가 v1과 어디서 갈리는지, 그리고 도커로 직접 띄워 트레이스를 넣어보는 순서로 살펴본다.
1. Jaeger 개요
Jaeger는 2016년 Uber Technologies가 오픈소스로 공개한 분산 추적 플랫폼이고, 현재 CNCF 졸업(graduated) 프로젝트다. 하는 일은 단순하다. 여러 서비스에 흩어진 추적 데이터를 받아서 저장하고, 검색과 시각화를 제공한다.
여기서 분산 추적이라는 말부터 짚고 가자. 한 요청이 API 게이트웨이 -> 주문 서비스 -> 재고 서비스 -> DB 순으로 지나갈 때, 각 구간을 개별 작업 단위로 쪼개 기록하고 그것들을 하나의 요청으로 다시 이어 붙이는 기법이다. 요청 단위로 병목을 보는 것이 목적이라, 서버 단위 CPU 사용률을 보는 메트릭 모니터링과는 보는 각도가 다르다.
한 가지 오해하기 쉬운 지점이 있다. Jaeger는 백엔드다. 애플리케이션에 코드를 심어 span을 만드는 계측(instrumentation)은 Jaeger가 아니라 OpenTelemetry SDK와 계측 라이브러리로 하는 것을 공식 문서가 권장한다. v1 시절의 Jaeger 전용 클라이언트 라이브러리를 떠올리고 있었다면 이 부분이 가장 크게 바뀐 셈이니 짚고 넘어가야 한다.
2. 동작 원리 - trace ID, span, 부모 자식 관계
trace ID는 요청 전체 여정을 식별하는 값이고, span ID는 그 안의 개별 작업 하나를 식별하는 값이다. 트레이스 하나는 span 여러 개로 이루어진다.
계측된 서비스에 요청이 들어오면 SDK가 먼저 요청에 trace context가 실려 있는지 본다. 없으면 새 trace ID를 발급하고 부모 span ID가 없는 루트 span을 만든다. 있으면 그 트레이스를 이어간다. span에는 작업명, 시작과 종료 시각, 소요시간, 상태, 속성(attributes), 이벤트(events)가 기록된다.
span은 부모를 0개 또는 1개 가진다. 그래서 트레이스는 트리 구조가 된다. 루트 span이 부모가 0개인 유일한 span이고 나머지는 각자 부모를 하나씩 가리킨다.
컨텍스트 전파
서비스 경계를 넘을 때 trace ID를 같이 실어 보내야 트리가 이어진다. HTTP에서는 W3C Trace Context 규격의 traceparent 헤더를 쓰는 방식이 일반적이다.
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
| | | |
version trace ID parent span ID trace flags
다운스트림 서비스는 이 헤더에서 컨텍스트를 꺼내, 같은 trace ID를 유지한 채 새 span ID로 span을 만들고 추출한 컨텍스트를 부모로 삼는다. 이 전파가 끊기는 것도 트레이스에 구멍이 생기는 원인 중 하나다. 큐에 메시지를 넣거나 스레드풀에 작업을 던지는 비동기 경계에서 잘 끊기니, 그 구간은 계측 라이브러리가 알아서 해줄 거라 믿지 말고 직접 확인해야 한다.
span이 Jaeger까지 가는 경로
span이 종료되면 OpenTelemetry SDK의 span processor로 넘어간다. 실무에서 쓰는 것은 대부분 Batch Span Processor로, 완료된 span을 큐에 모았다가 배치로 OTLP(gRPC 또는 HTTP)로 전송한다. 큐가 가득 차면 span이 드롭될 수 있다는 점은 기억해 둘 만하다. 트레이스가 군데군데 비는데 계측은 멀쩡해 보인다면 이 드롭을 의심해봐야 한다.
전송 대상은 두 갈래다. 앱에서 Jaeger로 직접 보내거나, 중간에 OpenTelemetry Collector를 하나 두고 거기로 보낸다. 배치, 재시도, 속성 가공, 여러 백엔드로의 라우팅이 필요하면 Collector를 두는 쪽을 택한다.
3. v2 아키텍처와 v1에서 달라진 점
데이터가 지나가는 경로는 이렇다.
앱 (OpenTelemetry SDK)
| OTLP (gRPC 4317 / HTTP 4318)
v
[선택] OpenTelemetry Collector
|
v
Jaeger Collector --- span 수신, 처리 파이프라인, 스토리지 기록
|
v
스토리지 (Elasticsearch / Cassandra / Badger / ...)
^
|
Jaeger Query --- 검색 및 조회 API
^
|
Jaeger UI (16686)
UI가 스토리지를 직접 읽지 않고 Query를 거친다는 점이 중요하다. 그래서 수집(Collector)과 조회(Query)를 분리 배치하면 쓰기 트래픽과 읽기 트래픽을 각각 독립적으로 확장할 수 있고, 둘에 서로 다른 보안 정책을 적용할 수도 있다. all-in-one은 이 둘을 한 프로세스로 합친 개발용 구성이다.
Kafka를 중간 버퍼로 두는 구성도 있다. Collector가 Kafka로 쓰고 ingester 역할이 그것을 읽어 스토리지에 넣는 방식인데, 트래픽이 튈 때 스토리지가 못 받아내서 데이터가 유실되는 것을 막는 용도다.
v1을 알고 있다면 달라진 점은 이 정도로 정리된다.
- 단일 바이너리 + 구성 파일: v1에서
jaeger-collector,jaeger-query,jaeger-agent로 나뉘어 있던 것이 v2에서는 하나의jaeger바이너리를 구성 파일로 역할 지정해 띄우는 방식으로 통합됐다. - OpenTelemetry Collector 기반: v2는 OpenTelemetry Collector 프레임워크 위에 얹혀 있다. 수신기(receiver), 처리기(processor), 내보내기(exporter)로 이어지는 파이프라인 개념이 그대로 들어왔다.
- Jaeger Agent 사이드카 패턴 대체: 앱 옆에 agent를 붙이던 v1 패턴 대신, 표준 OpenTelemetry Collector를 쓰는 쪽을 공식 문서가 권장한다. 어차피 메트릭과 로그도 같이 처리해야 할 일이 생기기 때문이다.
- OTLP가 1급 시민: Jaeger 고유 프로토콜 대신 OTLP로 받는 것이 기본이다.
참고로 v1 문서는 1.76 기준 아카이브 상태로 남아 있다. 새로 도입한다면 v2로 시작하는 것이 맞다.
4. 도커로 all-in-one 실행하기
일단 띄워놓고 보는 편이 이해가 빠르다. 공식 getting-started에 나온 명령 그대로다.
$ docker run --rm --name jaeger \ -p 16686:16686 \ -p 4317:4317 \ -p 4318:4318 \ -p 5778:5778 \ -p 9411:9411 \ cr.jaegertracing.io/jaegertracing/jaeger:2.20.0
포트는 이렇게 나뉜다.
16686- Jaeger UI4317- OTLP gRPC 수신4318- OTLP HTTP 수신5778- 샘플링 설정 제공(remote sampling)9411- Zipkin 호환 엔드포인트
이 구성은 collector와 query를 한 프로세스로 묶고 인메모리 스토리지를 쓴다. 컨테이너를 재시작하면 그동안 모은 트레이스는 전부 사라진다. 프로덕션에는 쓰지 않는다.
기동 로그가 여러 줄 지나간 뒤 다른 터미널에서 확인해보자. 출력 형식은 도커 버전과 환경에 따라 조금 다를 수 있다.
$ docker ps --filter name=jaeger --format '{{.Names}}\t{{.Status}}\t{{.Ports}}'
jaeger Up 12 seconds 0.0.0.0:4317-4318->4317-4318/tcp, 0.0.0.0:5778->5778/tcp, 0.0.0.0:9411->9411/tcp, 0.0.0.0:16686->16686/tcp
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:16686/
200
브라우저로 http://localhost:16686 을 열면 UI가 뜬다. 아직 아무 데이터도 없으니 서비스 목록은 비어 있다.
여기서 자주 걸리는 지점
4317번은 OTLP 표준 포트라 다른 관측 도구가 이미 물고 있는 경우가 흔하다. 그러면 컨테이너가 뜨지 않고 대략 이런 에러로 끝난다. 문구는 도커 버전에 따라 다르다.
docker: Error response from daemon: driver failed programming external connectivity on endpoint jaeger: Bind for 0.0.0.0:4317 failed: port is already allocated.
누가 쓰는지 확인하고, 겹치면 호스트 쪽 포트만 바꿔서 매핑한다.
$ sudo ss -lntp | grep 4317 # 호스트 포트만 옮긴다. 컨테이너 안쪽은 4317 그대로 두어야 한다 $ docker run --rm --name jaeger -p 16686:16686 -p 14317:4317 -p 4318:4318 \ cr.jaegertracing.io/jaegertracing/jaeger:2.20.0
또 하나. 계측할 애플리케이션도 컨테이너라면 localhost:4318로 보내면 안 된다. 그 localhost는 앱 컨테이너 자기 자신이다. 같은 도커 네트워크에 묶고 컨테이너 이름으로 부르는 것이 기본이다(http://jaeger:4318). 호스트를 거쳐야 하면 host.docker.internal을 쓰는데, 이 이름은 Docker Desktop(macOS/Windows)에서만 기본으로 해석된다. 리눅스 Docker Engine에서는 앱 컨테이너를 띄울 때 --add-host=host.docker.internal:host-gateway 옵션을 붙여야 호스트 게이트웨이 IP로 해석된다.
5. curl로 span 하나 밀어 넣어 확인하기
앱 계측을 붙이기 전에, 수신 경로가 살아 있는지부터 확인하는 편이 문제를 좁히기 쉽다. OTLP/HTTP는 JSON을 받으므로 curl로 직접 span 하나를 보낼 수 있다.
$ NOW=$(date +%s)000000000
$ END=$(( $(date +%s) + 1 ))000000000
$ curl -s -X POST http://localhost:4318/v1/traces \
-H 'Content-Type: application/json' \
-d '{
"resourceSpans": [{
"resource": { "attributes": [
{ "key": "service.name", "value": { "stringValue": "curl-test" } }
]},
"scopeSpans": [{
"spans": [{
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanId": "00f067aa0ba902b7",
"name": "manual-span",
"kind": 2,
"startTimeUnixNano": "'$NOW'",
"endTimeUnixNano": "'$END'"
}]
}]
}]
}'
{"partialSuccess":{}}
partialSuccess가 빈 객체로 오면(구현에 따라 빈 응답으로 올 수도 있다) 수신 단계에서 거부된 span이 없다는 뜻이다. UI를 새로고침하면 Service 드롭다운에 curl-test가 생긴다.
여기서 한 번 걸리기 쉽다. 타임스탬프를 아무 값이나 넣으면 span은 저장되지만 UI 검색 기간 기본값(최근 1시간, 설정으로 바꿀 수 있다)에 걸리지 않아 화면에 안 나온다. 있는데 없어 보이는 상황이라 헷갈린다. 위처럼 현재 시각으로 만들거나, UI에서 조회 기간을 넓혀서 확인하자.
6. 애플리케이션 계측 설정
실제 계측은 OpenTelemetry SDK 쪽 일이다. 언어별 SDK가 다르지만, Jaeger로 보내는 설정 자체는 환경변수 두 개로 끝나는 경우가 많다.
export OTEL_SERVICE_NAME=order-service export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://jaeger:4318/v1/traces
OTEL_SERVICE_NAME은 UI에서 서비스를 구분하는 이름이다. 배포할 때마다 값이 바뀌면 UI 서비스 목록이 지저분해지고 의존성 그래프도 쪼개지므로, 서비스마다 안정적으로 고정해야 한다.
gRPC로 보낼 거라면 엔드포인트는 http://jaeger:4317 형태가 되고, 경로(/v1/traces)는 붙이지 않는다. HTTP 엔드포인트 변수에 경로를 빠뜨리거나 gRPC 엔드포인트에 경로를 붙이는 실수가 흔하니 여기를 먼저 보자.
직접 만든 앱 없이 그럴듯한 트레이스를 보고 싶다면 공식 데모 앱 HotROD가 있다. 여러 마이크로서비스로 구성돼 있고 OpenTelemetry 계측 예시가 들어 있어서, jaeger 저장소의 examples/hotrod에서 docker compose로 실행하면 된다.
7. 샘플링을 어떻게 정할 것인가
트래픽이 조금만 커져도 모든 요청을 다 저장하는 것은 비현실적이다. 그래서 무엇을 남길지 고르는 샘플링이 필요하다. 방식은 세 가지로 나뉜다.
head-based sampling
트레이스가 시작되는 시점에 남길지 말지를 결정한다. OpenTelemetry SDK에 기본으로 들어 있어 추가 구성이 필요 없고 비용이 가장 싸다. 대신 결정 시점에는 그 요청이 느려질지 에러가 날지 알 수 없다.
tail-based sampling
트레이스가 완성되고 모든 span이 모인 뒤에 결정한다. 에러가 난 트레이스와 느린 트레이스만 골라 남기는 식이 가능해서 진단 가치는 훨씬 높다. 다만 앱은 모든 span을 기록해서 전송해야 하고, 판단을 내릴 때까지 span을 들고 있어야 하니 백엔드의 메모리와 처리 비용이 올라간다. tail sampling processor로 구성한다.
remote sampling
head-based의 한 형태인데, 샘플링 비율을 앱마다 박아두는 대신 SDK가 Jaeger 백엔드에서 전략을 받아온다(5778 포트가 이 용도다). 서비스가 수십 개로 늘어난 뒤 비율을 바꾸려고 전 서비스를 재배포하는 상황을 피할 수 있다.
전략은 파일에서 주기적으로 읽어오거나, 실제 트래픽을 관찰해 동적으로 계산할 수 있다. 후자를 adaptive sampling이라고 하는데, 서비스와 엔드포인트 조합별로 받은 span을 보고 목표 초당 샘플 수에 맞도록 확률을 계속 재계산한다. 샘플러는 확률로 뽑는 probabilistic과 leaky bucket으로 초당 건수를 고정하는 rate limiting을 쓴다.
고르는 기준은 단순하다. 시작 단계라면 head-based 확률 샘플링으로 충분하다. 서비스 수가 늘어 비율 관리가 번거로워지면 remote sampling으로 중앙화하고, 에러와 느린 요청을 놓치는 것이 문제가 되기 시작하면 그때 tail-based를 검토한다.
8. 스토리지 백엔드 선택
all-in-one의 메모리 스토리지는 개발용이다. 실제로 운영하려면 백엔드를 골라야 한다. 공식적으로 지원하는 것은 다음과 같다.
- Elasticsearch / OpenSearch - 많이 쓰이는 선택지 중 하나. SPM 구성에도 활용할 수 있다.
- Cassandra - 쓰기 처리량이 큰 환경.
- Badger - 단일 노드 로컬 파일 기반. all-in-one에 Badger를 붙이면 규모가 크지 않은 워크로드는 단일 인스턴스로 감당할 수 있다.
- Kafka - 스토리지라기보다 Collector와 ingester 사이의 중간 버퍼.
- 메모리 - 개발과 데모용.
ClickHouse 지원도 들어와 있지만 아직 실험적(experimental)이다. storage.clickhouse feature gate를 명시적으로 켜야 하고, 스키마가 앞으로 바뀔 수 있다고 공식 문서에 적혀 있다. 지원 버전은 ClickHouse 26.x다. 프로덕션에 바로 올릴 대상은 아니다.
목록에 없는 백엔드를 붙여야 한다면 Remote Storage API로 커스텀 구현을 연동할 수 있다.
9. UI에서 실제로 무엇을 보나
트레이스는 서비스명, 오퍼레이션, 기간, 태그, trace ID로 검색한다. 장애 대응 중이라면 에러 로그에 남은 trace ID를 그대로 붙여넣어 해당 요청 하나만 여는 방식이 가장 빠르다. 이걸 하려면 애플리케이션 로그에 trace ID를 같이 남기도록 로깅 설정을 미리 해둬야 한다.
트레이스를 열면 타임라인 뷰가 나온다. 들여쓰기가 부모 자식 관계를, 가로 막대의 위치와 길이가 각 span의 시작 시점과 소요시간을 나타낸다. 계단식으로 길게 늘어서 있으면 순차 호출이고, 막대가 같은 지점에서 나란히 시작하면 병렬 호출이다.
여기서 주의할 게 하나 있다. 부모 span은 자식이 도는 동안에도 열려 있다. 그래서 span 시간을 단순히 다 더하면 전체 요청 시간과 맞지 않는다. 합계가 아니라 막대의 겹침을 봐야 한다.
이 밖에 서비스 사이 호출 관계를 그려주는 System Architecture와 Deep Dependency Graph, 두 트레이스의 구조를 나란히 놓고 보는 비교 기능이 있다.
Monitor 탭 (SPM)
SPM(Service Performance Monitoring)은 개별 트레이스가 아니라 span 데이터를 집계해서 서비스 단위 추세를 보여주는 기능이다. UI의 Monitor 탭에서 RED 지표(요청률, 에러율, 소요시간)를 확인할 수 있고, 지연은 P50/P75/P95로 나온다.
Impact 지표가 눈여겨볼 만한데, 지연에 요청률을 곱한 값이다. 하루에 몇 번 불리는 느린 엔드포인트보다 초당 수백 번 불리는 조금 느린 엔드포인트가 위로 올라온다. 어디부터 손댈지 정할 때 쓸모가 있다.
SPM을 켜려면 별도 구성이 필요하다. PromQL 호환 메트릭 저장소를 연동하거나, Elasticsearch/OpenSearch 백엔드를 활용하는 두 가지 방법이 있다.
10. 한계와 운영 시 주의사항
Jaeger를 도입하기 전에 알고 있어야 하는 범위가 있다.
- 트레이스 전용이다. 로그와 범용 메트릭을 자체적으로 저장하지 않는다. 관측 스택 전체를 이걸로 덮을 수는 없고 다른 도구와 조합해야 한다.
- 프로덕션은 외부 스토리지 운영이 따라온다. 확장, 보존 기간, 백업, 업그레이드를 직접 감당해야 한다. 도커 한 줄로 띄우는 난이도와 운영 난이도는 다르다.
- UI에 사용자 계정과 권한 기능이 없다. 트레이스에는 내부 구조와 요청 파라미터가 그대로 드러나므로, 인증 리버스 프록시를 앞에 두는 식으로 접근을 제한해야 한다. 16686 포트를 그냥 열어두면 안 된다.
- 구멍 난 트레이스는 오답을 만든다. 계측이 빠진 서비스, 끊긴 컨텍스트 전파, 과도한 샘플링이 있으면 정작 원인인 구간이 트레이스에 없어서 엉뚱한 곳을 파게 된다.
운영하면서 챙기면 좋은 것들을 덧붙인다. span 속성에는 카디널리티가 낮은 진단 정보만 넣는다. 자격증명과 개인정보는 당연히 제외하고, 요청마다 값이 달라지는 무한 증가 값도 넣지 않는 편이 스토리지에 이롭다. 그리고 추적 파이프라인 자체를 모니터링해야 한다. 드롭된 span 수, exporter 전송 실패, 스토리지 상태를 안 보고 있으면 트레이스가 비기 시작해도 알아채지 못한다.
11. 정리
Jaeger는 요청 하나를 trace ID로 묶고 각 구간을 span으로 기록해 서비스 사이의 지연을 눈으로 보게 해주는 백엔드다. v2로 오면서 OpenTelemetry Collector 기반의 단일 바이너리로 재설계됐고, 계측은 OpenTelemetry SDK에 맡기고 전송은 OTLP로 받는 구조로 정리됐다.
시작은 도커 all-in-one 한 줄이면 충분하다. curl로 span 하나 밀어 넣어 수신 경로를 확인하고, HotROD로 실제 트레이스 모양을 본 뒤, 자기 서비스에 OTEL_SERVICE_NAME과 OTLP 엔드포인트를 붙이는 순서로 가면 막히는 지점이 적다. 프로덕션으로 넘어가는 시점에 collector와 query 분리, 스토리지 백엔드, 샘플링 전략, UI 접근 제어를 차례로 결정하면 된다.
버전 번호와 이미지 태그는 릴리스 상황에 따라 달라질 수 있으니, 실제 적용 전에 공식 문서에서 최신 태그를 확인하자.