4. 추적 — Tempo와 OpenTelemetry
추적은 앞의 둘과 성격이 다르다 — 앱 코드를 건드려야 시작된다
이 장에서 처음 나오는 말1개
트레이스 · 스팬Trace · Span- 요청 하나의 전체 여정이 트레이스, 그 안의 구간 하나가 스팬이다.
API 호출 3초아래DB 질의 2.8초처럼 트리로 쌓인다.
문제 — 로그로는 “어디서” 느린지 못 찾는다
섹션 제목: “문제 — 로그로는 “어디서” 느린지 못 찾는다”[orders] POST /orders → 3.2s3.2초가 어디서 갔을까. 인증 확인? DB? 결제 서비스 호출? 그 결제 서비스 안의 또 다른 호출? 로그로 하려면 서비스마다 시각을 찍고 사람이 눈으로 맞춰야 하는데, 요청이 초당 수백 개면 어느 로그가 어느 요청인지부터 문제다.
| 신호 | 이 질문에 |
|---|---|
| 메트릭 | “p99가 3초다” — 얼마나 느린지는 안다 |
| 로그 | “그 시각에 이런 에러가” — 무슨 일이 있었는지는 안다 |
| 트레이스 | “3.2초 중 2.8초가 재고 서비스의 DB 질의” — 어디서 갔는지를 안다 |

스팬 하나는 어떻게 생겼나
섹션 제목: “스팬 하나는 어떻게 생겼나”스팬 안의 두 종류 꼬리표2개
리소스 속성Resource Attributes- 어느 서비스·파드인가처럼 프로세스 전체에 붙는 꼬리표.
스팬 속성Span Attributes- 어떤 URL·SQL인가처럼 그 구간에만 붙는 꼬리표.
트레이스는 스팬의 트리이고, 스팬 하나는 이런 물건이다.
span_id : 00f067aa0ba902b7trace_id : 4bf92f3577b34da6 ← 같은 요청의 스팬은 전부 이 값을 공유한다parent_span_id : 05e3ac9a4f6e3b90 ← 이 값이 트리를 만든다name : "GET /stock"kind : client ← server / client / producer / consumer / internalstart / end : 03:12:04.601 → 03:12:07.451 (duration 2.85s)status : error
resource: ← 이 프로세스 전체에 붙는다 service.name : inventory k8s.namespace.name : prod k8s.pod.name : inventory-6c8-w2n4d
attributes: ← 이 구간에만 붙는다 http.request.method : GET http.route : /stock/{sku} db.system : postgresql db.statement : "SELECT ... FOR UPDATE"
events: 03:12:07.450 exception { type: "TimeoutError", message: "..." }리소스와 속성의 구분이 이 장 전체에 걸린다.
| 리소스 속성 | 스팬 속성 | |
|---|---|---|
| 붙는 대상 | 프로세스 하나 전체 | 구간 하나 |
| 예 | service.name · k8s.pod.name · deployment.environment | http.route · db.statement · messaging.system |
| 어디서 정하나 | 배포 설정(환경변수·컬렉터) | 계측 코드·자동 계측 |
| 질의할 때 | resource.service.name | span.http.route |
TraceQL — 느린 것만 골라낸다
섹션 제목: “TraceQL — 느린 것만 골라낸다”이 절에서 쓰는 말1개
TraceQL- Tempo의 트레이스 질의 언어. 서비스·시간·오류 같은 조건으로 원하는 요청만 골라낸다.
트레이스 ID를 이미 알고 있으면 그냥 열면 된다. 문제는 ID를 모를 때 — “어제 새벽에 느렸던 주문 요청”을 찾아야 하는 경우다. 그게 TraceQL이다.
{ resource.service.name = "orders" && duration > 2s && status = error } └── 조건에 맞는 스팬이 하나라도 있는 트레이스를 돌려준다{ resource.service.name = "orders" && duration > 2s }{ span.http.response.status_code >= 500 }{ status = error }{ resource.k8s.namespace.name = "prod" && span.db.system = "postgresql" && duration > 1s }접두사가 곧 앞 절의 구분이다 — resource.는 프로세스 꼬리표,
span.은 그 구간의 꼬리표. duration·status·name처럼 스팬 자체의 값은 접두사가 없다.
# orders를 거친 트레이스 중, 그 아래 어딘가에서 느린 DB 질의가 있던 것{ resource.service.name = "orders" } >> { span.db.system = "postgresql" && duration > 1s }
# 바로 아래 자식만{ resource.service.name = "orders" } > { resource.service.name = "inventory" }>>는 자손, >는 직계 자식이다. “A를 거친 요청 중 B에서 터진 것” 처럼
서비스 경계를 넘는 조건을 걸 수 있는 게 TraceQL의 진짜 값이다 —
메트릭으로는 이 질문을 만들 수조차 없다.
# 조건에 맞는 스팬이 3개 이상인 트레이스 (N+1 질의 찾기){ span.db.system = "postgresql" } | count() > 3
# 결과 표에 이 필드를 같이 보여 준다{ resource.service.name = "orders" && duration > 2s } | select(span.http.route, span.db.statement)count() > 3은 N+1 질의를 잡는 데 특히 잘 듣는다 — 한 요청 안에서
같은 종류의 DB 호출이 수십 번 일어나는 패턴이 그대로 걸린다.
계측 — 앱을 고쳐야 한다
섹션 제목: “계측 — 앱을 고쳐야 한다”요청을 끊기지 않게 잇는 말2개
컨텍스트 전파Context Propagation- 서비스 A가 B를 부를 때 같은 트레이스 ID를 HTTP 헤더로 넘기는 것. 끊기면 요청 여정도 둘로 갈라진다.
OTLPOpenTelemetry Protocol- 앱과 수집기가 트레이스를 주고받는 표준 전송 방식. 저장소를 바꿔도 앱 계측을 유지하게 해 준다.
메트릭과 로그는 앱이 가만히 있어도 어느 정도 모인다(노드 exporter, stdout). 추적은 다르다. 요청이 서비스를 건널 때 트레이스 ID를 이어 주는 코드가 필요하다.
빨간 부분 — 각 서비스에 계측이 들어가야 하는 자리다. 방법은 셋.
| 방법 | 어떻게 | 대가 |
|---|---|---|
| 자동 계측 | 에이전트를 붙이면 HTTP·DB 호출을 알아서 잡는다 (Java 에이전트, Python opentelemetry-instrument 등) | 코드 수정이 거의 없다. 대신 세밀한 구간은 안 잡힌다 |
| SDK로 수동 | 코드에서 스팬을 직접 연다 | 비즈니스 구간까지 볼 수 있다. 개발 공수 |
| 사이드카·메시 | 서비스 메시가 프록시 레벨에서 | 서비스 간 호출은 잡히지만 앱 안은 안 보인다 |
자동 계측으로 시작한다. 대부분의 지연은 HTTP 호출과 DB 질의에서 나오고, 그 둘은 자동 계측이 잡는다. 부족한 구간이 확인된 뒤에 SDK로 채운다.
샘플링 — 전부 저장할 수는 없다
섹션 제목: “샘플링 — 전부 저장할 수는 없다”이 절의 핵심 용어1개
샘플링Sampling- 모든 요청을 저장하지 않고 일부만 남기는 것. 시작할 때 정하는 방식과 요청이 끝난 뒤 정하는 방식이 있다.
트레이스는 세 신호 중 가장 빨리 용량을 먹는다. 요청 하나가 스팬 수십 개다.
| 방식 | 언제 정하나 | 장점 | 단점 |
|---|---|---|---|
| head 샘플링 | 요청 시작할 때 (예: 1%) | 단순하다. 부하가 예측 가능 | 문제가 난 요청이 안 뽑힐 수 있다 |
| tail 샘플링 | 요청이 끝난 뒤 결과를 보고 | 에러·느린 요청을 골라서 남긴다 | 컬렉터가 트레이스를 잠시 들고 있어야 한다(메모리) |
# OTel Collector / Alloy의 tail 샘플링 — 온프렘에서 이 조합이 실용적이다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: baseline # 나머지는 1%만 type: probabilistic probabilistic: { sampling_percentage: 1 }Tempo — 저장하고 되돌려 준다
섹션 제목: “Tempo — 저장하고 되돌려 준다”Tempo도 Loki처럼 오브젝트 스토리지에 블록을 쓴다. 그리고 특이한 성질이 하나 있다 — 트레이스 ID로 찾을 때는 인덱스가 필요 없다. ID에서 위치를 계산할 수 있어서다. 그래서 저장 비용이 아주 낮고, “로그에서 ID를 들고 와서 여는” 사용법이 특히 싸다.
# Tempo 저장소 설정 — 온프렘 덱 6장의 그 엔드포인트storage: trace: backend: s3 s3: bucket: tempo-traces endpoint: s3.example.internal forcepathstyle: true insecure: falseTempo 3.0 — 구조가 바뀌었다
섹션 제목: “Tempo 3.0 — 구조가 바뀌었다”배포 판단
섹션 제목: “배포 판단”| 상황 | 답 |
|---|---|
| 처음 도입, 트레이스 양이 적다 | 단일(monolithic) 구성으로 시작 |
| 서비스가 많고 지속적으로 들어온다 | 분리 구성 + tail 샘플링을 컬렉터에서 |
| 메트릭 생성(서비스 그래프·스팬 메트릭)을 쓰겠다 | 3.0에서는 Kafka가 인입 경로에 들어온다 (Kafka 덱) — 도입 비용을 먼저 따진다 |
부산물 — 서비스 그래프와 스팬 메트릭
섹션 제목: “부산물 — 서비스 그래프와 스팬 메트릭”트레이스에서 덤으로 얻는 것1개
서비스 그래프Service Graph- 트레이스에서 자동 생성한 누가 누구를 부르는가 지도. 문서와 다른 실제 호출 관계가 드러난다.
Tempo는 트레이스를 보고 메트릭을 만들어 낼 수 있다.
| 산출물 | 내용 | 쓸모 |
|---|---|---|
| 서비스 그래프 | 누가 누구를 부르는지의 지도 | 문서에 없는 실제 의존 관계가 드러난다. 장애 전파 경로 파악 |
| 스팬 메트릭 | 서비스·오퍼레이션별 요청 수·지연·에러율 | 앱이 메트릭을 안 내줘도 RED 지표를 얻는다 (2장) |
이 메트릭들은 Prometheus로 흘러가므로 2장의 알림 규칙에 그대로 쓸 수 있다. 다만 샘플링된 트레이스에서 나온 값이라 절대 건수는 실제와 다르다 — 비율과 추세로 읽고, 정확한 건수가 필요하면 앱의 counter를 쓴다.
세 신호를 잇기 — 이 장의 진짜 값
섹션 제목: “세 신호를 잇기 — 이 장의 진짜 값”-
메트릭 → 트레이스: 앱이 히스토그램에 exemplar를 붙이면, Grafana 그래프에서 튄 점을 클릭해 그 순간의 트레이스로 간다. Prometheus에서 exemplar 저장을 켜야 한다.
-
로그 → 트레이스: 로그에
trace_id가 있으면 Grafana derived field가 링크를 만든다. 가장 싸고 가장 자주 쓰인다. -
트레이스 → 로그: 트레이스 화면에서 해당 서비스·시각의 로그로 이동한다. Tempo 데이터소스의 “trace to logs” 설정이 그것이다.
-
셋 다 5장에서 실제로 연결한다.
# ① 컬렉터가 스팬을 받고 있나kubectl -n observability logs deploy/alloy | grep -i -m5 'trace\|otlp'
# ② Tempo가 저장하고 있나kubectl -n observability port-forward svc/tempo 3200:3200curl -s http://localhost:3200/api/echo # 살아 있나curl -s http://localhost:3200/metrics | grep -E 'tempo_distributor_spans_received_total'
# ③ 오브젝트 스토리지에 블록이 쌓이나mc ls store/tempo-traces/ --recursive | tail
# ④ 트레이스 ID로 직접 조회 (로그에서 하나 골라서)curl -s "http://localhost:3200/api/traces/<trace_id>" | jq '.batches | length'
# ⑤ TraceQL로 조회 — ID를 모를 때curl -sG http://localhost:3200/api/search \ --data-urlencode 'q={ resource.service.name = "orders" && duration > 2s }' | jq '.traces | length'
# ⑥ 전파가 이어지나 — 아는 trace_id를 심어 요청하고, 그 ID로 Tempo를 조회한다TID=4bf92f3577b34da6a3ce929d0e0e4736kubectl -n prod exec deploy/api -- curl -s -o /dev/null \ -H "traceparent: 00-${TID}-00f067aa0ba902b7-01" http://downstream.prod.svc:8080/healthcurl -s "http://localhost:3200/api/traces/${TID}" | jq '.batches | length'# 0이면 그 경계 어딘가에서 헤더가 끊기거나 downstream에 계측이 빠진 것이다| 증상 | 흔한 원인 | 확인 |
|---|---|---|
| 트레이스가 조각남 | traceparent 전파 끊김 | 프록시·클라이언트 래퍼·큐 구간 |
| 루트 스팬이 여러 개 | 같은 원인 — 중간에서 새 트레이스가 시작됨 | 게이트웨이가 헤더를 지우는지 |
| 트레이스가 아예 없음 | OTLP 엔드포인트 오설정 · 샘플링 0% | 컬렉터 로그, SDK 환경변수 |
| “그 요청”만 안 남음 | head 샘플링만 씀 | tail 샘플링에 에러·지연 정책 추가 |
| 반쪽짜리 트레이스가 많음 | tail 샘플링인데 스팬이 컬렉터에 흩어짐 | trace ID 기반 라우팅 계층 |
서비스 이름이 unknown_service | service.name 리소스 속성 미설정 | OTEL_SERVICE_NAME 환경변수 |
| 저장은 되는데 조회 실패 | S3 접근 · 블록 포맷 | Tempo 로그, 온프렘 덱 6장 함정 넷 |
| 업그레이드 후 기동 실패 | 3.0 구조 변경 | tempo-cli migrate config, 제거된 컴포넌트 확인 |
4장 요약
섹션 제목: “4장 요약”- 추적은 “어디서 느렸나” 에 답하고, 그 대가로 앱 계측이 필요하다 — 진입 장벽이 다른 둘과 다르다
- 스팬은 트리의 노드다.
trace_id가 묶고parent_span_id가 계층을 만든다 - 리소스 속성(프로세스)과 스팬 속성(구간) 의 구분이 계측 설정과 TraceQL 양쪽에 걸린다
- 트레이스 속성에는 고유 값을 넣어도 된다 — 카디널리티 제한은 메트릭·로그 라벨의 이야기다
- 계측의 급소는 컨텍스트 전파다.
traceparent헤더 하나가 끊기면 트레이스가 조각난다 - 가장 싼 첫걸음은 로그에
trace_id를 찍는 것이다 - 샘플링은 tail로 — 에러·느린 요청은 100%, 나머지는 1%. 다만 tail은 한 트레이스의 스팬이 같은 컬렉터에 모여야 제대로 판단한다
- Tempo는 블록을 오브젝트 스토리지에 두고, 트레이스 ID 조회에는 인덱스가 필요 없다
- ID를 모를 때는 TraceQL —
duration > 2s,>>로 서비스 경계 넘기,count() > 3으로 N+1 찾기 - Tempo 3.0은 구조가 바뀌었고 2.x로 되돌릴 수 없다 — 업그레이드는 계획된 작업으로