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 FromString(s: 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 MyClient(object):
def __init__(self):
- self.client = LegacyRPCClient('myservice')
+ self.client = CourierRPCClient('myservice')
注意,使用這個模型,我們可以一次遷移一個客戶端,從SLA較低的服務開始,如批處理服務和其他異步作業。
步驟4:清理
在所有服務客戶端遷移都完成之後,要證明遺留RPC不再使用(這個可以通過靜態代碼檢查以及在運行時觀察遺留服務器統計數據來完成。)這一步做完後,開發人員就可以進行舊代碼的清理和刪除了。
經驗總結
最終,Courier爲我們提供了一個統一的RPC框架,加快了服務開發,簡化了操作,提高了Dropbox的可靠性。
以下是我們在開發和部署Courier的過程中積累的主要經驗:
- 可觀測性是一個特性。在故障排除過程中,有許多現成的度量和故障信息可以使用非常重要。
- 標準化和一致性很重要。它們降低了認知負荷,簡化了操作和代碼維護。
- 儘量減少開發人員需要編寫的樣板代碼的數量。Codegen爲我們提供了幫助。
- 儘可能簡化遷移。遷移可能會比開發本身花費更多的時間。同時,遷移只有在清理完成後纔算完成。
- RPC框架是一個可以進行基礎設施層可靠性改進的地方,如強制性截止日期,過載保護等。常見的可靠性問題可以通過季度事件彙總報告識別出來。
未來工作
Courier以及gRPC本身都是變化的,讓我們以運行時團隊和可靠性團隊的路線圖作爲本文的結束。
在不久的將來,我們想向Python的gRPC代碼中添加一個適當的解析器API ,在Python/Rust中切換到C++綁定,並添加完整的斷路器和故障注入支持。明年晚些時候,我們計劃考察下ALTS並將TLS握手轉移到一個單獨的進程(甚至可能是服務容器之外)。