TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

HTTP/2 上的 gRPC:头部、5 字节前缀、Trailer 中的状态码

状态:草稿(2026-10-17),部分实测。 基准版本 grpc/grpc v1.84.0 doc/PROTOCOL-HTTP2.md·doc/statuscodes.md,grpc/grpc-go v1.83.2。 状态码分布是通过在 2026-10-17 03 时段(KST)的小型集群中仅读取 Prometheus 和控制节点 A 的 etcd /metrics 获得的。 我们没有抓取实际的帧(未实测,所需工具:grpcio 或 grpc-go 示例服务端、Wireshark 等抓取工具)。因此,第 3、4、5 节的字节是 根据规范和代码构建并计算出来的。

目录

1. 直觉:信封、信件、结果印章

联想到挂号信就很容易理解了。 信封(请求头部)上写着接收部门、业务名称和处理期限。里面装着 多封信件(消息),每张信件上都贴着一条「本页多少字节」的标签。处理结果则是最后盖上的 结果印章(Trailer)。邮局(HTTP)返回 200 表示投递成功,但业务是否成功必须看印章。

由于这种结构,gRPC 可以逐张流式传输响应(流式传输),并在最后附加状态。HTTP/1.1 的状态行必须放在最前面,因此很难做到这一点。

2. 图解

단항 호출 한 번이 HTTP/2 프레임으로 클라이언트 서버 HEADERS stream=1 :path /tt.v1.Scoreboard/Hit content-type: application/grpc · te: trailers DATA END_STREAM 00 00000015 + Rally 21바이트 HEADERS :status 200 · content-type DATA 00 + 길이 4바이트 + 응답 메시지 HEADERS END_STREAM 트레일러 grpc-status: 0 오류가 바로 나면 트레일러만 보낸다(Trailers-Only): :status 200 · grpc-status: 5 · grpc-message: %EB%9E%A0… HTTP 상태는 성공(200)이어도 호출의 성패는 grpc-status 가 정한다.
图解:单次一元调用的 HTTP/2 帧顺序与 Trailers-Only 响应

3. 请求头部

总结规范中的 Call-Definition 如下(PROTOCOL-HTTP2.md Requests 节)。

头部 值 原因
:method POST 始终为 POST
:path /tt.v1.Scoreboard/Hit 方法标识。区分大小写
:authority 目标名称 虚拟主机
te trailers 用于过滤掉无法处理 Trailer 的代理的标志
content-type application/grpc(+proto·json…) 否则服务端应返回 415 拒绝
grpc-timeout 8 位及以下整数 + 单位(H·M·S·m·u·n) 剩余时间。如果没有则为无限
grpc-encoding, grpc-accept-encoding gzip 等 消息压缩方式
用户元数据 小写名称。如果以 -bin 结尾则是 base64 grpc- 以

开头的名称是保留的 在 grpc-go 中, http2Client.createHeaderFields 会创建此列表。

headerFields = append(headerFields, hpack.HeaderField{Name: ":method", Value: "POST"})
headerFields = append(headerFields, hpack.HeaderField{Name: ":scheme", Value: t.scheme})
headerFields = append(headerFields, hpack.HeaderField{Name: ":path", Value: callHdr.Method})
headerFields = append(headerFields, hpack.HeaderField{Name: ":authority", Value: callHdr.Host})
headerFields = append(headerFields, hpack.HeaderField{Name: "content-type", Value: grpcutil.ContentType(callHdr.ContentSubtype)})
headerFields = append(headerFields, hpack.HeaderField{Name: "user-agent", Value: t.userAgent})
headerFields = append(headerFields, hpack.HeaderField{Name: "te", Value: "trailers"})
...
if dl, ok := ctx.Deadline(); ok {
	timeout := time.Until(dl)
	if timeout <= 0 {
		return nil, status.Error(codes.DeadlineExceeded, context.DeadlineExceeded.Error())
	}
	headerFields = append(headerFields, hpack.HeaderField{Name: "grpc-timeout", Value: grpcutil.EncodeDuration(timeout)})
}

出处:摘自 grpc/grpc-go v1.83.2 internal/transport/http2_client.go 第 578~614 行,Apache-2.0

3.1 选择 grpc-timeout 的单位

EncodeDuration 从纳秒开始选择 能够放入 8 位以内的 最小单位并向上取整。这是用 Python 转换相同规则计算出来的值(pb_by_hand.py).

剩余时间 grpc-timeout
50ms 50000000n
4.999987秒 4999987u
2分钟(kubelet CRI 默认值) 120000m
4分钟(RunPodSandbox) 240000m
1小时 3600000m

即使设置了 5 秒的 deadline,由于在构建头部之前流逝了几微秒,因此发出的不是 5S 而是 4999987u 。服务端将其 decodeTimeout 重新读回,并在其自身的上下文中设置 timeout。 如果数值损坏,则会以 INTERNAL 拒绝(http2_server.go:453 附近)。

4. 消息前缀 5 字节

DATA 帧中承载的是 Length-Prefixed-Message 的连续序列。1 字节压缩标志(0 或 1)+ 4 字节长度(大端序)+ 消息。

内容 字节(计算值)
第 2 篇中的 Rally 21 字节 00 00 00 00 15 + 08 96 01 … 28 03 = 26 字节
健康检查请求(空消息) 00 00 00 00 00
健康检查响应 SERVING 00 00 00 00 02 08 01

grpc-go 的接收端是这样的:

func (p *parser) recvMsg(maxReceiveMessageSize int) (payloadFormat, mem.BufferSlice, error) {
	err := p.r.ReadMessageHeader(p.header[:])
	if err != nil {
		return 0, nil, err
	}
	pf := payloadFormat(p.header[0])
	length := binary.BigEndian.Uint32(p.header[1:])
	...
	if int(length) > maxReceiveMessageSize {
		return 0, nil, status.Errorf(codes.ResourceExhausted, "grpc: received message larger than max (%d vs. %d)", length, maxReceiveMessageSize)
	}
	data, err := p.r.Read(int(length))

出处:摘自 grpc/grpc-go v1.83.2 rpc_util.go 第 785~800 行,Apache-2.0

5. 响应与 Trailer

响应是包含 :status 200 和 content-type 的头部、消息以及 Trailer。Trailer 中包含 grpc-status(十进制)以及可选的 grpc-message, grpc-status-details-bin(以 base64 承载的 google.rpc.Status)。即使成功(0),状态也必须加载到 Trailer 中。

grpc-message 对 UTF-8 进行 百分号编码。只有空格到波浪号(% 除外)保持不变,其余全部转换为 %XX 。这是转换了 grpc-go 的 encodeGrpcMessage 规则计算出的值。

消息 当在线路上收到 grpc-message
rally not found rally not found
랠리 없음 %EB%9E%A0%EB%A6%AC %EC%97%86%EC%9D%8C
100% done 100%25 done

HTTP 状态不是 200 的响应(例如中间代理创建的 502 等)时,客户端会 虚拟出状态。grpc-go 的 HTTPStatusConvTab 将 400 映射为 INTERNAL,401 映射为 UNAUTHENTICATED,403 映射为 PERMISSION_DENIED,404 映射为 UNIMPLEMENTED,429·502·503·504 映射为 UNAVAILABLE。如果在代理后面看到莫名其妙的 UNAVAILABLE,则意味着实际情况可能是 HTTP 503。其余的 HTTP 错误将变为 UNKNOWN。

相反,如果流以 RST_STREAM 中断,则会将 HTTP/2 错误代码转换为状态。REFUSED_STREAM→UNAVAILABLE(处理前,因此可以重试),CANCEL→CANCELLED,ENHANCE_YOUR_CALM→RESOURCE_EXHAUSTED(第 4 篇的 keepalive ping 违规),其余大部分为 INTERNAL (http2ErrConvTab,规范 Errors 节)。

6. 17 个状态码

statuscodes.md 从运维视角进行了总结。「库」一栏是指 gRPC 库可以自行产生的情况。

编号 名称 含义 库产生的示例
0 OK 成功 —
1 CANCELLED 通常由调用方取消 客户端取消(双方)
2 UNKNOWN 未知错误 服务端处理器抛出无状态错误,状态解析失败
3 INVALID_ARGUMENT 请求本身错误 文档中无(参见第 8 篇)
4 DEADLINE_EXCEEDED 超期。服务端可能已经成功 双方
5 NOT_FOUND 目标不存在 无
6 ALREADY_EXISTS 已存在 无
7 PERMISSION_DENIED 无权限 —
8 RESOURCE_EXHAUSTED 配额/资源耗尽 消息大小超出限制,流控额度超限
9 FAILED_PRECONDITION 状态不匹配,修复前请勿重试 无
10 ABORTED 并发冲突,在上层重试 无
11 OUT_OF_RANGE 超出范围(超出文件末尾等) 无
12 UNIMPLEMENTED 无此方法/不支持 服务端无此方法,不支持的压缩方式
13 INTERNAL 不变量破损 反序列化失败,违反流控规则
14 UNAVAILABLE 临时性故障。退避后重试 服务端正在关闭,连接断开,keepalive 超时
15 DATA_LOSS 不可恢复的丢失 无
16 UNAUTHENTICATED 无认证信息/错误 凭据失败

文档写道,INVALID_ARGUMENT、NOT_FOUND、ALREADY_EXISTS、FAILED_PRECONDITION、ABORTED、OUT_OF_RANGE、DATA_LOSS 是库 绝对不会产生的。因此,看到这些代码时,可以理解为「应用程序有意返回的」。不过,grpc-go 会在 content-type 错误的请求中,随 HTTP 415 一起携带 grpc-status: 3(INVALID_ARGUMENT)(http2_server.go:504)。由于这一点与文档不符,因此我将其记在了第 8 篇的贡献候选列表中。

7. 实测:从两端统计相同的调用

在控制节点 A 上,kube-apiserver 仅连接到同一节点上的 etcd(127.0.0.1:2379)。这两个进程在同一秒(2026-08-19 07:42:56 UTC)启动,因此累积计数器统计的是相同的时间段。客户端侧是 apiserver 的 grpc_client_handled_total(Prometheus),服务端侧是 etcd 的 grpc_server_handled_total(在节点上 curl -s http://127.0.0.1:2381/metrics,仅读取)。这里仅收集了非 OK 的情况。

sum by (instance, grpc_code, grpc_method) (grpc_client_handled_total{job="apiserver", grpc_code!="OK"})
方法 代码 apiserver(客户端) etcd(服务端)
Watch(双向) CANCELLED 6,982 6,980
Range OUT_OF_RANGE 94 94
Range UNAVAILABLE 1 1
Range CANCELLED 7 3
Range DEADLINE_EXCEEDED 1 0
Txn UNAVAILABLE 9 14
Txn CANCELLED 12 0
Txn DEADLINE_EXCEEDED 4 0
Txn UNKNOWN 0 4

在同一期间,服务端标准的 OK 调用次数为:Range 8333 万次,Txn 3867 万次,因此错误比例非常小。比起比例,更有看头的是 名称交错的方式。

一言以蔽之, 状态码会根据“由谁判定”而留下不同的记录。告警应使用调用端指标,而原因分析则需要将双方指标并排对照。

顺便多说一个。如果在 Cilium 代理内部运行 hubble list nodes ,就会出现 rpc error: code = Unimplemented desc = GetNodes not implemented 。单个节点的 Hubble 服务端没有特意实现 GetNodes (local_observer.go:250),这意味着应该将该问题提交给了解多个节点的 hubble-relay。这是 UNIMPLEMENTED 不仅用于“根本没有该方法”,还用于“此处不支持”的一个例子。

8. 局限性

9. 与小型集群的连接

如果要创建 apiserver 到 etcd 的告警, grpc_code!="OK" 不能直接照搬,而应该分别查看 Watch 的 CANCELLED 和 OUT_OF_RANGE。前者是正常的,而后者则表明 watch 请求了过旧的修订版本(revision),这正是需要检查压缩周期和 watch 缓存的原因。

Titi 寄语。 即使邮局说“已投递 (200)”,也不能掉以轻心。请务必检查信件末尾的印章 (grpc-status)!

参考资料

所有资料均于 2026-10-17 查阅。

登录后即可点赞

评论

还没有评论。

登录后即可发表评论