首页
/ 为 ttRPC 接入 OpenTelemetry 可观测性:otelttrpc 拦截器集成原理与实战

为 ttRPC 接入 OpenTelemetry 可观测性:otelttrpc 拦截器集成原理与实战

2026-09-06 18:42:05作者:牧宁李

导读

Moby 容器生态大量依赖 containerd 与 ttRPC(一种精简的 RPC 协议)实现组件间通信,而本文档介绍的 otelttrpc 正是 containerd 官方提供的、为 ttRPC 客户端与服务端自动生成 OpenTelemetry 链路追踪 Span 的 Go 插桩库(在本仓库中以 vendor/github.com/containerd/otelttrpc 形式随依赖一同分发)。读完本文,你将掌握如何用两个拦截器为 ttRPC 的 unary 调用接入分布式追踪上下文、如何理解其自动生成的 Span 属性与状态码语义、如何通过 Option 定制 Propagators / TracerProvider / MeterProvider 与消息事件,并能从 shim.go 等真实调用点看到它的落地方式。

otelttrpc 是什么

otelttrpc 是一个实现 OpenTelemetry 插桩支持的 Go 包,包名为 github.com/containerd/otelttrpc(源码 doc.go)。其核心价值在于:无需手工埋点,只要在创建 ttRPC client / server 时把两个拦截器传进去,所有经过它们的 unary RPC 方法调用就会自动生成 trace span;若进程其余部分已按 OpenTelemetry 规范配置好采集与导出,这些 span 就会出现在收集到的链路中。

它是 containerd 的子项目,采用 Apache 2.0 许可(见 LICENSE)。在 Moby / moby 仓库中,它作为 containerd 依赖被 vendored 进来,并被 containerd v2 的 shim 层实际引用(例如 vendor/github.com/containerd/containerd/v2/core/runtime/v2/shim.go 中的 ttrpc.WithUnaryClientInterceptor(otelttrpc.UnaryClientInterceptor()),以及 vendor/github.com/containerd/containerd/v2/pkg/shim/shim.go),可见这是一个已被生产代码采纳的官方可观测性方案,而非实验室玩具。

快速上手:在 client 与 server 中启用插桩

客户端侧:UnaryClientInterceptor

对于发起 RPC 的一方,使用 otelttrpc.UnaryClientInterceptor() 包装 ttRPC 的 UnaryClientInterceptor,再作为 ttrpc.ClientOpts 传给 ttrpc.NewClient

import (
    "github.com/containerd/ttrpc"
    "github.com/containerd/otelttrpc"
)

// on the client side
...
client := ttrpc.NewClient(
    conn,
    ttrpc.UnaryClientInterceptor(
        otelttrpc.UnaryClientInterceptor(),
    ),
)

服务端侧:UnaryServerInterceptor

对于提供服务的一方,把 otelttrpc.UnaryServerInterceptor() 作为服务端拦截器,通过 ttrpc.WithUnaryServerInterceptor 传给 ttrpc.NewServer

// and on the server side
...
server, err := ttrpc.NewServer(
    ttrpc.WithUnaryServerInterceptor(
        otelttrpc.UnaryServerInterceptor(),
    ),
)

启用之后,拦截器会对所有“被调用”与“被服务”的 unary 方法调用生成 trace span;两条代码路径的核心签名与执行逻辑都可以在 interceptor.gointerceptor.go 找到。

注意:README 提到的完整示例程序 example/client/main.goexample/server/main.go 存在于 otelttrpc 上游仓库;在本仓库的 vendor 分发目录中未包含 example 目录,但上方的两段代码就是最小可用骨架。

拦截器内部原理:Span 是如何自动生成的

客户端执行链路

UnaryClientInterceptorinterceptor.go)返回一个标准 ttrpc.UnaryClientInterceptor 闭包,其内部流程为:

  1. spanInfo(info.FullMethod, peerFromCtx(ctx)) 计算 span 名与属性;
  2. trace.SpanKindClient 开启 span,defer span.End() 保证收尾;
  3. 通过 inject(ctx, cfg.Propagators, req) 把当前 trace context 注入请求元数据,实现跨进程传播;
  4. 调用真正的 invoker(ctx, req, reply) 发起 RPC;
  5. 结束后依据错误把 span 置为 Error 或 OK,并写入 rpc.ttrpc.status_code 属性。

服务端执行链路

UnaryServerInterceptorinterceptor.go)则:

  1. 先用 extract(ctx, cfg.Propagators) 从请求元数据中还原上游的 remote span context,实现链路串联;
  2. trace.SpanKindServer 开启 span;
  3. 调用业务 method(ctx, unmarshal) 执行请求处理;
  4. 通过 serverStatus 把 RPC 状态码映射为 span 状态。

状态码到 Span 状态的映射规则

服务端错误映射定义在 interceptor.go:当 RPC 状态码为 UnknownDeadlineExceededUnimplementedInternalUnavailableDataLoss 时,span 置为 Error 并带上错误消息;其他状态码则保持 Unset。这个映射沿用了 OpenTelemetry 对 RPC 系统的推荐做法——只把真正代表服务端/传输故障的码记作错误,客户端传来的业务错误(如 InvalidArgument)不会污染链路状态。

自动携带的属性与消息事件

RPC 语义属性(semconv)

otelttrpc 同时复用了 OpenTelemetry 的通用 RPC 语义约定与自定义的 ttRPC 属性,定义集中在 semconv.go

属性键 类型 含义 取值说明
rpc.system string 远程调用体系 固定为 ttrpcRPCSystemTTRPC
rpc.service string RPC 服务名 由 FullMethod 解析出的 service 段
rpc.method string RPC 方法名 由 FullMethod 解析出的 method 段
rpc.ttrpc.status_code int ttRPC 请求状态码 数值化的 gRPC-style 状态码(定义见 config.go
net.sock.peer.addr / net.sock.peer.port string/int 对端地址 peer 为 IP 时的语义属性(interceptor.go
net.peer.name / net.peer.port string/int 对端主机名 peer 为主机名时的语义属性

其中 rpc.service / rpc.method / span 名来自对 FullMethod 的解析。解析逻辑在 internal/parse.go:先去掉前导 /,再按 / 切分为 /package.service/method 两段,服务名与方法名分别写入 rpc.servicerpc.method 属性;格式非法时不产生这两个属性,只返回方法名作为 span 名。

消息事件(可选)

默认只会在请求结束时添加上述“汇总属性”,若需要把收发消息也记录为 Span 事件,可通过 WithMessageEvents 打开(详见下文 Option 一节)。打开后拦截器会调用 span.AddEvent("message", ...),事件属性键为 message.type(取值 SENT / RECEIVED)与 message.id(数值消息序号),实现在 interceptor.gosemconv.go

定制 Option:Propagators、Provider 与消息事件

拦截器是可配置的,所有配置通过 Option 接口实现,默认配置定义在 config.go。未指定时,它会自动取用 OpenTelemetry 的三个全局对象:otel.GetTextMapPropagator()otel.GetTracerProvider()otel.GetMeterProvider(),因此在已做全局初始化的应用中“零配置”即可使用。以下是全部可用 Option:

WithPropagators

func WithPropagators(p propagation.TextMapPropagator) Option

设置用于向请求注入、从请求提取 trace context 的传播器。不传则用全局 TextMapPropagator(通常是 TraceContext + Baggage 的组合)。传播是跨进程链路得以串联的关键:客户端发送前注入,服务端接收后提取。

WithTracerProvider

func WithTracerProvider(tp trace.TracerProvider) Option

设置创建 Tracer 所用的 TracerProvider,便于在测试或需要多 Provider 的场景下隔离 span 输出,不传则用全局 Provider。

WithMeterProvider

func WithMeterProvider(mp metric.MeterProvider) Option

设置创建 Meter 所用的 MeterProvider,不传则用全局 Provider。Meter 会在构造配置时注册名为 rpc.server.duration 的 Int64Histogram 指标(单位 ms,见 config.go),因此该包除链路追踪外还同时提供服务端 RPC 耗时指标能力——服务端拦截器每次请求结束后都会以毫秒为单位记录耗时并附带 RPC 属性(interceptor.go)。

WithMessageEvents

func WithMessageEvents(events ...Event) Option

配置拦截器在 span 上记录消息事件(默认只记录汇总属性)。可选事件为两个常量:

  • ReceivedEvents:为每条接收到的消息记录事件;
  • SentEvents:为每条发送出的消息记录事件。

例如只关心发送方向时写作 WithMessageEvents(otelttrpc.SentEvents),两者都要则 WithMessageEvents(otelttrpc.SentEvents, otelttrpc.ReceivedEvents)

组合用法示例:

client := ttrpc.NewClient(
    conn,
    ttrpc.UnaryClientInterceptor(
        otelttrpc.UnaryClientInterceptor(
            otelttrpc.WithPropagators(propagation.NewCompositeTextMapPropagator(
                propagation.TraceContext{}, propagation.Baggage{},
            )),
            otelttrpc.WithTracerProvider(tp),
            otelttrpc.WithMessageEvents(otelttrpc.SentEvents),
        ),
    ),
)

上下文传播的实现细节:metadataSupplier

ttRPC 没有 gRPC 那样的 metadata 插件生态,因此 otelttrpc 自己实现了 propagation.TextMapCarrier 的适配层,见 metadata_supplier.go

  • metadataSupplier 包装 *ttrpc.MD,实现 Get / Set / Keys,并通过编译期断言 var _ propagation.TextMapCarrier = &metadataSupplier{} 保证接口契约(metadata_supplier.go);
  • inject:从 context 取 metadata,若不存在则新建;为避免并发读写 panic,先 Clone() 一份再注入;随后合并请求自带 metadata 与注入值,以 context 中注入的传播键为准(保留 req 中不冲突的键,冲突键取注入值),最后写回 req.Metadatametadata_supplier.go);
  • extract:读取 context 中的 metadata,交给 propagators.Extract 还原远程 span context(metadata_supplier.go)。

理解了这层适配后,需要排查“Span 为什么没串联成一条完整链路”时,重点即可落在两端 Propagator 是否一致、metadata 是否在自定义转发逻辑中被剥离这两个常见环节上。

在 Moby 仓库中的真实落地

观察本仓库可以发现 otelttrpc 并非孤立工具,而是被 containerd 的实际组件所采用:

这意味着当 containerd shim 进程启用 OpenTelemetry SDK 时,shim 与外部调用方之间的 ttRPC 交互即可被追踪,而 Moby 作为上层容器运行时在构建镜像、拉取镜像等场景中会与这些组件深度耦合。如果你的自定义组件与 containerd shim 之间也走 ttRPC,采用与 shim 相同的 otelttrpc 插桩即可让链路无缝贯通。需要留意的是,vendor 内该包的 Version() 目前固定返回 0.0.0version.go),因此采集到的 instrumentation 版本标识在 Moby 的 vendor 场景下并不反映真实发布版本。

已知限制与选型提醒

官方 README 明确指出的局限是:

目前只有 unary client 与 unary server 方法可以被插桩,对流式接口(streaming)的支持尚未实现。

因此:

  • 若你的 ttRPC 接口全部是请求—响应型 unary 调用,otelttrpc 可完整覆盖链路与耗时指标需求;
  • 若涉及 streaming 服务,这些调用暂时不会生成 span,只能考虑基于 Metrics(该包已提供 rpc.server.duration 直方图)做耗时观测,或等待上游实现流式插桩。

一句话总结它的定位:用两个拦截器 + 若干 Option,在不侵入业务代码的前提下,把 ttRPC 的 unary 远程调用完整接入 OpenTelemetry 的 Trace 与 Metrics 体系——这正是它被 containerd shim 生产环境选用的原因。

登录后查看全文
热门项目推荐
相关项目推荐