OpenTelemetry 多租户分流怎么做:按服务名路由 traces 的实战方案
OpenTelemetry 多租户分流怎么做:按服务名路由 traces 的实战方案
一、问题背景:单租户 Collector 不够用了
我们平台早期只有一个业务方,OpenTelemetry Collector 收到 SDK 上报的 trace 后,一股脑往 Jaeger 后端写,看着挺干净。但从 Q2 开始,业务方从 1 个变成了 6 个:3 个 toB 业务线、2 个内部中台、1 个支付子链。每个业务方都要求"我的 trace 别跟别人混在一起"——倒不是技术洁癖,主要是:
- 合规审计要求:支付子链的 trace 必须留存在我们自建的存储里,不能外发到第三方 SaaS。
- 成本分摊:toB 业务线按 trace 量结算,要能按 service.name 切片统计每家用了多少。
- 排查干扰:A 业务的告警如果命中 B 业务的 trace,定位效率会塌方。
一开始我天真地让每个业务方起一套独立的 OTel Agent + Collector + Jaeger,结果运维同学一周内就来找我骂街:6 套 collector、6 份配置、6 个 dashboard、6 个告警通道,更别说每多一套就多一个证书、多一个升级窗口。
这才回过味来:多租户的本质是"一份管道,多路出口",而不是"多份管道,每家一个"。今天我把最终跑通的方案拆给你看,按 service.name 路由 traces 到不同后端,配合独立的采样和落库策略。
一些关键的判断点
写方案之前,先明确两件事:
- 不要把"租户"和"服务"混为一谈:service.name 是 SDK 上报时打的标签,分流天然按它走;如果租户边界比服务更细(比如一个业务方有多个子服务,但数据要按业务方隔离),就需要再加一层 tenant_id 属性。
- 路由在 Collector 做,不在 SDK 做:让 SDK 关心业务,让 Collector 关心路由。SDK 只管打标签,Collector 的 connectors + routing processor 才是干这个的。
二、整体架构
[App SDK] --OTLP--> [Gateway Collector] --route--> [Tenant A Backend] (Jaeger) \--> [Tenant B Backend] (Tempo) \--> [Tenant C Backend] (Datadog) \--> default sink (ClickHouse)三层 Collector 的设计:
- Gateway (Agent 模式): 收 SDK 上报,做限流、auth、批处理,统一入口。
- Router (Collector 模式): 核心路由层,按 service.name / tenant_id 决定把 trace 转发到哪。
- Tenant Backend: 每个租户独立的后端存储,可以是 Jaeger / Tempo / ClickHouse / Datadog / S3+Athena 任意组合。
为什么不直接让 SDK 发到不同后端?因为 SDK 不该知道"后端在哪"。路由下沉到 Collector 后,未来加租户只需要改 Collector 配置,App 一行代码不动。
三、关键配置:Router Collector
下面是生产环境跑了大半年的 Router Collector 核心配置。注解直接写在 yaml 里:
# /etc/otelcol-contrib/config.yamlreceivers:otlp:protocols:grpc:endpoint:0.0.0.0:4317http:endpoint:0.0.0.0:4318processors:# 1. 批处理:聚合后批量转发,降低下游压力batch:timeout:5ssend_batch_size:8192# 2. 内存限制:保护 Collector 自身,防止 OOMmemory_limiter:check_interval:1slimit_percentage:80spike_limit_percentage:25# 3. 路由:核心!按 service.name 决定走哪条 export 链路routing/traces:default_pipelines:[traces/default]error_mode:ignoretable:# 租户 A:支付子链,必须落自建 Jaeger-context:resourcestatement:route() where attributes["service.name"]== "pay-gateway" \ or attributes["service.name"]== "pay-settlement"pipelines:[traces/tenant_a]# 租户 B:toB 业务线 A-context:resourcestatement:route() where attributes["service.name"]== "b2b-inventory" \ or attributes["service.name"]== "b2b-order"pipelines:[traces/tenant_b]# 租户 C:toB 业务线 B,要求用 Datadog-context:resourcestatement:route() where attributes["service.name"]== "b2b-report"pipelines:[traces/tenant_c]# 4. 不同租户独立采样(支付链路全采,报表链路 10% 采样)tail_sampling/tenant_a:decision_wait:10snum_traces:50000expected_new_traces_per_sec:5000policies:-name:keep-alltype:always_sampletail_sampling/tenant_b:decision_wait:10snum_traces:30000policies:-name:errors-onlytype:status_codestatus_code:{status_codes:[ERROR]}-name:slow-tracestype:latencylatency:{threshold_ms:500}-name:probabilistictype:probabilisticprobabilistic:{sampling_percentage:10}exporters:# 租户 A:自建 Jaegerotlp/tenant_a:endpoint:jaeger-tenant-a.internal:4317tls:insecure:falsecert_file:/etc/certs/jaeger-tenant-a.crtkey_file:/etc/certs/jaeger-tenant-a.keysending_queue:{enabled:true,num_consumers:10,queue_size:5000}retry_on_failure:{enabled:true,initial_interval:1s,max_interval:30s}# 租户 B:自建 Tempootlp/tenant_b:endpoint:tempo-tenant-b.internal:4317sending_queue:{enabled:true,num_consumers:10}# 租户 C:Datadog SaaSdatadog/tenant_c:api:key:${env:DD_API_KEY}site:datadoghq.com# 默认出口:长期冷存储 + 成本结算otlphttp/default:endpoint:https://clickhouse-traces.internal/v1/tracestls:{insecure:false}service:telemetry:logs:{level:info}pipelines:traces:receivers:[otlp]processors:[memory_limiter,batch,routing/traces]# routing 后会自动衍生出 traces/tenant_a, traces/tenant_b 等子 pipeline几个容易踩坑的地方:
routingprocessor 配完之后,真正的子 pipeline 是运行时衍生出来的,配置里写不写都无所谓,Collector 会自动生成traces/<pipeline_name>。default_pipelines一定要配,否则没匹配上的 trace 会被直接丢弃——我们一开始漏了这条,报警响了才发现有 30% 的 trace 不见了。error_mode: ignore比silent安全,前者会记日志,后者完全静默,排查时找不到线索。
四、踩过的坑
坑 1:service.name 写错,trace 全跑到 default 出口
SDK 默认把 service.name 设成unknown_service:<进程名>,结果一启动全跑到 default。强制要求所有 SDK 在启动时显式覆盖 service.name,我们用 lint 工具(OpenTelemetry SDK 提供的validate_service_name检查器)卡 CI,没填或填了unknown_开头的直接拦在流水线前。
坑 2:tail_sampling 改变了 trace 的完整性
在 Router Collector 上做 tail sampling 时,所有租户的 trace 都先汇总到这里再决定留还是丢。这意味着 tenant_a 的采样窗口(10s)会卡住 tenant_b 的转发速度。生产上我们做了两件事:
- 把
tail_sampling放到每个租户的 Backend Collector 而不是 Router Collector,让路由零延迟。 - 在 Router 上只做 head sampling(按概率粗筛),把 90% 的噪声提前干掉,剩下的 trace 再交给租户后端精筛。
坑 3:批量 + 路由的顺序反了
routingprocessor 必须在batch之后。否则一条 batch 里 5 条 trace 分属 3 个租户,被一次性发到第一个匹配的下游,路由就废了。实际写配置时把 routing 放 batch 后是 OK 的,但反过来绝对不行。
坑 4:租户后端挂了,整个路由链路卡死
我们用sending_queue + retry_on_failure隔离每个租户后端的故障:tenant_a 的 Jaeger 短暂不可达时,queue 会兜住,retry 30 秒一次,其他租户完全不受影响。这块的sending_queue.num_consumers不能开太大,否则一个慢后端会吃满 Collector 的连接池。
五、成本与可观测性收益
上线半年后的几个关键数据:
- 存储成本:分租户后,报表类业务(trace 量最大但价值最低)只采 10%,落 ClickHouse 冷存储,月度成本降了 62%。
- 排障效率:支付链路全量采独立存储后,P0 故障平均定位时间从 18 分钟降到 6 分钟——主要是不再被无关 trace 干扰。
- 接入新租户耗时:从原来的"开 6 套 collector + 6 套配置"变成"改一份 Router yaml 加 3 行",最快的一次 25 分钟完成新租户接入。
六、写在最后
把多租户分流做好,关键是认清三层角色:SDK 只管打标签,Router 只管分发,Backend 才管存储和查询。任何一层越界(比如让 SDK 知道后端地址、让 Backend 做路由),后续都会被自己埋的单点故障咬回来。
这套方案现在我们 6 个业务方共用一份 Router 配置,扩第 7 个租户基本就是改 yaml 的活。如果你也在搞多租户可观测性,希望这篇能给你一些可落地的参考。
EOF