状态:草稿(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. 图解
- 流编号(stream=1)代表一次调用。在单个连接中,客户端打开的流编号会以奇数递增。
- 如果请求的最后一个 DATA 附带了 END_STREAM,则表示「没有要发送的内容了」。响应的顺序是 HEADERS → DATA → HEADERS(Trailer,END_STREAM)。
- 当直接产生错误时,会以单次 HEADERS(Trailers-Only)结束。第 4 篇中的重试就是根据这种差异来进行判断的。
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
- deadline 传输的 不是绝对时间,而是剩余时间。这是为了确保即使各台机器的时钟不同也能正常运行的选择。如果已经超时,则甚至不会发送,直接以 DEADLINE_EXCEEDED 结束。
- 如果是重试的调用,则会附加
grpc-previous-rpc-attempts(第 4 篇)。
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
- 消息边界与 DATA 帧边界 没有任何关系。大型消息会被拆分到多个 DATA 中,而多个小型消息可以放入同一个 DATA 中(规范中的 Data Frames)。 因此,接收端不是以帧为单位,而是以 5 字节头部为基准来进行切分读取的。
- 先读取长度并 检查上限。客户端和服务端的默认接收上限均为 4MiB(
clientconn.go:139,server.go:61)。如果超出则返回 RESOURCE_EXHAUSTED。发送上限默认无限制(int32 最大值)。 - 压缩标志 1 意味着
grpc-encoding中指定的方式进行了压缩。压缩上下文在每条消息中重新开始。 发送端msgHeader会写入这 5 个字节。
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 万次,因此错误比例非常小。比起比例,更有看头的是 名称交错的方式。
- OUT_OF_RANGE 94 = 94。 这是一个库不会生成的代码,因此双方完全一致。这是 etcd 直接返回的,意思是「尝试读取已被压缩(compaction)的修订版本」。etcd 将「已压缩的修订版本」和「尚不存在的未来修订版本」这两种错误都定义为 OUT_OF_RANGE(
api/v3rpc/rpctypes/error.go:32)。指标无法区分这 94 起事件究竟属于哪一种(未确认)。 - Watch 的 CANCELLED 是正常终止。每次 apiserver 为了重新发起 watch 而关闭流时,它就会增加 1。这是仅凭指标而引发错误告警的反面典型。
- CANCELLED 和 DEADLINE_EXCEEDED 只存在于客户端。 这两个状态由各自的时钟和上下文(context)判定。在客户端因超时而放弃时,服务端可能仍在处理或已经完成。服务端的 Txn UNKNOWN 4 与客户端的 Txn DEADLINE_EXCEEDED 4 可能是同一事件,但未能确认(未证实)。
- Txn UNAVAILABLE 在服务端为 14,客户端为 9 。etcd 客户端(clientv3)采用了一种只在连接尚不存在等安全情况下进行重试的策略(
client/v3/retry.go),因此客户端指标中可能只记录了该重试后的最终结果(推测,拦截器顺序未确认)。
一言以蔽之, 状态码会根据“由谁判定”而留下不同的记录。告警应使用调用端指标,而原因分析则需要将双方指标并排对照。
顺便多说一个。如果在 Cilium 代理内部运行 hubble list nodes ,就会出现 rpc error: code = Unimplemented desc = GetNodes not implemented 。单个节点的 Hubble 服务端没有特意实现 GetNodes (local_observer.go:250),这意味着应该将该问题提交给了解多个节点的 hubble-relay。这是 UNIMPLEMENTED 不仅用于“根本没有该方法”,还用于“此处不支持”的一个例子。
8. 局限性
- 未捕获 HPACK(头部压缩)的实际字节数,以及 SETTINGS、WINDOW_UPDATE、PING 帧的流量(未实测)。
grpc-status-details-bin中承载的丰富错误信息(google.rpc.ErrorInfo·RetryInfo等)仅在第 6 篇的 OTLP 重试中简要涉及。- 第 7 节的表格是对累积计数器的一次性读取。由于并未按时间对每个事件进行精确对齐,“同一事件”的配对仅为推测。
9. 与小型集群的连接
如果要创建 apiserver 到 etcd 的告警, grpc_code!="OK" 不能直接照搬,而应该分别查看 Watch 的 CANCELLED 和 OUT_OF_RANGE。前者是正常的,而后者则表明 watch 请求了过旧的修订版本(revision),这正是需要检查压缩周期和 watch 缓存的原因。
Titi 寄语。 即使邮局说“已投递 (200)”,也不能掉以轻心。请务必检查信件末尾的印章 (grpc-status)!
参考资料
所有资料均于 2026-10-17 查阅。
- grpc/grpc v1.84.0:
doc/PROTOCOL-HTTP2.md,doc/statuscodes.md - grpc/grpc-go v1.83.2:
internal/transport/http2_client.go,internal/transport/http2_server.go,internal/transport/http_util.go,internal/grpcutil/encode_duration.go,rpc_util.go - etcd v3.6.5:
api/v3rpc/rpctypes/error.go,client/v3/retry.go,api/etcdserverpb/rpc.proto - cilium v1.20.1:
pkg/hubble/observer/local_observer.go - 计算代码:工作目录
measure/grpc/pb_by_hand.py - 引用代码:grpc-go v1.83.2,遵循 Apache License 2.0。