背景脱敏:一个 Go 微服务项目,十几个服务跑在 K8s 上,此前只有 Prometheus 指标、没有链路追踪。三周内做了「选型 → 试点 → 全量接入 → 换后端」的完整一轮。本文记录的不是怎么接 OTel,而是选型时看中的能力最后一个都没用上、真正救场的反而是最朴素的那部分。
选型:为什么没选 Jaeger
候选有三个:某商业 OTel APM(自带 Web UI、Doris 存储、AI 根因分析)、Jaeger all-in-one、只起一个 Collector 落文件。
选了第一个。理由写在设计文档里,很清楚:试点目的不只是「有 trace」,还要验证「AI 查 trace 排障」这条工作流。Jaeger 是保守备选。
后面会看到,这个「主要理由」最后一次都没兑现。而当时说服自己的另一句话——「反正走 OTLP 标准协议,随时可换后端」——倒是三周后救了场。
接入:业务代码零改动
埋点做在自研 framework 的 filter 插件体系里,和已有的 prometheus filter 同构:
- filter 经
init()自注册,服务配置的server.filter/client.filter列表加一行tracing就启用; - trace context 复用 Kratos 原生 OTel 实现,经 gRPC metadata 传播;
- 三个环境变量控制开关、采样率、后端地址,删掉即回退。
采样口径是团队定的:正确路径万分之一,错误全量。正式环境因为只有单节点存储,正确路径给到 1%——1% 已经足够看瓶颈分布,再高就是拿被测系统的开销换没用的数据。
导出是异步批量的,后端挂了只丢 span 不阻塞业务。这一条专门做了故障演练:把 ingest 缩容到 0,期间跑一条完整的核心业务链路(登录→进入房间→落座→操作→结算),业务零感知。
接入过程有一个坑值得单独说。第一版上线后发现 client 侧不注入 traceparent,跨服务串不起来。原因是 Kratos 的 tracing middleware 依赖 kratos transport 的 context,而 framework 自建的 client 拦截器只有自己 codec 层的 message 上下文,两者不通。修法是补 codec 通道——把 Message.RequestHeader 直接当 TextMapCarrier 用,双向才打通。
如果你的 RPC 框架不是原生 Kratos/gRPC,而是套了一层自研 codec,这一层几乎一定要自己接。
第一个意外:错误率不能直接看
接入后第一件事是看错误率。结果没法用——APM 把护栏的主动行为也标成了 error。
我们的服务上有两类保护机制会「主动失败」:
| 噪音 | 特征 | 真实语义 |
|---|---|---|
| 舱壁拒绝 | rpc.status_code = "429" |
过载保护主动拒绝,护栏正在正常工作 |
| 路由陈旧快速失败 | 自定义错误码 | 实例路由过期后快速失败,随即重试成功 |
这两类在 span 上都是 error=1。实测一个 30 分钟窗口:路由陈旧类 12965 条、舱壁拒绝 62 条,而真实错误(500/504/无码)合计不到 700 条。噪音是真实错误的 20 倍。
最极端的例子:某个游戏服 7137 个「错误」,100% 是噪音,真实错误 0。而另一个服务的 395 个错误里噪音 0,全是真的。原始错误率把这两种截然相反的局面显示成了同一个数量级,等于没有这个指标。
于是定义了净错误率:
净错误率 = (error span 数 − 其中 status_code ∈ 噪音集合 的数量) / 总 span 数
分母保持全量不变,只从分子剔噪,保证和原始口径能直接对比。效果立竿见影——某服务某个 5 分钟桶,原始错误率 1.4%,净错误率 0.17%。剔噪后仍然显著高于日常水位(约 0.03%),说明那个时段确实有值得查的真实错误。如果只看原始的 1.4%,你根本不知道它是护栏在干活还是系统在出血。
这个口径后来固化成了文档里的几条 SQL,并在一次容量压测中作为「双源监控」的一路(另一路是 Prometheus 面板 + 错误日志扫描),产出了各服务满载水位的基线:核心服务 0.6~0.8%,其余低于 0.07%,峰值超标时能立刻定位到具体的超时类型。
这一条是本文最有普适价值的部分。 现代服务普遍有熔断、限流、快速失败、优雅降级——它们在 APM 眼里全是 error。接入 APM 后的第一件事,应该是把「主动失败」和「被动失败」分开,否则错误率这个指标从第一天起就是废的。
第二个意外:想做个看板,发现没有这个功能
净错误率口径定好后,自然想在 APM 里做成一个常驻看板。实探结论是:做不了。
- 全局大盘是写死的固定页面,前端路由和后端控制器都没有自定义卡片入口;
- 后端不存在任何 dashboard 的 CRUD 接口,相关路径全部 404,翻源码也没有;
- 唯一可自定义的是它的 AI 技能模块,有完整的增删改查——但技能正文不入库,重启就没了,持久性不达标;
- ingest 侧也没有任何过滤能力,所以噪音没法在写入前剔除,只能做成查询期口径。
结果就是:所有定制分析全部绕过产品层,直接 SQL 查它底层的 Doris。 查询有两条通道,一条走它自己的只读查询 API(免 K8s 权限),一条 kubectl exec 进存储节点直连;两条通道在同一个固定时间窗口做过逐行比对,结果一致。
用下来,这套 APM 对我们的实际形态就是:一个带 Web UI 的 OTLP 收数端 + 一个能被 SQL 直查的列存数据库。UI 用来看单条调用链慢在哪一跳,其余全靠自己写 SQL。
至于当初选它的主要理由——AI 根因分析、AI 排障工作流——三周里一次都没进入日常。不是它不好用,是我们从来没把它排进任何一个验收项,于是它就自然地没有被用起来。
换型:一个环境变量的事
三周后要给一个海外的边缘集群加链路观测。那个集群只有单个工作节点、8 核 15G。这套 APM 的存储底座常态就要 6.5G,性价比明显不成立。
换成 Tempo 单体 + 错误优先采样。换型动作实际是:改一个 TRACE_OTLP_ENDPOINT 环境变量。
因为整套接入建立在 OTLP 标准协议之上,业务代码、framework 插件、采样配置一行没动。为了稳妥,先在正式环境做前验——把三个服务的 endpoint 指到临时部署的 Tempo,观察一段时间再切回去,不动任何账本,下次发版自动恢复,天然回退。
前验数据:
| 存储底座 | 实测占用 | |
|---|---|---|
| 原 APM | Doris | 常态 4.4 Gi |
| Tempo 单体 | 本地/对象存储 | 1m CPU / 22 Mi 内存 |
相差两个数量级。 span 正常入库、跨服务组树正确、滚动重启和 endpoint 双向切换全程业务零感知。原产品的镜像留在仓库里作 Plan B,将来要切回去同样是改一个环境变量。
已知边界
几条接入后才发现的限制,供参考:
- 调用方没开 tracing 会断链。没接入的服务发起的调用,在下游表现为 root 单跳,看起来像是「请求凭空出现」。要么全量接,要么接受链路有洞。
- 长连接场景下单条 trace 会膨胀。WebSocket 会话级的 context 被复用,导致一条 trace 挂了 58 个 span。按会话建 trace 是错的,应该按请求。
- 多环境共用一个后端必须加前缀。否则测试环境和正式环境的同名服务数据混在一起,这个坑是靠给 service 名加
<环境>-<服务>前缀解决的。 - 内网拉不到公网镜像是常态。这套东西五个镜像,全部得先转推到公司内部仓库。选型时把「镜像有几个」也算进部署成本。
教训
- 选型的决定性理由,必须在试点期就排进验收项,否则它不会自己发生。我们因为「AI 排障」选了 A 不选 B,然后从来没验证过这条工作流。真正被高频使用的,反而是「能直接 SQL 查存储」这个最朴素的能力。
- 产品的自定义能力要在选型阶段实探,不能看宣传页。自定义看板、写入侧过滤这两项,都是等到要用时才发现根本不存在,被迫把分析口径全部落到自己维护的 SQL 文档里。好在这些实探结论写进了文档,后面的人不用再排查一遍。
- 给 APM 接入排一条「噪音清理」的前置任务。有护栏的系统,错误率指标默认是废的,需要先定义什么叫真错误。
- 标准协议是最好的保险。这是唯一一条在选型时就写对、并且在三周后完整兑现价值的决策——换后端只改一个环境变量,不碰任何业务代码。选型时觉得这句话是套话,换型时才知道它值多少。