Courier:Dropbox 基于 gRPC 的 RPC 框架开发过程

阅读数:7691 2019 年 1 月 21 日

Dropbox 运行着数百个用不同语言编写的服务,每秒交换数百万次请求。Courier 是我们面向服务的架构的核心,这是一个基于 gRPC 的远程过程调用(RPC)框架。在开发 Courier 时,我们学习了很多关于扩展 gRPC、大规模优化性能以及从遗留 RPC 系统过渡的知识。

注意:本文的代码生成示例是 Python 和 Go 语言的。我们也支持 Rust 和 Java。

通向 gRPC 之路

Courier 并不是 Dropbox 的第一个 RPC 框架。甚至在我们开始认真地将 Python 整体应用分解为服务之前,我们就需要为服务间通信打下坚实的基础。特别是 RPC 框架的选择会具有深刻的可靠性影响。

之前,Dropbox 尝试了多个 RPC 框架。首先,我们从一个用于手动序列化和反序列化的自定义协议开始。有些服务,比如 Apache Thrift 用的基于 Scribe 的日志管道。但是,我们的主要 RPC 框架(遗留 RPC)是一个基于 HTTP/1.1 的协议,其中包含 protobuf 编码的消息。

对于新框架,有多个选项。我们可以改进遗留 RPC 框架,加入 Swagger(现在是OpenAPI)。或者我们可以建立一个新的标准。我们还考虑以 Thrift 和 gRPC 为基础进行构建。

我们之所以选择 gRPC,主要是因为它让我们能够继续使用现有的 protobuf 数据标准。对于我们的情况,多路复用 HTTP/2 传输和双向流也很有吸引力。

注意,如果fbthrift那会就已经有了,那么我们可能会更仔细地考察基于 Thrift 的解决方案。

Courier 为 gRPC 带来了什么

Courier 并不是一种不同的 RPC 协议,它只是 Dropbox 将 gRPC 与我们现有的基础设施集成在一起的一种方式。例如,它需要使用我们特定版本的身份验证、授权和服务发现。它还需要与我们的统计、事件日志和跟踪工具集成。所有这些工作的结果就是我们所说的 Courier。

虽然我们支持将Bandaid用作少数特定用例的 gRPC 代理,但为了最小化 RPC 对服务延迟的影响,我们的大多数服务之间不使用代理进行通信。

我们想要最小化需要编写的样本代码的数量。由于 Courier 是我们服务开发的通用框架,它包含了所有服务都需要的特性。这些特性中的大多数在默认情况下都是启用的,并且可以由命令行参数控制。其中一些还可以通过特性标识动态切换。

安全:服务标识和 TLS 双向认证

Courier 实现了我们的标准服务标识机制。我们所有的服务器和客户端都有自己的 TLS 证书,由我们内部的证书颁发机构颁发。每一个都有一个身份标识编码在证书中。然后,将此标识用于双向身份验证,其中服务器验证客户端,客户端验证服务器。

在 TLS 端,我们对通信双方进行控制,执行非常严格的缺省值设置。所有内部 RPC 都必须使用PFS加密。TLS 版本固定在 1.2+。我们还将对称 / 非对称算法限制为一个安全子集,首选 ECDHE-ECDSA-AES128-GCM-SHA256。

确认身份标识并解密请求之后,服务器会验证客户端是否具有适当的权限。访问控制列表(ACL)和速率限制可以在服务和单个方法上设置。它们也可以通过我们的分布式配置文件系统(AFS)进行更新。这使得服务所有者可以在几秒钟内卸掉负载,而不需要重新启动进程。Courier 框架负责订阅通知和处理配置更新。

服务“标识”是 ACL、速率限制、统计信息等的全局标识符。它还有一个额外的好处,就是具有加密安全性。

下面的示例是我们的光学字符识别(OCR)服务中的 Courier ACL/ 速率限制配置定义:

复制代码
limits:
dropbox_engine_ocr:
# All RPC methods.
default:
max_concurrency: 32
queue_timeout_ms: 1000
rate_acls:
# OCR clients are unlimited.
ocr: -1
# Nobody else gets to talk to us.
authenticated: 0
unauthenticated: 0

图片

我们正在考虑采用 SPIFFE 可验证身份文件(SVID),它是Secure Production Identity Framework for Everyone(SPIFFE)的一部分。这将使我们的 RPC 框架与各种开源项目兼容。

可观察性:统计和跟踪

仅使用一个标识,就可以轻松地定位有关 Courier 服务的标准日志、统计信息、跟踪信息和其他有用的信息。
图片
我们的代码生成为客户端和服务器添加了针对每个服务和每个方法的统计信息。服务器统计数据按客户端标识划分。对于任何 Courier 服务的负载、错误和延迟,我们提供了开箱即用的细粒度属性。
图片
Courier 统计数据包括客户端可用性和延迟,以及服务器端请求速率和队列大小。我们也有各种各样的分类,比如每个方法的延迟直方图或每个客户端的 TLS 握手。

自己拥有代码生成的好处之一是可以静态地初始化这些数据结构,包括直方图和跟踪范围。这可以最小化性能影响。

图片

我们的遗留 RPC 只跨 API 边界传播 request_id。这允许连接来自不同服务的日志。在 Courier 中,我们引入了一个基于OpenTracing规范子集的 API。我们编写了自己的客户端库,而服务器端以 Cassandra 和Jaeger为基础构建。关于我们如何实现系统性能跟踪的细节需要一篇专门的博文来介绍。

跟踪还使我们能够生成运行时服务依赖关系图。这有助于工程师理解服务的所有传递依赖。它还可以用作部署后检查,以避免无意识的依赖。

可靠性:截止日期和断路器

Courier 为所有客户端的通用功能(如超时)提供了一个集中的实现位置,我们在这里完成特定于语言的实现。随着时间的推移,我们在这一层添加了许多功能,通常作为事后分析的动作项。

截止日期

每个 gRPC 请求都包含一个截止日期,告诉服务器客户端将等待多长时间。由于 Courier 存根会自动传播已知的元数据,因此,截止日期甚至会与请求一起跨越 API 边界。在这个过程中,截止日期被转换为本地表示。例如,在 Go 中,它们由来自 WithDeadline 方法的结果 context.Context 表示。

在实践中,我们通过强制工程师在他们的服务定义中定义截止日期来解决整个可靠性问题。

这个上下文甚至可以传递到 RPC 层之外!例如,我们的遗留 MySQL ORM 将 RPC 上下文和截止日期序列化为 SQL 查询中的一条注释。我们的 SQLProxy 可以解析这些注释,并在超过截止日期时杀死查询。另一个好处是,在调试数据库查询时,我们可以获得每个请求的属性。

断路器

我们的遗留 RPC 客户端必须解决的另一个常见问题是在重试时实现自定义的指数退避和抖动(exponential backoff and jitter)。这对于防止从一个服务到另一个服务的级联过载通常是必要的。

在 Courier 中,我们想用一种更通用的方式来解决断路器问题。我们首先在监听器和工作池之间引入一个 LIFO 队列。
图片
在服务过载的情况下,这个 LIFO 队列充当自动断路器。队列不仅受大小限制,更重要的是,它还受时间限制。一个请求只能在队列中呆固定长的时间。

LIFO 有请求重新排序的缺点。如果你想保持顺序,可以使用CoDel。它还具有断路器特性,但不会打乱请求的顺序。

图片

内省:调式端点

尽管调试端点不是 Courier 本身的一部分,但它们在 Dropbox 被广泛采用。它们太有用了,我不得不提一下。这里有几个有用的内省的例子。

出于安全原因,你可能希望在单独的端口(可能仅在环回接口上)甚至 Unix 套接字上(因此可以使用 Unix 文件权限进行额外的控制访问)公开这些端点。你还应认真考虑使用双向 TLS 身份验证,要求开发人员提供访问调试端点(特别是非只读端点)的证书。

运行时

能够深入了解运行时状态是一个非常有用的调试特性,例如,堆和 CPU 概要文件可以作为 HTTP 或 gRPC 端点公开

我们计划在金丝雀验证过程中使用它来自动比较新旧代码版本之间的 CPU/ 内存差异。

这些调试端点允许修改运行时状态,例如,基于 golang 的服务允许动态设置GCPercent

对于库作者来说,能够自动导出一些特定于库的数据作为 RPC 端点可能非常有用。这里有一个很好的例子,malloc 库可以转储其内部统计信息。另一个例子是一个可以动态更改服务日志级别的读 / 写调试端点。

RPC

考虑到对加密的二进制编码协议进行故障诊断有点复杂,因此,在性能允许的条件下,在 RPC 层中尽可能多地插入度量工具是正确的。这种自省 API 的一个例子是最近的一项gRPC channelz 提案

应用程序

能够查看应用程序级的参数也很有用。一个很好的例子是带有 build/source 散列、命令行等的通用应用程序信息端点。编排系统可以使用它来验证服务部署的一致性。

性能优化

在大规模推广 gRPC 时,我们发现 Dropbox 存在一些特定的性能瓶颈。

TLS 握手开销

对于处理大量连接的服务,TLS 握手的累积 CPU 开销不容忽视。在大量服务重启期间尤其如此。

为了获得更好的签名操作性能,我们将 RSA 2048 密钥对转换为 ECDSA P-256。下面是 BoringSSL 的性能示例(注意,RSA 签名验证还是更快)。

RSA:

复制代码
???? ~/c0d3/boringssl bazel run -- //:bssl speed -filter 'RSA 2048'
Did ... RSA 2048 signing operations in .............. (1527.9 ops/sec)
Did ... RSA 2048 verify (same key) operations in .... (37066.4 ops/sec)
Did ... RSA 2048 verify (fresh key) operations in ... (25887.6 ops/sec)

ECDSA:

复制代码
???? ~/c0d3/boringssl bazel run -- //:bssl speed -filter 'ECDSA P-256'
Did ... ECDSA P-256 signing operations in ... (40410.9 ops/sec)
Did ... ECDSA P-256 verify operations in .... (17037.5 ops/sec)

由于 RSA 2048 验证比 ECDSA P-256 大约快 3 倍,从性能的角度来看,你可以考虑将 RSA 用于根 / 叶证书。从安全性的角度来看,这有点复杂,因为你将链接不同的安全原语,所以生成的安全属性将是它们中的最小值。

同样是因为性能的原因,在使用 RSA 4096(或更高版本)证书作为你的根 / 叶证书之前要慎重考虑。

我们还发现,TLS 库的选择(和编译标志)对性能和安全性非常重要。例如,下面是在相同硬件上对 MacOS X Mojave 的 LibreSSL 构建与自制的 OpenSSL 的比较。

LibreSSL 2.6.4:

复制代码
???? ~ openssl speed rsa2048
LibreSSL 2.6.4
...
sign verify sign/s verify/s
rsa 2048 bits 0.032491s 0.001505s 30.8 664.3

OpenSSL 1.1.1a:

复制代码
???? ~ openssl speed rsa2048
OpenSSL 1.1.1a 20 Nov 2018
...
sign verify sign/s verify/s
rsa 2048 bits 0.000992s 0.000029s 1208.0 34454.8

但是,最快的 TLS 握手方式就是完全不握手!我们已经修改了 gRPC-core 和 gRPC-python 以提供会话恢复支持,这大大降低了服务部署的 CPU 占用。

加密的开销并不高

认为加密开销很高是一种常见的误解。实际上,对称加密在现代硬件上非常快。桌面级处理器能够以单核 40Gbps 的速率加密和认证数据。

复制代码
???? ~/c0d3/boringssl bazel run -- //:bssl speed -filter 'AES'
Did ... AES-128-GCM (8192 bytes) seal operations in ... 4534.4 MB/s

然而,我们最终不得不针对我们速度为 50Gb/s 的存储盒进行 gRPC 调优。我们了解到,当加密速度与内存复制速度相当时,减少 memcpy 操作的数量至关重要。此外,我们还对 gRPC 本身做了一些修改

认证和加密协议已经捕获了许多棘手的硬件问题。例如,处理器、DMA 和网络数据损坏。即使你不使用 gRPC,使用 TLS 进行内部通信也是一个好主意。

高带宽延迟积链接

Dropbox有多个通过骨干网连接的数据中心。有时候,不同区域的节点需要通过 RPC 彼此通信,例如为了实现复制。当使用 TCP 时,其内核负责控制给定连接的数据流量(在 /proc/sys/net/ipv4/tcp_ { r w } mem 范围内),虽然 gRPC 是基于 HTTP/2 的,但它自己也有基于 TCP 的流控。BDP 的上限在 grpc-go 中硬编码为 16Mb,这可能成为单个高 BDP 连接的瓶颈。

Go 语言 net.Server 与 grpc.Server 比较

在我们的 Go 代码中,我们最初使用同一个net.Server支持 HTTP/1.1 和 gRPC。从代码维护的角度来看,这是合乎逻辑的,但是性能不是最优。将 HTTP/1.1 和 gRPC 路径分开,由不同的服务器处理,并将 gRPC 切换到grpc.Server大大改善了我们的 Courier 服务的吞吐量和内存使用情况。

golang/protobuf 与 gogo/protobuf 比较

当你切换到 gRPC 时,封送和解封处理可能开销很大。对于我们的 Go 代码,我们切换到了gogo/protobuf,在我们最繁忙的 Courier 服务器上,这明显降低了 CPU 的使用率。

像往常一样,人们对于 gogo/protobuf 的使用提出了一些警告,但如果你始终理智地使用它的一个功能子集,应该没问题。

实现细节

从这里开始,我们将深入 Courier 内部,通过例子看一下不同语言中的 protobuf 模式和存根。在下面的所有例子里,我们将使用我们的 Test 服务(我们在 Courier 集成测试中使用的服务)。

服务描述

以下是 Test 服务定义的代码片段:

复制代码
service Test {
option (rpc_core.service_default_deadline_ms) = 1000;
rpc UnaryUnary(TestRequest) returns (TestResponse) {
option (rpc_core.method_default_deadline_ms) = 5000;
}
rpc UnaryStream(TestRequest) returns (stream TestResponse) {
option (rpc_core.method_no_deadline) = true;
}
...
}

在前面的可靠性部分中已经提到过,所有 Courier 方法都有强制性的截止日期。可以使用以下 protobuf 选项设置整个服务的截止日期:

复制代码
option (rpc_core.service_default_deadline_ms) = 1000;

每个方法也可以设置方法自己的截止日期,覆盖服务范围的的截止日期(如果存在):

复制代码
option (rpc_core.method_default_deadline_ms) = 5000;

在极少情况下,截止日期是没有意义的(比如一个监控某些资源的方法),开发者可以显式禁用它:

复制代码
option (rpc_core.method_no_deadline) = true;

真正的服务定义也可能包含全面的 API 文档,有时甚至还包含用法示例。

存根生成

Courier 会自己生成存根,而不是依靠拦截器(除了 Java,因为 Java 的拦截器 API 足够强大),这主要是因为它给了我们更大的灵活性。让我们以 Go 语言为例比较下我们生成的存根与默认存根。

下面是默认的 gRPC 服务器存根:

复制代码
func _Test_UnaryUnary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TestRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TestServer).UnaryUnary(ctx, in
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/test.Test/UnaryUnary"
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TestServer).UnaryUnary(ctx, req.(*TestRequest))
}
return interceptor(ctx, in, info, handler)
}

在这里,所有的处理都是以内联方式进行的:解码 protobuf、运行拦截器、调用 UnaryUnary 处理程序本身。

现在看下 Courier 的存根:

复制代码
func _Test_UnaryUnary_dbxHandler(
srv interface{},
ctx context.Context,
dec func(interface{}) error,
interceptor grpc.UnaryServerInterceptor) (
interface{},
error) {
defer processor.PanicHandler()
impl := srv.(*dbxTestServerImpl)
metadata := impl.testUnaryUnaryMetadata
ctx = metadata.SetupContext(ctx)
clientId = client_info.ClientId(ctx)
stats := metadata.StatsMap.GetOrCreatePerClientStats(clientId)
stats.TotalCount.Inc()
req := &processor.UnaryUnaryRequest{
Srv: srv,
Ctx: ctx,
Dec: dec,
Interceptor: interceptor,
RpcStats: stats,
Metadata: metadata,
FullMethodPath: "/test.Test/UnaryUnary",
Req: &test.TestRequest{},
Handler: impl._UnaryUnary_internalHandler,
ClientId: clientId,
EnqueueTime: time.Now(),
}
metadata.WorkPool.Process(req).Wait()
return req.Resp, req.Err
}

代码很多,让我们逐行看下。

首先,我们推迟负责自动错误收集的应急处理程序。这使得我们可以将所有未捕获的异常发送到集中式存储,以便后续进行聚合和生成报告:

复制代码
defer processor.PanicHandler()

设置自定义应急处理程序的另一个原因是保证可以在紧急情况下中止应用程序。在默认情况下,golang/net HTTP 处理程序的行为是忽略它并继续为新的请求提供服务(可能损坏并处于不一致的状态)。

然后,通过覆盖来自传入请求的元数据的值来传播上下文:

复制代码
ctx = metadata.SetupContext(ctx)
clientId = client_info.ClientId(ctx)

我们还在服务器端创建(并为提高效率而缓存)每个客户端的统计信息,以实现更细粒度的归因:

复制代码
stats := metadata.StatsMap.GetOrCreatePerClientStats(clientId)

这会在运行时动态创建每个客户端(即每个 TLS 身份标识)的统计数据。我们也有每个服务中每个方法的统计信息,由于存根生成器可以在代码生成期间访问所有方法,所以我们可以静态地提前创建这些统计,以避免运行时开销。

然后,我们创建请求结构,将它传递给工作池,等待它完成:

复制代码
req := &processor.UnaryUnaryRequest{
Srv: srv,
Ctx: ctx,
Dec: dec,
Interceptor: interceptor,
RpcStats: stats,
Metadata: metadata,
...
}
metadata.WorkPool.Process(req).Wait()

注意,我们至此几乎什么工作也没完成:没有 protobuf 解码、没有拦截器执行等。ACL 执行、优先级、速率限制都是在上述任何一项完成之前在工作池中发生的。

注意,golang gRPC 库支持 Tap 接口,它允许早期请求拦截。这是构建开销最小的高效限速器的基础。

特定于应用程序的错误代码

我们的存根生成器还允许开发人员通过自定义选项定义特定于应用程序的错误代码:

复制代码
enum ErrorCode {
option (rpc_core.rpc_error) = true;
UNKNOWN = 0;
NOT_FOUND = 1 [(rpc_core.grpc_code)="NOT_FOUND"];
ALREADY_EXISTS = 2 [(rpc_core.grpc_code)="ALREADY_EXISTS"];
...
STALE_READ = 7 [(rpc_core.grpc_code)="UNAVAILABLE"];
SHUTTING_DOWN = 8 [(rpc_core.grpc_code)="CANCELLED"];
}

gRPC 错误和应用程序错误在相同的服务里传播,而所有错误都会在 API 边界被替换为 UNKNOWN。这避免了不同服务之间的偶然错误代理问题,那可能会改变它们的语义。

特定于 Python 的修改

我们的 Python 存根显式向所有 Courier 处理程序添加了一个上下文参数,例如:

复制代码
from dropbox.context import Context
from dropbox.proto.test.service_pb2 import
TestRequest,
TestResponse,
from typing_extensions import Protocol
class TestCourierClient(Protocol):
def UnaryUnary(
self,
ctx, # type: Context
request, # type: TestRequest
):
# type: (...) -> TestResponse
...

起初,这看起来有点奇怪,但一段时间后,开发人员习惯了显式 ctx,正如他们已经习惯了 self。

请注意,我们的存根也完全是 mypy 类型的,这在大规模重构时会让我们得到充分的回报。它还可以很好地与一些 IDE 集成,如 PyCharm。

随着静态类型化趋势的继续,我们还向 proto 本身添加了 mypy 注解:

复制代码
class TestMessage(Message):
field: int
def __init__self
field : Optional[int] = ...,
) -> None: ...
@staticmethod
def FromStrings: bytes) -> TestMessage: ...

这些注解可以避免常见的 Bug,如 Python 中的将 None 赋值给一个 string 字段。

这些代码已开源

迁移过程

编写一个新的 RPC 堆栈绝不是一件容易的事,但是,在操作复杂性方面,仍然不能与基础设施范围的迁移过程相比。为了保证这个项目的成功,我们尽量让开发人员更容易从遗留 RPC 迁移到 Courier。由于迁移本身是一个非常容易出错的过程,我们决定使用一个多步骤的过程。

步骤 0:冻结遗留 RPC

在做任何事情之前,我们冻结了遗留 RPC 特性集,所以它不再发生变化。这也刺激了人们向 Courier 迁移,因为跟踪、流媒体等所有的新功能都只在 Courier 中提供。

步骤 1:为遗留 RPC 和 Courier 提供通用的接口

我们首先为遗留 RPC 和 Courier 定义了一个公共接口。我们的代码生成负责生成符合这个接口的两个版本的存根:

复制代码
type TestServer interface {
UnaryUnary(
ctx context.Context,
req *test.TestRequest) (
*test.TestResponse,
error)
...
}

步骤 2:迁移到新接口

然后,我们开始将每个服务切换到新的接口,但继续使用遗留 RPC。对于服务及其客户端的所有方法,通常存在巨大的差异。因为这是最容易出错的一步,所以我们要尽可能的减少风险,一次修改一个变量。

方法数量少量且具备多余错误预算(spare error budget)的低配置服务可以在单个步骤中完成迁移,并忽略此警告。

步骤 3:把客户端切换到 Courier RPC

另外,作为 Courier 迁移的一部分,我们开始在同一个二进制文件但不同的端口上运行遗留服务器和 Courier 服务器。现在,更改 RPC 实现对客户端来说只是一行代码的差别:

复制代码
class MyClientobject):
def __init__self):
- self.client = LegacyRPCClient('myservice'
+ self.client = CourierRPCClient('myservice'

注意,使用这个模型,我们可以一次迁移一个客户端,从 SLA 较低的服务开始,如批处理服务和其他异步作业。

步骤 4:清理

在所有服务客户端迁移都完成之后,要证明遗留 RPC 不再使用(这个可以通过静态代码检查以及在运行时观察遗留服务器统计数据来完成。)这一步做完后,开发人员就可以进行旧代码的清理和删除了。

经验总结

最终,Courier 为我们提供了一个统一的 RPC 框架,加快了服务开发,简化了操作,提高了 Dropbox 的可靠性。

以下是我们在开发和部署 Courier 的过程中积累的主要经验:

  1. 可观测性是一个特性。在故障排除过程中,有许多现成的度量和故障信息可以使用非常重要。
  2. 标准化和一致性很重要。它们降低了认知负荷,简化了操作和代码维护。
  3. 尽量减少开发人员需要编写的样板代码的数量。Codegen 为我们提供了帮助。
  4. 尽可能简化迁移。迁移可能会比开发本身花费更多的时间。同时,迁移只有在清理完成后才算完成。
  5. RPC 框架是一个可以进行基础设施层可靠性改进的地方,如强制性截止日期,过载保护等。常见的可靠性问题可以通过季度事件汇总报告识别出来。

未来工作

Courier 以及 gRPC 本身都是变化的,让我们以运行时团队和可靠性团队的路线图作为本文的结束。

在不久的将来,我们想向 Python 的 gRPC 代码中添加一个适当的解析器 API ,在 Python/Rust 中切换到 C++ 绑定,并添加完整的断路器和故障注入支持。明年晚些时候,我们计划考察下ALTS 并将 TLS 握手转移到一个单独的进程(甚至可能是服务容器之外)。

查看英文原文:Courier: Dropbox migration to gRPC