从源码到实践:基于 OpenTelemetry 开源项目搭建可观测性链路的落地指南
很多团队在做微服务治理时,都会在某个阶段遇到同一个问题:系统能跑,但看不清。
接口变慢了,不知道卡在哪一层;跨服务调用失败了,不知道是网关、业务服务还是数据库;线上偶发异常,日志里一堆 requestId,但不同语言、不同框架之间根本串不起来。这个时候,“可观测性”就不再是锦上添花,而是基础设施。
如果你最近在关注 OpenTelemetry(后文简称 OTel),大概率已经知道它是 CNCF 生态里的事实标准。但很多文章只讲概念:Trace、Metric、Log 三件套,Collector 很重要,Exporter 能发到 Jaeger、Prometheus、Tempo、Zipkin……真正落地时,问题却变成:
- SDK、Agent、Collector 到底怎么分工?
- 为什么有时 trace 有了,span 却丢了?
- 为什么上下文传递明明配置了,跨线程后还是断链?
- Collector 该直接上生产吗?怎么做容量和性能控制?
- 从源码角度看,OTel 的链路到底是怎么拼起来的?
这篇文章我会从架构原理 + 源码视角 + 一套可运行示例带你走一遍,目标不是“看懂概念”,而是真的能搭起来,并知道出问题时该查哪里。
背景与问题
在传统单体应用里,定位问题往往靠日志就够了。但进入分布式系统后,问题复杂度会急剧上升:
-
请求路径拉长
一个请求可能经过 API Gateway、用户服务、订单服务、库存服务、消息队列、数据库、缓存。 -
异步与并发增多
线程池、协程、消息消费、重试机制让“请求上下文”容易丢失。 -
多语言栈混用
Java、Go、Node.js、Python 同时存在,链路标准不统一就很难整合。 -
监控与日志割裂
指标告诉你“变慢了”,日志告诉你“报错了”,但很难回答“究竟是哪一跳开始异常”。
OpenTelemetry 的价值就在于:统一埋点模型、统一上下文传递、统一采集与导出协议。它不是某个具体后端产品,而是一套开放标准和实现集合。
如果把它说得更接地气一点:
- SDK/Agent 负责在应用里“记录现场”
- Context Propagation 负责“别把现场记录串丢了”
- Collector 负责“汇总、加工、转发”
- Jaeger/Tempo/Zipkin/Prometheus 等后端负责“存起来、查出来、画出来”
方案全景与取舍分析
先给出一个典型落地架构。这个架构适合大多数中型团队,从 PoC 到生产都能平滑演进。
flowchart LR
A[Client] --> B[API Gateway]
B --> C[Service A]
C --> D[Service B]
D --> E[(MySQL)]
C --> F[(Redis)]
subgraph App Runtime
C1[OTel SDK/Agent]
D1[OTel SDK/Agent]
end
C --> C1
D --> D1
C1 -- OTLP --> G[OpenTelemetry Collector]
D1 -- OTLP --> G
G --> H[Jaeger/Tempo<br/>Trace Backend]
G --> I[Prometheus/Remote Write<br/>Metric Backend]
G --> J[Log Backend]
三种常见接入方式
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 纯手动 SDK 埋点 | 灵活、可控、可理解原理 | 成本高、容易漏 | 核心链路、定制业务 Span |
| 自动探针 Agent | 接入快、覆盖常见框架 | 黑盒感较强、个性化差 | Java 服务、快速试点 |
| SDK + Collector 混合 | 统一治理、易扩展 | 运维复杂度略高 | 中大型生产环境 |
我个人建议的落地顺序
- 先跑通一条 Trace 链路
- 再接 Collector 做统一出口
- 最后补业务埋点、采样策略、指标/日志联动
不要一上来就想三件套全开,不然排障时你会分不清是 SDK 问题、Collector 问题,还是后端问题。
核心原理
OTel 的核心概念不算多,但它们之间关系非常关键。
1. Trace、Span、Context
- Trace:一次完整请求链路
- Span:链路中的一个操作片段
- Context:携带 TraceId、SpanId、Baggage 等信息的上下文对象
一个 Trace 由多个 Span 组成,Span 之间通过父子关系串起来。
sequenceDiagram
participant U as User
participant G as Gateway
participant A as Service A
participant B as Service B
participant DB as MySQL
U->>G: HTTP Request
G->>A: 透传 traceparent
A->>B: 创建子 Span 并透传
B->>DB: DB Span
DB-->>B: Result
B-->>A: Response
A-->>G: Response
G-->>U: Final Response
2. 上下文传播
OTel 默认采用 W3C Trace Context 标准,HTTP 头里常见的是:
traceparenttracestate
只要服务间调用正确传递这些头,请求就能在不同服务里归属于同一个 Trace。
3. SDK、Processor、Exporter 的关系
从源码角度看,一次埋点大概会经历这条路径:
flowchart LR
A[业务代码创建 Span] --> B[Tracer]
B --> C[SpanProcessor]
C --> D[BatchSpanProcessor]
D --> E[Exporter]
E --> F[OTLP/Jaeger/Zipkin 后端]
更具体一点:
Tracer:生成 SpanSpanProcessor:控制 Span 结束后的处理逻辑BatchSpanProcessor:批量缓存和异步导出Exporter:负责把数据发到指定后端
4. Collector 的角色
Collector 是 OTel 生态里非常关键的一层,它把“应用接入”和“后端存储”解耦了。
Collector 通常由三类组件构成:
- Receiver:接收数据,比如 OTLP、Jaeger、Zipkin
- Processor:处理数据,比如 batch、memory_limiter、attributes
- Exporter:导出到后端
这层设计的好处是:
- 应用只要学会发 OTLP
- 后端怎么换,对应用影响很小
- 可以统一做采样、脱敏、限流、路由
从源码视角理解一次 Span 的生命周期
为了避免只停留在“会配置”,这里简单从 SDK 设计思路看一眼。
以 OTel Python 为例,常见链路大致如此:
trace.get_tracer(__name__)获取 Tracerwith tracer.start_as_current_span("name")创建并激活 Span- Span 被放进当前 Context
- 下游 HTTP 调用由 Instrumentation 自动注入
traceparent - Span 结束后交给
SpanProcessor BatchSpanProcessor按批次交给OTLPSpanExporter- Exporter 发往 Collector
虽然不同语言 SDK 的代码组织不完全一样,但设计思路基本一致:统一 API,SDK 实现,Exporter 输出,Collector 解耦后端。
这也是 OTel 和早期各种私有埋点体系最大的不同:它更强调标准化和可替换性。
环境准备
下面我们用一个最小可运行示例,搭建:
- 一个 Python Flask 服务
- 手动 + 半自动埋点
- OpenTelemetry Collector
- Jaeger UI 查看 Trace
目录结构
otel-demo/
├── app.py
├── requirements.txt
└── otel-collector-config.yaml
requirements.txt
flask==2.3.3
requests==2.31.0
opentelemetry-api==1.24.0
opentelemetry-sdk==1.24.0
opentelemetry-exporter-otlp-proto-grpc==1.24.0
opentelemetry-instrumentation-flask==0.45b0
opentelemetry-instrumentation-requests==0.45b0
实战代码(可运行)
下面这套代码可以直接跑,目的是让你完整看到“入口请求 -> 内部调用 -> Trace 上报”的全链路。
1. Python 应用代码
from flask import Flask, jsonify
import requests
import time
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
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.flask import FlaskInstrumentor
from opentelemetry.instrumentation.requests import RequestsInstrumentor
resource = Resource.create({
"service.name": "demo-flask-service",
"service.version": "1.0.0",
"deployment.environment": "dev"
})
provider = TracerProvider(resource=resource)
processor = BatchSpanProcessor(
OTLPSpanExporter(endpoint="localhost:4317", insecure=True)
)
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer(__name__)
app = Flask(__name__)
FlaskInstrumentor().instrument_app(app)
RequestsInstrumentor().instrument()
@app.route("/ping")
def ping():
return jsonify({"message": "pong"})
@app.route("/work")
def work():
with tracer.start_as_current_span("custom-business-span") as span:
span.set_attribute("user.id", "1001")
span.set_attribute("biz.scene", "checkout")
time.sleep(0.1)
resp = requests.get("http://127.0.0.1:5000/ping", timeout=3)
time.sleep(0.05)
return jsonify({
"upstream": resp.json(),
"status": "ok"
})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
这段代码里有三个关键点:
FlaskInstrumentor自动为入站 HTTP 请求创建 SpanRequestsInstrumentor自动为出站 HTTP 请求创建 Span,并注入上下文custom-business-span是我们手工补的业务埋点,用来标记关键流程
也就是说,框架埋点负责“通用链路”,手工埋点负责“业务语义”。生产里这两者通常要配合用。
2. Collector 配置
receivers:
otlp:
protocols:
grpc:
http:
processors:
memory_limiter:
check_interval: 1s
limit_mib: 256
batch:
timeout: 1s
send_batch_size: 1024
exporters:
logging:
loglevel: debug
jaeger:
endpoint: jaeger:14250
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [logging, jaeger]
这个配置很适合本地调试:
loggingexporter 可以直接打印收到的 tracejaegerexporter 把 trace 发到 Jaeger 后端memory_limiter + batch是比较推荐的基础组合
3. Docker Compose 启动 Jaeger 和 Collector
version: "3.8"
services:
jaeger:
image: jaegertracing/all-in-one:1.51
container_name: jaeger
ports:
- "16686:16686"
- "14250:14250"
otel-collector:
image: otel/opentelemetry-collector-contrib:0.95.0
container_name: otel-collector
command: ["--config=/etc/otelcol-contrib/config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml
ports:
- "4317:4317"
- "4318:4318"
depends_on:
- jaeger
4. 运行步骤
启动后端
docker compose up -d
安装依赖并启动应用
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python app.py
发送请求
curl http://127.0.0.1:5000/work
查看链路
打开:
http://127.0.0.1:16686
搜索 demo-flask-service,你应该能看到一条完整 Trace,里面至少有:
/workHTTP Spancustom-business-span- 调用
/ping的 client span /ping的 server span
如何验证链路是否真的“通了”
很多人以为页面能看到一条 Trace 就算成功了,其实不够。建议按下面清单验证。
验证清单
- 是否生成稳定的 service.name
- 是否能跨入站/出站串成同一个 Trace
- 是否能看到业务自定义属性
- Collector 是否稳定收到数据
- 应用退出时是否存在 Span 丢失
- 高并发下是否出现导出阻塞
常见坑与排查
这部分我尽量写得实战一点,因为真实项目里大多数时间都花在这里。
1. Trace 能看到,但服务名全是 unknown_service
原因
没有正确设置 service.name,或者 Resource 配置没生效。
排查方式
确认代码里是否显式设置:
resource = Resource.create({
"service.name": "demo-flask-service"
})
如果用环境变量,也可以这样:
export OTEL_SERVICE_NAME=demo-flask-service
建议
服务名必须纳入发布规范,不要靠开发同学手写,最好统一由启动脚本或平台注入。
2. 上下游 Span 没串起来,Trace 断链
常见原因
- HTTP 头没有传递
- 手写 HTTP 客户端,没接 OTel Instrumentation
- 异步任务/线程池上下文丢失
- 网关或中间件把
traceparent清掉了
排查思路
先看入口请求 Span 的 TraceId,再看下游服务 Span 的 TraceId 是否一致。
如果不一致,优先检查:
RequestsInstrumentor().instrument()
以及代理层是否保留请求头。
我的经验
我之前踩过一个坑:服务本身埋点都对,但网关层做了“安全头过滤”,把 traceparent 一并过滤掉了。结果单服务内 trace 正常,一跨服务就断。这个问题如果只盯应用代码,很难第一时间想到。
3. 本地测试正常,生产出现 Span 丢失
原因
最常见的是以下几类:
- 批量导出队列满了
- Collector 背压
- 采样率过低
- 应用进程退出太快,批处理来不及 flush
排查方式
重点看:
- SDK 日志
- Collector 日志
- 后端接收错误
- 应用关闭时是否调用 shutdown
Python 中可以显式关闭 provider,不过多数场景依赖进程正常退出即可。对短生命周期任务,建议主动 flush/shutdown。
4. Collector 启动了,但 Jaeger 没数据
排查顺序
- 应用是否真的向
4317发数据 - Collector 的
otlp receiver是否监听成功 - Collector logging exporter 有没有打印 Span
- Jaeger exporter endpoint 是否正确
- Jaeger 容器端口是否可达
一个很实用的方法
先保留 logging exporter。只要 Collector 日志里能看到 Span,说明问题已经不在应用侧,而在 Collector 到后端这段。
5. 指标、日志、链路“三件套”没关联起来
这是 OTel 落地里经常被忽略的点。
Trace 本身能告诉你“哪一步慢”,但想快速定位还需要:
- 日志里打印
trace_id - 指标里按服务/接口聚合
- 异常日志和 Span 状态一致
如果日志平台支持,你可以在应用日志格式中加入当前 TraceId。这样看到报错日志时,就能反查链路。
容量估算与架构取舍
到了生产环境,Collector 很容易成为“默认加上去”的组件,但不做容量估算就直接上,后面经常会被打脸。
一个粗略估算方法
假设:
- 平均 QPS:2000
- 每个请求平均 8 个 Span
- 每个 Span 序列化后约 500B ~ 1KB
那么每秒 trace 数据量大致为:
2000 * 8 * 500B = 8MB/s
如果按 1KB 估算,就是:
2000 * 8 * 1KB = 16MB/s
这还没算峰值流量、批量缓存、重试开销。
实际建议
- 小规模场景:单 Collector 可起步
- 中等规模:按可用区部署 Collector
- 大规模场景:Agent/Sidecar + Gateway Collector 分层
架构取舍
直连后端
优点:
- 简单
- 延迟低
缺点:
- 应用和后端强耦合
- 不便统一改协议、做脱敏、限流
经 Collector 中转
优点:
- 统一治理
- 易扩展
- 便于多后端路由
缺点:
- 多一跳
- 运维复杂度更高
如果问我生产建议:除非系统很小,否则尽量走 Collector。
安全/性能最佳实践
这部分非常重要,因为可观测性系统如果设计不好,可能会反过来影响业务系统。
安全最佳实践
1. 不要把敏感数据直接写进 Span
比如:
- 用户手机号
- 身份证号
- token
- SQL 原文中的敏感字段
- 请求体中的隐私内容
错误示例:
span.set_attribute("user.phone", "13800138000")
span.set_attribute("auth.token", "xxx")
建议改成:
span.set_attribute("user.id", "1001")
span.set_attribute("auth.present", True)
如果必须保留定位信息,用脱敏或哈希后的值。
2. Collector 层做统一脱敏
可以在 Collector 里对属性进行删改,避免每个应用重复实现。
3. 生产环境启用 TLS 与鉴权
OTLP 在生产里不要长期裸奔,尤其是跨节点、跨机房传输时。
性能最佳实践
1. 优先使用 BatchSpanProcessor
不要默认用同步导出。批量异步是生产常态。
2. 控制高基数标签
像 user_id、order_id、session_id 这种字段,写在 trace attribute 还相对可控;但如果你把它们直接打成 metric label,会立刻把时序库打爆。
3. 采样要分层设计
常见策略:
- 全量采集错误请求
- 低比例采样成功请求
- 核心链路提高采样率
- 高频健康检查直接忽略
4. 避免过度埋点
不是 Span 越多越好。一个请求创建几十上百个无意义 Span,只会增加存储成本和排障噪音。
一个实用判断标准:
如果这个 Span 不能帮助你做性能定位、错误归因或业务过程理解,那它很可能不该存在。
5. 给 Collector 设置内存限制和批处理
这是我很推荐的基础配置:
processors:
memory_limiter:
check_interval: 1s
limit_mib: 256
batch:
timeout: 1s
send_batch_size: 1024
至少能避免很多“流量一高 Collector 就崩”的问题。
一个更贴近生产的埋点建议
很多团队刚接 OTel 时,会纠结“到底该自动埋点还是手动埋点”。我的建议是:
自动埋点覆盖技术路径
例如:
- HTTP server/client
- 数据库访问
- Redis
- MQ
- gRPC
手动埋点补业务语义
例如:
- 风控校验
- 库存预占
- 支付下单
- 优惠券计算
- 第三方渠道调用
这样你在 Trace 里既能看到“技术调用栈”,又能看到“业务步骤”。
classDiagram
class AutoInstrumentation {
HTTP Server Span
HTTP Client Span
DB Span
Cache Span
}
class ManualBusinessSpan {
risk_check()
reserve_stock()
create_order()
call_payment_gateway()
}
AutoInstrumentation <.. ManualBusinessSpan : 互补
落地建议:从 0 到 1 的实施路径
如果你准备在团队里推动 OTel,我建议按这个节奏来:
第一阶段:最小闭环
- 选 1 条核心调用链
- 接 1 个服务
- 用 Jaeger/Tempo 看得到完整 Trace
- 验证上下文传递没断
第二阶段:统一出口
- 所有服务统一走 OTLP
- 中间加 Collector
- 统一
service.name、环境标签、版本标签
第三阶段:业务化增强
- 在关键节点补手动 Span
- 定义错误码、业务状态属性
- 打通日志与 TraceId
第四阶段:治理与运营
- 制定采样策略
- 做容量规划
- 做敏感信息脱敏
- 监控 Collector 自身健康
这个顺序很重要。先求可用,再求好用,最后才是体系化治理。
总结
OpenTelemetry 的难点,其实不在“会不会写一段埋点代码”,而在于你是否真正理解这条链路:
- 应用如何创建 Span
- Context 如何跨服务传播
- Processor 如何批量处理
- Exporter 如何发送
- Collector 如何做统一治理
- 后端如何承接查询与存储
如果只从概念看,它像一堆组件名;但从源码思路和实际部署串起来,它本质上是一条非常清晰的数据通路。
最后给几点可执行建议:
- 先做 Trace,别一开始就三件套全铺开
- 统一
service.name、环境、版本等 Resource 属性 - 技术埋点靠自动化,业务语义靠手动补充
- 生产环境优先走 Collector,不要应用直连各种后端
- 先把链路打通,再谈采样、脱敏、容量优化
- 把日志里的 TraceId 打出来,排障效率会明显提升
边界条件也要说清楚:
- 如果你的系统还是单体、调用链很短,OTel 的收益未必立刻显现
- 如果团队没有统一平台能力,Collector 和后端运维会增加复杂度
- 如果没有埋点规范,数据会很快从“可观测”变成“可疑测”
但只要系统进入微服务、多语言、异步化阶段,OTel 基本就是一条值得投入的路线。它不一定让问题消失,但会让问题更快被看见、被定位、被解释。这件事,在工程实践里非常值钱。