- 原文地址:Courier: Dropbox migration to gRPC
- 原文做者:blogs.dropbox.com
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:kasheemlew
- 校對者:shixi-li
Dropbox 運行着幾百個服務,它們由不一樣的語言編寫,每秒會交換幾百萬個請求。在咱們面向服務架構的中心就是 Courier,它是咱們基於 gRPC 的遠過程調用(RPC)框架。在開發 Courier 的過程當中,咱們學到了不少擴展 RPC 並優化性能和銜接原有 RPC 系統的東西。html
註釋:本文只展現了 Python 和 Go 生成代碼的例子。咱們也支持 Rust 和 Java。前端
Courier 不是 Dropbox 的第一個 RPC 框架。在咱們正式開始將龐大的的 Python 程序拆成多個服務以前,咱們就認識到服務之間的通訊須要有牢固的基礎,因此選擇一個高可靠性的 RPC 框架就顯得尤爲關鍵。python
開始以前,Dropbox 調研了多個 RPC 框架。首先,咱們從傳統的手動序列化和反序列化的協議着手,好比咱們用 Apache Thrift 搭建的基於 Scribe 的日誌管道之類的服務。但咱們主要的 RPC 框架(傳統的 RPC)是基於 HTTP/1.1 協議並使用 protobuf 編碼消息。android
咱們的新框架有幾個候選項。咱們能夠升級遺留的 RPC 框架使其兼容 Swagger(如今叫 OpenAPI),或者創建新標準,也能夠考慮在 Thrift 和 gRPC 的基礎上開發。ios
咱們最終選擇 gRPC 主要是由於它容許咱們沿用 protobuf。對於咱們的狀況,多路 HTTP/2 傳輸和雙向流也頗有吸引力。git
若是那時候有 fbthrift 的話,咱們也許會仔細瞧瞧基於 Thrift 的解決方案。github
Courier 不是一個新的 RPC 協議 —— 它只是 Dropbox 用來兼容 gRPC 和原有基礎設施的解決方案。例如,只有使用指定版本的驗證、受權和服務發現時它才能工做。它還必須兼容咱們的統計、事件日誌和追蹤工具。知足全部這些條件纔是咱們所說的 Courier。golang
儘管咱們支持在一些特殊狀況下使用 Bandaid 做爲 gRPC 代理,但爲了減少 RPC 的延遲,大多數服務間的通訊並不使用代理。算法
咱們想減小須要編寫的樣板文件的數量。做爲咱們服務開發的通用框架,Courier 擁有全部服務須要的特性。大多數特性都是默認開啓的,而且能夠經過命令行參數進行控制。有些還可使用特性標識動態開啓。數據庫
Courier 實現了咱們的標準服務身份機制。咱們的服務器和客戶端都有各自的 TLS 證書,這些證書由咱們內部的權威機構頒發。每一個服務器和客戶端還有一個使用這個證書加密的身份,用於他們之間的雙向驗證。
咱們在 TLS 側控制通訊的兩端,並強制進行一些默認的限制。內部的 RPC 通訊都強制使用 PFS 加密。TLS 的版本固定爲 1.2+。咱們還限制使用對稱/非對稱算法的安全的子集進行加密,這裏比較傾向於使用
ECDHE-ECDSA-AES128-GCM-SHA256
。
完成身份認證和請求的解碼以後,服務器會對客戶端進行權限驗證。在服務層和獨立的方法中均可以設置訪問控制表(ACL) 和限制速率,也可使用咱們的分佈式配置系統(AFS)進行更新。這樣就算服務管理者不重啓進程,也能在幾秒以內完成分流。訂閱通知和更新配置由 Courier 框架完成。
服務 「身份」 是用於 ACL、速率限制、統計等的全局標識符。另外,它也是加密安全的。
咱們的光學字符識別(OCR)服務中有這樣一個 Courier ACL/速率限制配置定義的例子:
limits:
dropbox_engine_ocr:
# 全部的 RPC 方法。
default:
max_concurrency: 32
queue_timeout_ms: 1000
rate_acls:
# OCR 客戶端無限制。
ocr: -1
# 沒有其餘人與咱們通訊。
authenticated: 0
unauthenticated: 0
複製代碼
咱們在考慮使用每一個人都該用的安全生產標識框架 (SPIFFE)中的 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 客戶端須要在重試時實現自定義指數補償和抖動。
在 Courier 中,咱們但願用一種更通用的方法解決斷路限制的問題,因而在監聽器和工做池之間採用了一個 LIFO 隊列。
在服務過載的時候,這個 LIFO 隊列就會像一個自動斷路器同樣工做。這個隊列不只有大小的限制,還有更嚴格的時間限制。一個請求只能在該隊列中存在指定的時間。
LIFO 在對請求排序時有缺陷。若是想維持順序,你能夠試試 CoDel。它也有斷路限制的功能,且不會打亂請求的順序
調試端點儘管不是 Courier 自己的一部分,但在 Dropbox 中獲得了普遍的使用。它們太有用了,我不能不提!這裏有些有用的自省的例子。
爲了安全考慮,你可能想將這些暴露到一個單獨的端口(也許只是一個迴環接口)甚至是一個 Unix 套接字(能夠用 Unix 文件系統進行控制。)你也必定要考慮使用雙向 TLS 驗證,要求開發者在訪問調試端點時提供他們的證書(特別是非只讀的那些。)
運行時
能在看到運行時的狀態是很是有用的。例如 堆和 CPU 文件能夠暴露爲 HTTP 或 gRPC 端點。
咱們打算在灰度驗證的階段用這個方法自動化新舊版本代碼間的對比。
這些調試端點容許在修改運行時的狀態,例如,一個用 golang 開發的服務能夠動態設置 GCPercent。
庫
動態導出某些特定庫的數據做爲 RPC 端點對於庫的做者來講頗有用。malloc 庫轉儲內部狀態就是個很好的例子。
RPC
考慮到對加密的和二進制編碼的協議進行故障診斷有點複雜,所以應該在性能容許的狀況下向 RPC 層加入儘量多的工具。最近有個這樣的自省 API 的例子,就是 gRPC 的 channelz 提案。
應用
查看 API 級別的參數也頗有用。將構建/原地址散列、命令行等用於通用應用信息端點就是很好的例子。編排系統能夠經過這些信息驗證服務部署的一致性。
在擴展 Dropbox 的 gRPC 規模的時候,咱們發現了不少性能瓶頸。
因爲服務要處理大量的鏈接,累積起來的 TLS 握手開銷是不可忽視的。在大規模服務重啓時這一點尤爲突出。
爲了提高簽約操做的性能,咱們將 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 構建和 homebrewed 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
複製代碼
儘管如此,咱們最終仍是要使 gRPC 適配咱們的 50Gb/s 儲存箱。咱們瞭解到,當加密速度能夠和內存拷貝速度相提並論的時候,下降 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 代碼中,咱們起初支持 HTTP/1.1 和 gRPC 使用相同的 net.Server。這從邏輯上講得通,可是在性能上表現不佳。將 HTTP/1.1 和 gRPC 拆分到不一樣的路徑、用不一樣的服務器管理而且將 gRPC 換成 grpc.Server 大大改進了 Courier 服務的吞吐量和內存佔用。
若是你使用 gRPC 的話,編組和解組開銷會很大。對於咱們的 Go 代碼,咱們使用了 gogo/protobuf,它顯著下降了對咱們最忙碌的 Courier 服務器的 CPU 使用。
一樣的,使用 gogo/protobuf 也有一些注意事項,但堅持使用一個正常的功能子集的話應該沒問題。
從這裏開始,咱們將會深挖 Courier 的內部,看看不一樣語言下的 protobuf 模式和存根的例子。下面全部的例子都會用咱們的 Test
服務(咱們在 Courier 中用這個進行集成測試)
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 除外,它的攔截器 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、運行攔截器、調用 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 邊界都會被替換成 UNKOWN。這避免了不一樣服務之間的意外錯誤代理的問題,修改了語義上的意思。
咱們在 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, # 類型:Context
request, # 類型:TestRequest
):
# 類型: (...) -> TestResponse
...
複製代碼
一開始,這看起來有些奇怪,但時候後來開發者們漸漸習慣了顯式的 ctx
,就像他們習慣 self
同樣。
請注意,咱們的存根也都是 mypy 類型的,這在大規模重構期間會獲得充分的回報。而且 mypy 在像 PyCharm 這樣的 IDE 中也已經獲得了很好的集成。
繼續靜態類型的趨勢,咱們還能夠將 mypy 的註解加入到 proto 中。
class TestMessage(Message):
field: int
def __init__(self,
field : Optional[int] = ...,
) -> None: ...
@staticmethod
def FromString(s: bytes) -> TestMessage: ...
複製代碼
這些註解避免了許多常見的漏洞,好比將 None
賦值給 Python 中的 string
字段。
這些代碼在 dropbox/mypy-protobuf 中開源了。
編寫一個新的 RPC 棧絕非易事,但就操做的複雜性而言仍是不能和跨範圍的遷移相提並論。爲了保證項目的成功,咱們嘗試簡化開發者從傳統 RPC 遷移到 Courier 的過程。因爲遷移自己就是個很容易出錯的過程,咱們決定分紅多個步驟來進行。
在開始以前,咱們會凍結傳統 RPC 的特徵集,這樣他就不會變化了。這樣,因爲追蹤和流之類的新特性只能在 Courier 的服務中使用,你們也會更願意遷移到 Courier。
咱們從給傳統 RPC 和 Courier 定義通用接口開始。咱們的代碼生成會生成適用於這兩種版本接口的存根:
type TestServer interface {
UnaryUnary(
ctx context.Context,
req *test.TestRequest) (
*test.TestResponse,
error)
...
}
複製代碼
而後咱們將每一個服務都切換到新的接口,但仍是使用傳統 RPC。這對於全部服務和客戶端中的方法來講一般都有很大的差別。這個過程很容易出錯,爲了儘量下降風險,咱們每次只改一個參數。
處理只有少數方法和備用錯誤預算的低階服務時能夠一步完成遷移,不用管這個警告。
做爲遷移到 Courier 的一部分,咱們須要在不一樣的端口上同時運行傳統和 Courier 服務器的二進制文件。而後將客戶端中 RPC 實現的一行進行修改。
class MyClient(object):
def __init__(self):
- self.client = LegacyRPCClient('myservice')
+ self.client = CourierRPCClient('myservice')
複製代碼
請注意,使用上面的模型一次能夠遷移一個客戶端,咱們能夠從批處理進程和其餘一些異步任務等擁有較低 SLA 的開始。
在全部的服務客戶端都遷移完成以後,咱們須要證實傳統的 RPC 已經再也不被使用了(能夠經過代碼檢查靜態地完成,或者經過檢查傳統服務器統計來動態地完成。)這一步完成以後,開發者就能夠繼續進行清理並刪掉舊的代碼了。
到了最後,Courier 帶給咱們的是一個能夠加速服務開發的統一 RPC 框架,它簡化了操做並增強了 Dropbox 的可靠性。
這裏咱們總結了開發和部署 Courier 過程當中主要的經驗教訓:
Courier 和 gRPC 自己都在不斷變化,因此咱們最後來總結一下運行時團隊和可靠性團隊的工做路線。
在不遠的未來,咱們會給 Python 的 gRPC 代碼加一個合適的解析器 API,切換到 Python/Rust 中的 C++ 綁定,並加上完整的斷路控制和故障注入的支持。明年咱們準備調研一下 ALTS 而且將 TLS 握手移到單獨的進程(可能甚至與服務容器分離開。)
你想作運行時相關的工做嗎?Dropbox 在山景城和舊金山的小團隊負責全球分佈的邊緣網絡、兆比特流量、每秒數百萬次的請求。
通訊量/運行時/可靠性團隊都在招 SWE 和 SRE,負責開發 TCP/IP 包處理器和負載均衡器、HTTP/gRPC 代理和咱們內部的運行時 service mesh:Courier/gRPC、服務發現和 AFS。感受不合適?咱們舊金山、紐約、西雅圖、特拉維等地的辦公室還有各個方向的職位。
項目貢獻者:Ashwin Amit、Can Berk Guder、Dave Zbarsky、Giang Nguyen、Mehrdad Afshari、Patrick Lee、Ross Delinger、Ruslan Nigmatullin、Russ Allbery 和 Santosh Ananthakrishnan。
同時也很是感謝 gRPC 團隊的支持。
若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。