Apache APISIX 如何与 gRPC 服务通信

更新时间 10/8/2022

gRPC 是由 Google 开源的一个 RPC 框架,旨在统一服务间通信的方式。该框架基于 HTTP/2 协议传输,使用 Protocol Buffers 作为接口描述语言,可以自动生成服务间调用的代码。

为什么 gRPC 那么重要

由于 Google 在云原生和开发者关系上无与伦比的影响力,gRPC 逐渐成为了 RPC 框架的事实上标准。

gRPC 的地位是如此强势,以至于你在设计时如果没有选择 gRPC 作为 RPC 的方式,你就必须给出为什么不选择 gRPC 的理由。

不然总会有人问,为什么不选择主流的 gRPC 呢?甚至连曾经大力推广自己的 RPC 标准 Dubbo 的阿里巴巴,在最新版本的 Dubbo 3 中也大幅度修改了协议设计,改成了能同时兼容 gRPC 和 Dubbo 2 的 gRPC 变种。实际上,Dubbo 3 与其说是继承 Dubbo 2 的续作,不如说是对 gRPC 霸主地位的一个承认。

许多提供了 gRPC 的服务,虽然也提供了对应的 HTTP 接口,但是由于这类接口往往只是出于兼容的目的,所以在实际体验上并不能与 gRPC 版本相媲美。

一个常见的现状是,如果能通过 gRPC 方式接入,那么可以直接引入对应的 SDK;如果只能走普通的 HTTP 方式接入,那么通常会被引向一个文档页面,需要自行实现对应的 HTTP 操作。哪怕 HTTP 接入也可以通过 OpenAPI spec 等方式来生成对应的 SDK,但是由于优先级不高,很少会有开发者会像对待 gRPC 一样认真对待 HTTP 接入的使用者。

没有直接与 gRPC 服务通信所带来的限制

APISIX 采用 etcd 作为配置中心。etcd 从 v3 版本开始,就把接口迁移到了 gRPC 上。但是由于 OpenResty 生态里面没有对 gRPC 支持的项目,APISIX 就只能调用 etcd 的 HTTP 接口。

etcd 的 HTTP 接口是通过 gRPC-gateway 提供的,本质上是 etcd 在服务端运行一个 HTTP to gRPC 的代理,外部的 HTTP 请求会转换成 gRPC 请求。在实践过程中,我们也发现了一些 HTTP API 跟 gRPC API 交互的问题。事实上,拥有 gRPC-gateway 并不意味着能够完美支持 HTTP 访问,这里还是有些细微的差别。

我把过去几年来在 etcd 上遇到的相关问题列举了一下:

  1. 某些情况下,etcd 默认没有开启 gRPC-gateway。由于维护者的疏忽,在有些平台下,etcd 的默认配置中不会开启 gRPC-gateway。所以我们不得不在文档中增加了“检查当前 etcd 是否开启了 gRPC-gateway”的说明。详情可参考 PR-2940

  2. gRPC 默认会限制响应最大不会超过 4MB。etcd 在它提供的 SDK 中把这个限制去掉了,但是忘记在 gRPC-gateway 里面去掉这个限制。结果导致官方的 etcdctl (基于它提供的 SDK 构建)能正常工作,但是 APISIX 却访问不到。详情可参考 issue-12576

  3. 同样的问题也发生在同一连接的最大请求数上。Go 的 HTTP2 实现有一个 MaxConcurrentStreams的配置,控制单个客户端能同时发送的请求数,默认是 250。正常情况下,哪个客户端会同时发送超过 250 个请求呢?所以 etcd 一直沿用这一配置。然而 gRPC-gateway 这个代理所有 HTTP 请求到本机的 gRPC 接口的“客户端”,却有可能超出这一限制。详情可参考 issue-14185

  4. etcd 在开启 mTLS 后,会用同一个证书来作为 gRPC-gateway 的服务端证书,兼 gRPC-gateway 访问 gRPC 接口时的客户端证书。如果该证书上启用了 server auth 的拓展,但是没有启用 client auth 的拓展,那么会导致证书检验出错。直接用 etcdctl 访问不会出现这个问题,因为该证书在这种情况下不会作为客户端证书使用。详情可参考 issue-9785

  5. etcd 在开启 mTLS 后,允许针对证书里面的用户信息配置安全策略。如上所述,gRPC-gateway 在访问 gRPC 接口时用的是固定的一个客户端证书,而非一开始访问 HTTP 接口所用的证书信息。这个功能自然就没办法正确工作了。详情可参考 issue-5608

导致以上问题出现的原因,可以归类为两点:

  • gRPC-gateway(或许包括其他试图把 HTTP 转成 gRPC 的方式)并非灵丹妙药;

  • etcd 的开发者对 HTTP 转 gRPC 这一路径关注度不够。而且他们的最大用户——Kubernetes 不会用到这个功能。

当然,我在这里并不是讨论特定软件的问题。etcd 只是个典型的 gRPC 服务。事实上,把 gRPC 作为一等公民的服务,在对 HTTP 的支持上或多或少都有点类似的限制。

APISIX 3.0 是如何解决上述问题

常言道,“如果山不会向你走来,那么你就走向山”。我们如果实现一个在 OpenResty 下可用的 gRPC 客户端,那么就能直接跟 gRPC 服务通信了。

出于工作量和成熟稳定的考虑,我们决定基于常用的 gRPC 库进行二次开发,而不是自己从头写一个。这个过程中主要考察了以下的 gRPC 库:

  • NGINX 的 gRPC 功能。NGINX 并没有把 gRPC 的能力暴露给外部用户,甚至连高层次的接口也没有。如果要用,只能自己复制几个底层的函数,然后整合成高层次的接口。这违背了我们的目标“工作量和成熟稳定”。

  • 官方 C++ 的 gRPC 库。由于我们的构建体系是基于 NGINX 的,要想整合 C++ 的库会有点复杂。另外该库的依赖接近 2GB,总对于 APISIX 的构建是一大挑战。

  • gRPC 的官方 Go 实现。得益于 Go 强大的工具链,我们可以很方便地把项目构建起来里面来。可惜 Go 的性能离 C++ 的版本差得太远。我们考察了另外一个 Go 实现 Connect。但是该项目的性能并不比官方版本强。

  • Rust 实现的 gRPC 库。应该是结合依赖管理和性能的天作之选,可惜我们不熟悉 Rust,所以不敢在这上面下注。

考虑到作为 gRPC Client 基本上的操作都是 IO Bound,所以对性能要求不高,所以我们最后选择了基于 go-grpc 来实现。

实现过程

为了能够跟 Lua 的协程调度打通,我们编写了一个 NGINX C module。一开始,我们是想把 Go 代码通过 cgo 编译成静态链接库的方式集成到这个 C module 当中,但是发现由于 Go 是多线程应用,而在 fork 之后,子进程并不会继承父进程的所有线程,没办法适应 NGINX 的 master-worker 多进程架构。所以我们选择把 Go 代码编译成动态链接库,然后在运行时再加载到 Worker 进程进来。

为了能把 Go 的协程和 Lua 的协程协调起来,我们实现了一个任务队列机制。当 Lua 代码发起 gRPC 的 IO 操作时,它会提交一个任务到 Go 一侧,然后把自己挂起来。这个任务会由一个 Go 协程执行,执行结果写入到队列中。NGINX 一侧有个 background thread 会消费任务执行结果,并重新调度对应的 Lua 协程,继续执行 Lua 代码。这么一来,gRPC IO 操作,在 Lua 代码眼里,跟普通的 socket 操作并无二致。

目前,该 NGINX C module 已大体完工。现在我们只需要把 etcd 的 .proto 文件(该文件定义了它的 gRPC 接口)拿出来修改一下,然后在 Lua 代码里面加载该文件,就得到了如下的 etcd 客户端:

1local gcli = require("resty.grpc")
2assert(gcli.load("t/testdata/rpc.proto"))
3local conn = assert(gcli.connect("127.0.0.1:2379"))
4local st, err = conn:new_server_stream("etcdserverpb.Watch", "Watch",
5                                        {create_request =
6                                            {key = ngx.var.arg_key}},
7                                        {timeout = 30000})
8if not st then
9    ngx.status = 503
10    ngx.say(err)
11    return
12end
13for i = 1, (ngx.var.arg_count or 10) do
14    local res, err = st:recv()
15    ngx.log(ngx.WARN, "received ", cjson.encode(res))
16    if not res then
17        ngx.status = 503
18        ngx.say(err)
19        break
20    end
21end

当下,我们跟 etcd HTTP 接口对接的客户端项目 lua-resty-etcd,光 Lua 代码就有 1600 行。这么一对比,目前的基于 gRPC 实现可以说是工程上的奇迹了。

当然,目前我们离替换 lua-resty-etcd 还有一段不短的距离。要想完备地对接 etcd,grpc-client-nginx-module 还需要补全以下的功能:

  • 支持 mTLS;

  • 支持设置 gRPC metadata;

  • 支持设置诸如 MaxConcurrentStreamsMaxRecvMsgSize 等参数;

  • 支持在 L4 下调用。

好在我们已经搭好了架子,支持这些也不过是水到渠成的事情。

grpc-client-nginx-module 将会整合到 APISIX 3.0 版本中,届时 APISIX 开发者可以在 APISIX 的插件代码中使用该模块的方法,跟 gRPC 服务直接通信。

有了对 gRPC 的原生支持,APISIX 除了可以得到更好的 etcd 体验外,还为诸如 gRPC health check 、基于 gRPC 的 OpenTelemetry 数据上报等功能打开了可能性的大门。相信在不久的将来,我们会看到 APISIX 基于 gRPC 的更多用法!