使用 gRPC 改造 Kubernetes 通信

在過去的一年半時間裏,Cloudflare 一直致力於將非邊緣側運行的後端服務從裸金屬和 Mesos Marathon 解決方案中轉移到使用 Kubernetes 的更統一的方法。我們選擇 Kubernetes 是因爲它允許我們將單體應用拆分爲許多不同的微服務,並能對通信進行細粒度控制。

例如,Kubernetes 中的 ReplicaSet 可以通過確保始終有正確數量的 Pod 可用來提供高可用性。Kubernetes 中的 Pod 類似於 Docker 中的容器。兩者都負責運行實際的程序。這些 Pod 可通過 Kubernetes 的服務提煉副本數來對外暴露,服務通過一個單一入口來負載均衡其背後的所有 Pod。然後服務可通過 Ingress 暴露給 Internet。最後,網絡策略通過確保程序使用正確的策略來防止不必要的通信。這些策略可以包括 L3 或 L4 規則。

下圖展示了這種方式的一個簡單示例。

儘管 Kubernetes 在提供通信和流量管理工具方面做得很好,但它並不能幫助開發人員決策運行在 Pod 上的程序之間進行通信的最佳方式。在這篇博客中,我們通過回顧我們所做的一些決定及其背後的原因,來討論兩種常用的 API 架構:REST 和 gRPC,它們之間的優缺點。

除舊迎新

當 DNS 團隊第一次遷移到 Kubernetes 時,我們所有的 pod-to-pod 通信都是通過 REST API 完成的,其中很多場景也包含了 Kafka。一般通信流程如下:

我們使用 Kafka 是因爲它允許我們在不丟失信息的情況下處理大流量峯值。例如,在二級 DNS 域的域傳送過程中,服務 A 告訴服務 B 它的域準備發佈到邊緣。然後服務 B 調用服務 A 的 REST API,生成域,並將其推到邊緣。如果你想了解更多關於它是如何工作的,我在 Cloudflare 上寫了一篇關於二級 DNS 管道 [1] 的完整博客文章。

對於這兩個服務之間的大多數通信,HTTP 工作得很好。然而,隨着我們擴大規模並添加新的端點,我們意識到只要控制通信的兩端,我們就可以提高通信的可用性和性能。此外,使用 HTTP 通過網絡發送大型 DNS 域經常會引發大小限制和壓縮問題。

相比之下,gRPC 可以方便地在客戶端和服務器之間傳輸數據,常被用於微服務體系結構中。這些特性使 gRPC 成爲 REST API 的明顯替代品。

gRPC 可用性

從開發人員的角度來看,HTTP 客戶端庫很笨重,需要自己用代碼定義路徑、處理參數以及處理以字節爲單位的響應結果。gRPC 將所有這些都抽象出來,通過定義一個 struct 使網絡調用感覺像調用任何其他函數一樣。

下面的例子展示了建立 GRPC 客戶端 / 服務器系統的一個非常基本的模式。由於 gRPC 使用 protobuf 進行序列化,它在很大程度上是與語言無關的。一旦定義了模式,就可以使用 protoc 命令爲多種語言生成代碼。

協議緩衝區數據組裝成消息,每個消息包含以字段形式存儲的信息。字段是強類型的,提供類型安全性,這點與 JSON 或 XML 不同。下面定義了兩條消息,Hello 和 HelloResponse。接下來,我們定義一個名爲 HelloWorldHandler 的服務,它包含一個名爲 SayHello 的 RPC 函數,如果任何對象希望將自己稱爲 HelloWorldHandler,就必須實現這個函數。

簡單的原型:

message Hello{
        string Name = 1;
}

message HelloResponse{}

service HelloWorldHandler {
        rpc SayHello(Hello) returns (HelloResponse){}
}

一旦運行了 protoc 命令,就可以編寫服務器端代碼了。爲了實現 HelloWorldHandler,我們必須定義一個實現上面 protobuf 模式中指定的所有 RPC 函數的結構體。在本例中,結構體 Server 定義了一個函數 SayHello,它接受兩個參數,分別是 context 和 * pb.Hello。*pb.Hello 先前在模式中指定,它包含一個字段 Name。SayHello 還必須返回 * pbHelloResponse,爲簡單起見,pbHelloResponse 定義爲不帶字段的。

在 main 函數內部,我們創建一個 TCP 監聽器,以及一個新的 gRPC 服務器,然後將處理程序註冊爲 HelloWorldHandlerServer。在我們的 gRPC 服務器上調用 Serve 之後,客戶端將能夠通過 SayHello 函數與服務器通信。

簡單的服務器:

type Server struct{}

func (s *Server) SayHello(ctx context.Context, in *pb.Hello) (*pb.HelloResponse, error) {
         fmt.Println("%s says hello\n", in.Name)
         return &pb.HelloResponse{}, nil
}

func main() {
         lis, err := net.Listen("tcp"":8080")
         if err != nil {
                  panic(err)
         }
         gRPCServer := gRPC.NewServer()
         handler := Server{}
         pb.RegisterHelloWorldHandlerServer(gRPCServer, &handler)
         if err := gRPCServer.Serve(lis); err != nil {
                   panic(err)
         }
}

最後,我們需要實現 gRPC 客戶端。首先,我們建立一個與服務器的 TCP 連接。然後,我們創建一個新的 pb.HandlerClient。客戶端可以通過傳入一個 * pb.Hello 對象來調用服務器的 SayHello 函數。

簡單的客戶端:

conn, err := gRPC.Dial("127.0.0.1:8080", gRPC.WithInsecure())
if err != nil {
        panic(err)
}
client := pb.NewHelloWorldHandlerClient(conn)
client.SayHello(context.Background()&pb.Hello{Name: "alex"})

爲簡單起見,我已刪除了一些代碼,但如有必要,這些服務和消息可能會變得非常複雜。需要理解的最重要的一點是,當服務器試圖將自己宣佈爲 HelloWorldHandlerServer 時,它需要實現 protobuf 模式中指定的 RPC 函數。通過這個約定,客戶端和服務器之間的跨語言的網絡調用就像常規的函數調用一樣了。

除了上面描述的基本的一元服務器,gRPC 讓你在四種服務方法之間做出選擇:

gRPC 性能

並不是所有的 HTTP 連接都是相同的。雖然 Golang 本身支持 HTTP/2,但是 HTTP/2 傳輸必須由客戶端設置,服務器也必須支持 HTTP/2。在轉向 gRPC 之前,我們仍然使用 HTTP/1.1 作爲客戶端連接。我們本可以切換到 HTTP/2 來獲得性能提升,但我們可能會失去原生 protobuf 壓縮和可用性改變帶來的一些好處。

HTTP/1.1 中最好的選項是管道。管道意味着,儘管請求可以共享一個連接,但它們必須一個接一個地排隊,直到前面的請求完成。HTTP/2 通過使用連接多路複用改進了管道。多路複用允許在同一連接上同時發送多個請求。

HTTP REST API 通常使用 JSON 作爲其請求和響應格式。Protobuf 是 gRPC 的本地請求 / 響應格式,因爲它有一個客戶端和服務器在註冊期間協商的標準模式。此外,由於其序列化速度,protobuf 比 JSON 要快得多。我在我的筆記本電腦上運行了一些基準測試,源代碼可以在這裏 [2] 找到。

如你所見,protobuf 在小型、中型和大型數據中性能都更好。每個操作更快,編組後更小,並且可以很好地隨輸入大小擴展。在解組非常大的數據集時,這一點更加明顯。Protobuf 需要 96.4ns/op,而 JSON 需要 22647ns/op,減少了 235 倍的時間! 對於大型 DNS 區域,這種效率使得我們從 API 中的記錄更改到在邊緣提供服務所花費的時間產生了巨大的差異。

從我們程序的角度來看,結合 HTTP/2 和 protobuf 的優點幾乎沒有顯示出性能上的變化。這可能是因爲我們的 pod 離得很近,所以我們的連接時間已經很短了。此外,我們的大多數 gRPC 調用都是用少量的數據完成的,差異可以忽略不計。我們注意到一件事——可能與 HTTP/2 的多路複用有關——將新創建 / 編輯 / 刪除的記錄寫入邊緣時效率更高。我們的延遲峯值的幅度和頻率都下降了。

gRPC 安全

Kubernetes 最好的特性之一是網絡策略。這讓開發人員可以控制哪些內容可以輸入,哪些內容可以輸出。

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
     name: test-network-policy
     namespace: default
spec:
    podSelector:
         matchLabels:
               role: db
    policyTypes:
    - Ingress
    - Egress
    ingress:
    - from:
       - ipBlock:
             cidr: 172.17.0.0/16
             except:
             - 172.17.1.0/24
    - namespaceSelector:
           matchLabels:
                 project: myproject
     - podSelector:
            matchLabels:
                  role: frontend
      ports:
      - protocol: TCP
         port: 6379
    egress:
    - to:
       - ipBlock:
             cidr: 10.0.0.0/24
      ports:
      - protocol: TCP
        port: 5978

在這個例子中(取自 Kubernetes 文檔),我們可以看到這將創建一個名爲 test-network-policy 的網絡策略。這個策略控制與角色 db 匹配的任何 Pod 的入口和出口通信,並強制執行以下規則:

入口連接允許:

出口連接允許:

網絡策略在網絡級保護 API 方面做得很好,但是在程序級卻沒有保護 API。如果希望控制可以在 API 中訪問哪些端點,則需要 k8 不僅能夠區分 pod,還能夠區分這些 pod 中的端點。這些問題導致我們使用每個 RPC 憑據。在已存在的 gRPC 代碼之上很容易設置每個 RPC 憑據。你所需要做的就是將攔截器添加到流處理程序和一元處理程序中。

func (s *Server) UnaryAuthInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
       // Get the targeted function
       functionInfo := strings.Split(info.FullMethod, "/")
       function := functionInfo[len(functionInfo)-1]
       md, _ := metadata.FromIncomingContext(ctx)

       // Authenticate
       err := authenticateClient(md.Get("username")[0], md.Get("password")[0]function)
       // Blocked
       if err != nil {
             return nil, err
       }
       // Verified
       return handler(ctx, req)
}

在這個示例代碼片段中,我們從 info 對象獲取用戶名、密碼和請求的函數。然後針對客戶端進行身份驗證,以確保它擁有調用該函數的正確權限。這個攔截器將在任何其他函數被調用之前運行,這意味着一個實現保護所有函數。客戶端將初始化其安全連接併發送如下憑證:

transportCreds, err := credentials.NewClientTLSFromFile(certFile, "")
if err != nil {
      return nil, err
}
perRPCCreds := Creds{Password: grpcPassword, User: user}
conn, err := grpc.Dial(endpoint, grpc.WithTransportCredentials(transportCreds), grpc.WithPerRPCCredentials(perRPCCreds))
if err != nil {
      return nil, err
}
client:= pb.NewRecordHandlerClient(conn)
// Can now start using the client

在這裏,客戶端首先驗證服務器是否與 certFile 匹配。此步驟確保客戶端不會意外地將其密碼發送給壞的參與者。接下來,客戶端使用其用戶名和密碼初始化 perRPCCreds 結構,並使用該信息向服務器撥號。每當客戶端調用 rpc 定義的函數時,服務器將驗證其憑據。

下一個步驟

我們的下一步是消除許多程序訪問數據庫的需求,並通過將所有與 dns 相關的代碼放入一個 API(從一個 gRPC 接口訪問),最終使我們的代碼庫精簡。這消除了在單個程序中發生錯誤的可能性,並使更新我們的數據庫模式更容易。它還爲我們提供了更細粒度的控制,可以訪問哪些函數,而不是訪問哪些表。

到目前爲止,DNS 團隊對我們 gRPC 遷移的結果非常滿意。然而,要完全擺脫 REST,我們還有很長的路要走。我們也在耐心地等待 gRPC 對 HTTP/3[3] 的支持,這樣我們就可以充分利用這些超快的速度!

相關鏈接:

  1. https://blog.cloudflare.com/secondary-dns-deep-dive/

  2. https://github.com/Fattouche/protobuf-benchmark

  3. https://github.com/grpc/grpc/issues/19126

原文鏈接:https://blog.cloudflare.com/moving-k8s-communication-to-grpc/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/6HIjwsFtxEKbjvR1osji2w