标签: OpenTelemetry

  • 深入 OpenTelemetry 追踪雪崩排查:全量采样引发的 Collector OOM 与无用 Span 泛洪实战

    某次微服务全链路追踪(OpenTelemetry)大范围铺开后,核心集群的 OTel Collector DaemonSet 出现大面积 OOMKilled,后端 Jaeger 和 ClickHouse 写入 QPS 瞬间跌零。业务线研发在排查线上故障时,发现应用日志与 TraceID 彻底脱节,排查陷入盲人摸象。最终结论:研发在 SDK 端开启了 100% 盲目全量采样,且未对 Kubelet 探针与 Redis 心跳做任何过滤;更有甚者,将动辄几百 KB 的 Base64 图片 Payload 强塞进 Span Attribute。由于 Collector 端未配置防御性的 memory_limiter 与背压处理,积压的脏数据瞬间打爆内存。

    解决思路很明确:SDK 端收敛采样率、Collector 端强行过滤高频无价值 Path 并截断超长 Attribute,同时修复异步线程池丢失 OTel Context 导致的 Trace-Log 关联断裂。

    案发现场与指标异常

    排查过程中,监控大盘发出刺耳的告警:otel-collector 的 Pod 频繁重启。登录节点查看系统日志,死因极其经典:

    $ dmesg -T | grep -i oom
    [xxx] Memory cgroup out of memory: Killed process 12345 (otelcol-contrib) total-vm:4250112kB, anon-rss:2097152kB
    

    此时去看 OTel Collector 本身的暴露指标(:8888/metrics),在每次 Pod 阵亡前,以下两个指标呈现出垂直拉升的态势:

    • otelcol_receiver_refused_spans{transport="grpc"}:暴增,说明 Receiver 端已经拒绝接收数据。

    • otelcol_processor_dropped_spans:激增,Processor 队列被打满。

    为了查清楚到底是什么垃圾数据塞满了 Collector,我临时开了一个 Debug Exporter 拦截了部分流量写入本地文件。打开一看,简直是灾难:

    1. 70% 的 Span 是无效心跳/healthz/metrics 以及海量的 Redis PING/PONG。一个 500 规模的 Pod 集群,每 10 秒一次的 Readiness/Liveness 探针,加上无脑的全量采样,一分钟就能造出几十万个毫无追踪价值的孤儿 Span。

    2. 把 Trace 当对象存储用:某几个核心业务的 Span 里面,http.request.body 居然包含了完整的 HTTP POST 数据,其中夹带了大量未经压缩的 JSON Array,单 Span 大小突破了 500KB。

    为什么说这是愚蠢的配置?

    全链路追踪的核心价值是系统拓扑呈现与分布式请求的瓶颈定位(Control Flow),绝不是用来存明细业务数据甚至二进制文件的。 把 500KB 的 Payload 塞进 Span,不仅极大消耗了应用的 CPU(序列化开销),还把网络带宽和 Collector 的内存当成了免费午餐。此外,对 /healthz 进行全量 Trace,相当于你在高速公路上给每一道测速探头拍个特写,除了把磁盘撑爆,没有任何排障意义。

    另外,排查业务日志时发现另一个低级失误:大量核心报错日志里没有 trace_id。查阅业务代码发现,研发在处理高并发请求时使用了 Java 的 @Async 或自建的 ThreadPoolExecutor,但完全没有做 MDC(Mapped Diagnostic Context)的跨线程传递。主线程的 OTel Context 一到子线程就丢失了,导致 Trace 和 Log 在最关键的异步执行环节强行脱钩。

    落地实战:防御性配置与采样重构

    针对这种乱象,必须在 Collector 层面实施强硬的“防御性编程”策略,不能指望所有业务线都能自觉写好 SDK 配置。

    1. Collector 内存兜底与背压(Memory Limiter)

    绝对不要在生产环境裸奔 Collector,必须配置 memory_limiter processor。它会在内存接近上限时主动拒绝新数据(触发背压限制),并强制执行 GC,宁可丢弃(Drop)部分监控数据,也不能让 Collector OOM 导致 DaemonSet 崩溃影响宿主机。

    processors:
      memory_limiter:
        check_interval: 1s
        limit_mib: 1500        # 硬限制,视 Pod Limit (如 2G) 预留 20%
        spike_limit_mib: 300   # 软限制阈值,超过 limit - spike 时开始 drop 数据
    

    2. 强行清洗无价值垃圾 Span (Filter Processor)

    使用 filter processor,在 Collector 入口处直接把探针和心跳干掉。

    processors:
      filter/drop_health:
        error_mode: ignore
        traces:
          span:
            - 'attributes["http.target"] == "/healthz"'
            - 'attributes["http.target"] == "/metrics"'
            - 'name == "PING"' # Redis Ping
    

    3. 截断超大 Attribute (Transform Processor)

    对于研发乱塞 Payload 的行为,使用 OTTL (OpenTelemetry Transformation Language) 强制截断,超过 2048 字节直接截断,保护后端存储引擎。

    processors:
      transform/truncate_payload:
        trace_statements:
          - context: span
            statements:
              - set(attributes["http.request.body"], substring(attributes["http.request.body"], 0, 2048)) where IsString(attributes["http.request.body"])
    

    4. 尾部采样(Tail-based Sampling)替换全量盲采

    在 SDK 端仅保留基础的 ParentBased(TraceIdRatioBased) 头采样策略(如 5%),将重头戏交给 Collector 层面的 Tail Sampling。对于状态码为 5xx 或延迟大于 2 秒的 Trace 实施 100% 抓取,而对正常的 200 OK 请求执行极低概率的采样。

    processors:
      tail_sampling:
        decision_wait: 10s # 等待 Trace 组装的时间
        num_traces: 50000
        policies:
          - name: always-sample-errors
            type: status_code
            status_code: {status_codes: [ERROR]}
          - name: slow-traces
            type: latency
            latency: {threshold_ms: 2000}
          - name: probabilistic-normal
            type: probabilistic
            probabilistic: {sampling_percentage: 5}
    

    5. 解决跨线程 Trace-Log 上下文丢失

    在业务代码中,严禁裸调线程池。如果是 Java + Spring Boot,推荐直接使用 OTel Java Agent,它对常见的线程池(ForkJoinPool, ThreadPoolExecutor)做了底层字节码增强。如果是手动传递,必须使用 Context.current().makeCurrent() 或包装 Runnable

    // 错误写法:Context 丢失
    executor.submit(() -> doSomething());
    
    // 正确写法:Context 传播
    Runnable instrumentedRunnable = Context.current().wrap(() -> doSomething());
    executor.submit(instrumentedRunnable);
    

    排查清单与同类问题速查

    1. Collector 频繁重启 / OOMKilled:第一时间检查 memory_limiter 是否配置正确。limit_mib 必须小于 K8S Pod 的 Memory Limit,通常预留 20%~25% 给非 Go 运行时开销。

    2. 存储后端(Elasticsearch/ClickHouse)CPU打满 / IO 瓶颈:大概率是 Span Payload 过大。通过 Collector 的 debug exporter 抽样排查 http.request.bodydb.statement 字段是否包含异常的巨型文本。

    3. Trace 找得到,但报错日志搜不到 TraceID:检查应用是否发生了跨线程/跨协程的异步调用,重点排查 OTel Context propagator 是否在线程切换时被正确传递。

    4. 无用 Span 泛洪排查:查询后端存储中 Span Name 排名前 10 的列表,如果 /health/pingSELECT 1 占据了大部分比例,立即在 Collector 侧追加 filter processor。

  • 突破 OpenTelemetry Collector 内存瓶颈:万级 QPS 下的尾部采样策略与 Trace-Log 关联机制深度解析

    在高并发场景接入 OpenTelemetry 时,全量采集必定导致 Collector 频繁 OOM 与存储雪崩。本文的核心结论:必须采用 loadbalancing 结合双层采样(头部概率 + 尾部兜底)架构,配合基于内存限额的批处理机制。同时,利用 OTel Agent 的 MDC 自动注入,并修正自定义线程池的 Context 传递,才能实现 100% 异常 Trace 捕获及 Trace-Log 精准关联。

    某次核心网关服务(约 50k QPS)接入 OpenTelemetry(下文简称 OTel)后,监控告警迅速亮起红灯。部署在集群内的 OTel Collector 容器频繁发生 OOMKilled,Load Average 飙升至 40 以上,导致大面积的 Span 丢失。

    查看 Collector 容器的报错日志,满屏的内存申请失败和连接重置:

    2023-10-18T10:23:45.102Z error   receiver/otlp   error reading from server: read tcp 10.244.2.10:4317->10.244.3.15:58392: read: connection reset by peer
    2023-10-18T10:23:46.001Z warn    memorylimiter   Memory usage is strictly above the limit. Dropping data. {"kind": "processor", "name": "memory_limiter", "usage": 4096, "limit": 4096}
    

    单纯增加 Collector 的内存只是延缓死亡时间。分布式追踪工程化落地的核心难点,不在于如何生成数据,而在于如何克制地丢弃数据

    为什么单节点尾部采样(Tail Sampling)注定会触发 OOM?

    为了保留请求报错(HTTP 5xx)和慢请求(Latency > 1s)的完整调用链路,很多团队会直接开启 OTel 的 tail_sampling 处理器。但尾部采样的底层逻辑是:必须等待一个 Trace 的所有 Span 收集完毕(或达到超时时间),才能做出是否保留的采样决策。

    假设当前系统的全局 QPS 为 50,000,每个请求平均产生 10 个 Span,每个 Span 大小约 1KB。 如果 tail_sampling 的决策等待时间(decision_wait)设置为默认的 10 秒。那么 Collector 在内存中至少需要维持 10 秒的在途数据: 50,000 * 10 * 1KB * 10s ≈ 5GB

    这只是理论上的最小内存。遇到网络抖动、流量突增或者垃圾回收(GC)停顿,内存占用会轻易突破 10GB。如果你只部署了几个 OTel Collector 实例,OOM 是必然结果。

    更致命的是,在 Kubernetes 部署架构下,网关的请求会通过 Service 负载均衡随机打到后端的 OTel Collector 实例上。同一个 Trace 的不同 Span,可能会落在不同的 Collector 节点上。 这导致单节点的 tail_sampling 永远无法拼凑出完整的 Trace,最终因为等不到数据而将关键 Trace 判定为不完整并丢弃(即产生大量的孤儿 Span)。

    破局:两层架构与基于 TraceID 的路由分发

    要解决这个问题,必须对 Collector 的架构进行解耦,分为 Gateway CollectorProcessor Collector 两层(基于 OpenTelemetry Collector Contrib v0.87.0)。

    1. 第一层:Gateway Collector(轻量级,只做路由) 接收所有 Agent 发来的全量数据,使用 loadbalancing exporter 根据 trace_id 进行哈希一致性路由,确保同一个 Trace 的所有 Span 被精确转发到第二层的同一个实例。

    2. 第二层:Processor Collector(重负载,做尾部采样) 接收到完整的 Trace 数据后,在内存中进行聚合与尾部采样决策。

    Gateway Collector 核心配置片段

    receivers:
      otlp:
        protocols:
          grpc:
            endpoint: 0.0.0.0:4317
    
    processors:
      memory_limiter:
        check_interval: 1s
        limit_mib: 2048
        spike_limit_mib: 512
    
    exporters:
      # 关键配置:根据 trace_id 进行一致性哈希负载均衡
      loadbalancing:
        protocol:
          otlp:
            tls:
              insecure: true
        resolver:
          dns:
            hostname: otel-processor-headless.monitoring.svc.cluster.local
            port: 4317
        routing_key: "traceID"
    
    service:
      pipelines:
        traces:
          receivers: [otlp]
          processors: [memory_limiter]
          exporters: [loadbalancing]
    

    Processor Collector 核心配置片段

    在第二层,我们通过 tail_sampling 组合多种策略:保留所有的 Error 链路,保留耗时超过 1000ms 的链路,其余正常链路按 1% 概率采样。

    processors:
      tail_sampling:
        decision_wait: 10s # 等待 Trace 收集完整的时间
        num_traces: 100000 # 内存中最大维持的 Trace 数量
        expected_new_traces_per_sec: 10000 # 预估新 Trace 速率,用于预分配内存
        policies:
          [
            {
              name: retain-errors,
              type: status_code,
              status_code: {status_codes: [ERROR]}
            },
            {
              name: retain-slow,
              type: latency,
              latency: {threshold_ms: 1000}
            },
            {
              name: retain-probabilistic,
              type: probabilistic,
              probabilistic: {sampling_percentage: 1} # 正常流量保留 1%
            }
          ]
    
      batch:
        send_batch_size: 8192
        timeout: 1s
    
    exporters:
      otlp/storage:
        endpoint: jaeger-collector:4317
        tls:
          insecure: true
    
    service:
      pipelines:
        traces:
          receivers: [otlp]
          # 必须严格遵守 memory_limiter -> tail_sampling -> batch 的顺序
          processors: [memory_limiter, tail_sampling, batch]
          exporters: [otlp/storage]
    

    注意:memory_limiter 必须放在第一位进行自我防御,防止突发流量直接打死进程。

    补齐可观测的拼图:Trace 与 Log 的强关联

    仅仅收集到 Trace 是不够的。在实战排查中,我们需要通过 TraceID 精准检索到那一刻的业务日志。

    对于 Java 应用,OTel Java Agent(v1.30.0+)默认会自动将 trace_idspan_id 注入到 MDC(Mapped Diagnostic Context)中。但这里有两个常见的踩坑点:

    1. 日志格式未配置占位符

    如果在 logback-spring.xmllog4j2.xml 中没有修改 pattern,打印出来的日志依然是匿名的。必须在 Pattern 中显式提取 MDC 的值:

    <!-- Logback 示例 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <!-- %X{trace_id} 和 %X{span_id} 是 OTel 默认注入的 Key -->
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [traceId=%X{trace_id} spanId=%X{span_id}] %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>
    

    2. 异步线程池导致上下文丢失

    这是业务开发最容易忽略的痛点。当业务代码使用 CompletableFuture 或自定义的 ThreadPoolExecutor 时,由于 MDC 底层依赖 ThreadLocal,不同线程间无法自然继承,导致日志中的 traceId 突然断代变为空白。

    不要企图去魔改 ThreadPoolExecutor。标准的做法是利用 OTel API 提供的 Context 进行上下文传播包装:

    import io.opentelemetry.context.Context;
    
    // 错误写法:在新线程中丢失 Trace 上下文
    executor.submit(() -> {
        log.info("Processing async task"); // 这里的日志 traceId 会是空的
    });
    
    // 正确写法:使用当前 Context 包装 Runnable
    Runnable wrappedRunnable = Context.current().wrap(() -> {
        log.info("Processing async task"); // 这里能准确关联到父级 TraceId
    });
    executor.submit(wrappedRunnable);
    

    对于 Spring 的 @Async 注解,可以通过实现 TaskDecorator 并在配置类中注入,实现自动的上下文转移,这里不再贴冗长的 Spring 模板代码。

    常见问题 (FAQ)

    Q1:使用 tail_sampling 后,在 Jaeger UI 上偶尔还是会看到一些断掉的“孤儿 Span”,为什么? A: 通常是因为服务优雅下线或 Collector 重启期间,上游数据流被打断。另一个常见原因是 decision_wait 设得太短。如果业务逻辑中有一个长达 15 秒的外部调用,而等待时间只有 10 秒,那么 10 秒后的 Span 就会变成孤儿。可以根据 99 线延迟适当拉长 decision_wait,但要做好内存预估。

    Q2:如果不想部署复杂的 Collector 集群,只在客户端做头部采样(Head Sampling),有办法保留错误日志吗? A: 纯头部采样是确定性采样(在请求刚进入时就决定是否采样),此时并不知道后续是否会报错。一种妥协方案是:客户端不全量采样,但利用 OTel 的 Span.current().recordException(e) 和业务全局异常处理器联动。但这只能记录到报错那一刻的 Span,无法回溯完整的调用链,这是头部采样的硬伤。

    Q3:底层存储用 ElasticSearch 还是 ClickHouse? A: 坚决推荐 ClickHouse。Trace 数据的特点是:海量写入、弱更新、固定维度的分析。ES 的倒排索引在应对数万 TPS 的 Span 写入时会产生极大的 CPU 和 IO 损耗,且磁盘占用通常是 CH 的 3-5 倍。借助开源的 jaeger-clickhouse 插件或者直接用 SigNoz 等原生基于 CH 的可观测产品,能大幅降低存储成本。

    Q4:为什么加入了 memory_limiter,Collector 还是会被 OOMKilled? A: 检查你的 limit_mib 和容器的 Limit 配置。通常建议 limit_mib 设置为容器内存 Limit 的 70%-80%。因为 Golang 的 GC 是有延迟的,memory_limiter 触发 GC 和拦截请求的瞬间,系统底层的实际物理内存占用可能会有个短暂的尖峰。如果不留缓冲,就会被内核直接杀掉。