记录 gRPC GOAWAY 报错排查过程

2023-10-20 ⏳2.8分钟(1.1千字)

公司内部新服务基本都使用 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:
                req.ContentLength = int64(v.Len())
                // ...
            case *bytes.Reader:
                req.ContentLength = int64(v.Len())
                // ...
            case *strings.Reader:
                req.ContentLength = int64(v.Len())
                // ...
        }
    }
}

代码中的三种类型都能明确 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 虽然应用广泛,但这并不意味着它在设计上就没有问题。我还是得充分了解底层的协议并结合实际的需要来做技术选型。


  1. ./go/sniper.html↩︎

  2. ./go/grpc-unary-client.html↩︎

  3. ./grpc.html↩︎