某次微服务全链路追踪(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 拦截了部分流量写入本地文件。打开一看,简直是灾难:
-
70% 的 Span 是无效心跳:
/healthz、/metrics以及海量的 RedisPING/PONG。一个 500 规模的 Pod 集群,每 10 秒一次的 Readiness/Liveness 探针,加上无脑的全量采样,一分钟就能造出几十万个毫无追踪价值的孤儿 Span。 -
把 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);
排查清单与同类问题速查
-
Collector 频繁重启 / OOMKilled:第一时间检查
memory_limiter是否配置正确。limit_mib必须小于 K8S Pod 的 Memory Limit,通常预留 20%~25% 给非 Go 运行时开销。 -
存储后端(Elasticsearch/ClickHouse)CPU打满 / IO 瓶颈:大概率是 Span Payload 过大。通过 Collector 的 debug exporter 抽样排查
http.request.body或db.statement字段是否包含异常的巨型文本。 -
Trace 找得到,但报错日志搜不到 TraceID:检查应用是否发生了跨线程/跨协程的异步调用,重点排查 OTel Context propagator 是否在线程切换时被正确传递。
-
无用 Span 泛洪排查:查询后端存储中 Span Name 排名前 10 的列表,如果
/health、/ping、SELECT 1占据了大部分比例,立即在 Collector 侧追加filterprocessor。