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 |
三个必须配置的点(缺一个就会在故障时丢数据或放大故障):
- 批量:逐条上报会把网络打爆,
batch处理器按大小/超时聚合。 - 重试与退避:后端 5xx 要退避重试;4xx(格式/鉴权错)不要重试,重试也没用。
- 背压与丢弃:队列满了要丢而不是阻塞业务(可观测性数据的价值低于业务请求)。
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 里做映射),再切换后端。