从源码到部署:用开源可观测性项目 OpenTelemetry 构建中型微服务链路追踪实践
在微服务系统里,“请求到底卡在哪了” 往往不是一个日志 grep 就能解决的问题。服务一多,调用链一长,网关、业务服务、数据库、消息队列、缓存再混在一起,定位一次线上慢请求,可能需要跨好几个团队翻日志、对时间线、猜上下游。
如果你正处在一个中型微服务系统阶段:服务数量十几个到几十个,业务链路开始复杂,但又没到“专职可观测性平台团队”那种规模,那么 OpenTelemetry 是一个很现实的选择。它不是一个单点产品,而是一套标准 + SDK + Collector 生态,核心价值在于:让埋点、采集、传输、处理、导出这条链路标准化。
这篇文章我会从架构视角,把一条链路追踪实践从源码、协议、Collector 到部署方式讲清楚,并给出一套可以运行的最小示例。
背景与问题
中型微服务团队在链路追踪上,通常会遇到这些典型问题:
-
调用链断裂
- 网关有 trace id,业务服务没有
- HTTP 透传了,异步消息没透传
- Java 服务有链路,Node/Python 服务没有统一上下文
-
埋点方式不统一
- 有的团队手写 span
- 有的团队只接了自动注入
- 结果是同一条链路命名风格混乱,标签也不一致
-
后端系统耦合过重
- 代码里直接写死 Jaeger/Zipkin exporter
- 后续迁移到 Tempo、ClickHouse、云厂商 APM,成本很高
-
性能与成本失控
- 全量采样导致 collector 压力大
- span attribute 塞太多业务字段
- trace 有了,但账单也上来了
-
排障路径不顺
- 只看 trace,不结合日志和 metrics
- 只知道“慢”,不知道慢在数据库还是下游重试
所以,真正要落地一套追踪体系,目标不该只是“看到一条 trace”,而应该是:
- 让链路不断
- 让数据可用
- 让接入可维护
- 让部署可扩展
- 让成本可控
方案概览:为什么是 OpenTelemetry
OpenTelemetry(常写作 OTel)解决的不是“存储 trace 数据”的问题,而是统一遥测数据生产与传输的问题。
一个典型架构如下:
flowchart LR
A[Client] --> B[API Gateway]
B --> C[Service A]
C --> D[Service B]
C --> E[Redis]
D --> F[MySQL]
subgraph App Side
B1[OTel SDK/Auto Instrumentation]
C1[OTel SDK]
D1[OTel SDK]
end
B -. spans/metrics/logs .-> G[OpenTelemetry Collector]
C -. spans/metrics/logs .-> G
D -. spans/metrics/logs .-> G
G --> H[Jaeger/Tempo]
G --> I[Prometheus/Remote Write]
G --> J[Log Backend]
它的关键价值
- 标准化上下文传播
- W3C Trace Context
- 多语言 SDK
- Java、Go、Node.js、Python 等
- Collector 解耦后端
- 应用只认 OTLP,后端可换
- 自动埋点 + 手工埋点结合
- 快速接入与业务语义兼顾
适合中型团队的原因
我比较推荐中型团队优先选 OpenTelemetry,而不是直接把代码绑死某个 tracing 产品,主要因为:
- 先统一“采集标准”,比先统一“平台产品”更重要
- Collector 能把接入成本和后端演进解耦
- 自动埋点能快速铺开,手工埋点再逐步补关键业务语义
核心原理
要把 OpenTelemetry 用顺手,建议先把几个核心对象搞明白。
1. Trace、Span、Context 的关系
- Trace:一次完整请求的调用链
- Span:调用链中的一个操作单元
- Context:在进程内/进程间传递 trace 信息的上下文
可以理解成:
- 一个用户请求进入系统,生成一个
trace - 请求经过每个服务、数据库调用、外部 HTTP 调用,各自产生
span traceparent这样的上下文头在服务间传递,保证整条链能串起来
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 + request
A->>B: traceparent + request
B->>DB: SQL Query
DB-->>B: Result
B-->>A: Response
A-->>G: Response
G-->>U: Response
2. Instrumentation 的两种方式
自动埋点
优点:
- 接入快
- 对框架层能力覆盖较全
- HTTP、数据库、RPC 常见场景开箱即用
缺点:
- 业务语义不够
- span 名称和 attributes 有时不够贴近业务
手工埋点
优点:
- 可以围绕核心业务流程建模
- 能增加自定义事件、错误、属性
缺点:
- 需要开发规范
- 不同团队容易埋点风格不一致
一个比较稳妥的实践是:
先自动埋点覆盖基础链路,再对关键交易链路补手工 span。
3. 为什么要用 Collector
很多人第一次接触 OTel 时,会想“应用直接 export 到 Jaeger 不就完了?”
小规模 demo 可以,中型系统不建议这么做。
Collector 的作用包括:
- 协议统一:应用只发 OTLP
- 批处理与重试
- 采样
- 属性清洗/脱敏
- 多路导出
- 统一观测入口
架构上,它像一个“遥测流量网关”。
flowchart TD
A[App 1] --> D[OTLP Receiver]
B[App 2] --> D
C[App 3] --> D
D --> E[Batch Processor]
E --> F[Memory Limiter]
F --> G[Attributes/Sampling Processor]
G --> H[Jaeger Exporter]
G --> I[Tempo Exporter]
G --> J[Logging Exporter]
方案对比与取舍分析
方案一:应用直连 Jaeger/Zipkin
优点
- 简单
- 适合本地开发或 PoC
缺点
- 应用与后端强耦合
- 导出策略无法集中治理
- 无法统一做采样、脱敏、限流
适用场景
- 单机 demo
- 临时验证
方案二:应用统一接入 OpenTelemetry Collector
优点
- 架构清晰
- 后端可替换
- 便于标准化治理
- 适合多语言、多团队
缺点
- 多一层组件
- 需要维护 Collector 配置和容量
适用场景
- 大多数中型微服务系统
方案三:Service Mesh/Agent 辅助采集 + OTel
优点
- 对基础网络调用可见性更强
- 某些场景下减少业务侵入
缺点
- 业务语义不足
- 运维复杂度更高
- 很难完全替代应用层埋点
适用场景
- 对基础设施观测要求高的大型平台
我的建议
如果你现在是中型系统第一次系统化建设追踪能力,优先落地:
- 应用统一 OTLP
- 中心化 Collector
- 自动埋点优先
- 关键业务链路再补手工 span
这套组合最平衡。
实战代码(可运行)
下面用一个最小可运行示例演示:
service-a:接收 HTTP 请求,调用service-b- 两个服务都接入 OpenTelemetry
- 通过 Collector 导出到 Jaeger
示例使用 Node.js,因为上手快、依赖少,适合把原理讲清楚。
目录结构
otel-demo/
├── docker-compose.yml
├── otel-collector-config.yaml
├── service-a/
│ ├── package.json
│ ├── tracing.js
│ └── app.js
└── service-b/
├── package.json
├── tracing.js
└── app.js
1)service-b:下游服务
service-b/package.json
{
"name": "service-b",
"version": "1.0.0",
"main": "app.js",
"type": "commonjs",
"scripts": {
"start": "node -r ./tracing.js app.js"
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-node": "^0.52.1",
"@opentelemetry/semantic-conventions": "^1.25.1",
"express": "^4.19.2"
}
}
service-b/tracing.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { resourceFromAttributes } = require('@opentelemetry/resources');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
const traceExporter = new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces'
});
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[SemanticResourceAttributes.SERVICE_NAME]: 'service-b',
[SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
deployment_environment: 'dev'
}),
traceExporter,
instrumentations: [getNodeAutoInstrumentations()]
});
sdk.start();
process.on('SIGTERM', async () => {
await sdk.shutdown();
process.exit(0);
});
service-b/app.js
const express = require('express');
const app = express();
const port = 3002;
app.get('/work', async (req, res) => {
const wait = Math.floor(Math.random() * 200) + 50;
await new Promise(resolve => setTimeout(resolve, wait));
res.json({
service: 'service-b',
delayMs: wait,
traceparent: req.headers['traceparent'] || null
});
});
app.listen(port, () => {
console.log(`service-b listening on ${port}`);
});
2)service-a:入口服务
service-a/package.json
{
"name": "service-a",
"version": "1.0.0",
"main": "app.js",
"type": "commonjs",
"scripts": {
"start": "node -r ./tracing.js app.js"
},
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.52.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.52.1",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-node": "^0.52.1",
"@opentelemetry/semantic-conventions": "^1.25.1",
"express": "^4.19.2"
}
}
service-a/tracing.js
const { NodeSDK } = require('@opentelemetry/sdk-node');
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
const { resourceFromAttributes } = require('@opentelemetry/resources');
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
const traceExporter = new OTLPTraceExporter({
url: 'http://localhost:4318/v1/traces'
});
const sdk = new NodeSDK({
resource: resourceFromAttributes({
[SemanticResourceAttributes.SERVICE_NAME]: 'service-a',
[SemanticResourceAttributes.SERVICE_VERSION]: '1.0.0',
deployment_environment: 'dev'
}),
traceExporter,
instrumentations: [getNodeAutoInstrumentations()]
});
sdk.start();
process.on('SIGTERM', async () => {
await sdk.shutdown();
process.exit(0);
});
service-a/app.js
const express = require('express');
const http = require('http');
const { trace, SpanStatusCode } = require('@opentelemetry/api');
const app = express();
const port = 3001;
const tracer = trace.getTracer('service-a-manual');
app.get('/api', async (req, res) => {
const span = tracer.startSpan('business.validateOrder');
try {
span.setAttribute('biz.order_type', 'normal');
const data = await new Promise((resolve, reject) => {
http.get('http://localhost:3002/work', response => {
let body = '';
response.on('data', chunk => (body += chunk));
response.on('end', () => resolve(JSON.parse(body)));
}).on('error', reject);
});
span.addEvent('downstream_response_received', {
'downstream.service': 'service-b'
});
res.json({
service: 'service-a',
downstream: data
});
} catch (err) {
span.recordException(err);
span.setStatus({
code: SpanStatusCode.ERROR,
message: err.message
});
res.status(500).json({ error: err.message });
} finally {
span.end();
}
});
app.listen(port, () => {
console.log(`service-a listening on ${port}`);
});
3)Collector 配置
otel-collector-config.yaml
receivers:
otlp:
protocols:
http:
grpc:
processors:
memory_limiter:
check_interval: 1s
limit_mib: 256
spike_limit_mib: 64
batch:
timeout: 2s
send_batch_size: 512
exporters:
jaeger:
endpoint: jaeger:14250
tls:
insecure: true
logging:
loglevel: debug
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [jaeger, logging]
4)docker-compose 部署
docker-compose.yml
version: '3.8'
services:
jaeger:
image: jaegertracing/all-in-one:1.57
ports:
- "16686:16686"
- "14250:14250"
otel-collector:
image: otel/opentelemetry-collector:0.100.0
command: ["--config=/etc/otelcol/config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otelcol/config.yaml
ports:
- "4317:4317"
- "4318:4318"
depends_on:
- jaeger
service-a:
image: node:20-alpine
working_dir: /app
volumes:
- ./service-a:/app
command: sh -c "npm install && npm start"
ports:
- "3001:3001"
depends_on:
- otel-collector
service-b:
image: node:20-alpine
working_dir: /app
volumes:
- ./service-b:/app
command: sh -c "npm install && npm start"
ports:
- "3002:3002"
depends_on:
- otel-collector
5)运行与验证
在项目根目录执行:
docker compose up
浏览器访问:
http://localhost:3001/api
然后打开 Jaeger UI:
http://localhost:16686
选择 service-a 或 service-b,你应该能看到一条完整 trace,其中包括:
GET /apibusiness.validateOrderHTTP GET到service-bGET /work
验证点
如果一切正常,你会看到:
service-a和service-b在同一条 trace 下service-a中有手工 span:business.validateOrder- 自动埋点生成 HTTP server/client span
traceparent自动透传到了下游
从源码理解:为什么链路能串起来
很多同学把 OTel 跑通后,还是会困惑:“我明明没手动传 trace id,为什么它自动串起来了?”
核心原因在于自动埋点做了两件事:
-
在入口请求创建当前上下文
- 比如 Express 收到请求时,提取 HTTP header 中的
traceparent - 如果没有,就创建新的 root span
- 比如 Express 收到请求时,提取 HTTP header 中的
-
在下游调用时注入上下文
- 比如 Node 的 HTTP instrumentation 会自动往请求头里写入
traceparent
- 比如 Node 的 HTTP instrumentation 会自动往请求头里写入
简化理解就是:
入站请求 -> 提取 context -> 当前 span 生效 -> 出站请求自动注入 context
如果某一层框架不受 instrumentation 支持,或者你用了自定义网络库,那链路就可能断。这时就需要手工处理上下文传播。
容量估算:中型系统怎么评估 Collector 压力
中型团队常常忽略一个现实问题:trace 不是免费的。
做个粗略估算,假设:
- 峰值 QPS:1000
- 每个请求平均 span 数:12
- 采样率:20%
则每秒 span 数约为:
1000 * 12 * 20% = 2400 spans/s
如果每个 span 序列化后平均 1~2 KB,那么 Collector 入口流量大致是:
2400 * 1.5KB ≈ 3.6MB/s
再考虑批处理、重试、导出复制、多副本,实际资源还要上浮。
经验建议
- 小于 3k spans/s:单 Collector 可起步
- 3k ~ 20k spans/s:建议至少 2 副本 + 负载均衡
- 更高流量:区分 agent / gateway 模式,或者按业务域拆 pipeline
这不是绝对值,但足够作为第一版容量规划参考。
常见坑与排查
这一部分我尽量写得接地气一点,因为实际落地时,大家不是卡在“概念不懂”,而是卡在“为什么我看不到 trace”。
1. 服务有数据,链路却串不起来
现象
service-a和service-b都有 trace- 但它们不在同一个 trace 下
常见原因
- 出站 HTTP 调用未被自动埋点覆盖
- 中间代理层丢失了
traceparent - 自定义消息协议没有透传上下文
排查方法
- 抓下游请求头,看有没有
traceparent - 检查自动埋点是否在业务代码前加载
- 确认使用的 HTTP/RPC 库是否被支持
- 检查网关、Ingress、Sidecar 是否改写了头
我自己踩过一个很典型的坑:OTel 初始化写在应用启动之后。这样某些模块先被加载,自动埋点就错过了 patch 时机,导致 HTTP client span 根本没生成。
2. 本地有 trace,部署后没 trace
常见原因
- Collector 地址写成 localhost,容器内并不是宿主机
- OTLP 用了 HTTP exporter,Collector 只开了 gRPC
- 网络策略或 Service 配置没放通 4317/4318
排查命令
看 Collector 日志:
docker compose logs -f otel-collector
检查应用容器里能否访问 Collector:
docker exec -it <service-container> sh
wget -qO- http://otel-collector:4318/
3. span 有了,但名字特别乱
现象
- 页面上全是
GET,POST,middleware,handler - 很难看出业务含义
解决办法
- 自动埋点保底
- 关键业务路径增加手工 span
- 统一命名规范,例如:
business.placeOrderpayment.authorizeinventory.reserve
4. Trace 很多,但查问题依然费劲
根因
- 只有 trace,没有关键业务字段
- 错误没有 recordException
- span status 没设置
- 日志里没有 trace id
建议
- 给关键 span 加少量高价值 attributes
- 错误一定要记录异常和状态
- 应用日志打印 trace_id / span_id
5. Collector 内存持续上涨
常见原因
- 批处理过大
- 导出后端慢
- 全量采样导致堆积
- 短时流量尖峰过高
处理思路
- 加
memory_limiter - 调低
send_batch_size - 先降采样率止血
- 检查后端写入延迟
安全/性能最佳实践
链路追踪常被当作“开发效率工具”,但一旦进生产,它就是基础设施的一部分。这里我把安全和性能放在一起讲,因为这两者最容易被忽略。
安全最佳实践
1. 不要把敏感信息写进 span attributes
禁止直接写入:
- 用户身份证号
- 手机号
- token
- session id
- 完整 SQL 参数
- 完整请求体中的敏感字段
错误示例:
span.setAttribute('user.phone', '13800138000');
span.setAttribute('auth.token', token);
更好的方式是脱敏或摘要化:
span.setAttribute('user.id_hash', 'a13f...');
span.setAttribute('order.id', 'ORD-20240201-001');
2. Collector 层做统一脱敏
如果团队多、代码风格不一,最好在 Collector 或采集网关层补一层策略,避免“某个服务埋了一次敏感字段,全平台都泄漏”。
3. 生产环境启用加密与鉴权
至少要考虑:
- OTLP over TLS
- Collector 暴露面最小化
- 内网白名单或 mTLS
- 多租户隔离时增加 tenant 维度控制
性能最佳实践
1. 控制采样率,不要盲目全采
全量采样只适合:
- 开发环境
- 短时排查
- 极低流量系统
中型系统建议:
- 默认 5% ~ 20% head-based sampling
- 对错误请求、慢请求提高保留率
- 关键交易链路单独策略
2. Attributes 少而精
高价值字段应满足:
- 能帮助过滤和聚合
- 基数不过高
- 不含敏感信息
比较合适的例子:
service.namehttp.methodhttp.routedb.systembiz.order_type
不太合适的例子:
user_id(高基数)full_url_with_queryraw_request_body
3. 手工 span 不要包太细
不是每个函数都值得一个 span。
我一般建议 span 粒度对齐到:
- 外部调用
- 关键业务阶段
- 明显耗时操作
如果把内部几十个小函数都变成 span,最后 trace 看起来很“热闹”,但分析价值反而下降。
4. 日志、指标、追踪要能关联
理想状态不是“trace 替代日志”,而是三者联动:
- trace 定位慢在哪
- metrics 判断是否系统性异常
- logs 补充上下文细节
这也是 OpenTelemetry 越来越强调统一遥测模型的原因。
落地建议:一版到三版的演进路线
如果你准备在团队里推进,我建议不要一上来就“全面铺开、全栈统一”,容易失败。更稳的路径是分三步。
第一版:跑通主链路
目标:
- 网关 -> 核心服务 -> 下游服务 trace 打通
- 建 Collector
- 接 Jaeger/Tempo
- 自动埋点优先
重点不是做漂亮,而是确保链路不断。
第二版:补业务语义
目标:
- 给核心交易链路增加手工 span
- 统一命名规范
- 增加错误记录与关键属性
这一步做完,trace 才真正开始“能用”。
第三版:治理与成本优化
目标:
- 分级采样
- Collector 扩容和高可用
- 敏感字段治理
- 日志关联 trace id
- 仪表盘和告警联动
到这一步,链路追踪才算进入生产可持续状态。
总结
OpenTelemetry 真正适合中型微服务团队的地方,不只是它“开源”或者“流行”,而是它把可观测性接入拆成了几个可以渐进演进的层次:
- 应用埋点
- 上下文传播
- Collector 管道
- 后端存储与展示
这种分层意味着你可以先把采集标准立起来,再去优化后端、成本和治理,而不是一开始就把所有问题绑死在某个厂商产品上。
如果你现在就准备动手,我建议按这个顺序:
- 所有服务统一接 OTLP,不直连后端
- 先自动埋点,优先打通 HTTP/RPC 主链路
- 对关键业务流程补 2~5 个手工 span
- Collector 加上 batch 和 memory_limiter
- 生产环境先做采样和脱敏,再谈全量覆盖
边界条件也要说清楚:
- 如果你的系统非常小,直接用 Jaeger demo 即可,不必过度设计
- 如果你的系统非常大,仅靠应用层 OTel 不够,通常要结合 Mesh、日志平台、指标平台一起建设
- 如果你的团队缺乏统一埋点规范,再强的工具也会变成“数据很多,但没人用得好”
一句话收尾:
链路追踪不是把 span 发出去就结束了,而是把“请求在系统里发生了什么”讲清楚。OpenTelemetry 提供的是这套讲述能力的标准底座。
如果你把这套底座搭稳了,后面的性能分析、故障排查和跨团队协作,都会轻松很多。