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

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

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

可观察性:统计和跟踪

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

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

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

我们的遗留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队列。
Courier:Dropbox 基于gRPC 的 RPC 框架开发过程
在服务过载的情况下,这个LIFO队列充当自动断路器。队列不仅受大小限制,更重要的是,它还受时间限制。一个请求只能在队列中呆固定长的时间。

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

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

内省:调式端点

尽管调试端点不是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 rsa2048LibreSSL 2.6.4...                  sign    verify    sign/s verify/srsa 2048 bits 0.032491s 0.001505s     30.8    664.3

OpenSSL 1.1.1a:

???? ~ openssl speed rsa2048OpenSSL 1.1.1a  20 Nov 2018...                  sign    verify    sign/s verify/srsa 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 := \u0026amp;grpc.UnaryServerInfo{                Server:     srv,                FullMethod: \u0026quot;/test.Test/UnaryUnary\u0026quot;,        }        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 := \u0026amp;processor.UnaryUnaryRequest{                Srv:            srv,                Ctx:            ctx,                Dec:            dec,                Interceptor:    interceptor,                RpcStats:       stats,                Metadata:       metadata,                FullMethodPath: \u0026quot;/test.Test/UnaryUnary\u0026quot;,                Req:            \u0026amp;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 := \u0026amp;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)=\u0026quot;NOT_FOUND\u0026quot;];  ALREADY_EXISTS = 2 [(rpc_core.grpc_code)=\u0026quot;ALREADY_EXISTS\u0026quot;];  ...  STALE_READ = 7 [(rpc_core.grpc_code)=\u0026quot;UNAVAILABLE\u0026quot;];  SHUTTING_DOWN = 8 [(rpc_core.grpc_code)=\u0026quot;CANCELLED\u0026quot;];}

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

特定于Python的修改

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

from dropbox.context import Contextfrom dropbox.proto.test.service_pb2 import (        TestRequest,        TestResponse,)from typing_extensions import Protocolclass TestCourierClient(Protocol):    def UnaryUnary(            self,            ctx,      # type: Context            request,  # type: TestRequest            ):        # type: (...) -\u0026gt; TestResponse        ...

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

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

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

class TestMessage(Message):    field: int    def __init__(self,        field : Optional[int] = ...,        ) -\u0026gt; None: ...    @staticmethod    def FromString(s: bytes) -\u0026gt; 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 MyClient(object):  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

分类:

技术点:

相关文章: