OpenTelemetry 生产实践:从 3 个服务到 24 个集群,我踩过的采样与 Collector 扩容坑
去年冬天我第一次把 OpenTelemetry 接进生产,只有 3 个服务、一个 all-in-one 的 Collector,跑得挺舒服。今年这套东西铺到了 24 个集群、180 多个服务,问题就一个接一个冒出来了:Collector 内存涨到 12G 被 OOMKill、trace 数据量一天 900G 打爆存储、采样率配错导致故障现场全是断链。
这篇不讲概念,讲我在真实环境里怎么一步步把这些问题收掉的。所有配置都是能直接抄的,坑也都标了原因。
一、先说我为什么从 all-in-one 换成了两级 Collector
一开始的架构非常朴素:每个服务用 OTLP 直接把 span 打到 Collector,Collector 用 otlp receiver + batch processor + otlp exporter 转发到后端。3 个服务的时候没问题,扩到 40 个服务后出现了两个症状:
一是 Collector 的 CPU 打满。所有服务直连同一个 Collector,每个 Pod 重启时都会建立新连接,连接数抖到几千,Collector 的 goroutine 数跟着飙。
二是丢数据。Collector 一重启,客户端 buffer 满了就直接丢,而且没有任何日志提示——因为默认的 memory_limiter 会静默拒绝。
我改成了两级:每个集群部署一组 agent Collector(DaemonSet 或 sidecar 模式),只负责接收本集群数据、做基础处理和批量发送;中心侧部署 gateway Collector,负责采样、脱敏、路由到不同后端。
agent 侧的配置长这样:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 75
spike_limit_percentage: 20
batch:
timeout: 5s
send_batch_size: 8192
send_batch_max_size: 10000
exporters:
otlp:
endpoint: otel-gateway.observability.svc:4317
tls:
insecure: true
retry_on_failure:
enabled: true
initial_interval: 1s
max_interval: 30s
sending_queue:
enabled: true
num_consumers: 8
queue_size: 5000
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp]
这里有两个参数我调了很久才定下来。send_batch_size: 8192 是压测出来的:太小则网络往返次数多,太大则单批序列化耗时超过 batch timeout,反而增加延迟。queue_size: 5000 配合 num_consumers: 8,在网关短暂不可用时能扛大约 90 秒,足够一次滚动重启。
二、采样:别在客户端用固定比例,那是给自己挖坑
我最初的采样策略是每个服务在 SDK 里写死 parentbased_traceidratio 0.1,也就是 10%。上线两周后出了个事故,一个核心支付链路报错率上升,我去查 trace,发现现场的链路全是断的——因为上游服务采样到了,下游服务因为随机数没中就没采,span 只剩半截。
正确的做法是 ParentBased:根 span 决定采样与否,子 span 无条件跟随父决策。OpenTelemetry SDK 里默认的 parentbased_traceidratio 就是这个语义,关键是别自己手写随机判断。
import io.opentelemetry.api.OpenTelemetry;
import io.opentelemetry.sdk.OpenTelemetrySdk;
import io.opentelemetry.sdk.trace.SdkTracerProvider;
import io.opentelemetry.sdk.trace.export.BatchSpanProcessor;
import io.opentelemetry.sdk.trace.export.SpanExporter;
import io.opentelemetry.sdk.trace.samplers.Sampler;
public final class OtelInit {
public static OpenTelemetry init(SpanExporter exporter) {
SdkTracerProvider provider = SdkTracerProvider.builder()
.setSampler(Sampler.parentBased(
Sampler.traceIdRatioBased(0.05)))
.addSpanProcessor(BatchSpanProcessor.builder(exporter)
.setMaxQueueSize(4096)
.setMaxExportBatchSize(512)
.setScheduleDelay(java.time.Duration.ofSeconds(5))
.build())
.build();
return OpenTelemetrySdk.builder()
.setTracerProvider(provider)
.buildAndRegisterGlobal();
}
}
Sampler.parentBased(...) 这层包装是必须的,很多人直接 traceIdRatioBased(0.05) 就上了,结果就是断链。
第二个问题是:尾部采样。固定比例采样对错误请求不友好——错误请求可能只占 0.3%,5% 采样下大概率被丢掉。我在 gateway Collector 上加了 tail_sampling processor:
processors:
tail_sampling:
decision_wait: 10s
num_traces: 100000
expected_new_traces_per_sec: 2000
policies:
- name: errors
type: status_code
status_code:
status_codes: [ERROR]
- name: slow
type: latency
latency:
threshold_ms: 800
- name: baseline
type: probabilistic
probabilistic:
sampling_percentage: 3
decision_wait: 10s 意味着一个 trace 要等 10 秒才决定去留,这期间所有 span 都堆在内存里。num_traces: 100000 是我按峰值 QPS 乘出来的:峰值 2000 trace/s,10 秒就是 2 万条在途,留 5 倍余量。这个值给小了会开始丢 span,而且日志里只会提示 num_traces 相关的 drop 计数,很容易忽略。
上线尾部采样后,存储从 900G/天 降到 210G/天,而错误链路的完整率从 62% 提到了 99.4%。这个收益比单纯调采样比例大得多。
三、Collector 内存暴涨的三个真实原因
原因一:memory_limiter 放在了 batch 后面。 processor 的顺序是有语义的,memory_limiter 必须放在 pipeline 的第一个位置。放后面的话,batch 已经攒了一大堆数据在内存里,limiter 才做判断,等于没用。我见过一个配置把 limiter 放最后,Collector 稳定每 4 小时 OOM 一次。
原因二:sending_queue 无界。 早期版本 sending_queue.enabled: true 不写 queue_size 时是无限队列,后端一慢,队列就把内存吃光。现在必须显式写 queue_size,我统一设 5000。
原因三:属性基数爆炸。 有人在 span 上打了 user.id、order.id 这种高基数字段,Collector 做指标聚合时每个不同值都建一条时间序列,内存直接指数级涨。修法是 gateway 上做一次属性裁剪:
processors:
attributes/scrub:
actions:
- key: user.id
action: delete
- key: order.id
action: delete
- key: http.request.header.authorization
action: delete
- key: db.statement
action: hash
action: hash 是 OpenTelemetry Collector 里很实用的一个动作,把 SQL 语句哈希成固定长度,既保留「同一条 SQL」的关联能力,又不泄露参数。这个动作帮我们过了一次安全审计。
四、24 个集群怎么统一管理
集群多了以后最痛的不是技术,是配置漂移。24 个集群的 Collector 配置各写各的,出了问题根本对不上。
我的解法是用 GitOps:所有 Collector 配置放在一个仓库里,用 Helm chart 的 values 做差异化,只有 endpoint、resource limits、采样率 三个字段允许按集群覆盖,其余全部走公共模板。CI 里跑一次 otelcol validate --config,配置语法错误在合并前就拦住了。
# values-prod-cluster-a.yaml
collector:
resources:
limits:
memory: 2Gi
cpu: "1"
config:
exporters:
otlp:
endpoint: otel-gateway.observability.svc:4317
processors:
tail_sampling:
policies:
- name: baseline
type: probabilistic
probabilistic:
sampling_percentage: 3
另外每个集群必须打一个 k8s.cluster.name 资源属性,否则 24 个集群的数据混在一起,查问题时根本分不清是哪边的。这个属性我是在 agent Collector 上用 resource processor 统一注入的,不让业务方自己写,避免漏打。
五、几个我觉得最值钱的细节
trace 和 log 的关联一定要做。 在日志里注入 trace_id 和 span_id,出问题时从日志一键跳到 trace。我们是在日志框架的 MDC 里塞的,改动量很小,但排查效率提升非常明显。
别把 span 当日志用。 见过有人在 span 上挂整个请求体,一条 span 几十 KB,存储和网络全被拖垮。span 只放关键属性,请求体走日志。
给 Collector 加健康检查。 health_check extension 暴露 /health/status,K8s 的 liveness probe 指向它。没有这个,Collector 卡死时 Pod 看起来还是 Running,数据静默丢失。
监控 Collector 自身。 Collector 的 telemetry.metrics 里有 otelcol_exporter_send_failed_spans、otelcol_processor_dropped_spans 这些指标,一定要接到告警上。我第一次丢数据就是因为没人看这个指标,事后才发现已经丢了三天。
六、现在的架构长什么样
最终形态是:业务服务 → agent Collector(每集群一组,DaemonSet)→ gateway Collector(中心,3 副本)→ 后端(trace 存 Tempo,指标存 Prometheus,日志存 Loki)。
数据量从最初的 900G/天 降到 210G/天,Collector 的 CPU 总量反而下降了 30%,因为两级架构把大量重复的序列化工作分摊到了边缘。故障排查的平均耗时(MTTR)从 47 分钟降到 19 分钟,这个数字是我最看重的。
如果你们团队正准备从零接 OpenTelemetry,我的建议是:一开始就上两级架构,别图省事用 all-in-one;采样从一开始就用 ParentBased,别自己写随机;memory_limiter 永远放第一位。这三条能帮你省掉我踩过的至少一半坑。