首页
/ 深入解读 ttrpc 协议规范:Moby 内嵌 containerd 的超轻量进程间 RPC 帧协议

深入解读 ttrpc 协议规范:Moby 内嵌 containerd 的超轻量进程间 RPC 帧协议

2026-09-06 18:46:10作者:龚格成

导读

ttrpc(transparent ttrpc / thin ttrpc)是 containerd 子项目中的一个客户端/服务器 RPC 协议,专为同主机、低延迟、低内存占用场景而设计。Moby(Docker 引擎)在其内嵌 containerd 服务中实际使用了该协议——go.mod 声明依赖 github.com/containerd/ttrpc v1.2.9,因此 vendor/github.com/containerd/ttrpc/ 下的这份 PROTOCOL.md 是理解 Moby 内部进程间通信的关键文档。本文将带你逐字段拆解它的 10 字节消息头、三类消息与标志位、基于 local closed/remote closed 的流状态机,并对照本仓库内 vendor 源码与实际集成代码验证协议实现,读完你将能独立阅读任何 ttrpc 流量抓包,并理解为何它不适合替代公网 HTTP/2、却在同主机场景下如此轻快。

ttrpc 协议定位:为同主机低开销而生的非对称协议

ttrpc 是一个客户端/服务器协议,用于在单一连接上以极轻量的帧封装支持多个请求流(request streams)。从 PROTOCOL.md 开篇的定义看,协议具有三个核心特征:

  • 角色分明:发起底层连接的一方是客户端,接受连接的一方是服务器;
  • 当前非对称:只支持客户端发请求、服务器回响应;虽然两端都能发送流数据,但服务器主动发起流在最新版本中并不支持
  • 流 ID 奇偶约定:客户端发起的流使用奇数标识符,服务器发起的流使用偶数标识符(后者目前仅作预留约定,用于未来可能的扩展)。

协议设计目的在 Purpose 一节说得很直白:轻量、针对同主机进程间低延迟与可靠连接优化。因此它刻意不含握手、重置(reset)、心跳 ping、流控(flow control)等针对不可靠网络的功能——这些特性正是 HTTP/2 或 HTTP/3 必须内建的能力,ttrpc 选择把 net/httpnet/http2、TLS 等整个协议栈从依赖中剔除,换来更小的二进制与更低的内存占用(参见同目录 README.md 的定位 "GRPC for low-memory environments")。文档同时给出明确边界:它不打算成为 HTTP/2/3 在网络上的替代品

这一"同主机、轻量、单向发起"的设计在 Moby 中的落点非常典型:daemon/internal/containerd/server/embedded 在守护进程内嵌了一个 containerd 服务,通过本机 socket 与其内部插件通信,其中就独立暴露了一个 .ttrpc 地址并运行 ttrpc 服务端。我们会在后文结合该目录源码进一步印证协议在真实系统中的形态。

消息帧(Message Frame):10 字节固定头 + 数据体

协议的最小传输单元是消息帧(Message Frame)。每一帧由 10 字节的消息头(message header)和紧随其后的消息数据(message data)组成,字节布局如下(沿用规范原文的 ASCII 图):

+---------------------------------------------------------------+
|                       Data Length (32)                        |
+---------------------------------------------------------------+
|                        Stream ID (32)                         |
+---------------+-----------------------------------------------+
| Msg Type (8)  |
+---------------+
|   Flags (8)   |
+---------------+-----------------------------------------------+
|                           Data (*)                            |
+---------------------------------------------------------------+

各字段规格要点:

字段 位宽 编码 说明
Data Length 32 bit(4 字节) 大端(big-endian)无符号整数 Data 字段的字节数
Stream ID 32 bit(4 字节) 大端无符号整数 消息所属流的标识
Msg Type 8 bit(1 字节) 无符号整数 消息类型(见下表)
Flags 8 bit(1 字节) 无符号整数 类型相关标志,语义由消息类型决定
Data 变长(* —— 实际负载

协议对尺寸边界做了三条硬性约束,均可在 vendor 源码中一一对应:

  • 总帧大小 = Data Length + 10 字节
  • 单帧 Data 最大 4MB,超过应被拒绝。在 channel.go 中可见常量 messageHeaderLength = 10messageLengthMax = 4 << 20(即 4MB)的源码实现;接收路径若发现 mh.Length > messageLengthMax,会丢弃超限消息并以 grpc ResourceExhausted 状态码报错(同文件 recv() 函数)。
  • 由于最大数据长度小于 16MB,帧首字节恒为 0,该字节被视为保留字节(reserved),供未来使用

Stream ID 的取值规则再次强调:客户端发起的流必须是奇数,服务器发起的流为偶数——不过由于服务器主动流尚不支持,当前实际只会出现奇数 ID。10 字节头的读写细节(大端序、字段偏移)同样在 channel.gomessageHeader 结构体与 readMessageHeader/writeMessageHeader 中实现。

消息类型(Message Types)与标志位

协议当前定义了三种消息类型:

消息类型 名称 描述
0x01 Request 发起(初始化)一条流
0x02 Response 流的最终数据,并终止该流
0x03 Data 流数据传输

vendor/github.com/containerd/ttrpc/channel.gomessageTypeRequest = 0x1messageTypeResponse = 0x2messageTypeData = 0x3 三个常量原样实现了该枚举。

Request:发起流的语义由标志位表达

Request 消息用于发起一条流,同时携带用于正确路由和处理该流的请求数据。其流形态(unary 还是 streaming)完全由标志位决定,这是 ttrpc 与 gRPC 的一大差异——没有独立的控制帧,而是用请求的标志位在首帧就声明流的性质:

  • 空标志位 = unary 请求(兼容非流式客户端的关键约定:不设任何标志即一元请求);
  • remote open 标志 = 非 unary,远端仍在发送数据;
  • remote closed 标志 = 非 unary,但远端不会再发送更多数据。

Request 消息的标志位定义如下:

标志 名称 描述
0x01 remote closed 非 unary,但远端不再有更多数据
0x02 remote open 非 unary,远端仍在发送数据

源码常量 flagRemoteClosed = 0x1flagRemoteOpen = 0x2 定义于 channel.go。文档特别提示了 remote closed 的语义细节:当远端以该标志关闭时,说明远端仍期望收到一条响应或剩余流数据,流并未被放弃。

Response:一条流的数据或错误终结

Response 消息用于结束一条流,负载可以是最终数据、空响应或错误。规则要点:

  • unary 请求之后唯一期待的消息就是 Response
  • 非 unary 请求则不强制要求 Response——若服务器已通过流数据返回结果,可以不再发送 Response;
  • 非 unary 流也可以返回一条 Response,但一旦发送,其后不得再跟任何流数据

Response 标志位:当前未定义任何标志,Flags 字段应为空(0x00)

Data:已初始化流上的双向数据传输

Data 消息在已初始化的流上传输数据,客户端与服务器均可发送。它的约束包括:

  • 不允许出现在 unary 流上(unary 流只含 Request + Response 两个消息);
  • 向对端发出 remote closed 之后不得再发 Data
  • 流上最后一条 Data 必须设置 remote closed 标志

Data 标志位定义:

标志 名称 描述
0x01 remote closed 远端不再有更多数据
0x04 no data 本条消息不携带数据

no dataflagNoData = 0x4,见 channel.go)通常与 remote closed 组合使用:表示流已关闭且未传输任何数据,实现上等价于一个零长度的收尾信号。这里有一个非常精妙的细节值得注意:由于 ttrpc 习惯上每消息只传一个对象,零长度 Data 可被解释为"空对象"——例如以 protobuf 序列化整数 0,得到的编码长度恰为 0,此时消息仍应被视为"包含一个空对象的数据"并正常处理,而不是被当作没有数据。

流状态机:只用两个标志完成的流管理

所有 ttrpc 请求都通过**流(stream)**传输数据。流的两种形态对应完全不同的消息数量:

  • Unary 流:每条流只发送两个消息——客户端一个 Request、服务器一个 Response;
  • 非 unary 流:客户端与服务器都可能发送任意数量的消息,因此两端都需要跟踪额外状态,流管理远比 unary 复杂。

为了让这种管理尽可能简单,ttrpc 最小化状态数量,用两个标志位而非控制帧表达状态迁移。协议在流的生命周期内定义了恰好两个状态:

  • local closed(本地已关闭);
  • remote closed(远端已关闭)。

每个对端都从自己的视角区分 local 与 remote,并且总是站在"对端视角"来设置标志。这正是最容易绕晕、也最值得反复读的一处规则,规范原文的例子是:

若客户端发送一个带 remote closed 标志的 Data 帧,那表示客户端此刻进入 local closed,相应地服务器将(在它的视角)感知 remote closed

也就是说:我发 remote closed,宣告的是"我这边不再发了",等价于我自己 local closed

其余规则顺理成章:

  • unary 操作不必显式发送这些标志——因为 unary 流中接收到的每个消息本身就隐含 remote closed 语义(收完 Request 收 Response,流即终结);
  • 一旦某端同时处于 local closedremote closed,该流即被视为完成(finished),可以清理回收。

得益于协议的非对称性,状态先后顺序也是确定的:

  • 客户端应总是local closed、后 remote closed
  • 服务器应总是remote closed、后 local closed

原因是客户端总是发起请求的一方,且总是期待服务器返回最终响应以确认请求已被满足;因此即使服务器先于客户端发完数据,也可能需要补发一条空的最终 Response 来结束整条流。

Unary 状态图

规范用下列状态图描述 unary 流的完成路径(finished 表示流可清理):

         +--------+                                    +--------+
         | Client |                                    | Server |
         +---+----+                                    +----+---+
             |               +---------+                    |
      local  >---------------+ Request +--------------------> remote
      closed |               +---------+                    | closed
             |                                              |
             |              +----------+                    |
    finished <--------------+ Response +--------------------< finished
             |              +----------+                    |
             |                                              |

非 Unary 状态图

三种非 unary 情形分别对应客户端-服务器数据流向的组合(RC = remote closed 标志,RO = remote open 标志):

情形一:客户端流式发送(Client streaming)——请求带 [RO],数据全由客户端发送,末条数据带 [RC] 关闭,服务器回一条 Response 结束:

         +--------+                                    +--------+
         | Client |                                    | Server |
         +---+----+                                    +----+---+
             |             +--------------+                 |
             >-------------+ Request [RO] +----------------->
             |             +--------------+                 |
             |                                              |
             |                 +------+                     |
             >-----------------+ Data +--------------------->
             |                 +------+                     |
             |                                              |
             |               +-----------+                  |
      local  >---------------+ Data [RC] +------------------> remote
      closed |               +-----------+                  | closed
             |                                              |
             |              +----------+                    |
    finished <--------------+ Response +--------------------< finished
             |              +----------+                    |
             |                                              |

情形二:服务器流式发送(Server streaming)——请求带 [RC](客户端立刻声明本地关闭),服务器回送若干 Data,末条 Data 带 [RC] 关闭:

         +--------+                                    +--------+
         | Client |                                    | Server |
         +---+----+                                    +----+---+
             |             +--------------+                 |
      local  >-------------+ Request [RC] +-----------------> remote
      closed |             +--------------+                 | closed
             |                                              |
             |                 +------+                     |
             <-----------------+ Data +---------------------<
             |                 +------+                     |
             |                                              |
             |               +-----------+                  |
    finished <---------------+ Data [RC] +------------------< finished
             |               +-----------+                  |
             |                                              |

情形三:双向流式(Bidirectional streaming)——请求带 [RO],两端交替发送 Data,各自以末条带 [RC] 的 Data 声明本地关闭:

         +--------+                                    +--------+
         | Client |                                    | Server |
         +---+----+                                    +----+---+
             |             +--------------+                 |
             >-------------+ Request [RO] +----------------->
             |             +--------------+                 |
             |                                              |
             |                 +------+                     |
             >-----------------+ Data +--------------------->
             |                 +------+                     |
             |                                              |
             |                 +------+                     |
             <-----------------+ Data +---------------------<
             |                 +------+                     |
             |                                              |
             |                 +------+                     |
             >-----------------+ Data +--------------------->
             |                 +------+                     |
             |                                              |
             |               +-----------+                  |
      local  >---------------+ Data [RC] +------------------> remote
      closed |               +-----------+                  | closed
             |                                              |
             |                 +------+                     |
             <-----------------+ Data +---------------------<
             |                 +------+                     |
             |                                              |
             |               +-----------+                  |
    finished <---------------+ Data [RC] +------------------< finished
             |               +-----------+                  |
             |                                              |

综合三种情形可见设计哲学:流的"完整性保证"不依赖显式 close 控制帧,而是靠首帧(Request)标志位声明意图、末帧(带 RC 的 Data/Response)承载关闭语义,配合奇偶流 ID 与每流独立的收发状态,任何一端的收尾次序都是可判定的。

RPC 层:协议不管序列化,路由交给过程名

虽然 ttrpc 协议主要就是为远程过程调用(RPC)设计的,但协议本身并不定义 Request/Response 的具体业务类型——它只提供上面三种传输层消息。协议的约定收敛为一条:

所有实现至少应定义一个支持按过程名(procedure name)路由的请求类型,以及一个支持调用状态(call status)的响应类型

为了支持跨语言的默认互操作,本仓库 vendor 内提供了一份默认的 protobuf 定义(见 request.proto),其消息结构与文档"按过程名路由 + 携带调用状态"的要求一一对应:

syntax = "proto3";

package ttrpc;

import "google/rpc/status.proto";

message Request {
    string service = 1;          // 目标服务名,用于路由
    string method = 2;           // 过程名/方法名,用于路由
    bytes payload = 3;           // 序列化后的调用参数
    int64 timeout_nano = 4;      // 超时(纳秒)
    repeated KeyValue metadata = 5; // 元数据
}

message Response {
    google.rpc.Status status = 1; // 标准调用状态(错误码/错误信息)
    bytes payload = 2;           // 序列化后的返回值
}

从这组默认定义可以看出 RPC 语义与流的对应:Request.payloadservice + method 完成服务发现与方法分发,Response.status 复用 google.rpc.Status 提供跨语言一致的错误表达。也就是说——传输是否流式由上一节的消息标志位决定,而"调用了哪个方法、成功还是失败"由这一层的 protobuf 请求/响应结构承载,两层解耦得非常干净。该文件对应的生成代码为同目录 request.pb.go

版本演进:1.0 仅有 unary,1.2 加入流式

协议版本历史非常简洁:

版本 功能
1.0 仅支持 Unary 请求
1.2 加入流式(Streaming)支持

对照本仓库实际 vendored 的版本 github.com/containerd/ttrpc v1.2.9go.mod),其协议能力已包含全部流式特性,即本协议规范中"Unary + 三种非 Unary 流"的完整状态机均已可用;协议的 1.0/1.2 版本主要影响两端实现必须协商到的能力集,1.2 之后的消息头、标志位与状态机语义没有再被破坏性调整。

在 Moby 中的实际集成:ttrpc 服务端如何被使用

回到当前仓库,ttrpc 并非孤立的理论协议,而是 Moby 内嵌 containerd 架构中真实运转的一环。集成点集中在 daemon/internal/containerd/server/embedded/ 目录:

  • 内嵌 server 在标准 containerd socket 之外,另以 <address>.ttrpc 命名暴露一个独立的 ttrpc 监听地址(server.go),并单独启动名为 "ttrpc" 的 serve 协程(同文件 serve("ttrpc", ttrpcL, srv.ServeTTRPC))。
  • containerd.go 中通过 ttrpcService 接口(RegisterTTRPC(*ttrpc.Server) error)让内嵌 containerd 的各插件服务把自己的服务注册到 ttrpc Server 上,随后 srv.ttrpcServer.Serve(s.serveCtx, listener) 开始对外服务;连接被优雅关闭时还会处理 ttrpc.ErrServerClosed
  • 服务端握手(handshake)在 Linux 下被用来做同用户校验embedded_linux.go 创建 ttrpc Server 时传入 ttrpc.WithServerHandshaker(ttrpc.UnixSocketRequireSameUser())——这正是 handshake.goHandshaker 接口的典型用途:连接建立时通过 unix socket 凭据确认客户端与守护进程属于同一用户,从而在剥离 HTTP/TLS 等重协议后,仍然把"同主机 + 可信调用方"的安全边界补回来(这也呼应了协议规范中"可靠性交给底层连接"的设计前提)。
  • 端到端行为在 containerd_test.go 有测试佐证:测试直接 ttrpc.NewServer() 并通过 RegisterService("test", &ttrpc.ServiceDesc{...}) 注册服务,用 ttrpc.NewClient(conn) 发起真实调用,还验证了服务关闭后的请求取消与客户端调用返回路径。

从这套集成可以反推协议在系统中的真实分工:Moby 与内嵌 containerd 之间,需要低延迟、低资源开销的本地方法调用(如容器运行时操作);ttrpc 以 10 字节帧头 + 2 状态标志的最小机制承载这些高频同主机调用,同时用 unix socket 凭据握手替代网络层的认证与加密——这就是 PROTOCOL.md 所述定位在真实容器生态中的具体落地。

小结:读协议时该记住的五件事

  1. 帧结构固定:10 字节头(4B Data Length + 4B Stream ID + 1B Type + 1B Flags)+ Data,均为大端序,单帧 Data 上限 4MB,帧首字节保留;
  2. 三类消息Request(0x01) 开流、Data(0x03) 传流数据、Response(0x02) 终流,unary 流只有 Request+Response 两帧;
  3. 标志即状态机remote closed(0x01)/remote open(0x02)/no data(0x04),用"对端视角"设置标志,先本地关闭再远端关闭(客户端序),流两端均 closed 即 finished;
  4. 流 ID 分奇偶:客户端流为奇数、服务器流为偶数(服务器流暂不支持);
  5. RPC 与传输解耦:协议只保证流与帧的语义,方法路由与调用状态交给 service/method + google.rpc.Status 形态的 protobuf 层(request.proto)。

若想继续深挖,推荐按此顺序阅读本仓库源码:先看 channel.go 理解帧读写与 4MB 上限校验,再看 stream.go 的每流收发缓冲与背压实现(接收缓冲满时最长等待 1 秒,超时以 ErrStreamFull 关闭该流以保持接收主循环对其他流的处理),最后回到 containerd.go 观察它如何在 Moby 内嵌 containerd 中以 .ttrpc socket 对外服务。协议全文始终以仓库内 PROTOCOL.md 为权威依据。

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