OpenTelemetry 生产实践:24 个集群的采样与 Collector 扩容

0 阅读7分钟

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 永远放第一位。这三条能帮你省掉我踩过的至少一半坑。