
译者注
大多数现代软件产品不是整体的,而是由许多相互交互的部分组成。 在这种情况下,必须以一种语言进行系统交互部分的通信(尽管这些部分本身可以用不同的编程语言编写并在不同的机器上运行)。 简化此问题的解决方案有助于gRPC-Google于2015年发布的开源框架。 他立即解决了许多问题,并允许:
- 使用协议缓冲区语言描述服务的交互;
- 基于所描述的协议为客户端部分和服务器部分生成针对11种不同语言的程序代码;
- 在交互组件之间实施授权;
- 同时使用同步和异步交互。
gRPC在我看来是一个非常有趣的框架,我很想了解Dropbox在构建基于它的系统方面的真实经验。 这篇文章有许多细节,涉及加密的使用,可靠,可观察和生产性系统的构建,以及从旧RPC解决方案到新RPC解决方案的迁移过程。
免责声明原始文章不包含gRPC的描述,您可能不清楚其中的一些要点。 如果您不熟悉gRPC或其他类似的框架(例如Apache Thrift),我建议您首先熟悉主要思想(阅读官方网站上的两篇小文章就足够了:
“什么是gRPC?”和
“ gRPC概念” )。
感谢Aleksey Ivanov(又名
SaveTheRbtz)撰写原始文章并帮助翻译困难的地方。
Dropbox管理着许多用不同语言编写的服务,每秒可处理数百万个请求。 我们面向服务的体系结构的中心是Courier,这是一个基于gPC的RPC框架。 在其开发过程中,我们从以前的RPC系统中学到了很多有关gRPC可扩展性,性能优化和过渡的知识。
注意:该帖子包含Python和Go的代码片段。 我们还使用Rust和Java。gRPC之路
Courier不是第一个Dropbox RPC框架。 甚至在我们开始将整体Python系统拆分为单独的服务之前,我们还需要一个可靠的基础来在服务之间交换数据-尤其是因为选择框架会带来长期的后果。
在此之前,Dropbox尝试了不同的RPC框架。 首先,我们有一个用于手动序列化和反序列化的单独协议。 某些服务(例如
基于Scribe的日志记录 )使用了
Apache Thrift 。 同时,我们的主要RPC框架是HTTP / 1.1协议,其中消息使用Protobuf进行了序列化。
创建一个框架,我们从几个选项中进行选择。 我们可以将Swagger(现在称为
OpenAPI )引入旧的RPC框架,
引入新的标准,或基于Thrift或gRPC构建框架。 支持gRPC的主要论点是可以使用预先存在的protobuf。 同样,多路复用HTTP / 2和双向数据传输对于我们的任务很有用。
注意:如果那时fbthrift存在,我们可能会仔细研究Thrift解决方案。Courier带给gRPC的是什么
Courier不是RPC协议; 它是将gRPC集成到现有基础架构中的一种方法。 该框架应该与我们的身份验证,授权和服务发现工具以及统计信息收集,日志记录和跟踪兼容。 因此,我们创建了Courier。
尽管在某些情况下,我们将Bandaid用作gRPC代理,但是我们的大多数服务都直接相互通信,以最大程度地减小RPC对延迟的影响。对我们而言,减少需要编写的例程代码的数量非常重要。 由于Courier是开发服务的通用框架,因此它包含了每个人都需要的功能。 它们中的大多数默认情况下处于启用状态,并且可以通过命令行参数进行控制,有些则通过复选框进行选中。
安全性:服务身份和TLS相互认证
Courier实施我们的标准服务识别机制。 每个服务器和客户端都分配有由我们自己的证书颁发机构颁发的单独的TLS证书。 证书编码的个人标识符,用于相互身份验证-服务器验证客户端,客户端验证服务器。
在我们控制连接双方的TLS中,我们引入了严格的限制。 所有内部RPC都需要
PFS加密。 要求的TLS版本为1.2及更高版本。 我们还限制了对称和非对称算法的数量,因此更偏爱
ECDHE-ECDSA-AES128-GCM-SHA256 。
通过对请求的标识和解密之后,服务器将检查客户端是否具有必要的权限。 可以为一般服务和个别方法配置访问控制列表(ACL)和速度限制。 它们的参数也可以通过我们的分布式文件系统(AFS)进行更改。 因此,服务所有者可以在几秒钟内降低负载,甚至无需重新启动进程。 Courier将负责订阅通知并更新配置。
身份服务是ACL,速度限制,统计信息等的全局标识符。此外,它是加密安全的。这是我们的
光学模式识别服务中使用的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
我们正在考虑切换到SVID格式(经过加密验证的文档SPIFFE )的可能性,这将有助于将我们的框架与许多开源项目结合起来。可观察性:统计和跟踪
仅需一个标识符,您就可以轻松找到有关Courier的日志,统计信息,跟踪文件和其他数据。

在代码生成期间,将在客户端和服务器端为每个服务和每个方法添加统计信息收集。 服务器端统计信息除以客户端ID。 在标准配置中,您将使用Courier接收有关每种服务的负载,错误和延迟时间的详细数据。

Courier统计信息包括客户端的可用性和延迟数据,以及服务器端的请求数量和队列大小。 还有其他有用的图形,特别是每种方法的响应时间直方图和每个客户端的TLS握手时间。
代码生成的优点之一是可以静态初始化数据结构,例如直方图和跟踪图。 这样可以最大程度地降低性能影响。
旧的RPC系统仅通过API分发了
request_id 。 这样就可以合并来自不同服务日志的数据。 在Courier,我们引入了基于
OpenTracing规范子集的API。 我们在客户端编写了自己的库,在服务器端编写了基于Cassandra和
Jaeger的解决方案。

跟踪使我们能够在运行时生成服务的依赖关系图。 这有助于工程师查看特定服务的所有传递依赖关系。 此外,该功能对于跟踪部署后不需要的依赖项很有用。
可靠性:截止日期和断开连接
Courier提供了一个中心位置,可以用不同的语言实现常见的客户端功能(例如,超时)。 我们通常基于对新出现问题的“适当”分析的结果,逐渐添加了各种功能。
截止期限
每个gRPC请求都有一个指示客户端超时
的截止日期 。 由于Courier存根会自动分发已知的元数据,因此请求截止日期甚至可以转移到API之外。 在此过程中,截止日期将显示为本地。 例如,在Go中,它们由
WithDeadline方法中
context.Context的结果表示。
实际上,通过迫使工程师设定定义适当服务的期限,我们能够解决所有类别的可靠性问题。
这种方法甚至超越了RPC。 例如,我们的ORM MySQL在SQL查询注释中将RPC上下文以及截止日期序列化。 当截止日期到来时,我们的SQL代理可以解析注释和“杀死”查询。 作为调试数据库调用时的一项奖励,我们有一个绑定到特定RPC查询的SQL查询。
断开连接
以前的RPC系统的客户面临的另一个常见问题是,在重复请求时,个体指数延迟和波动算法的实现。
我们试图从服务和任务池之间的LIFO缓冲区(后进先出)的实现开始,找到针对Courier中断开连接问题的智能解决方案。

如果发生过载,LIFO将自动断开连接。 重要的队列不仅受大小限制,而且
受时间限制 (请求只能在特定时间花费在队列中)。
减LIFO-更改处理请求的顺序。 如果要保留原始订单,请使用CoDel 。 那里也有断开连接的可能性,并且处理请求的顺序将保持不变。
内省:调试端点
尽管调试终结点不是Courier的直接组成部分,但它们在Dropbox的整个过程中得到了广泛的使用,其作用实在不容提及。
出于安全原因,您可以在单独的端口或Unix套接字上打开它们(以使用文件许可权控制访问)。 您还应该考虑双向TLS身份验证,开发人员必须使用它们进行身份验证,以提供对端点访问的证书(主要不仅限于只读)。执行力
在服务运行期间分析服务状态的能力对于调试非常有用。 例如,
可以通过HTTP或gRPC端点访问动态内存和CPU配置文件 。
我们计划在金丝雀验证程序中利用这一机会-自动搜索新旧版本代码之间的差异。端点使在运行时修改服务状态成为可能。 特别是,基于Golang的服务可以动态配置
GCPercent 。
图书馆
将库特定数据自动导出为RPC端点可能对库开发人员很有用。 例如,
malloc库
可以将内部统计信息转储到dump 。 另一个示例:调试端点可以即时更改服务日志记录的级别。
Rpc
当然,对加密和编码协议进行故障排除并不容易。 因此,在RPC级别引入尽可能多的工具是一个好主意。 这种自省型API的一个示例
是Channelz解决方案 。
应用等级
能够学习应用程序级别的选项也很有用。 一个很好的例子是一个端点,其中包含有关应用程序的常规信息(带有源文件或汇编文件的散列,命令行等)。 业务流程系统可以在部署服务时使用它来验证完整性。
性能优化
通过将gRPC框架扩展到所需的规模,我们发现了Dropbox特有的几个瓶颈。
TLS握手的资源消耗
在服务于许多关系的服务中,由于TLS握手,合并的CPU负载可能非常严重(尤其是在重新启动流行的服务时)。
为了提高签名时的性能,我们用ECDSA P-256替换了RSA-2048密钥对。 以下是其性能的示例(注意:使用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进行验证的速度快三倍,因此可以为根证书和最终证书选择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的负载。
加密便宜
许多人错误地认为加密是昂贵的。 实际上,即使是最简单的现代计算机也几乎可以立即执行对称加密。 标准处理器能够以每个核心40 Gb / s的速度加密和认证数据:
~/c0d3/boringssl bazel run -- //:bssl speed -filter 'AES' Did ... AES-128-GCM (8192 bytes) seal operations in ... 4534.4 MB/s
但是,我们仍然必须为我们的内存块配置gRPC,以50 Gb / s的速度运行。 我们发现,如果加密速度大约等于复制速度,那么最小化
memcpy操作的数量就很重要
。 此外,我们对gRPC本身进行了一些更改。
经过身份验证和加密的协议避免了许多不愉快的问题(例如,处理器,DMA或网络上的数据损坏)。 即使您不使用gRPC,我们也建议对内部联系人使用TLS。高延迟数据通道(BDP)
译者注:原始字幕使用了术语
带宽延迟乘积 ,该词没有确定的俄语翻译。
Dropbox骨干网络包括许多数据中心 。 有时,位于不同区域的节点必须通过RPC进行通信,例如进行复制。 使用TCP时,尽管基于HTTP / 2的gRPC拥有自己的工具,但系统内核负责限制特定连接(在/
proc / sys / net / ipv4 / tcp_ {r,w} mem内 )传输的数据量。流量控制。
在grpc-go中 ,BDP的上限
严格限制为16 MB ,这可能会引发瓶颈。
net.Server Golang或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方案和来自各种语言的存根示例。 所有示例均来自我们在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方法都需要截止日期。 使用以下选项,您可以设置整个服务的截止日期:
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无需依赖gRPC提供的侦听器功能即可生成自己的存根(Java除外,侦听器API具有足够的功能)。 让我们比较一下我们的存根和标准的Golang存根。
这是默认的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,启动拦截器(请参阅代码中的
interceptor
变量),启动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,也未启动拦截器。 在此之前,访问池,优先级和已执行请求数的限制必须经过任务池。
请注意,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 ):
起初看起来很奇怪,但是随着时间的流逝,开发人员已经习惯于显式
ctx ,就像他们习惯于
self一样 。
请注意,我们的存根是
mypy的完整类型,在主要重构过程中会被抵消。 此外,简化了与某些IDE(例如PyCharm)的集成。
继续遵循静态类型化的趋势,我们将mypy注释添加到协议本身:
class TestMessage(Message): field: int def __init__(self, field : Optional[int] = ..., ) -> None: ... @staticmethod def FromString(s: bytes) -> TestMessage: ...
这些注释将避免许多常见的错误,例如,将
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。 通常,代码更改是一个巨大的差异,影响了服务及其客户端的所有方法。 由于此阶段存在最大问题,因此我们希望一次只更改一件事情来完全消除风险。
可以同时迁移具有少量方法和犯错权利的简单服务,而无需注意我们的警告。步骤3:将客户迁移到RPC Courier
在迁移过程中,我们开始在同一台计算机的不同端口上同时启动旧服务器和新服务器。 切换客户端RPC实现是通过更改以下一行来完成的:
class MyClient(object): def __init__(self): - self.client = LegacyRPCClient('myservice') + self.client = CourierRPCClient('myservice')
请注意,使用此模型,您可以一次转移一个客户端,从具有较低SLA级别的客户端开始。
步骤4:清洁
, , RPC ( ). — .
结论
, Courier — RPC-, , Dropbox.
, Courier:
- — . .
- — , .
- , . Codegen.
- . , , . , : .
- RPC- — , . . .
Courier, gRPC , , , .
gRPC Python , C++ Python Rust . ALTS TLS- (, ).