콘텐츠로 이동
Study Note관측

4. 추적 — Tempo와 OpenTelemetry

추적은 앞의 둘과 성격이 다르다 — 앱 코드를 건드려야 시작된다

이 장에서 처음 나오는 말1개
트레이스 · 스팬Trace · Span
요청 하나의 전체 여정이 트레이스, 그 안의 구간 하나가 스팬이다. API 호출 3초 아래 DB 질의 2.8초처럼 트리로 쌓인다.

문제 — 로그로는 “어디서” 느린지 못 찾는다

섹션 제목: “문제 — 로그로는 “어디서” 느린지 못 찾는다”
[orders] POST /orders → 3.2s

3.2초가 어디서 갔을까. 인증 확인? DB? 결제 서비스 호출? 그 결제 서비스 안의 또 다른 호출? 로그로 하려면 서비스마다 시각을 찍고 사람이 눈으로 맞춰야 하는데, 요청이 초당 수백 개면 어느 로그가 어느 요청인지부터 문제다.

신호이 질문에
메트릭“p99가 3초다” — 얼마나 느린지는 안다
로그“그 시각에 이런 에러가” — 무슨 일이 있었는지는 안다
트레이스“3.2초 중 2.8초가 재고 서비스의 DB 질의” — 어디서 갔는지를 안다
Grafana Explore의 로그 결과에서 TraceID를 찾아 Tempo 트레이스를 열고, 여러 서비스의 스팬을 가로 막대로 보는 공식 예시 화면
왼쪽 로그의 TraceID가 오른쪽 요청 여정으로 이어진다. 오른쪽 가로 막대 하나가 스팬이고, 긴 막대가 시간을 많이 쓴 구간이다.출처: Grafana Tempo 공식 문서 — Introduction
스팬 안의 두 종류 꼬리표2개
리소스 속성Resource Attributes
어느 서비스·파드인가처럼 프로세스 전체에 붙는 꼬리표.
스팬 속성Span Attributes
어떤 URL·SQL인가처럼 그 구간에만 붙는 꼬리표.

트레이스는 스팬의 트리이고, 스팬 하나는 이런 물건이다.

span_id : 00f067aa0ba902b7
trace_id : 4bf92f3577b34da6 ← 같은 요청의 스팬은 전부 이 값을 공유한다
parent_span_id : 05e3ac9a4f6e3b90 ← 이 값이 트리를 만든다
name : "GET /stock"
kind : client ← server / client / producer / consumer / internal
start / 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.environmenthttp.route · db.statement · messaging.system
어디서 정하나배포 설정(환경변수·컬렉터)계측 코드·자동 계측
질의할 때resource.service.namespan.http.route
이 절에서 쓰는 말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처럼 스팬 자체의 값은 접두사가 없다.

요청을 끊기지 않게 잇는 말2개
컨텍스트 전파Context Propagation
서비스 A가 B를 부를 때 같은 트레이스 ID를 HTTP 헤더로 넘기는 것. 끊기면 요청 여정도 둘로 갈라진다.
OTLPOpenTelemetry Protocol
앱과 수집기가 트레이스를 주고받는 표준 전송 방식. 저장소를 바꿔도 앱 계측을 유지하게 해 준다.

메트릭과 로그는 앱이 가만히 있어도 어느 정도 모인다(노드 exporter, stdout). 추적은 다르다. 요청이 서비스를 건널 때 트레이스 ID를 이어 주는 코드가 필요하다.

요청이 서비스 A·B·C를 traceparent 헤더로 타고 가는 동안 각 서비스가 스팬을 Collector로 보내고 Tempo를 거쳐 오브젝트 스토리지에 저장되는 흐름

빨간 부분 — 각 서비스에 계측이 들어가야 하는 자리다. 방법은 셋.

방법어떻게대가
자동 계측에이전트를 붙이면 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도 Loki처럼 오브젝트 스토리지에 블록을 쓴다. 그리고 특이한 성질이 하나 있다 — 트레이스 ID로 찾을 때는 인덱스가 필요 없다. ID에서 위치를 계산할 수 있어서다. 그래서 저장 비용이 아주 낮고, “로그에서 ID를 들고 와서 여는” 사용법이 특히 싸다.

# Tempo 저장소 설정 — 온프렘 덱 6장의 그 엔드포인트
storage:
trace:
backend: s3
s3:
bucket: tempo-traces
endpoint: s3.example.internal
forcepathstyle: true
insecure: false
상황답
처음 도입, 트레이스 양이 적다단일(monolithic) 구성으로 시작
서비스가 많고 지속적으로 들어온다분리 구성 + tail 샘플링을 컬렉터에서
메트릭 생성(서비스 그래프·스팬 메트릭)을 쓰겠다3.0에서는 Kafka가 인입 경로에 들어온다 (Kafka 덱) — 도입 비용을 먼저 따진다

부산물 — 서비스 그래프와 스팬 메트릭

섹션 제목: “부산물 — 서비스 그래프와 스팬 메트릭”
트레이스에서 덤으로 얻는 것1개
서비스 그래프Service Graph
트레이스에서 자동 생성한 누가 누구를 부르는가 지도. 문서와 다른 실제 호출 관계가 드러난다.

Tempo는 트레이스를 보고 메트릭을 만들어 낼 수 있다.

산출물내용쓸모
서비스 그래프누가 누구를 부르는지의 지도문서에 없는 실제 의존 관계가 드러난다. 장애 전파 경로 파악
스팬 메트릭서비스·오퍼레이션별 요청 수·지연·에러율앱이 메트릭을 안 내줘도 RED 지표를 얻는다 (2장)

이 메트릭들은 Prometheus로 흘러가므로 2장의 알림 규칙에 그대로 쓸 수 있다. 다만 샘플링된 트레이스에서 나온 값이라 절대 건수는 실제와 다르다 — 비율과 추세로 읽고, 정확한 건수가 필요하면 앱의 counter를 쓴다.

세 신호를 잇기 — 이 장의 진짜 값

섹션 제목: “세 신호를 잇기 — 이 장의 진짜 값”
메트릭의 exemplar로 트레이스에 들어가고, 트레이스에서 로그로, 로그의 trace_id로 다시 트레이스로 돌아오는 세 신호의 왕복
  1. 메트릭 → 트레이스: 앱이 히스토그램에 exemplar를 붙이면, Grafana 그래프에서 튄 점을 클릭해 그 순간의 트레이스로 간다. Prometheus에서 exemplar 저장을 켜야 한다.

  2. 로그 → 트레이스: 로그에 trace_id가 있으면 Grafana derived field가 링크를 만든다. 가장 싸고 가장 자주 쓰인다.

  3. 트레이스 → 로그: 트레이스 화면에서 해당 서비스·시각의 로그로 이동한다. Tempo 데이터소스의 “trace to logs” 설정이 그것이다.

  4. 셋 다 5장에서 실제로 연결한다.

터미널 창
# ① 컬렉터가 스팬을 받고 있나
kubectl -n observability logs deploy/alloy | grep -i -m5 'trace\|otlp'
# ② Tempo가 저장하고 있나
kubectl -n observability port-forward svc/tempo 3200:3200
curl -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=4bf92f3577b34da6a3ce929d0e0e4736
kubectl -n prod exec deploy/api -- curl -s -o /dev/null \
-H "traceparent: 00-${TID}-00f067aa0ba902b7-01" http://downstream.prod.svc:8080/health
curl -s "http://localhost:3200/api/traces/${TID}" | jq '.batches | length'
# 0이면 그 경계 어딘가에서 헤더가 끊기거나 downstream에 계측이 빠진 것이다
증상흔한 원인확인
트레이스가 조각남traceparent 전파 끊김프록시·클라이언트 래퍼·큐 구간
루트 스팬이 여러 개같은 원인 — 중간에서 새 트레이스가 시작됨게이트웨이가 헤더를 지우는지
트레이스가 아예 없음OTLP 엔드포인트 오설정 · 샘플링 0%컬렉터 로그, SDK 환경변수
“그 요청”만 안 남음head 샘플링만 씀tail 샘플링에 에러·지연 정책 추가
반쪽짜리 트레이스가 많음tail 샘플링인데 스팬이 컬렉터에 흩어짐trace ID 기반 라우팅 계층
서비스 이름이 unknown_serviceservice.name 리소스 속성 미설정OTEL_SERVICE_NAME 환경변수
저장은 되는데 조회 실패S3 접근 · 블록 포맷Tempo 로그, 온프렘 덱 6장 함정 넷
업그레이드 후 기동 실패3.0 구조 변경tempo-cli migrate config, 제거된 컴포넌트 확인
  • 추적은 “어디서 느렸나” 에 답하고, 그 대가로 앱 계측이 필요하다 — 진입 장벽이 다른 둘과 다르다
  • 스팬은 트리의 노드다. 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로 되돌릴 수 없다 — 업그레이드는 계획된 작업으로