11.1. 链路追踪与 SLO:OpenTelemetry、Tempo 与 Jaeger
指标告诉你错误率涨了,日志告诉你哪台机器在报错,只有链路能指出是哪个服务、哪次调用把延迟放大。本章用 OpenTelemetry 统一采集,用 Tempo 或 Jaeger 存储与查询,再把 trace 与日志、指标串起来,最后落到 SLO 告警。
- 层级:
L3 · 平台
- 难度:
进阶
- 预计:
70 分钟
- 前置:
Prometheus 监控、日志与 ELK / Loki
11.1.1. 你将学到
区分 Metrics、Logs、Traces 三类信号,理解 trace_id 与 span 树如何还原一次请求。
用 OpenTelemetry SDK、Collector 与 OTLP 搭出一条不绑定后端的采集链路。
判断 head-based 与 tail-based 采样的代价,避免上下文传播断裂。
对比 Tempo 与 Jaeger 的存储、查询与运维差异,按场景选型。
用 exemplar、derived field 把指标、日志与 trace 互相关联。
用 Pyrra 与 Alertmanager 实现多窗口燃烧率 SLO 告警。
11.1.2. 为什么需要它
微服务把一次用户请求拆成十几跳,任何一跳变慢或失败,单看某一个服务的指标都像是“正常”的:上游只看到自己的 P99 升高,下游只看到 QPS 下降。日志能给出错误堆栈,但无法自动回答“这次请求经过了哪些服务、在哪一跳卡了 800ms”。链路追踪补的正是这条因果链。
链路数据量远大于指标,采样是必选项;采样又会带来“出问题时恰好没采到”的风险。因此生产上不是“装一个 Jaeger”,而是设计一条链路:SDK 埋点 → 传播上下文 → Collector 处理与采样 → 后端存储 → 与指标、日志关联 → SLO 告警。指标见 Prometheus 监控,日志见 日志与 ELK / Loki。
11.1.3. 核心概念
图:应用同时产出三类信号,Collector 负责加工与分流,Grafana 用 trace_id 把三者拼回一次请求。
11.1.3.1. 三类信号的分工
信号 |
数据形态 |
强项 |
成本 |
典型问题 |
|---|---|---|---|---|
Metrics |
聚合数值 + 标签 |
趋势、告警、容量 |
低 |
错误率涨了,但不知道哪一跳 |
Logs |
离散事件 + 上下文 |
细节、堆栈、审计 |
中 |
量大,跨服务难以串联 |
Traces |
trace_id + span 树 |
调用链、耗时分解 |
高,需采样 |
采样丢失、埋点不全 |
一次 trace 由 trace_id 标识,内部是树状 span:每个 span 有 span_id、parent_span_id、开始/结束时间、名称、属性、事件和状态。根 span 的耗时是总延迟,子 span 的耗时差就是每一跳的贡献。不要把 trace 当成“更详细的日志”:它的价值是结构与因果,不是文本。
11.1.3.2. OpenTelemetry:API、SDK、Collector 与 OTLP
OpenTelemetry 把“产生数据”和“存储数据”解耦。API 是埋点接口,SDK 负责采样、批处理、导出,Collector 是独立的接收、处理、导出进程,OTLP 是它们之间的协议:gRPC 默认 4317,HTTP 默认 4318。换后端只改 exporter,不改业务代码,这是它相对厂商 SDK 的最大优势。
Collector 的配置由四段组成:receivers 接收 OTLP 或 Jaeger/Zipkin 协议,processors 做 memory_limiter、batch、k8sattributes、脱敏与采样,exporters 写往 Tempo、Jaeger 或 Prometheus,service.pipelines 把三者按信号类型串起来。生产常用两层部署:每节点一个 agent 负责接收与补元数据,中心 gateway 负责尾采样与出口,gateway 前用 loadbalancing exporter 按 traceID 哈希,保证同一条 trace 的所有 span 落到同一个实例。
11.1.3.3. 采样、上下文传播与 span 属性
Head-based 采样在请求入口就决定“采不采”,实现简单、开销低,但无法预知后面是否出错;parentbased_traceidratio 让子服务继承父级的采样决定,避免一条 trace 只采到一半。Tail-based 采样在 Collector 中缓存整条 trace,再按“有错误、P99 超阈值、特定租户”决定保留,代价是内存与等待时间,且必须保证同一 trace 的 span 到达同一 Collector。
上下文传播靠 W3C Trace Context:HTTP 头 traceparent 携带 trace_id、span_id 和采样标志,tracestate 携带厂商信息,baggage 携带业务键值。HTTP/gRPC 拦截器通常自动完成注入与提取;消息队列、线程池、异步回调、定时任务必须手工传递,否则下游会新起一条根 span,表现为“链路断成两截”。服务网格只能补齐网络跳,不能替代应用内埋点。
span 属性要遵循语义约定:http.request.method、http.response.status_code、db.system、messaging.system,资源属性至少包含 service.name、service.version、deployment.environment。名称保持低基数,用户 ID、订单号、原始 URL 放属性而不是 span 名;密码、Token、身份证号必须在 Collector 或 SDK 里删除。只有在请求真正失败时才把 span 状态设为 ERROR,把 4xx 业务拒绝一律标错会让错误率失真。
11.1.3.4. Tempo 与 Jaeger 的差异
Tempo 是 Grafana 生态的trace 后端,只按 trace_id 建索引,数据块直接写对象存储(S3、GCS、Azure Blob),查询靠 Grafana 或 TraceQL。它没有独立的全文检索,也不能按 tag 做任意组合搜索,但存储成本低、运维面小,适合已经有 Grafana 和对象存储的团队。
Jaeger 是 CNCF 毕业项目,自带 UI,支持按 service、operation、tag、耗时搜索,存储可用 Cassandra、Elasticsearch/OpenSearch 或 Badger。Jaeger v2 以 OTel Collector 为内核,原生接收 OTLP;老版本依赖 agent/collector 分层,agent 已不推荐。它的查询能力更强,代价是要维护一套有状态存储。
维度 |
Tempo |
Jaeger |
|---|---|---|
索引 |
仅 trace_id,TraceQL 过滤 |
service/operation/tag 倒排索引 |
存储 |
对象存储,成本低 |
Cassandra/ES,需容量规划 |
查询入口 |
Grafana、TraceQL |
自带 UI + API |
运维复杂度 |
低,无状态组件多 |
高,存储是有状态服务 |
适合 |
已有 Grafana、追求低成本 |
需要强搜索、独立 trace UI |
11.1.3.5. 与 Prometheus / Loki 的关联
指标到 trace 靠 exemplar:直方图 bucket 上附带一条样本的 trace_id,Grafana 在延迟图上的小圆点可以直接跳到对应 trace。应用用 OpenMetrics 格式暴露 http_request_duration_seconds_bucket{le="0.5"} 12 # {trace_id="4bf92f..."} 1,Prometheus 开启 exemplar 存储后即可关联。Tempo 的 metrics-generator 还能从 span 生成 service graph 与 span metrics,把 RED 指标写回 Prometheus。
日志到 trace 靠 trace_id 字段。应用把 trace_id 打进 JSON 日志,Loki 用 derived field 提取;Grafana 的 Tempo 数据源配置 tracesToLogsV2 后,trace 详情页会出现“Logs”按钮,按 trace_id 和时间窗查 Loki。反向也可以从日志跳到 trace。两条链路都依赖同一个 service.name,否则标签对不上。
11.1.4. SLO:从 SLI 到告警
SLI 是“好事件 / 总事件”的比例,例如“非 5xx 请求占比”;SLO 是这个比例的目标,例如 28 天 99.9%;错误预算就是 1 - SLO,即 0.1% 的请求可以失败。SLO 告警不应该直接对“错误率 > 1%”报警,而应该对错误预算消耗速度报警,否则低流量时噪声大、高流量时又太迟钝。
多窗口多燃烧率是业界常用做法:1 小时窗口燃烧率 14.4 倍(2% 预算)配 5 分钟短窗口做快速告警;6 小时 6 倍配 30 分钟做中速告警;3 天 1 倍做慢速工单。短窗口用于恢复,长窗口用于抑制抖动,两个条件同时满足才触发。Pyrra 把 SLO 写成 CRD,自动生成 PrometheusRule 和 Grafana 看板;Grafana SLO 与 Sloth 也能做同样的事。
apiVersion: pyrra.dev/v1alpha1
kind: SLO
metadata:
name: demo-api-availability
namespace: monitoring
spec:
target: "99.5"
window: 28d
description: demo-api 非 5xx 请求占比
indicator:
ratio:
errors:
metric: http_requests_total{job="demo-api",status=~"5.."}
total:
metric: http_requests_total{job="demo-api"}
Pyrra 生成的规则带 slo:demo-api-availability 等标签,Alertmanager 按 severity 路由到值班频道,并用 inhibit_rules 让快速告警抑制慢速告警。没有 Pyrra 时,可以手写 burn_rate 表达式,但要把窗口、阈值和 runbook 一起放进 Git。
11.1.5. 动手实验
实验:Collector + Tempo 打通一条可跳转的 trace
目标:部署 Tempo 与 OTel Collector,运行一个自动埋点的 Flask 服务,按 trace_id 查询 Tempo,并从日志按 trace_id 跳回同一次请求。
步骤 1 · 安装 Tempo 与 Grafana
tempo:
storage:
trace:
backend: local
local: {path: /var/tempo/traces}
receivers:
otlp:
protocols:
grpc: {endpoint: 0.0.0.0:4317}
http: {endpoint: 0.0.0.0:4318}
persistence: {enabled: false}
helm repo add grafana https://grafana.github.io/helm-charts
helm upgrade --install tempo grafana/tempo -n observability --create-namespace -f tempo-values.yaml
helm upgrade --install grafana grafana/grafana -n observability \
--set adminPassword=admin
kubectl -n observability get svc tempo
步骤 2 · 部署 Collector
mode: deployment
config:
receivers:
otlp:
protocols:
grpc: {endpoint: 0.0.0.0:4317}
http: {endpoint: 0.0.0.0:4318}
processors:
memory_limiter: {check_interval: 1s, limit_miB: 256}
batch: {}
exporters:
otlp:
endpoint: tempo.observability.svc:4317
tls: {insecure: true}
debug: {verbosity: basic}
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp, debug]
helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts
helm upgrade --install otel-collector open-telemetry/opentelemetry-collector \
-n observability -f otel-values.yaml
kubectl -n observability logs deploy/otel-collector --tail=20
步骤 3 · 构建并部署自动埋点的 demo
import logging
from flask import Flask
from opentelemetry import trace
from opentelemetry.instrumentation.flask import FlaskInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s level=%(levelname)s trace_id=%(otelTraceID)s %(message)s",
)
LoggingInstrumentor().instrument(set_logging_format=True)
app = Flask(__name__)
FlaskInstrumentor().instrument_app(app)
tracer = trace.get_tracer(__name__)
@app.get("/work")
def work():
with tracer.start_as_current_span("load-config") as span:
span.set_attribute("demo.step", "load")
logging.info("handling /work")
return {"ok": True}
FROM python:3.12-slim
RUN pip install --no-cache-dir flask opentelemetry-distro \
opentelemetry-exporter-otlp && opentelemetry-bootstrap -a install
COPY app.py /app/app.py
WORKDIR /app
CMD ["opentelemetry-instrument", "python", "app.py"]
把镜像构建后加载进 kind,再创建 Deployment;OTEL_TRACES_SAMPLER_ARG=1.0 只用于实验,生产要下调。
docker build -t demo-tracing:0.1 .
kind load docker-image demo-tracing:0.1
kubectl create deployment demo-api --image=demo-tracing:0.1 --port=8080
kubectl set env deploy/demo-api \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector.observability.svc:4318 \
OTEL_SERVICE_NAME=demo-api OTEL_TRACES_SAMPLER=parentbased_traceidratio \
OTEL_TRACES_SAMPLER_ARG=1.0
kubectl expose deploy/demo-api --port=80 --target-port=8080
kubectl run curl --image=curlimages/curl -it --rm -- \
sh -c 'for i in $(seq 10); do curl -s http://demo-api/work; done'
步骤 4 · 按 trace_id 查 trace
TRACE_ID=$(kubectl logs deploy/demo-api | grep 'handling /work' | tail -1 \
| sed -E 's/.*trace_id=([a-f0-9]{32}).*/\1/')
echo "$TRACE_ID"
kubectl -n observability port-forward svc/tempo 3200:3200 &
curl -s "http://127.0.0.1:3200/api/traces/$TRACE_ID" | jq '.batches | length'
返回大于 0 表示 Tempo 已收到这条 trace。打开 Grafana Explore,数据源选 Tempo,粘贴 trace_id 也能看到 demo-api 根 span 与 load-config 子 span。
步骤 5 · 按 trace_id 跳日志
kubectl logs deploy/demo-api | grep "$TRACE_ID"
同一 trace_id 应命中 handling /work 那一行,且日志里的 span_id 与子 span 对应。若同时部署了 Loki,在 Grafana 的 Tempo 数据源里开启 tracesToLogsV2,把 filterByTraceID 设为 true,trace 页就会出现 Logs 按钮;也可以在 Loki 数据源配 derivedFields,用正则 trace_id=([a-f0-9]{32}) 把日志直接链到 Tempo。
验证清单
tempo与otel-collectorPod 均 Running,Collector 日志没有connection refused。curl http://demo-api/work返回{"ok":true},Pod 日志包含 32 位trace_id。Tempo API 能按 trace_id 返回 batches,Grafana 中能看到根 span 与子 span。
同一 trace_id 能在日志中检索到,且时间与 trace 窗口一致。
Collector 的
debugexporter 能看到 span,说明链路不是 SDK 直连 Tempo。
11.1.6. 常见问题与排错
现象 |
可能原因 |
处理方式 |
|---|---|---|
Grafana 中 trace 只有根 span |
下游没有传播上下文,或拦截器未启用 |
检查 |
Tempo 查询 404 |
trace 未到达、端口错误或 trace_id 不完整 |
看 Collector debug 日志,确认 32 位十六进制 ID |
日志里的 trace_id 全是 0 |
日志格式没启用 LoggingInstrumentor |
设置 |
Collector 报 |
流量超过 limit 或后端阻塞 |
调大 limit、增加 gateway 副本、检查 Tempo 写入 |
采样率调低后错误 trace 全丢 |
只做了 head-based 采样 |
用 tail sampling 保留错误与慢请求 |
Grafana exemplar 不显示 |
Prometheus 未开 exemplar 或格式不对 |
确认 OpenMetrics 输出与 |
11.1.7. 生产落地要点
Collector 用 agent + gateway 两层,gateway 前置
loadbalancingexporter,尾采样才有意义。SDK 采样率按服务分级,入口服务高、内部服务低;错误与慢请求由 tail sampling 兜底。
Tempo 的 retention 由 compactor 控制,对象存储生命周期与租户配额要一起设。
span 名称保持低基数,属性按语义约定,PII 在 Collector 统一删除,别指望后端脱敏。
SLO 先有 SLI 和数据质量校验,再谈告警;告警必须带 runbook 和 dashboard 链接。
每月回看 trace 采样率与存储成本,span 量增长通常来自新增埋点而非流量。
11.1.8. 进阶挑战
给 demo 增加一个调用下游服务的接口,验证 W3C
traceparent在 HTTP 与 gRPC 两种协议下的传播。在 Collector 中启用 tail sampling,只保留错误和超过 500ms 的 trace,比较存储量。
用 Tempo 的 metrics-generator 生成 span metrics,写一条按服务维度聚合的延迟看板。
用 Pyrra 为 demo-api 建一个 99.9% 的 SLO,故意打 5xx,观察燃烧率告警何时触发。
11.1.9. 小结
Metrics 看趋势、Logs 看细节、Traces 看因果,三者用
service.name与trace_id关联。OpenTelemetry 把埋点与后端解耦,Collector 承担批处理、采样、脱敏与分流。
采样决定成本与可观测性上限;上下文传播断裂会让 trace 退化成孤立的 span。
Tempo 低成本、依赖 Grafana;Jaeger 搜索强、存储重,按团队现状选。
SLO 告警看错误预算燃烧率,多窗口组合才能兼顾灵敏与稳定。