跳转到内容
123xiao | 无名键客

《从源码到部署:用开源可观测性项目 OpenTelemetry 构建中型微服务链路追踪实践》

字数: 0 阅读时长: 1 分钟

从源码到部署:用开源可观测性项目 OpenTelemetry 构建中型微服务链路追踪实践

在微服务系统里,“请求到底卡在哪了” 往往不是一个日志 grep 就能解决的问题。服务一多,调用链一长,网关、业务服务、数据库、消息队列、缓存再混在一起,定位一次线上慢请求,可能需要跨好几个团队翻日志、对时间线、猜上下游。

如果你正处在一个中型微服务系统阶段:服务数量十几个到几十个,业务链路开始复杂,但又没到“专职可观测性平台团队”那种规模,那么 OpenTelemetry 是一个很现实的选择。它不是一个单点产品,而是一套标准 + SDK + Collector 生态,核心价值在于:让埋点、采集、传输、处理、导出这条链路标准化

这篇文章我会从架构视角,把一条链路追踪实践从源码、协议、Collector 到部署方式讲清楚,并给出一套可以运行的最小示例。


背景与问题

中型微服务团队在链路追踪上,通常会遇到这些典型问题:

  1. 调用链断裂

    • 网关有 trace id,业务服务没有
    • HTTP 透传了,异步消息没透传
    • Java 服务有链路,Node/Python 服务没有统一上下文
  2. 埋点方式不统一

    • 有的团队手写 span
    • 有的团队只接了自动注入
    • 结果是同一条链路命名风格混乱,标签也不一致
  3. 后端系统耦合过重

    • 代码里直接写死 Jaeger/Zipkin exporter
    • 后续迁移到 Tempo、ClickHouse、云厂商 APM,成本很高
  4. 性能与成本失控

    • 全量采样导致 collector 压力大
    • span attribute 塞太多业务字段
    • trace 有了,但账单也上来了
  5. 排障路径不顺

    • 只看 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

优点

  • 对基础网络调用可见性更强
  • 某些场景下减少业务侵入

缺点

  • 业务语义不足
  • 运维复杂度更高
  • 很难完全替代应用层埋点

适用场景

  • 对基础设施观测要求高的大型平台

我的建议

如果你现在是中型系统第一次系统化建设追踪能力,优先落地:

  1. 应用统一 OTLP
  2. 中心化 Collector
  3. 自动埋点优先
  4. 关键业务链路再补手工 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-aservice-b,你应该能看到一条完整 trace,其中包括:

  • GET /api
  • business.validateOrder
  • HTTP GETservice-b
  • GET /work

验证点

如果一切正常,你会看到:

  1. service-aservice-b 在同一条 trace 下
  2. service-a 中有手工 span:business.validateOrder
  3. 自动埋点生成 HTTP server/client span
  4. traceparent 自动透传到了下游

从源码理解:为什么链路能串起来

很多同学把 OTel 跑通后,还是会困惑:“我明明没手动传 trace id,为什么它自动串起来了?”

核心原因在于自动埋点做了两件事:

  1. 在入口请求创建当前上下文

    • 比如 Express 收到请求时,提取 HTTP header 中的 traceparent
    • 如果没有,就创建新的 root span
  2. 在下游调用时注入上下文

    • 比如 Node 的 HTTP instrumentation 会自动往请求头里写入 traceparent

简化理解就是:

入站请求 -> 提取 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-aservice-b 都有 trace
  • 但它们不在同一个 trace 下

常见原因

  • 出站 HTTP 调用未被自动埋点覆盖
  • 中间代理层丢失了 traceparent
  • 自定义消息协议没有透传上下文

排查方法

  1. 抓下游请求头,看有没有 traceparent
  2. 检查自动埋点是否在业务代码前加载
  3. 确认使用的 HTTP/RPC 库是否被支持
  4. 检查网关、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.placeOrder
    • payment.authorize
    • inventory.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.name
  • http.method
  • http.route
  • db.system
  • biz.order_type

不太合适的例子:

  • user_id(高基数)
  • full_url_with_query
  • raw_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 管道
  • 后端存储与展示

这种分层意味着你可以先把采集标准立起来,再去优化后端、成本和治理,而不是一开始就把所有问题绑死在某个厂商产品上。

如果你现在就准备动手,我建议按这个顺序:

  1. 所有服务统一接 OTLP,不直连后端
  2. 先自动埋点,优先打通 HTTP/RPC 主链路
  3. 对关键业务流程补 2~5 个手工 span
  4. Collector 加上 batch 和 memory_limiter
  5. 生产环境先做采样和脱敏,再谈全量覆盖

边界条件也要说清楚:

  • 如果你的系统非常小,直接用 Jaeger demo 即可,不必过度设计
  • 如果你的系统非常大,仅靠应用层 OTel 不够,通常要结合 Mesh、日志平台、指标平台一起建设
  • 如果你的团队缺乏统一埋点规范,再强的工具也会变成“数据很多,但没人用得好”

一句话收尾:

链路追踪不是把 span 发出去就结束了,而是把“请求在系统里发生了什么”讲清楚。OpenTelemetry 提供的是这套讲述能力的标准底座。

如果你把这套底座搭稳了,后面的性能分析、故障排查和跨团队协作,都会轻松很多。


分享到:

上一篇
《Kubernetes 集群高可用架构实战:控制平面冗余、etcd 容灾与故障切换设计》
下一篇
《从零搭建到生产落地:基于开源项目 Harbor 的企业级容器镜像仓库实战指南》