Grafana 입문 4편. Tempo + TraceQL 깊이 — Trace · Span · Service · Operation 의 4 단어, OpenTelemetry (OTel) · Jaeger · Zipkin 의 프로토콜 호환, Tempo 의 object storage 비용 효율, TraceQL 의 syntax (span filter · structural relationship · aggregate), Service Graph 자동 생성, Tail-based sampling (모든 trace 저장 X), Metric Generator (trace → RED metric), Exemplar 의 metric→trace 연결. 분산 시스템 디버그 의 핵심.
이 글은 Grafana 입문에서 운영까지 시리즈 4편이에요. 3편 Loki 가 logs 였다면, 이번 글 = 3 pillar 의 마지막 = traces.
이번 글의 범위
마이크로서비스 환경 에서 한 요청 의 전체 여정 = trace. 이 trace 를 수집 · 저장 · query 하는 도구 = Tempo (Grafana Labs 의 trace backend). Loki 가 logs 의 Prometheus 였다면, Tempo = traces 의 Loki.
| 자리 | 자산 |
|---|---|
| 개념 | Trace · Span · Service · Operation |
| 수집 | OpenTelemetry · Jaeger · Zipkin |
| 저장 | Object Storage 의 비용 효율 |
| Query | TraceQL (PromQL · LogQL 영감) |
| 부가 | Service Graph · Metric Generator |
Trace · Span 의 의미
분산 시스템 의 함정
Monolith 시절:
요청 → DB → 응답
한 process → 한 stack trace → 디버그 간단
마이크로서비스 시절:
요청 → API Gateway → Auth Service → User Service → DB
↘ Cache Service
↘ Notification → Email Service
→ SMS Service
→ 어디서 느림? · 어디서 에러? · 의존성 그림은?
Trace 의 정의
Trace = 한 요청 의 전체 여정 (모든 service 통과)
↓
Span = trace 의 한 service 의 작업 단위
예 — 한 trace:
[Frontend] ─→ [API Gateway] ─→ [Auth] ─→ [User Service] ─→ [DB]
trace_id = "abc123"
span 1: Frontend (span_id=001, parent=null)
span 2: API Gateway (span_id=002, parent=001)
span 3: Auth (span_id=003, parent=002)
span 4: User Service (span_id=004, parent=003)
span 5: DB query (span_id=005, parent=004)
Span 의 구조
Span:
- trace_id 같은 trace 의 모든 span 동일
- span_id 이 span 의 고유 ID
- parent_span_id 부모 span (root 는 null)
- service.name 어느 service
- operation.name 어떤 작업 (HTTP method · DB query 등)
- start_time 시작
- duration 소요 시간
- attributes 메타데이터 (http.status_code · http.method · ...)
- events 중간 event 들
- status ok · error
Trace 의 시각화
Trace timeline:
├─ Frontend [████████████████ 1.2s]
│ └─ API Gateway [██████████ 850ms]
│ └─ Auth [██ 50ms]
│ └─ User Service [████████ 700ms]
│ └─ DB Query [████████ 680ms] ← 가장 느림!
한 view = 어느 service 가 bottleneck 즉시 발견.
OpenTelemetry · Jaeger · Zipkin
OpenTelemetry (OTel) — 표준
OpenTelemetry (OTel, 관측성 표준 SDK) 는 Trace · Metric · Log 셋을 한 API 로 묶어요.
OpenTelemetry:
- CNCF (Cloud Native Computing Foundation) 의 표준
- Trace · Metric · Log 모두 한 API
- 모든 vendor 호환 (Grafana · Datadog · New Relic · ...)
- 2019 부터 합쳐진 표준 (OpenTracing + OpenCensus)
3 프로토콜 호환
Tempo 가 받는 trace format:
- OpenTelemetry (OTLP — OpenTelemetry Protocol)
- Jaeger (legacy)
- Zipkin (legacy)
→ 어느 프로토콜 의 trace 도 Tempo 가 수신
Jaeger 와 Zipkin 의 자리
Jaeger:
- Uber 출신 (오픈소스 후)
- 자체 UI 보유 (Tempo 는 Grafana UI 사용)
- 여전히 인기
Zipkin:
- Twitter 출신
- 가장 오래 된 표준
- 단순 한 deployment
OpenTelemetry:
- 둘 다 통합 한 표준
- 새 프로젝트 = OTel 권장
OpenTelemetry Instrumentation
# Python 예
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource
# Setup
resource = Resource.create({"service.name": "user-api"})
trace.set_tracer_provider(TracerProvider(resource=resource))
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://tempo:4317")
)
)
tracer = trace.get_tracer(__name__)
# Usage
@app.get("/users/{user_id}")
async def get_user(user_id: int):
with tracer.start_as_current_span("get_user") as span:
span.set_attribute("user.id", user_id)
with tracer.start_as_current_span("db_query"):
user = await db.get_user(user_id)
span.set_attribute("db.rows", 1)
return user
Auto-instrumentation
# Python — code 수정 없이 자동 instrumentation
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install
opentelemetry-instrument \
--traces_exporter otlp \
--service_name my-api \
python app.py
여기서 시험 함정 — Auto-instrumentation 이 manual 보다 멋져 보여도 모든 framework 가 자동 지원 X. 비표준 framework = manual instrumentation 필요.
eBPF Auto-instrumentation (Beyla)
eBPF (커널에서 안전하게 도는 hook) 기반의 Beyla 는 코드 한 줄도 안 건드려요.
Beyla (Grafana Labs):
- eBPF 기반
- 코드 수정 0 (zero code change)
- HTTP · gRPC · DB call 자동 trace
- 모든 언어 지원 (Go · Python · Java · Node · Ruby · Rust · ...)
장점:
- legacy app 의 instrumentation 자동
- language 무관
단점:
- kernel 의식 (eBPF)
- 일부 framework 한계
Tempo 의 저장 구조
Object Storage 의 비용 효율
Jaeger 의 전통적 backend:
- Cassandra (또는 Elasticsearch)
- 운영 부담 큼
- 비용 높음
Tempo 의 backend:
- S3 · GCS · Azure Blob · 로컬
- 운영 단순
- 비용 1/10
Block 의 구조
Bloom filter (있을 가능성만 빠르게 판별하는 자료구조) 가 block scan 의 부담을 잘라 줘요.
Tempo Block:
- 한 시간 의 trace 들의 묶음
- flat file (압축됨)
- object storage 에 저장
- 검색 = bloom filter + 의도된 block scan
TraceID Lookup vs Search
1. TraceID Lookup (O(1))
- trace_id 알면 바로 검색
- 가장 빠름
2. Search (TraceQL)
- service · operation · duration 같은 조건
- block scan 필요 (조금 느림)
- bloom filter 의 최적화
TraceQL — Query Language
TraceQL (Tempo 의 trace 전용 query 언어) 은 PromQL · LogQL 의 발상을 가져왔어요.
4 종 Query
1. Span Filter
{ resource.service.name = "api" }
2. Span Field Comparison
{ duration > 1s }
3. Structural Relationship
{ resource.service.name = "api" } >> { name = "db_query" }
4. Aggregate
{ duration > 1s } | count() > 0
Span Filter
# service 별
{ resource.service.name = "user-api" }
# HTTP 의 status code
{ span.http.status_code = 500 }
# duration
{ duration > 1s }
# operation 이름
{ name =~ ".*db.*" }
# attribute
{ span.user.id = "12345" }
# 조합 (AND)
{ resource.service.name = "user-api" && span.http.status_code = 500 }
# 조합 (OR)
{ duration > 5s || span.http.status_code = 500 }
Structural Relationship
# A 의 child 가 B
{ resource.service.name = "frontend" } > { resource.service.name = "backend" }
# A 의 descendant 가 B (간접 child 포함)
{ resource.service.name = "frontend" } >> { resource.service.name = "db" }
# A 가 child of B
{ resource.service.name = "db" } < { resource.service.name = "backend" }
# A 의 sibling 이 B
{ resource.service.name = "auth" } ~ { resource.service.name = "cache" }
여기서 정말 중요한 시험 함정 — Structural Relationship = TraceQL 의 핵심 차별성. 단순 filter 가 아닌 trace 안 의 흐름 으로 query. "이 service 가 다음 service 호출 시 에러" 같은 패턴 자동 발견.
Aggregate
# 한 trace 에 span > 100 개
{ } | count() > 100
# 한 service 의 평균 duration
{ resource.service.name = "api" } | avg(duration) > 500ms
# Service 의 span 분포
{ resource.service.name = "api" } | quantile_over_time(duration, 0.99) > 1s
TraceQL Metric (Tempo의 새 기능)
# trace 의 metric 생성
{ resource.service.name = "api" } | rate() by (resource.service.name)
# 동일 의미: 서비스 별 trace 의 초당 발생
LogQL 의 metric query 와 같은 발상. Trace → metric 시계열.
Service Graph — 자동 dependency
Service Graph 의 자리
Trace 를 분석:
- "frontend 가 backend 호출" (span 의 parent-child)
- "backend 가 db 호출"
- "backend 가 cache 호출"
→ Service Dependency Graph 자동 생성
[frontend] → [backend] → [db]
→ [cache]
데이터
Service Graph 의 metric:
- traces_service_graph_request_total (요청 수)
- traces_service_graph_request_failed_total (실패)
- traces_service_graph_request_server_seconds (server 시간)
- traces_service_graph_request_client_seconds (client 시간)
Tempo 의 Metric Generator 가 trace 보면서 자동 생성. Prometheus 로 export.
Grafana 의 Visualization
Grafana Tempo datasource > Service Graph:
- 자동 노드 + 엣지 그림
- 각 service 의 RPS (초당 요청 수) · 에러율 표시
- 엣지 두께 = 호출 빈도
- 색 = 에러율
마이크로서비스 환경 의 지도 자동.
Metric Generator — Trace → Metric
동기
Trace 의 한계:
- 양 매우 큼 (모든 요청 의 모든 span)
- sampling 필요 (모든 trace 저장 X)
- 빠른 query 어려움
해결 — Metric Generator:
- Tempo 가 trace 받으면 자동 RED metric 생성
- Rate · Error · Duration metric
- Prometheus 로 export
- dashboard query 매우 빠름 (Prometheus 직접)
RED metric (Rate · Error · Duration 3종) 은 서비스 건강을 한눈에 보는 표준 셋이에요.
생성 metric
traces_spanmetrics_calls_total 요청 수
traces_spanmetrics_latency_bucket latency histogram
traces_spanmetrics_calls_total{status_code="..."} status 별
각 service · operation · status 별 자동.
활용
# RED metric (trace 에서 자동 추출)
# Rate
sum(rate(traces_spanmetrics_calls_total[5m])) by (service)
# Errors
sum(rate(traces_spanmetrics_calls_total{status_code="STATUS_CODE_ERROR"}[5m])) by (service)
# Duration
histogram_quantile(0.99,
sum(rate(traces_spanmetrics_latency_bucket[5m])) by (le, service)
)
→ application 의 code 수정 없이 RED metric 자동. Auto-instrumentation 의 강점.
Tail-based Sampling
Sampling 의 의미
모든 trace 저장 = 매우 비싸:
- 1000 RPS × 10 span/trace × 24h = 약 8억 span/일
- 저장소 비용 폭증
해결 — Sampling:
- Head sampling: trace 시작 시 결정 (1% · 10% 등 무작위)
- Tail sampling: trace 끝나면 결정 (에러 · 느린 trace 위주)
Head Sampling 의 한계
Head Sampling:
- "1% sample" → 100 trace 중 99 drop
- 에러 trace 도 99% drop
- 디버그 자료 부족
Tail Sampling 의 가치
Tail Sampling (Tempo · OTel Collector 의 기능):
- 모든 trace 받음 → 분석
- 의미 있는 trace 만 저장:
* 모든 에러 trace (100%)
* 느린 trace (p99 이상)
* sampling rate (5% 의 정상 trace)
- 의미 없는 trace drop
→ 비용 1/10 + 디버그 자료 100% 유지
Tail Sampling Config (OTel Collector)
processors:
tail_sampling:
decision_wait: 10s
num_traces: 100000
policies:
# 1. 모든 에러
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
# 2. 느린 trace
- name: slow
type: latency
latency:
threshold_ms: 1000
# 3. 5% 의 정상 trace
- name: probabilistic
type: probabilistic
probabilistic:
sampling_percentage: 5
Exemplar — Metric ↔ Trace 연결
Exemplar (metric sample 에 붙는 대표 trace 포인터) 가 metric 과 trace 를 한 클릭으로 이어 줘요.
동기
Dashboard 의 metric 보고 있음:
"p99 latency 가 5s 폭증!"
질문: "어느 특정 trace 가 그 5s 야?"
답: Exemplar
Exemplar 의 의미
Histogram metric 의 sample 마다:
- 평소: count · sum 만
- Exemplar 있으면: 그 sample 의 *실제 trace_id* 도 저장
Grafana 의 visualization:
- histogram chart 의 점 들 (exemplar)
- 점 클릭 → 자동으로 Tempo 의 trace view
Setup
# Python 의 prometheus_client
from prometheus_client import Histogram
LATENCY = Histogram(
'http_request_duration_seconds',
'HTTP latency',
['endpoint']
)
# Trace context 와 함께 observe
from opentelemetry.trace import get_current_span
current_span = get_current_span()
trace_id = format(current_span.get_span_context().trace_id, '032x')
LATENCY.labels(endpoint='/api/users').observe(
elapsed,
exemplar={'trace_id': trace_id}
)
→ Prometheus 의 모든 sample 이 trace_id 와 결합 → Grafana 에서 클릭 한 번에 trace.
함정 정리
사고 1: 모든 trace 저장 → 비용 폭증
원인 — Sampling 없이 100% 저장 → 저장소 · network 비용 폭발.
해결 — Tail-based sampling. 에러 · 느린 trace 만 저장 + 정상 일부.
사고 2: Service name 일관성 X
원인 — 한 service 가 어떤 곳에선 "api" · 다른 곳에선 "user-api" · 또 다른 곳에선 "User API".
해결 — resource.service.name 의 표준화. environment variable 또는 config 일원화.
사고 3: Span 의 너무 깊은 nesting
원인 — 단순 query 가 수백 span 의 깊은 tree → trace view 의 가독성 0.
해결 — 의미 있는 span 만. function call 마다 span X. 서비스 boundary · 외부 호출 · 의도된 단계.
사고 4: Trace ID 의 log 안 박힘
원인 — application log 안에 trace_id 없음 → log 와 trace 연결 X.
해결 — 모든 log 에 trace_id 박기. OpenTelemetry 의 logging instrumentation 활용.
사고 5: OTel Collector 의 부담
원인 — 모든 service → OTel Collector 한 인스턴스 → Collector 의 CPU/메모리 폭발.
해결 — Collector 의 horizontal scaling + tail sampling 의 분산 + agent + gateway 의 2단계.
사고 6: Auto-instrumentation 의 누락
원인 — Auto-instrumentation 활성화 → 일부 framework 자동 X → 누락된 service 의 trace.
해결 — 지원 framework 확인 + manual instrumentation 보강.
사고 7: Tempo 의 metric generator 의 cardinality
원인 — Metric Generator 가 모든 service × operation × status metric → cardinality 폭발.
해결 — generator 의 label 제한 + dimensions 의 의식.
사고 8: Block compaction 의 잘못
원인 — Tempo 의 compactor 안 동작 → block 수 폭증 → query 느림.
해결 — Compactor pod 의 resource 충분 + config 의 retention 명시.
사고 9: Trace 의 PII 누출
PII (개인식별정보 — 이메일·신용카드 등) 가 span 에 박히면 그대로 저장소까지 흘러가요.
원인 — span attribute 에 user email · password · 신용카드 박힘.
해결 — OTel Collector 의 attribute processor 로 redaction + application 의 의식.
사고 10: Trace + Log + Metric 의 timestamp 불일치
원인 — UTC vs Local · clock skew → correlation 시 시각 안 맞음.
해결 — 모든 service 의 NTP (시각 동기화 프로토콜) 동기화 + UTC 표준 + clock skew 의식 (보통 < 1s).
운영 권장 패턴
Pattern 1: 표준 stack 의 OTel Collector
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 10s
send_batch_size: 1024
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: probabilistic
type: probabilistic
probabilistic:
sampling_percentage: 5
attributes/redact:
actions:
- key: user.email
action: delete
- key: http.request.body
action: delete
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch, tail_sampling, attributes/redact]
exporters: [otlp/tempo]
Pattern 2: Application 의 instrumentation 표준
# 모든 service 의 표준 setup (Python)
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
def setup_tracing(service_name: str, environment: str):
resource = Resource.create({
"service.name": service_name,
"service.namespace": "company",
"deployment.environment": environment,
"service.version": os.getenv("APP_VERSION", "unknown"),
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
)
)
trace.set_tracer_provider(provider)
# Auto-instrumentation
FastAPIInstrumentor.instrument_app(app)
SQLAlchemyInstrumentor().instrument(engine=db_engine)
HTTPXClientInstrumentor().instrument()
# Application 시작 시
setup_tracing("user-api", "production")
Pattern 3: Service Graph + Alerts
# Tempo metric_generator config
metric_generator:
registry:
external_labels:
cluster: prod
storage:
path: /var/tempo/generator/wal
remote_write:
- url: http://prometheus:9090/api/v1/write
processor:
service_graphs:
enable_messaging_system_latency_histogram: true
dimensions:
- environment
span_metrics:
dimensions:
- http.method
- http.status_code
→ Prometheus 에 Service Graph metric + Span metric 자동 push.
# Service Graph 기반 alert
- alert: ServiceDependencyErrorRate
expr: |
sum(rate(traces_service_graph_request_failed_total[5m])) by (client, server) /
sum(rate(traces_service_graph_request_total[5m])) by (client, server) > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "{{ $labels.client }} → {{ $labels.server }} error rate > 5%"
Pattern 4: Trace + Log 의 link
Loki datasource 의 설정 (3편 의 Derived Field):
Field name: TraceID
Regex: trace_id=(\w+)
URL: ${__value.raw}
Internal link: Tempo datasource
→ Loki 의 log 에 trace_id=xxx 보이면 자동 클릭 가능
→ 클릭 = Tempo 의 trace view
반대로:
Tempo datasource 의 설정 — Trace to logs:
Datasource: Loki
Tags: ['service.name']
Query: {service="${__tags.service.name}"} |= "${__span.traceID}"
→ Tempo 의 span 에서 "View logs" 클릭
→ Loki 의 해당 service 의 같은 trace 의 log
3 pillar 의 완전한 correlation.
Pattern 5: 비즈니스 의식 의 span
# 단순 function span X
# 의미 있는 boundary 만
@app.post("/orders")
async def create_order(order_data: OrderCreate):
with tracer.start_as_current_span("create_order") as span:
# 비즈니스 attribute
span.set_attribute("order.amount", order_data.amount)
span.set_attribute("order.currency", order_data.currency)
span.set_attribute("user.tier", user.tier)
# 외부 service 호출 = span
with tracer.start_as_current_span("validate_inventory"):
await inventory_service.check(order_data.items)
with tracer.start_as_current_span("process_payment"):
result = await payment_service.charge(
user.id, order_data.amount
)
span.set_attribute("payment.transaction_id", result.id)
with tracer.start_as_current_span("save_order"):
order = await db.save_order(order_data)
span.set_attribute("order.id", order.id)
# 비동기 작업도 trace
with tracer.start_as_current_span("send_confirmation"):
await notification_service.send(user.email, order)
return order
Pattern 6: Exemplar 의 활용
# Grafana panel 의 query
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
)
# Panel 의 옵션:
# Exemplars: ON
# Datasource link: Tempo
→ 그래프 의 점 클릭 = trace view 즉시
p99 latency 차트 의 모든 spike = 클릭 한 번에 정확한 trace. 디버그 속도 폭증.
시험 직전 한 번 더 — Tempo + TraceQL 압축 노트
Trace · Span
- Trace = 한 요청 의 전체 여정 (trace_id 동일)
- Span = trace 의 한 service 의 작업 단위 (span_id · parent_span_id)
- Span = service · operation · duration · attributes · events · status
프로토콜
- OpenTelemetry (OTLP) — CNCF 표준, 권장
- Jaeger — Uber, 자체 UI 보유
- Zipkin — Twitter, 가장 오래
- Tempo = 3 프로토콜 모두 호환
Instrumentation
- Manual — 코드 안의 tracer.start_as_current_span
- Auto-instrumentation — opentelemetry-instrument
- eBPF (Beyla) — 코드 수정 0, 모든 언어
Tempo Storage
- Object Storage (S3 · GCS · Azure)
- Block (압축 flat file)
- TraceID Lookup (O(1)) vs Search (block scan)
- Bloom filter 의 최적화
TraceQL 4 type
- Span Filter —
{ resource.service.name = "api" } - Field Comparison —
{ duration > 1s } - Structural Relationship —
>>><~ - Aggregate —
| count()·| avg(duration)
Service Graph
- trace 자동 분석 → dependency graph
- traces_service_graph_request_total 같은 metric 생성
- Prometheus 로 export → Grafana visualization
Metric Generator
- trace → RED metric 자동
- traces_spanmetrics_calls_total · _latency_bucket
- Auto-instrumentation 의 강점 (코드 수정 0)
Tail Sampling
- Head sampling = trace 시작 시 결정 (한계: 에러도 99% drop)
- Tail sampling = trace 끝나면 결정 (에러 · 느린 trace · 일부 정상)
- OTel Collector 의 tail_sampling processor
Exemplar
- Histogram sample 에 trace_id 결합
- Grafana 차트 의 점 클릭 → trace view
- Prometheus client 의 exemplar 인자
사고
- 모든 trace 저장 (비용 폭증)
- service.name 일관성 X
- 너무 깊은 span nesting (가독성 0)
- log 에 trace_id 안 박힘 (correlation X)
- OTel Collector 의 부담 (scaling 필요)
- Auto-instrumentation 누락 framework
- Metric Generator cardinality 폭발
- Block compaction 잘못
- Trace 의 PII 누출
- Timestamp 불일치 (NTP)
패턴
- 표준 OTel Collector (batch + tail_sampling + attribute redact)
- Application instrumentation 표준 (FastAPI · SQLAlchemy · HTTPX 자동)
- Service Graph + dependency alert
- Trace ↔ Log link (Derived Field · Trace to logs)
- 비즈니스 attribute (order.amount · user.tier)
- Exemplar 의 활용 (p99 spike → 정확한 trace)
공식 문서: Grafana Tempo · OpenTelemetry 에서 더 깊은 spec 을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 1편 — Observability 3 pillar · LGTM stack 종합
- 2편 — Prometheus + PromQL 깊이 (Pull · Exporter · Alertmanager)
- 3편 — Loki + LogQL 깊이 (Label Index · 비용 효율)
다음 글: