分布式追踪把一次请求经过的每个服务串成一条 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 的位置固定成模板,比每次现场调参可靠得多。