分布式追踪脱敏实战:OTel Collector配置

分布式追踪把一次请求经过的每个服务串成一条 Trace,排障时价值极高;但同一条 Trace 里也装着 Authorization 头、Cookie、URL 里的 token、用户手机号和身份证号。默认配置下这些字段会原样写进 Jaeger 或 Tempo,一存就是几个月。换句话说,链路数据本身已经变成一类必须治理的敏感数据,它比日志更危险,因为调用链天然跨系统、跨团队、甚至跨云。

本文用 OpenTelemetry Collector 在数据出口处做统一脱敏:敏感 header 直接删除,用户标识做哈希,URL 查询串按正则打码;同时用 mTLS 保证 agent 到 collector 这一段不可被旁路嗅探,用尾部采样把存储量压下来。配置可直接落地在 K8s DaemonSet 或虚拟机部署。

适用场景

以下三种情况,分布式追踪的脱敏不是加分项而是必选项:

  • 合规审计:个人信息保护法与等保 2.0 都把「可识别到具体自然人」的数据纳入管控范围,Trace 里的手机号、身份证号、用户 ID 属于同一类对象。链路数据保存 30 天,就等于把一份用户画像在可观测性平台里又存了一遍。
  • 多团队共享后端:一个 Collecter 后端往往被十几个业务线共用。A 团队的开发能搜到 B 团队的订单号、支付流水,这是权限模型的漏洞,靠查询侧做 ACL 很难补干净,必须在写入前就抹掉。
  • Trace 出网:把链路数据发给第三方 SaaS 观测平台时,数据一旦离开自己的网络边界,脱敏就是唯一可控的环节。合同里的「数据处理协议」不能替代技术手段。

反过来,纯内部压测环境、单机 demo 不需要这套配置。脱敏有性能成本,也有排障成本,只在真正需要的地方开。

前置条件

  • otelcol-contrib 0.9x 及以上版本。transform、tail_sampling 这些处理器只存在于 contrib 发行版,用核心版会直接报 unknown type。
  • 应用侧已接入 OTel SDK 或 Java Agent,能修改启动参数与资源属性。
  • 一套内部 CA 或自签证书,用于 collector 服务端与 agent 客户端双向认证。
  • 后端具备按 TraceID 查询能力(Jaeger、Tempo、SkyWalking 均可)。
  • 能接受尾部采样带来的延迟:Trace 必须等 decision_wait 结束后才导出,默认 10 秒。

原理说明

敏感数据是怎么进来的

HTTP 语义约定会把请求头自动转成 Span 属性,键名形如 http.request.header.authorization。很多 SDK 默认采集全部请求头,于是 Bearer Token 直接进了后端。第二条路径是 URL:url.full 里带上 ?token=xxx 这类查询参数,在对接老系统时非常常见。第三条是 db.statement,SQL 里的明文参数会把用户数据整条带出来。

这三条路径的共同点是:数据在采集阶段就已经被序列化了,服务端再想补救只能靠事后删除,而删除接口通常按天粒度,做不到精确抹除。

Collector 的管道模型

Collector 内部是 receiver → processor → exporter 的单向管道,数据以 pdata 结构传递。processor 的书写顺序就是执行顺序,这一点决定了脱敏能否生效:如果 batch 排在脱敏之前,数据已经被打包,后续 processor 依然能改,但内存占用会先上去;如果 tail_sampling 排在脱敏之前,未被采样的 Trace 白白做了一次正则匹配,纯属浪费 CPU。

推荐顺序固定为:memory_limiter → 脱敏 → 采样 → batch。memory_limiter 必须第一个,它是背压的总闸门。

脱敏做在哪一层

三个可选位置,各有代价:

  • SDK 采集侧:成本最低,从源头就不产生数据。缺点是每个语言、每个版本的行为不一致,靠人治很难保证覆盖率。
  • Collector 侧:统一入口,一处配置全局生效,本次方案的主战场。缺点是数据已经在网络上传输过一次,agent 到 collector 之间必须加密。
  • 后端查询侧:只做展示层遮蔽,数据仍完整落盘。这一层不能算脱敏,只能算遮羞,磁盘被拿走照样泄露。

结论:SDK 侧关掉 header 采集做减法,Collector 侧做正则兜底,两层叠加。

删除、哈希还是打码

delete 最彻底,但排障时失去关联能力;hash 保留同一用户的可关联性且不可逆,适合 enduser.id 这类标识;正则打码保留字段结构和前后文,适合 URL 查询串、手机号,运维还能看出「这里原本有个 token」。

实践口径:凭据类一律删除,用户标识一律哈希,业务参数按正则打码。不要试图保留凭据的前几位做「便于核对」,那等于泄露了一半。

操作步骤

步骤一:在 SDK 侧关闭请求头采集

以 Java Agent 为例,这三个环境变量分别控制请求头采集、响应头采集和需要清洗的字段名单。设成空值即完全不采集,最省事。

export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST=""
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE=""
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS="authorization,cookie,set-cookie,x-api-key"

# 同时打开资源属性里的服务名,便于后续按服务设采样策略
export OTEL_SERVICE_NAME="order-api"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=prod,service.namespace=shop"

步骤二:部署 Collector 并开启 mTLS

client_ca_file 一配,服务端就会强制校验客户端证书,agent 不带证书连不上。这一步是防止「有人伪造一个 agent,把带敏感数据的 Trace 混进来」的关键。http 协议也要一并配 TLS,否则 4318 端口是个明文后门。

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
        tls:
          cert_file: /etc/otel/tls/server.crt
          key_file: /etc/otel/tls/server.key
          client_ca_file: /etc/otel/tls/ca.crt
      http:
        endpoint: 0.0.0.0:4318
        tls:
          cert_file: /etc/otel/tls/server.crt
          key_file: /etc/otel/tls/server.key
          client_ca_file: /etc/otel/tls/ca.crt

步骤三:删除凭据类属性,哈希用户标识

attributes 处理器按属性键做动作,hash 使用 SHA1 且不可逆。db.statement 建议直接删除:SQL 里的参数极难逐条清洗,删掉后仍保留 db.operation 和 db.sql.table,排障信息足够。

processors:
  memory_limiter:
    check_interval: 1s
    limit_percentage: 75
    spike_limit_percentage: 20

  attributes/scrub:
    actions:
      - key: http.request.header.authorization
        action: delete
      - key: http.request.header.cookie
        action: delete
      - key: http.request.header.x-api-key
        action: delete
      - key: http.response.header.set-cookie
        action: delete
      - key: db.statement
        action: delete
      - key: enduser.id
        action: hash

步骤四:用 OTTL 对 URL 查询串做正则打码

transform 处理器的语句跑在 OTTL 上,replace_pattern 的第三个参数是替换模板。正则里的 (?i) 必须写在最前,否则大小写混写的 Token= 会漏掉。匹配失败时语句不报错,原值保留,所以这一层只能当兜底,不能当唯一防线。

  transform/url:
    trace_statements:
      - context: span
        statements:
          - replace_pattern(attributes["url.full"], "(?i)(token|access_key|password|secret|sign)=[^&]*", "${1}=[REDACTED]")
          - replace_pattern(attributes["url.full"], "(?i)(phone|mobile)=1[3-9][0-9]{9}", "${1}=[REDACTED]")

步骤五:尾部采样压缩存储量

尾部采样的价值在于「先看完整 Trace 再决定留不留」,错误与慢请求全留,正常请求按比例留。这是脱敏之后第二重要的省钱手段,通常能把存储量压到原来的 10% 以下。注意 decision_wait 必须大于链路的最长耗时,否则慢请求还没收集完整就被判定了。

  tail_sampling:
    decision_wait: 15s
    num_traces: 100000
    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: 5

步骤六:拼装 service 段并校验配置

Collector 提供了 validate 子命令,能在启动前把 YAML 结构、处理器类型、端口冲突全部检查一遍。上线前必须先跑这一步,否则配置写错只会在启动瞬间退出,容器反复重启却看不到明确错误。

  batch:
    timeout: 5s
    send_batch_size: 8192

exporters:
  otlp/tempo:
    endpoint: tempo.observability.svc:4317
    tls:
      ca_file: /etc/otel/tls/ca.crt
      cert_file: /etc/otel/tls/client.crt
      key_file: /etc/otel/tls/client.key
  debug:
    verbosity: detailed

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, attributes/scrub, transform/url, tail_sampling, batch]
      exporters: [otlp/tempo, debug]
  telemetry:
    logs:
      level: info

# 校验并启动
# otelcol-contrib validate --config=/etc/otel/config.yaml
# systemctl restart otelcol-contrib && journalctl -u otelcol-contrib -f

配置验证

造一条带 token 和手机号的 Trace 打进去,看 debug exporter 打印出来的实体属性里还有没有原值。这一步比查后端界面快得多,因为 debug 输出就是脱敏之后的最终形态。

cat > /tmp/span.json <<'JSON'
{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"order-api"}}]},
"scopeSpans":[{"spans":[{"traceId":"5b8efff798038103d269b633813fc60c",
"spanId":"eee19b7ec3c1b174","name":"/pay","kind":2,
"startTimeUnixNano":"1762500000000000000","endTimeUnixNano":"1762500001000000000",
"attributes":[
 {"key":"url.full","value":{"stringValue":"https://api.example.com/pay?token=abc123&phone=13800138000"}},
 {"key":"http.request.header.authorization","value":{"stringValue":"Bearer sk-live-xxx"}},
 {"key":"enduser.id","value":{"stringValue":"u_10086"}}]}]}]}]}
JSON

curl -s -X POST https://collector.example.com:4318/v1/traces \
  --cert /etc/otel/tls/client.crt --key /etc/otel/tls/client.key \
  --cacert /etc/otel/tls/ca.crt \
  -H 'Content-Type: application/json' --data-binary @/tmp/span.json

预期结果三条,缺一条就说明管道没生效:

  • http.request.header.authorization 整条消失,不是变成空字符串。
  • url.full 变成 ...?token=[REDACTED]&phone=[REDACTED],其它参数原样保留。
  • enduser.id 变成 40 位十六进制哈希,同一用户多次请求结果一致。

再确认 mTLS 真的在起作用:不带客户端证书访问 4317 端口,应当直接被拒绝握手,而不是照常返回数据。这条测试比看配置项可靠,因为配置写错时服务往往能起来,只是没校验。

常见问题

Q1:脱敏后排查故障看不到原始参数了,怎么平衡

这是真实矛盾,靠「全删」解决不了。做法是把排障所需的结构信息与敏感值分开:URL 只打码参数值,路径与参数名保留;用户标识用哈希,运维拿用户 ID 算一次哈希就能反查该用户的全部 Trace;业务单号这类非敏感主键原样保留。这样 95% 的排障场景不依赖原始凭据。

确实需要临时查看原值的场景,走受控旁路:在测试环境单独一套不做脱敏的 Collector,生产环境不开口子。生产临时开调试采样要留审计记录,事毕立即关闭。

Q2:配了 tail_sampling 之后 Trace 缺失严重,甚至整条链路断成几段

先看 decision_wait。它必须大于全链路最长耗时,15 秒是常见起点,有异步任务或消息队列时链路可能持续几分钟,此时尾部采样会把后半段判成「新 Trace」而丢弃。

第二个原因是多个 Collector 实例各自采样。尾部采样依赖同一 Trace 的所有 Span 进入同一个实例,Deployment 多副本加负载均衡必然打散。解法是前面挂一层 loadbalancing exporter,按 traceID 做一致性哈希,让同一 Trace 固定落到一个 collector 上。

Q3:Collector 启动报 x509 证书错误

按报错顺序排查三处:证书 SAN 是否包含连接时使用的主机名(用 IP 连接却只签了域名是最常见的坑)、client_ca_file 是否包含了签发客户端证书的那条链、证书是否已过期。用 openssl x509 -in server.crt -noout -text | grep -A1 "Subject Alternative Name" 一眼可查。

另外,Collector 的 TLS 默认要求 TLS 1.2 以上,如果对端是老旧设备只支持 TLS 1.0,握手会失败。这种情况不要降级协议,而是把该设备隔离到独立管道。

总结

链路数据脱敏做得对不对,取决于四个环节是否闭合:

  • 源头做减法:SDK 侧关闭 header 采集,不产生数据就不会泄露。
  • 出口做兜底:Collector 用 attributes 删凭据、用 OTTL 打码,一处配置全局生效。
  • 传输要加密:双向 mTLS,禁止明文 4318,防止伪造 agent 注入数据。
  • 能验证:造一条带敏感字段的 Trace 打进管道,用 debug exporter 看最终形态,把「应该脱敏了」变成「验证过脱敏了」。

最容易翻车的地方在顺序:memory_limiter 不在最前会因突发流量被 OOM 杀掉,采样在脱敏之前会白烧 CPU,batch 提前会把内存峰值顶起来。把这四个 processor 的位置固定成模板,比每次现场调参可靠得多。