记录 gRPC GOAWAY 报错排查过程
涛叔公司内部新服务基本都使用 gRPC 协议通信。我们的业务使用 Sniper 框架1,并没有内置 gRPC 客户端。所以我基于 Go 语言的 net/http
标准库自己撸了一个简单版本2,仅支持 gRPC Unary 接口,但足够应付绝大多数场景了。最近在调用某部门的接口时,对方服务直接报 GOAWAY 错误。一番排查后发现居然跟Content-Length
头有关,万万没想到。今天跟大家分享排查过程。
我们的简化客户端用了很久,一直没遇到问题。对接某新服务时对方直接关闭链接:
http2: server sent GOAWAY and closed the connection
联系对方开发人员,他们说没有看到业务请求日志。于是猜测可能是框架做了某种校验,而我们用的简化客户端不符合对方程序的要求。进一步沟通发现,他们用的是 Java 语言,而我们之前正常对接的服务大多是 Go 语言开发。这更印证了我们的猜测。
更有意思的是同样的接口,同样的数据,用 Postman 测试就能过,用我们自己的客户端就报错。确实有点理解不了。但有两个排查方向。一个是 body 编码,另一个是请求 Header。
讲道理 body 编码不应该有问题,因为都是走标准的 Protobuf SDK,而且之前跟其他服务都能正常通信。但稳妥起间,还是用 Wireshark 抓包做了对比。发现我们的 SDK 跟 Postman 没有区别。那只可能是 Header 出问题了。
我们的 SDK 使用标准的 HTTP 库实现,发送请求时会自动附带一些业务 Header。它们主要用于传递用户的登录态、网络地址等公共信息,理论上不应该影响 gRPC 通信才对。为了控制变量,我们把所有业务 Header 都移除,调用还是会报错。
通过对比抓包数据,我发现我们的客户端会发自动发送Content-Length
头信息。难道是因为它?我有点不太相信。因为 gRPC 协议会在所有的 Protobuf 消息之前添加五个字节的前缀,其中前四个字节保存消息长度。理论上 gRPC 框架应该不关心 Header 中的 Content-Length
才对。但也不排除某些框架实现得用力过猛,严格要求不能传输无关字段。
但怎么去掉这个 Header 呢?翻了代码发现 Go 语言的 HTTP 标准库会自动添加 Content-Length
头。在 Go 代码库中搜索 Content-Length
发现如下函数:
func (t *transferWriter) writeHeader(...) error {
// ...
if t.shouldSendContentLength() {
if _, err := io.WriteString(w, "Content-Length: "); err != nil {
return err
}
}
// ...
}
这里的 shouldSendContentLength()
主的判断依据是 t.ContentLength > 0
。而这个值就来自于 Request
对像的 ContentLength
字段。这部分可以参见 newTransferWriter()
函数的 case *Request
部分。而 Request
对象的 ContentLength
则是在创建的时确定的:
func NewRequestWithContext(..., body io.Reader) (*Request, error) {
// ...
if body != nil {
switch v := body.(type) {
case *bytes.Buffer:
.ContentLength = int64(v.Len())
req// ...
case *bytes.Reader:
.ContentLength = int64(v.Len())
req// ...
case *strings.Reader:
.ContentLength = int64(v.Len())
req// ...
}
}
}
代码中的三种类型都能明确 body 数据的长度,所以标准库会自动添加 ContentLength
Header。要想去掉,就得换成别的类型,但不能改变 io.Reader
接口。最简单的办法就是:
diff --git a/xhttp/grpc/grpc.go b/xhttp/grpc/grpc.go
index be5be70..53ceb4d 100644--- a/xhttp/grpc/grpc.go
+++ b/xhttp/grpc/grpc.go
@@ -77,7 +77,8 @@ func (c *MyClient) DoUnary(ctx context.Context, api string, req, resp proto.Mess
copy(d, twirp.GrpcPrefix(pb, ""))
copy(d[5:], pb)
- h2req, err := http.NewRequest("POST", api, bytes.NewReader(d))
+ b := io.NopCloser(bytes.NewReader(d))
+ h2req, err := http.NewRequest("POST", api, b)
if err != nil {
return }
换成 io.NopCloser
,标准库因为拿不到 body 的内容长度,自然不会添加 ContentLength
Header,问题解决✌️
其实这里引出一个问题,为什么 gRPC 不使用标准的 ContentLength
传输 body 内容长度呢?甚至有些框架还会强制要求不能传递此 Header?我在之前的文章3有过分析,那是为了支持 stream 接口。但我曾分析过我司所有内部服务的 gRPC 接口,用到 stream 特性的可能连千分之一都不到。gRPC 为了实现 stream 接口这种小众特性,让本来很简单的 Unary 接口变得很复杂,以至于像 curl 这类的标准 HTTP 工具都无法请求 gRPC 服务。这是我不认可它的原因之一。
gRPC 虽然应用广泛,但这并不意味着它在设计上就没有问题。我还是得充分了解底层的协议并结合实际的需要来做技术选型。