50.9 OpenTelemetry 标准

一句话

OpenTelemetry 把可观测性拆成四层——API(埋什么)、SDK(怎么采、采多少)、OTLP(怎么传)、Collector(怎么转、送给谁);它的价值不是「又一个 agent」,而是让埋点与后端解耦:换掉 Jaeger/Tempo/Prometheus 时,业务代码一行不改。

四层:每一层解决一个「换」的问题

层 职责 谁实现 换掉它意味着
API 定义 Tracer/Meter/Logger 等接口与语义 库作者、框架、业务代码只依赖这一层 换实现不改调用点
SDK 采样、批量、导出、资源属性、上下文管理 应用启动时装配一次 换配置/换导出目标,不改业务
OTLP 传输协议(protobuf over gRPC/HTTP) SDK 与 Collector 换后端不改协议
Collector 接收、处理(脱敏/采样/批处理)、导出到任意后端 平台/SRE 团队 后端迁移对应用完全透明

分层的核心是第一行:库和业务只 import API。如果库代码 import 了 SDK 并自己 Init 一个 exporter,那这个库就把你的采样策略、上报地址、凭证全锁死了——这是 OTel 落地里最常见、也最难返工的错误。

// 只依赖 API:库/框架里应该写成这样(Tracer 从全局 Provider 取,SDK 由应用装配)
import (
	"context"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
)

func Charge(ctx context.Context, orderID string) error {
	tracer := otel.Tracer("order") // 没有 SDK 时是 no-op,装上 SDK 后自动生效
	ctx, span := tracer.Start(ctx, "charge")
	defer span.End()

	span.SetAttributes(attribute.String("order.id", orderID))
	// ... 业务逻辑,ctx 继续往下传
	return nil
}

otel.Tracer 在没装 SDK 时返回 no-op 实现,所以「库带埋点、应用决定采不采」是成立的。go get go.opentelemetry.io/otel 即可编译;SDK 装配(provider、exporter、采样)放在 main 里,也可以只指向本地 Collector,把采样与后端路由交给它。

信号与语义约定:三支柱的「普通话」

信号 状态 说明
traces 稳定 链路,本节的 API 示例即属于它
metrics 稳定 指标,Counter/Histogram 等与 Prometheus 类型基本对得上(见 50.4 度量)
logs 仍在推进 桥接已有日志库(slog/logrus/zap)是主流用法
profiles 推进中 持续剖析的标准化还在早期,厂商格式尚未统一(见 50.8 持续性能剖析)

语义约定(semantic conventions) 是 OTel 里最容易被低估的部分:它规定了属性叫什么名字。没有它,三个团队会写出 service、service_name、svc 三种写法,三支柱永远关联不上。

位置 关键属性 作用
resource(整个进程) service.name、service.version、deployment.environment.name、host.name、k8s.pod.name、k8s.namespace.name 三支柱的公共维度,看板与下钻全靠它
span http.request.method、http.response.status_code、url.path、server.address、db.system、messaging.system 过滤、聚合、SLO 计算
metric http.server.request.duration(Histogram)、单位 s 直方图与分位数(见 50.4 度量)

⚠️ 语义约定改过名:http.method → http.request.method、http.status_code → http.response.status_code、http.url → url.full,资源侧的 deployment.environment 也调整为 deployment.environment.name。属性名是接口,升级 SDK 时要么同步改查询/看板/告警,要么在 Collector 里用 transform 处理器做一层映射。以官方文档为准,不要凭记忆写。

OTLP:一个协议,两个端口

传输 默认端口 适用 备注
OTLP/gRPC 4317 服务端到 Collector(主流) 长连接、流控、压缩都成熟
OTLP/HTTP 4318 浏览器、Serverless、跨网段/过代理 支持 protobuf 与 JSON;路径 /v1/traces、/v1/metrics、/v1/logs

三个必须配置的点(缺一个就会在故障时丢数据或放大故障):

  1. 批量:逐条上报会把网络打爆,batch 处理器按大小/超时聚合。
  2. 重试与退避:后端 5xx 要退避重试;4xx(格式/鉴权错)不要重试,重试也没用。
  3. 背压与丢弃:队列满了要丢而不是阻塞业务(可观测性数据的价值低于业务请求)。

Collector:四种组件,一份真实配置

组件 作用 常见实现
receiver 接收数据 otlp、prometheus(拉取)、filelog、jaeger(兼容老协议)
processor 加工 memory_limiter、batch、resource/attributes(改属性)、tail_sampling、transform(脱敏)
exporter 送出 otlp(到 Tempo/Jaeger)、prometheus/prometheusremotewrite、loki、debug
connector 信号互转 spanmetrics(span → 指标)、servicegraph(span → 依赖拓扑)
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }

processors:
  memory_limiter:            # 必须放第一个:超限先丢数据,别把 Collector 自己 OOM
    check_interval: 1s
    limit_percentage: 75
    spike_limit_percentage: 20
  resource:
    attributes:
    - key: deployment.environment.name
      value: prod
      action: upsert
  tail_sampling:             # 尾采样:保错误、保慢,其余 1%
    decision_wait: 30s
    num_traces: 100000
    policies:
    - name: errors
      type: status_code
      status_code: { status_codes: [ERROR] }
    - name: slow
      type: latency
      latency: { threshold_ms: 500 }
    - name: baseline
      type: probabilistic
      probabilistic: { sampling_percentage: 1 }
  batch:
    timeout: 5s
    send_batch_size: 8192

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls: { insecure: true }
  prometheus:
    endpoint: 0.0.0.0:8889

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, resource, tail_sampling, batch]
      exporters: [otlp/tempo]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, resource, batch]
      exporters: [prometheus]

三个顺序上的硬约束(配错会静默失效):

  • memory_limiter 必须是第一个 processor:它要在数据进入其他处理器之前就拦住内存增长。
  • tail_sampling 必须在 batch 之前:先决策再打包,否则一批数据里混着该丢的 trace。
  • 尾采样要求「同一 trace 的所有 span 到同一个实例」——多副本时必须用按 trace_id 路由的负载均衡(loadbalancing exporter),否则每条 trace 都只看到片段,判不出错误(见 50.3 链路)。

配置校验与发布

# 1) 先校验,再重启(Collector 默认不热加载配置)
otelcol validate --config=/etc/otelcol/config.yaml

# 2) 单实例灰度:新配置只发一台,观察 10 分钟
# 3) 四个指标都正常再全量
curl -s localhost:8888/metrics | grep -E \
  'receiver_accepted_spans|exporter_send_failed_spans|tail_sampling_count_traces|batch_send_size'

回滚:保留上一版 config,重启即回滚(秒级)。注意 tail_sampling 回滚会放大后端写入——从 1% 回到全量,写入量按 QPS × 平均 span 数上涨;先确认后端容量,再回滚采样配置。

两级部署:agent + gateway

应用 SDK ──▶ agent(每节点 DaemonSet)──▶ gateway(Deployment,N 副本)──▶ Tempo / Prometheus / Loki
             批量、压缩、加 host 标签          统一采样、脱敏、路由、限流
形态 放什么 为什么
agent(节点级) batch、resource(补 host.name/k8s.pod.name) 就近收敛,减少出节点连接数;节点标签只有 agent 拿得到
gateway(集群级) tail_sampling、transform(脱敏)、多后端路由与限流 采样与脱敏要全局视角;后端凭证不下发到每个节点

只有一两个服务时,直接 SDK → 后端也能跑;一旦超过几十个 Pod,缺 gateway 会让「采样策略」「凭证轮换」「后端迁移」三件事同时在 N 个地方改。

迁移路径:别想着一次换完

步骤 做什么 判据
1 先统一 service.name / service.version 与 deployment.environment.name 三支柱能按同一维度 join
2 部署 Collector(agent 或 gateway),先只接收、原样转发 现有后端行为不变、延迟增加 < 1 ms
3 应用接入 OTel SDK / 自动埋点(auto-instrumentation),新代码只用 API 新服务不再引入旧 tracer client
4 在 Collector 上开采样、脱敏、多路由(双写过渡:旧后端 + 新后端) 新旧数据可对比,随时回退
5 下掉旧 agent/SDK,收敛到单一后端 删除旧依赖,文档与 runbook 同步更新

第 4 步的「双写」是关键:迁移期最怕「新后端没数据、旧后端已停」,双写让回退成本接近零;代价是双份出口带宽,通常保留 2–4 周。

反模式

反模式 后果
业务/库代码直接 import SDK 并初始化 exporter 采样、地址、凭证被锁死,换后端要改所有服务
各团队自定义属性名(service vs service_name) 三支柱永远关联不上,看板要写 N 套查询
Collector 不配 memory_limiter 后端变慢 → Collector OOM → 全部信号同时丢失(最典型的事故形态)
一个 Collector 同时收 logs/metrics/traces 且不分区 日志洪峰挤掉链路,故障时恰好没有链路
尾采样集群没做按 trace 路由 每条 trace 只看到片段,错误策略失效
用 OTLP/HTTP 逐条上报 请求数爆炸,Collector 的连接与 CPU 先崩
4xx 也重试 鉴权/格式错误被无限放大,打满后端
升级 SDK 不同步更新属性名 看板与告警静默失效(查询条件永远匹配不到数据)

自检

Q1 为什么库作者只应该依赖 OTel API,而不是 SDK?

▸ 答案:API 是接口 + no-op 实现,SDK 才决定采样率、上报地址、批量与凭证。库依赖 SDK 就等于替使用者做了这些决定:使用者想换后端、想关掉某类信号、想改采样率,都必须改库或改库的初始化代码。只依赖 API 时,otel.Tracer 在没装 SDK 时是 no-op,装了以后同一份库代码自动开始产出数据。

Q2 service.name 为什么比其他属性都重要?

▸ 答案:它是三支柱唯一的公共主键:指标用它分组、trace 用它归属、日志用它过滤,看板与下钻(服务 → 实例 → 依赖 → 日志/trace)全靠它串。它缺失或不统一(order vs order-svc vs order_service),三支柱就只能靠时间戳猜,前面所有关联设计全部失效。

Q3 Collector 里 memory_limiter 为什么必须放第一个 processor?

▸ 答案:它靠拒绝/丢弃新数据来保护进程内存。如果放在 batch 或 tail_sampling 之后,数据已经进入缓冲(tail_sampling 还要缓存整条 trace),内存已经涨上去了,限流器再拦也来不及。规则是「先限流,再加工」。

Q4 尾采样为什么要求同一 trace 的 span 到同一个 Collector 实例?

▸ 答案:尾采样要等一条 trace 的 span 收齐(decision_wait)后按「是否有 ERROR / 是否超过延迟阈值」决策。span 被哈希分散到多个实例时,每个实例只看到片段,既判不出错误,也导不出完整树。多副本时要用按 trace_id 路由的负载均衡(loadbalancing exporter),把同一条 trace 固定到一个实例。

Q5 迁移到 OTel 时,为什么先统一属性名、再谈换后端?

▸ 答案:换后端只是 exporter 的地址变化,改一行配置;而属性名不一致是数据模型问题,会让新后端的查询、看板、告警全部对不上——迁移完成后你会发现「数据在,但什么都查不出来」。所以顺序是:先统一 service.name/service.version/deployment.environment.name 与关键 span 属性(必要时在 Collector 里做映射),再切换后端。

关联