Jason Pan

gRPC doc 阅读笔记

潘忠显 / 2021-06-08


gRPC 项目中的 doc ,大部分为面向使用者的功能使用的介绍,少部分为面向开发者的功能实现介绍。

本文是对 doc 目录下文档进行简单分类,并记录阅读笔记。通过阅读本文,能对 gRPC 从开源项目管理、协议、实现等各方面有一个基本的了解。如果需要了解 gRPC 的基本概念,可以阅读《gRPC 基础概念详解》。

先简单介绍下我将 doc 中的文件分为哪几类:

在分类排序上,会尽量将有联系的放在了一起,比如:服务发现和负载均衡联系紧密,CLI 很大程度上基于服务端反射。

另外,本文没有将对 doc/core 中文档的阅读笔记放在这里,是因为 core 中的内容详细介绍了 gRPC Core 使用的事件引擎,相对深入,会另起一篇结合代码介绍这部分内容。gRPC 还单独开辟了一个项目 grpc/proposal 用来管理实现方案提议,类似于 RFC,有不同的状态。

规范

因为 gRPC core (最初)是用 C 语言实现的,因此有个项目的 C 编程风格文档。其实文中也提到,如果是代码格式化,可以直接使用脚本,利用 Docker 进行文件的处理。

gRPC 库是被广泛引用库,因此其公共的头文件需要有几个特点:

这些特点也是我们在平时开发过程中需要注意的,因此特别罗列于此。

另外,了解该文件中的 命名规则 也有助于后续对源码的阅读。

协议

文章详细描述了基于 HTTP 2 通信的原生 gRPC 协议,请求和返回分别是如何构造、如何结束的。可以参考《gRPC 基础概念详解》中的 四、通信协议

看这个文档需要先了解 ABNF 语法(扩展巴科斯范式),这个文档中,对 HTTP2 的每个字段中内容的说明,是使用 ABNF 语法描述的。

协议用于在浏览器的中的 JavaScript 发出 gRPC 请求,跟上边提到的原生协议有类似也有不同,该文档中主要就是描述的不同点。主要的不同就是针对浏览器中 JavaScript 运行时和 Nodejs 不同的场景:比如不一定支持 HTTP/2,因此 gRPC-Web 就支持各种 HTTP 协议;比如不同浏览器对字符和二进制支持程度不同,因此gRPC-Web 支持 base64 的文本流协议传输。

TODO: gRPC Server 可以直接兼容 gRPC-Web 协议吗?

简述 gRPC 处理压缩的流程,以及具体到不同级别上的压缩设置方法。处理流程和是哪端(Client 还是 Server)没有关系,而是区分 Incoming 和 Outgoing:

流入 的处理主要取决于 Incoming Msg,接收端处理流程相对简单:

是否有 grpc-encoding -> 是否是合法的压缩算法 -> 压缩算法是否被禁用 -> 是否在 grpc-accept-encoding -> 解压缩。

其中 accept-encoding 是说客户端可以接受的编码方式,所以即使不一致也只是记录一下。

流出 的处理则取决于发出端自身的设置以及对端可以接受压缩算法,所以逻辑相对流入会更复杂一些:

判断消息级是否打开 GRPC_WRITE_NO_COMPRESS 标志-> 判断 metadata 中是否有压缩算法 -> 判断是否为 ServerChannel && 是否有设置默认的调用级压缩 -> 是否有设置默认的Channel级压缩 -> 判断压缩等级 -> 压缩

如上所述,压缩有三个级别Channel级调用级消息级,前两个级别可以设置压缩算法等级,而消息级只能禁用压缩。

压缩的目的是为了减少带宽。调用级或消息级的压缩设置可以防止 CRIME/BEAST 攻击(攻击原理)。

配置压缩算法的两种场景: 创建 Channel 的时候指定;Unary RPC 的 Context 和 Streaming RPC 的 Writer(只能禁用)

支持非对称的压缩算法,是指接收端可以选择不使用或使用不同的压缩算法。如果发出端使用某种压缩算法,而受到了带 grpc-accept-encodingUNIMPLEMENTED 错误,可能是Server端不支持这种压缩算法。

压缩包括等级算法,而等级范围取决于算法,比如 “low” 会映射到 “gzip -3”。

gRPC 的 keepalive 是通过 HTTP2 PING 来检查当前 Channel 是否工作的一种机制。文档中介绍了 keepalive 使用时的相关参数。具体的实现在 Proposal 中有对应的文档介绍。

4 个参数是 Client 和 Server端都会用到的

有两个是只有服务端会使用的:

服务端的这两个参数是为了限制客户端过度的发送 PING 的:如果两次 PING 时间过短,第二次的 PING 会被视为恶意的 PING 并被记录到 PING strike 的计数器中。如果对端的 PIN strike 超过了最后一个参数,则会直接回复 携带 “too_many_pings”的信息 GOAWAY 帧。

非英语环境中,可能会用到除了 ASCII 码之外的其他编码方式。该文档列举了 API 中一些概念允许的编码方式。

连接

文档介绍了 gRPC 中 Channel 的连接状态,包括状态语义、对 RPC 的影响、相关 API。之前基本概念中有介绍 Channel,只有 Client 中有 Channel 的概念,因此这里讨论的连接状态也是 Client 的概念。

连接状态在代码中对应枚举类型 grpc_connectivity_state,共 IDLE, CONNECTING, READY, TRANSIENT_FAILURE, SHUTDOWN 5 种状态。根据文档中的描述,通过状态图来描述它们的关系。

connection-state

接口类 ChannelInterface 提供了两个函数,分别是:

上面提到,TRANSIENT_FAILURE 会在一定的时间间隔之后,进行重连,会改变到 CONNECTING 状态。这个时间间隔就是 Connection Backoff,其计算由几个参数决定:

收到 HTTP/2 的 SETTINGS 帧,意味着连接建立成功,会重置 Backoff。

TODO: grpc_core::RetryFilter 中实现的?ClientChannel::UpdateServiceConfigInDataPlaneLocked() 中会将 RetryFilter 的相关内容 push 到 dynamic_filters_ 中。

该文档介绍对上边 Backoff 的测试流程,对应的源文件在 test/cpp/interop 目录下的 reconnect_interop_client.cc 和 reconnect_interop_server.cc。

有两个端口,分别用来控制(server_control_port,非 TLS)和实际传输(server_retry_port,TLS),这样一来,Client 可以主动控制 Server 在特定的场景下实际传输端口上服务启停。

通过 Server 的返回或者 Client 自己检查连接状态来判断测试是否通过。

Channel 处于 TRANSIENT_FAILURE、SHUTDOWN 状态会无法及时传输 RPC,默认的实现方式是的立马返回失败,曾被称之为 “fail fast”;Channel 处于 CONNECTING、READY、IDLE状态的时候,RPC 不应该失败。

gRPC 提供了一种按 RPC 设置的选项,在 Channel 处于 TRANSIENT_FAILURE 的时候,可以 “wait for ready”;而如果 Channel 状态转移为非 Ready 状态,该 RPC 也应该失败。

安全

ALPN是一个传输层安全协议 (TLS) 的扩展,它使得应用层可以协商在安全连接层之上使用什么协议,避免了额外的往返通讯,并且独立于应用层协议。 ALPN 是一个相对比较新的协议,2014年发布的 RFC

gRPC 的通信基础 HTTP/2 是需要 ALPN 支持的。gRPC 为了能在实现流畅的编译和分发,在某些 编译系统 + 操作系统 场景下,会通过设置 OPENSSL_NO_ASM 等选项来禁用汇编优化,会带来一定的性能损失。

这个文档就是什么场景下会禁用汇编优化。比如我们用 Bazel + Linux 环境则是不会受到这个影响的。

基于 C-core 的 gRPC Server 能够验证 Client 的身份(gRPC-Go 和 gRPC-Java也可以,但是机制不同)。

CallCredentials 只能在去安全 Channel 上使用。这点跟之前在 AWS 上碰到的一个场景很相似:

只能为启用了传输中加密的 ElastiCache for Redis 集群启用 AUTH

Server 端通过 authentication context 来对对端进行鉴权。授权上下文是 multi-map 存储的 KV 对(Auth Properties)。文档中提供了一个使用 TLS 的典型授权上下文。

通过 Auth Interceptor 进行填充(从上下文中提取相关的 key)

Auth Interceptor 能调用的上下文和内部状态中,提取对应的 key-value 对并填充 auth context。

gRPC 内建有一些基础的 拦截器(Interceptor),也支持自定义的拦截器,需要继承 AuthMetadataProcessor 并实现其中的纯虚函数。但是涉及身份验证非常敏感,建议用官方的: TLS/SSL 证书验证、 JWT 验证。

状态码

Status Code = OK + Error Code

Client 端发起的 RPC 会返回一个状态码(对应着状态信息),状态码可能是以下几种情况产生的:

以上几种情况又对应着不同错误码的返回:

HTTP 状态码(4xx 和 5xx) 到 gRPC 状态码的单向映射,只有 Client 端会用到这个映射关系,而且不是一一映射,应用场景只有:客户端收到的响应中没有 grpc-status

介绍实现 gRPC API 时 Status 和 Read/Write 的顺序关系:

作为发送方

作为接收方

库对读取消息和验证码的处理

服务发现

服务名字语法遵循 RFC 3986

gRPC 使用 DNS 作为默认的名字系统,同时也提供 API 支持使用其他名字系统,不同语言的 Client 库以插件的机制来进行支持。

Resolver 插件介于权威解析服务和 Client 库之间,能够返回名字对应的服务器列表服务配置,还可以携带与地址相关的 key-value 属性对集合。

服务配置是一种允许服务提供者向所有 Client 端发布配置参数的机制。服务配置与某个服务器名字关联,需要上边提到的 name resolver plugin 的配合,在接收到解析服务地址的同时,将服务配置一并下发给客户端。

其协议格式详见 service_config.proto

用到 service config 的场景(API)包括:

TODO

负载均衡

gRPC Blog 中有一篇 《gRPC Load Balancing》 图文并茂,介绍常用的负载均衡方法比本文档更清晰,可以参考。

gRPC 使用的负载均衡是基于调用的,而不是基于连接的。

负载均衡的方法:

The load balancer may be separate from the actual server backends and a compromise of the load balancer should only lead to a compromise of the loadbalancing functionality. In other words, a compromised load balancer should not be able to cause a client to trust a (potentially malicious) backend server any more than in a comparable situation without loadbalancing.

gRPC 就是使用的最后一种策略,既扩展负载均衡:客户端中使用简单的负载均衡策略,由 grpclb 命名空间中的函数提供,其他的复杂负载均衡策略均应该在扩展负载均衡器中实现。

Client 跟 Load Balancer 之间的通信使用通过 rpc BalanceLoad进行通信的,返回是一个 Server 列表,Client 收到之后会建立所有跟下游的连接。协议详见 load_balancer.proto

Server 端是主动向 Load Balancer 报告自己的负载情况的,通过 rpc ReportLoad 进行上报。协议详见 load_reporter.proto

所以如果需要通过 gRPC 实现负载均衡,需要服务端进行一定的开发?

Client 发送健康检查请求,之后判定 Server 不健康的场景有两种:

健康检查有两种应用场景,client-to-server 场景 和 负载均衡类似的控制系统。健康检查使用普通的 RPC 的方式,协议文件为 health.proto;可重用 gRPC 框架的基础功能,完全掌控健康服务的访问;还拥有丰富的状态语义(需要用户自己实现)。

Client 可以选择发送一元的RPC Check(),检查某个服务的状态;也可以使用服务端流的 Watch() 函数,在立马返回当前状态之后,会在状态有变更的时候再返回更新后的状态。

如果 Client 可以服务名传空字符串,来检查服务器整体的健康状况(只要 Server 端有响应就说明是健康的?)

服务端反射

gRPC 服务器反射 可以提供服务器上可公开访问服务的相关信息,包括:

服务器的反射机制能够让 Client 在运行时构建 RPC 的请求和响应,而无需预编译服务信息。

本质上,是 Client 通过 grpc::ProtoReflectionDescriptorDatabase 来实现 google::protobuf::DescriptorDat abase 接口,管理 Client 与反射服务之间的通信和接收信息的存储,而在 Client 内部,可以将此作为本地的描述符数据库。目前库不支持除了protobuf之外协议的反射

Client 内具体的使用方法:

  1. 创建 Channel 对象
  2. 使用 Channel 对象初始化 ProtoReflectionDescriptorDatabase 对象(实际是有网络通信,从Server端获得后解析的)
  3. 使用 DB 对象初始化 DescriptorPool 对象 desc_pool
  4. 利用 desc_pool 对象的成员函数,进行查询
  5. 创建 google::protobuf::DynamicMessageFactory 对象 dmf
  6. 利用 dmf 对象动态的创建 Message

gRPC CLI 就是利用了 Server 的反射机制,提供命令行工具,查询和发送消息。

该文档中介绍了实现服务端反射机制的协议,其实就是 Server 实现了一个反射服务,协议文件 reflection.proto

文中再次提到了,Protobuf 协议的描述符,是通过传递 DescriptorDatabase 之后,Client 通过其成员函数调用实现:

文档中还讨论了使用反向代理会面临的协议冲突的问题以及解决方法。目前主要的几种服务端语言都实现了服务端的反射。

CLI

gRPC 提供了一个命令行工具,目前在 grpc/grpc 项目中编译(成熟了之后可能单独一个项目):

bazel build //test/cpp/util:grpc_cli

CLI 除了能够查询上文提到的服务端反射信息之外,还能够直接调用一元 RPC(都需要指定方法):

以上只有介绍如何动态的创建Message,但是没有介绍如何动态的调用 RPC。CLI 动态调用 gRPC 是通过类 CliCall 来实现的,更底层是通过 grpc::GenericClientAsyncReaderWriter 来完成的。

版本与发布

该版本控制的描述也是用于具体到某种语言的项目,比如 grpc-gogrpc-java

gRPC 版本控制使用三段的版本号 vX.Y.Z ,分别表示主、次、补丁的变更,参照但是不完全按照 Semantic Versionin 来的:

文档中给出了主版本变更的策略:要么是依赖的语言生态发生强烈变化,要么是内部有巨大的动力去修改 API。当前条件下,一时半会应该不会有主版本的变化。

gRPC 给每个版本的都给 “g” 赋予了一个美好的含义,除了 1.0 的 g 就是就是代表 gRPC 之外,其他的有 ‘good’‘green’‘gentle’ 等等,总之都是褒义词啦。

这个文档除了列出了 gRPC 的版本发布计划之外,还描述了一些开发和发布之间的一些关系。这里针对 v1.38 和 v1.39 两个版本,来展示一下其联系。


2021年3月31 Bump master version to 1.38

2021年5月11日 Bump version to v1.38.0-pre1

2021年5月20日 Bump version to v1.38.0

2021年5月21日 发布 v1.38.0


2021年5月11日 Bump master to v1.39.0 ,该变更在主分支上,主要变更:

2021年7月2日 Bump v1.39.x branch to 1.39.0-pre1,切换至新分支 v1.39.x

2021年7月16日 Bump version to 1.39.0,在分支 v1.39.x 上:

2021年7月22日 在分支 v1.39.x 上打了 tag v1.39.0


如文档中描述,发布日期两周之前被从主分支中 CUT 出来:

另外,可以发现:

version_change

项目安全审计

以上两个文档分别是第三方安全评估报告和 gRPC 对该评估的回复。

Cure53(德国网络安全公司) 从渗透测试代码审计两个方面对 gRPC 项目做了安全评估,重点关注三个方面:HTTP2 协议栈、压缩特性、缓存机制。从Cure53 的描述中,可以看出他们也有对其他的 CNCF 资助的项目进行过类似的测试。这些项目评估中普遍使用的方法有:

一共发现了三个问题:

环境变量

gRPC C core 能够使用环境变量做一些配置。这里简单的列举:

测试

该文档只描述的如何写 gRPC C Client 的单元测试,并非是项目中的单元测试。

gRPC 提供基于 googletest/googlemock 的 Stub 合并到代码中,进行客户端的同步 API 的单元测试

产生 mock 代码:通过 protoc 的选项,直接命令行使用 --grpc_out=generate_mock_code=true:. 或 Bazel 中的 grpc_proto_library 中使用 generate_mocks = True,会产生对应的 echo_mock.grpc.pb.h 其中 echo 是对应 echo.proto 中的 echo。

利用 mocked Stub 开发 Fake Client,进行测试:写 FakeClient,可以将 RPC 的调用和相关测试内容进行封装,但是该 FakeClient 中使用的 Stub 是 EchoTestService::StubInterface* stub_,初始化的该 Stub 的时候传入的是 MockEchoTestServiceStub

互通性 (Interoperability) 测试用例描述,每个功能都有对应 Client 和 Server 代码实现,这些功能包括:

一元调用 流调用 状态码相关

缓存
巨量负载
客户端压缩
服务端压缩
空流
客户端流
客户端压缩流
服务端流
服务端压缩流
状态码和状态消息
带特殊字符的状态消息
方法未实现
服务未实现
服务端 DEADLINE_EXCEEDED

其他一些测试

授权相关

什么是 cloud-to-prod path?

其他

描述了一个问题的“绕过”解决方法。

Python GIL 决定了多线程进程无法完全利用起多核 CPU,通常使用多进程来克服这个问题。但多进程调用 gRPC Core 的时候,会产生很多问题,因此 gRPC Python 不支持 fork。

gRPC 记录二进制日志 API 的说明。但是因为我在源代码中没有搜到文中提到的 API,这里就先不做说明了。

附录1:相关 RFC

RFC 5234: ABNF

RFC 2119: Key words for use in RFCs to Indicate Requirement Levels

RFC 3986: URI

RFC 7301: ALPN

RFC 5280: X.509

Semantic Versionin

附录2:Words

depict 描绘 irrespective 不管 whereby 因此 incorporated 合并 dedicated 专用的、专门的 security audit 安全审计 security assessment 安全评估 exponential 指数的 penetration test 渗透测试 entail 包含 delineate 勾画、描写 Interoperability 互操作性 traversal 遍历 critical section 临界区

ALPN: Application-Layer Protocol Negotiation GA :generally available CLI: Command Line Interface GIL: global interpreter lock) TLDR (or TL;DR): Too Long; Didn’t Read

附录3:脚本

如果你想自己阅读 grpc/doc 中的内容,可以使用以下脚本,将输出重定向中 markdown 文件中,可以生成文档的阅读列表。

cd doc

/usr/bin/tree --noreport --charset unicode --dirsfirst -n  `# 通过 tree 打印目录` \
  -P "*md" -I "sphinx|images"                              `# 排除无关目录` \
  | sed "s/^.--/- [ ]/g"                                   `# 替换一级目录` \
  | sed "s/^.   .--/  - [ ]/g"                             `# 替换二级目录` \
  | grep -v "^\.$"                                         `# 去掉.` \
  | sed "s#\(^- \[[x ]\] \)\([^\[]*$\)#\1[\2](https://github.com/grpc/grpc/blob/master/doc/\2)#g"