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 也能做同样的事。

demo-api-slo.yaml
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-values.yaml
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

otel-values.yaml
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

app.py
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}
Dockerfile
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-collector Pod 均 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 的 debug exporter 能看到 span,说明链路不是 SDK 直连 Tempo。

11.1.6. 常见问题与排错

现象

可能原因

处理方式

Grafana 中 trace 只有根 span

下游没有传播上下文,或拦截器未启用

检查 traceparent 是否透传,异步任务手工 inject/extract

Tempo 查询 404

trace 未到达、端口错误或 trace_id 不完整

看 Collector debug 日志,确认 32 位十六进制 ID

日志里的 trace_id 全是 0

日志格式没启用 LoggingInstrumentor

设置 OTEL_PYTHON_LOG_CORRELATION=true 或手工注入

Collector 报 memory_limiter 拒绝

流量超过 limit 或后端阻塞

调大 limit、增加 gateway 副本、检查 Tempo 写入

采样率调低后错误 trace 全丢

只做了 head-based 采样

用 tail sampling 保留错误与慢请求

Grafana exemplar 不显示

Prometheus 未开 exemplar 或格式不对

确认 OpenMetrics 输出与 --enable-feature=exemplar-storage

11.1.7. 生产落地要点

  • Collector 用 agent + gateway 两层,gateway 前置 loadbalancing exporter,尾采样才有意义。

  • SDK 采样率按服务分级,入口服务高、内部服务低;错误与慢请求由 tail sampling 兜底。

  • Tempo 的 retention 由 compactor 控制,对象存储生命周期与租户配额要一起设。

  • span 名称保持低基数,属性按语义约定,PII 在 Collector 统一删除,别指望后端脱敏。

  • SLO 先有 SLI 和数据质量校验,再谈告警;告警必须带 runbook 和 dashboard 链接。

  • 每月回看 trace 采样率与存储成本,span 量增长通常来自新增埋点而非流量。

11.1.8. 进阶挑战

  1. 给 demo 增加一个调用下游服务的接口,验证 W3C traceparent 在 HTTP 与 gRPC 两种协议下的传播。

  2. 在 Collector 中启用 tail sampling,只保留错误和超过 500ms 的 trace,比较存储量。

  3. 用 Tempo 的 metrics-generator 生成 span metrics,写一条按服务维度聚合的延迟看板。

  4. 用 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 告警看错误预算燃烧率,多窗口组合才能兼顾灵敏与稳定。

11.1.10. 延伸阅读