Kubernetes 有個很方便的地方:只要修改 deployment 的 spec.replicas 數字,就能橫向擴展 pod,以應付更大的流量負載需求。

這一招,對於 stateless 的 HTTP 服務很管用,也是 Kubernetes 入門教學愛用的例子。但是,對於 gRPC 呢?

gRPC 是以 HTTP/2 作為傳輸協定,而 HTTP/2 的特點是持久連線:

One connection per origin

all HTTP/2 connections are persistent, and only one connection per origin is required, which offers numerous performance benefits.     – Introduction to HTTP/2

既然 gRPC 會盡可能維持既有連線,不會傻傻地一直斷線與重連,那麼,當 Kubernetes 橫向擴展 pod 了,gRPC 流量是否也能順便平均分散到新長出來的 pod 身上,還是死咬著原本的連線不放?

做個簡單的實驗吧。

實驗環境

實驗程式放在 https://github.com/William-Yeh/grpc-lb

實驗所需環境:

  • Go 1.14 以上。
  • Kubernetes 1.14 以上。
  • Skaffold 1.6.0 以上。
  • Linkerd 2.7.0 以上。

實驗進行之前,請先編譯所需的執行檔及容器映像檔:

# Generate native binaries in `out` directory: 
./build.sh

# Generate Docker images:
skaffold build

會產生以下程式:

  • 執行檔 out/server & 映像檔 addr-server:接收 HTTP 及 gRPC 連線,傳回自己的 IP 位址。

  • 執行檔 client-http & 映像檔 addr-client-http:透過 HTTP 查詢 server 位址。

  • 執行檔 client-grpc & 映像檔 addr-client-grpc:透過 gRPC 查詢 server 位址。

實驗一:純 Kubernetes 模式

首先實驗看看,若 server 程式跑在 Kubernetes 裡,除了 HTTP 流量會自動負載均衡之外,是否 gRPC 流量也享有同等待遇。

實驗一:純 K8s 模式的佈局

實驗一:純 K8s 模式的佈局

為了方便一眼看出整體狀況,請將終端機面板配置如下:

實驗一:終端機建議配置

實驗一:終端機建議配置

請依照以下步驟進行實驗:

  1. 建立 grpc-lb namespace:

    kubectl create ns grpc-lb
    
  2. grpc-lb 裡執行 server,此時 spec.replicas1

    skaffold dev  -n grpc-lb
    
  3. 執行 client-http,透過 HTTP 連線到 server:

    out/client-http  http://127.0.0.1:30080/addr
    
  4. 執行 client-grpc,透過 gRPC 連線到 server:

    out/client-grpc  localhost:30051
    

因為現在 server pod 只有一個,所以 client-http 及 client-grpc 都只會連線到同一個 IP 位址(即此例的 10.1.0.8)。

  

現在,讓我們把 server pod 的 spec.replicas 數字擴展成 5

kubectl scale -n grpc-lb \
  --replicas=5  deployment/addr-server

如下圖所示,當 server pod 數目從 1 變成 5,Kubernetes 的確開始將 HTTP 流量平均分配到 5 個 pods 身上(即此例的 10.1.0.810.1.0.12)。可是 gRPC 流量卻仍然綁在舊的那一個 pod 身上(即此例的 10.1.0.8)。

實驗一:gRPC 流量並未平均分配

實驗一:gRPC 流量並未平均分配

可見,Kubernetes 並沒有對 gRPC 進行負載均衡。

為什麼?

L4 vs L7 負載均衡

根據 “gRPC Load Balancing inside Kubernetes” 一文所述,Kubernetes 原生的負載均衡機制,是建立在 L4:

When you create a Service in Kubernetes, it creates a layer 4 proxy and load balance connections to your pods using iptables, the service endpoint is one IP and a port hiding your real pods.

因此,對於不具備真正持久連線的 HTTP 來說,Kubernetes 原生的 L4 負載均衡機制足以應付 1;但是,對於真正具有持久連線的 gRPC,L4 就行不通了。

文中列出三種解決方法:

  1. Client 端的負載均衡:叫 client 維持一個 gRPC connection pool,主動與每一個 gRPC server pods 都維持連線。缺點是:必須修改 client 程式碼,也暴露 server pods 的一些內部細節。

  2. Kubernetes edge 端的負載均衡:透過 ingress 之類的機制。

  3. Kubernetes service mesh:透過 sidecar 之類的機制。

為了簡單起見,我參考 Kubernetes 官網文章 “gRPC Load Balancing on Kubernetes without Tears” 的做法,用 Linkerd 2 這個 service mesh 方案來實驗。 2

實驗二:Service mesh 模式

根據 Linkerd 官網 FAQ 所述:

Linkerd 1.x is built on the “Twitter stack”: Finagle, Netty, Scala, and the JVM.

Linkerd 2.x is built in Rust and Go. It is significantly faster and lighter weight than 1.x, but currently only supports Kubernetes.

Linkerd 2 比前一版有長足的進步,又是 SMI 陣營的一員,頗值得一試。

Linkerd 前置作業

  1. 安裝 Linkerd 2 到現行的 Kubernetes cluster:

    # Install
    linkerd install | kubectl apply -f -
    
    # Check
    linkerd check
    
  2. 先刪掉舊的 grpc-lb namespace:

    kubectl delete ns grpc-lb
    
  3. 建立新的 grpc-lb namespace,並注入 Linkerd:

    kubectl apply -f ns.yml
    
  4. 檢查一下 grpc-lb namespace 的 data plane 是否正常:

    linkerd -n grpc-lb check --proxy
    

我們把 grpc-lb namespace 圈成一塊 service mesh 疆域。如果一切順利,在 grpc-lb namespace 裡面的東西,會自動被 Linkerd 接管——包括 gRPC 負載均衡。

Replicas = 1

首先實驗看看,若我們將 addr-client-grpc 與 server 都放在 service mesh 疆域內,兩者間的 gRPC 流量是否會自動負載均衡。我也保留一份舊的 client-grpc 故意「」讓它跑在 Kubernetes 裡面,作為對照組。

實驗二:K8s service mesh 模式的佈局

實驗二:K8s service mesh 模式的佈局

為了方便一眼看出整體狀況,請將終端機面板配置如下:

實驗二:終端機建議配置

實驗二:終端機建議配置

請依照以下步驟進行實驗:

  1. 移掉 skaffold.yaml 這一行的註解符號:

    deploy:
      kubectl:
        manifests:
          - server.yml
          #- client-grpc.yml  ← 請讓這一行生效!
    
  2. grpc-lb namespace 裡執行 addr-client-grpc 及 server,此時 server 的 spec.replicas1

    skaffold dev  -n grpc-lb
    
  3. 等前一個步驟跑到穩定狀態之後,在 Kubernetes 外面也執行一份舊的 client-grpc 作為對照組:

    out/client-grpc  localhost:30051
    

此時,因為 server pod 只有一個,所以,位於 service mesh 疆域內的 addr-client-grpc 及疆域外的 client-grpc 都只會連線到同一個 IP 位址(即此例的 10.1.0.71)。

Replicas = 5

一切就緒,讓我們把 server pod 的 spec.replicas 數字擴展成 5

kubectl scale -n grpc-lb \
  --replicas=5  deployment/addr-server

如下圖所示,當 server pod 數目從 1 變成 5,Kubernetes 的確開始將 service mesh 疆域內 addr-client-grpc 產生的 gRPC 流量平均分配到 5 個 pods 身上(即此例的 10.1.0.7110.1.0.75)。可是「」位於 service mesh 疆域的 client-grpc 產生的 gRPC 流量,就仍然綁在舊的那一個 pod 身上(即此例的 10.1.0.71)。

實驗二:gRPC 流量分配狀況

實驗二:gRPC 流量分配狀況

Linkerd 儀表板

讓我們看看酷炫一點的東西吧!請用以下命令打開 Linkerd 儀表板:

linkerd dashboard &

請切換到 grpc-lb namespace,觀察三個角色之間的網路互連架構,並檢視一些效率指標。如下圖所示,Linkerd 2.7.0 對於 gRPC load balancing 的表現很不錯,即使打開 mesh sidecar,P99 latency 也仍然壓在 10ms 左右的水準:

實驗二:Topology

實驗二:Topology

從下圖也可以看到 5 個 server pods 都有輪流服務到 addr-client-grpc。其中,最後一個 pod 比較可憐,湊巧服務到獨佔連線的外部 client-grpc:

實驗二:gRPC 負載均衡的數據

實驗二:gRPC 負載均衡的數據

  

簡單的實驗,示範在 Kubernetes 裡,可以透過 service mesh 替 gRPC 加上負載均衡機制。


  1. 嚴格來說,HTTP/1.1 的 keep alive 機制,或多或少會影響到 Kubernetes 的負載均衡效果。不過正如維基百科所說,許多 Web server 本來就會針對 HTTP 設定較短的 timeout 以避免副作用。若真的要在 Web 上進行持久連線,一般都會建議改走 WebSocket 路線。 ↩︎

  2. 有在玩 Istio 的朋友,可以參考〈Istio 基礎 — gRPC 負載均衡〉一文的做法。不過,比較起來,Istio 設定上會比 Linkerd 複雜。 ↩︎