链路追踪:Jaeger 与 OpenTelemetry 实践
一句话定位:跨服务调用怎么追?慢在哪一段?Jaeger + OpenTelemetry 帮你把分布式调用链路画成一张"地铁图"。
写在前面
去年秋天我们有个诡异的故障:订单服务的 P99 延迟偶尔飙升到 3 秒,但 CPU/内存/网络指标一切正常,ALM 没有一条告警触发。当时查了一整天,最后靠 tcpdump 抓包才发现------调用下游支付服务的某个 API 间歇性超时。
那次之后我痛定思痛:没有链路追踪,微服务排查问题基本靠猜。这篇文章把 Jaeger 和 OpenTelemetry 的部署、采样策略、链路日志关联讲透。
读完你能带走:
- 分布式追踪的核心概念(Trace / Span / Context Propagation)
- OpenTelemetry Operator 自动注入部署方案
- Jaeger / Tempo 后端选型决策
- 采样策略配置(头部采样 vs 尾部采样)
- 链路与日志、指标关联的实操方法
核心问题
跨服务调用怎么追?慢在哪一段?答案是链路追踪------在每个服务边界插入"埋点",把一次完整的请求链路串起来。
一、原理剖析
1.1 Trace / Span / Context Propagation
分布式追踪的三个核心概念:
Trace(追踪链路)
│
├── Span A (根 Span) --- 用户请求入口,API Gateway
│ ├── 开始: 10:00:00.000
│ ├── 结束: 10:00:03.500
│ ├── TraceID: abc123
│ ├── SpanID: span-a
│ └── ParentSpanID: (none)
│
├── Span B --- 调用订单服务
│ ├── 开始: 10:00:00.050
│ ├── 结束: 10:00:02.800
│ ├── TraceID: abc123 ← 同一个 TraceID 关联所有 Span
│ ├── SpanID: span-b
│ └── ParentSpanID: span-a ← 父子关系链
│
└── Span C --- 调用支付服务(订单服务内部调用)
├── 开始: 10:00:01.200
├── 结束: 10:00:02.500 ← 可以看到这个 Span 占了 1.3s!
├── TraceID: abc123
├── SpanID: span-c
└── ParentSpanID: span-b
从 Trace 视图可以直接看到:
总耗时 3.5s → 订单服务 2.75s → 支付服务 1.3s → 慢在支付服务
Context Propagation(上下文传播)是怎么把 Span 串起来的:
服务 A 服务 B
┌─────────────────┐ ┌─────────────────┐
│ 创建 Span B │ │ 创建 Span C │
│ trace_id: abc123 │ │ trace_id: abc123 │
│ span_id: span-b │ │ span_id: span-c │
│ │ │ parent_id: span-b│
└────────┬─────────┘ └────────▲─────────┘
│ │
│ HTTP Request │
│ Headers: │
│ traceparent: 00-abc123- │
│ span-b-01 │
│ tracestate: vendor=xxx │
└───────────────────────────────┘
W3C Trace Context 标准 HTTP Header:
traceparent: 00-{trace_id}-{span_id}-{trace_flags}
│ │ │ │
│ └─ 32hex │ └─ 采样标志 (01=采样)
│ └─ 16hex parent span id
└─ 版本号(00)
1.2 OpenTelemetry 架构
OpenTelemetry(简称 OTel)是 CNCF 继 Kubernetes、Prometheus 之后的第三个"毕业"项目,定位为可观测性数据的统一标准。
┌─────────────────────────────────────────────────────────┐
│ OpenTelemetry 架构 │
├─────────────────────────────────────────────────────────┤
│ │
│ 应用代码 │
│ ┌─────────────────────────────────────────────┐ │
│ │ OTel SDK (各种语言 SDK) │ │
│ │ ├─ TracerProvider ── 生成 Span │ │
│ │ ├─ MeterProvider ── 生成 Metric │ │
│ │ └─ LoggerProvider ── 生成 Log │ │
│ └─────────────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────┼──────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ OTLP │ │ OTLP │ │ OTLP │ (统一协议) │
│ │ Traces │ │ Metrics │ │ Logs │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ OTel Collector │ │
│ │ ├─ Receiver (接收) │ │
│ │ ├─ Processor (处理/过滤/采样) │ │
│ │ └─ Exporter (导出到后端) │ │
│ └──┬──────────┬──────────┬───────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────┐ ┌────────┐ ┌─────────┐ │
│ │Jaeger │ │ Tempo │ │Prometheus│ (后端存储) │
│ └───────┘ └────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
为什么选 OTel 而不是直接上 Jaeger SDK?
| 维度 | Jaeger SDK | OpenTelemetry SDK |
|---|---|---|
| 标准性 | Jaeger 私有协议 | OTLP 开放标准,CNCF 毕业项目 |
| 后端灵活性 | 锁死 Jaeger | 换后端只需改 Exporter 配置 |
| 功能覆盖 | 仅 Tracing | Tracing + Metrics + Logging |
| 社区 | 维护中但不再活跃开发 | 最活跃的可观测性项目 |
| 厂商中立 | 需要 Jaeger 后端 | 任何支持 OTLP 的后端 |
结论:新项目无条件直接上 OpenTelemetry。老项目从 Jaeger SDK 迁移到 OTel SDK。
1.3 采样策略深度对比
采样是链路追踪中最重要的配置决策,直接影响存储成本和排查能力。
采样策略对比:
┌──────────────┬──────────────────┬──────────────────┬──────────────────┐
│ 策略 │ 头部采样 │ 尾部采样 │ 自适应采样 │
│ │ (Head Sampling) │ (Tail Sampling) │(Adaptive Sampling)│
├──────────────┼──────────────────┼──────────────────┼──────────────────┤
│ 决策时机 │ Span 创建时 │ Trace 完成后 │ Span 创建时 │
│ 原理 │ 随机决定是否采样 │ 全量采集后再筛选 │ 根据速率动态调整 │
│ 优点 │ 简单,开销低 │ 不会漏掉异常 Trace │ 平衡存储和覆盖 │
│ 缺点 │ 可能漏掉错误 Trace │ 需要全量采集+存储 │ 实现复杂 │
│ 存储成本 │ 低(1%-10%) │ 高(100%采集) │ 中(可配置) │
│ 错误 Trace │ 可能丢失 │ ✅ 不会丢失 │ ✅ 大概率保留 │
│ 适用场景 │ 高流量服务 │ 低流量核心服务 │ 中大规模服务 │
│ 代表实现 │ OTel SDK 内置 │ Jaeger Remote │ Jaeger Adaptive │
│ │ │ Sampling │ │
└──────────────┴──────────────────┴──────────────────┴──────────────────┘
生产建议:
- 高流量服务(> 1000 QPS):头部采样 1%-5%
- 核心业务服务:尾部采样 + 错误 Trace 100% 保留
- 低流量服务(< 10 QPS):100% 采集(成本可控)
二、实战操作
2.1 部署 OpenTelemetry Operator
bash
# 添加 OTel Helm 仓库
helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
helm repo update
# 创建命名空间
kubectl create namespace otel
# 安装 OTel Operator(负责自动注入 SDK)
helm upgrade --install otel-operator open-telemetry/opentelemetry-operator \
--namespace otel \
--set "manager.collectorImage.repository=otel/opentelemetry-collector-k8s" \
--set admissionWebhooks.certManager.enabled=true \
--wait
2.2 部署 OTel Collector 和 Jaeger 后端
otel-collector-values.yaml:
yaml
# ============================================
# OpenTelemetry Collector + Jaeger 部署配置
# ============================================
mode: deployment
# ── 副本数 ──
replicaCount: 2
image:
repository: otel/opentelemetry-collector-contrib
tag: 0.97.0
# ── Collector 配置 ──
config:
receivers:
# OTLP 协议接收(gRPC + HTTP)
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
# Jaeger 协议兼容(旧版 SDK 兼容)
jaeger:
protocols:
grpc:
endpoint: 0.0.0.0:14250
thrift_http:
endpoint: 0.0.0.0:14268
# Zipkin 协议兼容
zipkin:
endpoint: 0.0.0.0:9411
processors:
# 批次处理:平衡延迟和吞吐
batch:
send_batch_size: 1024
timeout: 5s
send_batch_max_size: 2048
# 内存限制:防止 OOM
memory_limiter:
check_interval: 1s
limit_mib: 1024
spike_limit_mib: 256
# ── 尾部采样配置(关键!) ──
tail_sampling:
decision_wait: 30s # 等待 30 秒完成 Trace 再做采样决策
num_traces: 50000 # 内存中保留 50000 条 Trace
expected_new_traces_per_sec: 100
policies:
# 策略 1: 包含错误的 Trace 100% 采样
- name: errors-policy
type: status_code
status_code:
status_codes: [ERROR]
# 策略 2: 延迟 > 1s 的 Trace 100% 采样
- name: latency-policy
type: latency
latency:
threshold_ms: 1000
# 策略 3: 特定服务 100% 采样
- name: core-services-policy
type: string_attribute
string_attribute:
key: service.name
values: [payment-service, order-service, user-service]
enabled_regex_matching: false
# 策略 4: 其他 Trace 采样 5%
- name: probabilistic-policy
type: probabilistic
probabilistic:
sampling_percentage: 5
# 属性处理
transform:
trace_statements:
# 为内部 Span 添加 cluster 信息
- context: resource
statements:
- set(attributes["k8s.cluster.name"], "prod-k8s-1")
# K8s 属性丰富
k8sattributes:
auth_type: serviceAccount
passthrough: false
extract:
metadata:
- k8s.pod.name
- k8s.pod.uid
- k8s.deployment.name
- k8s.namespace.name
- k8s.node.name
pod_association:
- sources:
- from: resource_attribute
name: k8s.pod.ip
exporters:
# 导出到 Jaeger
jaeger:
endpoint: jaeger-collector.jaeger.svc.cluster.local:14250
tls:
insecure: true
# 调试导出(开发环境,生产关闭)
# debug:
# verbosity: detailed
service:
pipelines:
traces:
receivers: [otlp, jaeger, zipkin]
processors: [memory_limiter, k8sattributes, tail_sampling, batch, transform]
exporters: [jaeger]
# ── 端口 ──
ports:
otlp:
enabled: true
containerPort: 4317
servicePort: 4317
protocol: TCP
otlp-http:
enabled: true
containerPort: 4318
servicePort: 4318
protocol: TCP
# ── 资源配额 ──
resources:
limits:
cpu: 1000m
memory: 2Gi
requests:
cpu: 200m
memory: 512Mi
# ── 容忍度 ──
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
部署 OTel Collector:
bash
helm upgrade --install otel-collector open-telemetry/opentelemetry-collector \
--namespace otel \
--values otel-collector-values.yaml \
--wait
Jaeger 后端部署:
bash
# 使用 Jaeger Operator 或直接 Helm 部署
kubectl create namespace jaeger
# 安装 Jaeger (All-In-One 模式,生产用 Production 模式)
helm repo add jaegertracing https://jaegertracing.github.io/helm-charts
helm repo update
cat <<'EOF' > jaeger-values.yaml
provisionDataStore:
cassandra: false
elasticsearch: false
query:
enabled: true
service:
type: ClusterIP
port: 16686
collector:
enabled: true
service:
otlp:
grpc:
port: 4317
http:
port: 4318
zipkin:
port: 9411
# 生产建议使用 Elasticsearch 做后端存储
storage:
type: elasticsearch
elasticsearch:
host: elasticsearch-master.logging.svc
port: 9200
# 保留策略
esIndexCleaner:
enabled: true
numberOfDays: 7
schedule: "55 23 * * *"
EOF
helm upgrade --install jaeger jaegertracing/jaeger \
--namespace jaeger \
--values jaeger-values.yaml \
--wait
2.3 应用侧接入 OTel SDK
Python 示例(FastAPI + OTel SDK):
python
# app.py - FastAPI 应用接入 OpenTelemetry
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from opentelemetry.baggage.propagation import W3CBaggagePropagator
from fastapi import FastAPI, Request
import logging
import uuid
# ── 配置 Tracer ──
resource = Resource.create({
"service.name": "order-service",
"service.version": "v2.1.3",
"deployment.environment": "production",
})
# 头部采样: 10% 采样率
sampler = TraceIdRatioBased(1/10)
provider = TracerProvider(resource=resource, sampler=sampler)
# OTLP Exporter → Collector → Jaeger
otlp_exporter = OTLPSpanExporter(
endpoint="http://otel-collector.otel.svc.cluster.local:4317",
insecure=True,
)
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
trace.set_tracer_provider(provider)
# W3C Trace Context 传播
set_global_textmap(CompositePropagator([
TraceContextTextMapPropagator(),
W3CBaggagePropagator(),
]))
# ── FastAPI 自动埋点 ──
app = FastAPI()
# 自动为所有 HTTP 端点创建 Span
FastAPIInstrumentor.instrument_app(app)
# 自动为 requests 库调用创建 Span
RequestsInstrumentor().instrument()
tracer = trace.get_tracer(__name__)
@app.get("/api/orders/{order_id}")
async def get_order(order_id: str, request: Request):
# 获取当前 Span(自动创建,trace_id 从 Header 传播而来)
current_span = trace.get_current_span()
# 添加业务属性
current_span.set_attribute("order.id", order_id)
current_span.set_attribute("user.id", request.headers.get("X-User-ID", "unknown"))
# 关键: 将 trace_id 写入日志,实现关联
span_context = current_span.get_span_context()
trace_id_hex = format(span_context.trace_id, '032x')
# 手动创建子 Span
with tracer.start_as_current_span("query-database") as db_span:
db_span.set_attribute("db.statement", f"SELECT * FROM orders WHERE id={order_id}")
db_span.set_attribute("db.system", "postgresql")
# ... 数据库查询逻辑
return {"order_id": order_id, "trace_id": trace_id_hex}
Java 示例(Spring Boot + OTel Agent,零代码侵入):
bash
# 方式: Java Agent 自动注入,不需要改一行代码!
# Dockerfile
FROM openjdk:17-slim
# 下载 OTel Java Agent
ADD https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v1.33.0/opentelemetry-javaagent.jar /opt/opentelemetry-javaagent.jar
COPY target/order-service.jar /app/order-service.jar
ENTRYPOINT ["java", \
"-javaagent:/opt/opentelemetry-javaagent.jar", \
"-Dotel.service.name=order-service", \
"-Dotel.traces.exporter=otlp", \
"-Dotel.metrics.exporter=none", \
"-Dotel.logs.exporter=none", \
"-Dotel.exporter.otlp.endpoint=http://otel-collector.otel.svc:4317", \
"-Dotel.propagators=tracecontext,baggage", \
"-jar", "/app/order-service.jar"]
2.4 自动注入(Instrumentation CRD)
OTel Operator 支持通过注解零代码注入:
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
annotations:
# 这是关键!告诉 OTel Operator 自动注入 SDK
instrumentation.opentelemetry.io/inject-java: "true"
instrumentation.opentelemetry.io/java-container-names: "order-service"
# 或者注入 Python SDK
# instrumentation.opentelemetry.io/inject-python: "true"
spec:
replicas: 3
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
annotations:
instrumentation.opentelemetry.io/inject-java: "true"
spec:
containers:
- name: order-service
image: order-service:v2.1.3
env:
- name: OTEL_SERVICE_NAME
value: order-service
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://otel-collector.otel.svc.cluster.local:4317
- name: OTEL_TRACES_SAMPLER
value: parentbased_traceidratio
- name: OTEL_TRACES_SAMPLER_ARG
value: "0.1"
Instrumentation CRD 定义:
yaml
apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
name: java-instrumentation
namespace: otel
spec:
exporter:
endpoint: http://otel-collector.otel.svc.cluster.local:4317
propagators:
- tracecontext
- baggage
sampler:
type: parentbased_traceidratio
argument: "0.1"
java:
image: ghcr.io/open-telemetry/opentelemetry-operator/autoinstrumentation-java:1.33.0
env:
- name: OTEL_RESOURCE_ATTRIBUTES
value: cluster=prod-k8s-1
三、踩坑与排查
3.1 坑一:Context 传播断裂
现象:Jaeger UI 中看到多个孤立的 Span,无法组成完整的 Trace。服务 A 调服务 B,Trace 断开。
原因:
- HTTP 调用时没有传播
traceparentheader - 消息队列(Kafka/RabbitMQ)中没有在消息体中携带 trace context
- 不同语言/框架的传播器不一致
排查:
bash
# 1. 抓包检查 HTTP Header
kubectl exec -n production order-service-xxx -- \
tcpdump -A -s 0 -i eth0 port 8080 | grep -i traceparent
# 2. 检查 OTel Collector 日志
kubectl logs -n otel deployment/otel-collector | grep -i "traceid"
# 3. 用 Jaeger UI 验证
# 访问: http://jaeger-query.jaeger:16686
# 搜索特定 TraceID,看 Span 数量是否 = 期望的调用次数
# 4. 检查应用日志中的 trace_id
kubectl logs order-service-xxx | grep "trace_id" | head -20
解决方案:
python
# 确保使用正确的传播器
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
# 只用 W3C TraceContext(标准),避免混合使用
set_global_textmap(TraceContextTextMapPropagator())
检查服务间调用是否传播了 header:
python
# 确保 HTTP 客户端使用了 OTel 自动埋点
import requests
from opentelemetry.instrumentation.requests import RequestsInstrumentor
RequestsInstrumentor().instrument() # 一行搞定
3.2 坑二:采样策略导致关键 Trace 丢失
现象:生产出问题后,在 Jaeger 中怎么都找不到出错的那条 Trace。
原因:头部采样率设得太低(如 1%),导致错误 Trace 被丢弃。
解决方案 :头部采样 + 尾部采样 双保险:
yaml
# OTel Collector 配置 - 双保险采样
processors:
tail_sampling:
decision_wait: 30s
policies:
# 第一层: 错误 Trace 100% 保留(尾部采样核心价值)
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
# 第二层: 慢 Trace 100% 保留
- name: slow-traces
type: latency
latency:
threshold_ms: 2000
# 第三层: 其余按比例采样
- name: probabilistic
type: probabilistic
probabilistic:
sampling_percentage: 5
同时在 SDK 层设置较高基础采样率作为兜底:
bash
# 应用层头部采样保留 20%(保证有基础数据)
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.2
3.3 坑三:Jaeger 存储爆炸
现象:Jaeger 后端存储(ES/Cassandra)磁盘使用量每天增长 50GB+,7 天就接近 400GB。
原因:
- 采样率配置不合理(100% 采样高流量服务)
- 没有配置数据保留策略
- ES 索引没有按天轮转
解决方案:
yaml
# Jaeger - 生产存储配置
storage:
type: elasticsearch
elasticsearch:
host: elasticsearch-master
port: 9200
# 关键: 按天创建索引
indexPrefix: jaeger
useILM: true # ES Index Lifecycle Management
# ILM 策略: 3 天 hot, 7 天 warm, 30 天后删除
createIndexTemplates: true
ilmPolicyName: jaeger-ilm-policy
# 定时清理
esIndexCleaner:
enabled: true
numberOfDays: 7
schedule: "55 23 * * *" # 每天 23:55 清理
ES ILM 策略:
json
{
"policy": {
"phases": {
"hot": {
"min_age": "0ms",
"actions": {
"rollover": {
"max_size": "50gb",
"max_age": "1d"
}
}
},
"warm": {
"min_age": "3d",
"actions": {
"shrink": {
"number_of_shards": 1
},
"forcemerge": {
"max_num_segments": 1
}
}
},
"delete": {
"min_age": "7d",
"actions": {
"delete": {}
}
}
}
}
}
四、最佳实践
部署清单
- 使用 OTel Operator 管理自动注入,不要手动配置每个应用
- 部署 OTel Collector 作为中间网关(解耦 SDK 和后端)
- 生产环境 Collector 部署模式用 Deployment + HPA(非 DaemonSet)
- 配置 尾部采样 + 头部采样双保险
- Jaeger 后端存储建议 Elasticsearch 7.x+ (生产)或 Badger(开发/测试)
采样策略清单
- 高流量服务:头部 5%-20% + 尾部错误/慢 Trace 100% 保留
- 低流量核心服务:100% 采集
- 内部/后台服务:头部 1%-5%
- 确保 Error Trace 和 P99+ 慢 Trace 永远不被丢弃
链路关联清单
- 应用日志中必须打印 trace_id(关键!关联 LogQL 和 Jaeger 查询)
- Prometheus 指标中添加 trace_id 作为 exemplar:
promql
# Prometheus exemplar(指标中内嵌 trace 示例)
histogram_quantile(0.99,
rate(http_request_duration_seconds_bucket[5m])
)
# Grafana 中点击数据点可直接跳转到 Jaeger Trace
- 统一服务命名规范:
{team}-{service}-{env},如payment-order-service-prod
后端选型决策
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| K8s <= 10 节点 | Jaeger All-in-One | 简单,内置内存/Badger 存储 |
| K8s 10-50 节点 | Jaeger + Elasticsearch | 成熟的 ES 后端,查询支持好 |
| K8s > 50 节点 | Tempo(Grafana 生态) | 对象存储原生,成本低,与 Grafana 无缝集成 |
| 已有 Grafana + Loki | Tempo | 一套 Grafana 搞定日志+指标+链路 |
| 高吞吐 + 低成本 | Grafana Tempo | 不需要索引,对象存储 + 轮询查询 |
五、小结
链路追踪是一个"慢工出细活"的工程。我的实施建议:
- 第一步不要追求全量覆盖:先给 2-3 个核心服务接入 OTel SDK,打通 Trace → Jaeger → Grafana 查询链路
- 采样策略是灵魂:头部 + 尾部双保险,确保出错时一定能找到 Trace
- trace_id 是一等公民:日志里带 trace_id,指标里带 exemplar,排查问题时一个 trace_id 串起三根支柱
分布式系统排查慢请求的四步法:
① 看 Grafana 面板 → 哪个服务 P99 高?
② 点 exemplar 跳 Jaeger → 哪段 Span 慢?
③ 用 trace_id 查 Loki → 这段时间发生了什么?
④ 看 K8s Event → 有 Pod 重启/调度/扩缩容吗?
思考题
- 为什么尾部采样需要等待 30 秒才能做决策?这个等待时间怎么权衡?
- 如果服务间通过 Kafka 异步通信,如何保证 Consumer 能够继承 Producer 的 TraceID?
- Grafana Tempo 为什么能做到不需要建立索引也能快速查询 Trace?和 Loki 的"不索引内容只索引标签"有什么异同?