寫給 go 開發者的 gRPC 教程 - 服務發現與負載均衡


對於一個客戶端創建請求的過程

conn, err := grpc.Dial("example:8009", grpc.WithInsecure())
if err != nil {
  panic(err)
}
  1. gRPC 客戶端通過服務發現解析請求,將名稱解析爲一個或多個 IP 地址,以及服務配置

  2. 客戶端使用上一步的服務配置、ip 列表、實例化負載均衡策略

  3. 負載均衡策略爲每個服務器地址創建一個子通道(channel),並監測每一個子通道狀態

  4. 當有 rpc 請求時,負載均衡策略決定那個子通道即 gRPC 服務器將接收請求,當可用服務器爲空時客戶端的請求將被阻塞

gRPC 官方提供了基本的服務發現和負載均衡邏輯,並提供了接口供擴展用於開發自定義的服務發現與負載均衡

服務發現

用通俗易懂的方式來解釋下什麼是服務發現。通常情況下客戶端需要知道服務端的 IP + 端口號才能建立連接,但服務端的 IP 和端口號並不是那麼容易記憶。還有更重要的,在雲部署的環境中,服務端的 IP 和端口可能隨時會發生變化。

所以我們可以給某一個服務起一個名字,客戶端通過名字創建與服務端的連接,客戶端底層使用服務發現系統,解析這個名字來獲取真正的 IP 和端口,並在服務端的 IP 和端口發生變化時,重新建立連接。這樣的系統通常也會被叫做name-system(名字服務)

gRPC 中的默認name-system是 DNS,同時在客戶端以插件形式提供了自定義name-system的機制。

名字格式

gRPC 採用的名字格式遵循的RFC 3986中定義的 URI 語法

scheme:[//[user[:password]@]host[:port]][/path][?query][#fragment]

例如

gRPC 關注其中兩部分

大部分 gRPC 實現默認支持以下的 URI schemes

服務的服務註冊

如果 gRPC 服務端的地址是靜態的,可以在客戶端服務發現時直接解析爲靜態的地址

如果 gRPC 服務端的地址是動態的,可以有兩種選擇

關於服務註冊這裏不在做更多介紹了

客戶端的服務發現

自定義服務發現需要在客戶端啓動前,註冊一個服務解析器(Resolve

Golang 中使用google.golang.org/grpc/resolver.Register(resolver.Builder)註冊,這個函數不是直接接收一個解析器,而是使用工廠模式接收一個解析器的構造器

type Builder interface {
 // Build creates a new resolver for the given target.
 //
 // gRPC dial calls Build synchronously, and fails if the returned error is
 // not nil.
 Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)
 // Scheme returns the scheme supported by this resolver.
 // Scheme is defined at https://github.com/grpc/grpc/blob/master/doc/naming.md.
 Scheme() string
}

Scheme()需要返回的就是名字格式中提到的 URI scheme

Build(...)需要返回一個服務發現解析器google.golang.org/grpc/resolver.Resolver

✨✨cc ClientConn代表客戶端與服務端的連接,其擁有的cc.UpdateState(State) error可以讓我們更新鏈接的狀態

type Resolver interface {
 // ResolveNow will be called by gRPC to try to resolve the target name
 // again. It's just a hint, resolver can ignore this if it's not necessary.
 //
 // It could be called multiple times concurrently.
  // ResolveNow 嘗試再一次對域名進行解析,在個人實踐中,服務端進程掛掉會觸發該調用
 ResolveNow(ResolveNowOptions)
  // Close closes the resolver.
  // 資源釋放。
 Close()
}

解析器需要有能力從註冊中心獲取解析結果,並更新客戶端中連接 (cc ClientConn) 的信息。還可以持續 watch 一個名字的解析結果,實時的更新客戶端中連接的信息

示例代碼

gRPC resolver 原理

🌲 在 init() 階段時

🌲 客戶端啓動時通過自定義Dail()方法構造grpc.ClientConn單例

代碼

# builder.go
package resolver

import "google.golang.org/grpc/resolver"

var _ resolver.Builder = Builder{}

type Builder struct {
 addrsStore map[string][]string
}

func NewResolverBuilder(addrsStore map[string][]string) *Builder {
 return &Builder{addrsStore: addrsStore}
}

func (b Builder) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
 r := &Resolver{
  target:     target,
  cc:         cc,
  addrsStore: b.addrsStore,
 }
 r.Start()
 return r, nil
}

func (b Builder) Scheme() string {
 return "example"
}
# resolver.go
package resolver

import (
 "google.golang.org/grpc/resolver"
)

var _ resolver.Resolver = &Resolver{}

// impl google.golang.org/grpc/resolver.Resolver
type Resolver struct {
 target resolver.Target
 cc     resolver.ClientConn

 addrsStore map[string][]string
}

func (r *Resolver) Start() {
 // 在靜態路由表中查詢此 Endpoint 對應 addrs
 var addrs []resolver.Address
 for _, addr := range r.addrsStore[r.target.URL.Opaque] {
  addrs = append(addrs, resolver.Address{Addr: addr})
 }

 r.cc.UpdateState(resolver.State{
  Addresses: addrs,
    // 設置負載均衡策略爲round_robin
  ServiceConfig: r.cc.ParseServiceConfig(
    `{"loadBalancingPolicy":"round_robin"}`),
 })
}

func (r *Resolver) ResolveNow(resolver.ResolveNowOptions) {

}

func (r *Resolver) Close() {

}
# main.go
package main

import (
 "context"
 "log"

 rs "github.com/liangwt/note/grpc/name_resolver_lb_example/client/resolver"
 pb "github.com/liangwt/note/grpc/name_resolver_lb_example/ecommerce"
 "google.golang.org/grpc"
 "google.golang.org/grpc/resolver"
)

func main() {
 resolver.Register(rs.NewResolverBuilder(map[string][]string{
  "cluster@callee"{
   "127.0.0.1:8009",
  },
 }))

 conn, err := grpc.Dial("example:cluster@callee", grpc.WithInsecure())
 if err != nil {
  panic(err)
 }
  
 // ...
}

負載均衡

同樣來通俗易懂的解釋下什麼負載均衡。爲了提高系統的負載能力和穩定性,我們的服務端往往具有多臺服務器,負載均衡的目的就是希望請求能分散到不同的服務器,從服務器列表中選擇一臺服務器的算法就是負載均衡的策略,常見的輪循、加權輪詢等

負載均衡器要在多臺服務器之間選擇,所以通常情況下負載均衡器是具備服務發現的能力的

根據負載均衡實現所在的位置不同,通常可分爲以下三種解決方案:

1、集中式負載均衡(Proxy Model)

在客戶端和服務端之間有一個獨立的 LB,通常是專門的硬件設備如 F5,或者基於軟件如 LVS,HAproxy,Nginx 等實現。LB 使用負載均衡策略將請求轉發到目標服務

因爲所有服務調用流量都經過 LB,當服務數量和調用量大的時候,LB 容易成爲瓶頸;一旦 LB 發生故障影響整個系統;客戶端、服務端之間增加了一級,有一定性能開銷

2、客戶端負載均衡(Balancing-aware Client)

客戶端負載將 LB 的功能集成到客戶端進程裏,然後使用負載均衡策略選擇一個目標服務地址,向目標服務發起請求。LB 能力被分散到每一個服務消費者的進程內部,同時服務消費方和服務提供方之間是直接調用,沒有額外開銷,性能比較好。

但如果有多種不同的語言棧,就要配合開發多種不同的客戶端,有一定的研發和維護成本;後續如果要對客戶庫進行升級,勢必要求服務調用方修改代碼並重新發布,升級較複雜。

3、獨立負載均衡進程(External Load Balancing Service)

將 LB 從進程內移出來,變成主機上的一個獨立進程。主機上的一個或者多個服務要訪問目標服務時,他們都通過同一主機上的獨立 LB 進程做負載均衡

此方案有兩種模式

第一種是直接由 LB 進行轉發請求,被稱爲 sidecar 方案

第二種是從 LB 獲取到 IP 後依舊由客戶端發起請求,gRPC 曾經支持過此方案叫 lookaside 方案,目前已廢棄

該方案也是一種分佈式方案沒有單點問題,一個 LB 進程掛了隻影響該主機上的客戶端;客戶端和 LB 之間是本地調用調用性能好;同時該方案還簡化了客戶端,不需要爲不同語言開發客戶庫,LB 的升級不需要服務調用方改代碼。該方案主要問題:部署較複雜,環節多,出錯調試排查問題不方便

gRPC 的負載均衡

上文介紹的三種負載均衡方式,集中式負載均衡和 gRPC 無關,屬於外部的基礎設施,因此我們不再介紹

gRPC 中的負載平衡是以每次調用爲基礎,而不是以每個連接爲基礎。換句話說,即使所有的請求都來自一個客戶端,它仍能在所有的服務器上實現負載平衡

gRPC 目前內置四種策略

🌲 pick_first:默認策略,選擇第一個

🌲 round_robin:輪詢

使用默認的負載均衡器很簡單,只需要在建立連接的時候指定負載均衡策略即可。

⚠️ 注意

舊版本 gRPC 使用 grpc.WithBalancerName("round_robin"),已經被廢棄,使用grpc.WithDefaultServiceConfig

grpc.WithDefaultServiceConfig可以被上文服務發現中提到的cc.UpdateState(State) error覆蓋配置

 conn, err := grpc.Dial("example:cluster@callee",
  grpc.WithInsecure(),
  grpc.WithDefaultServiceConfig(
   `{"loadBalancingPolicy":"round_robin"}`,
  ),
 )

🌲 grpclb:已廢棄

它屬於上文介紹的負載均衡中獨立負載均衡進程第二種。不必直接在客戶端中添加新的 LB 策略,而只實現諸如 round-robin 之類的簡單算法,任何更復雜的算法都將由 lookaside 負載平衡器提供

🌲 xDS

如果接觸過 servicemesh 那麼對 xDS 並不會陌生,xDS 本身是 Envoy 中的概念,現在已經發展爲用於配置各種數據平面軟件的標準,最新版本的 gRPC 已經支持 xDS。不同於單純的負載均衡策略,xDS 在 gRPC 包含了服務發現和負載均衡的概念

這裏簡單的理解下 xDS,本質上 xDS 就是一個標準的協議

它規定了 xDS 客戶端和 xDS 服務端的交互流程,即 API 調用的順序

我們在 xDS 服務端實現服務發現,配置負載均衡策略等等,支持 xDS 的客戶端連接到 xDS 服務端並通過 xDS api 來獲取各種需要的數據和配置

xDS 主要應用於 servicemesh 中,在 mesh 中由 sidecar 連接到 xDS server 進行數據交互,同時由 sidecar 來控制流量的分發。也就是上文提到的獨立負載均衡進程的第一種模式

gRPC 使用 xDS 是一種無 proxy 的客戶端負載均衡方案。對比 Mesh 方案,性能更好。我們把 servicemesh 的負載均衡和 grpc 的 xds 負載均衡放在一起感受下區別

📖 這意味着,grpclb廢棄後,gRPC 內置的pick_firstround_robinxDS三種模式都屬於客戶端的負載均衡模式。pick_firstround_robin是單純的負載均衡策略;xDS包含了服務發現等一系列能力,並且還在不斷拓展中,而我們控制 gRPC 的行爲也被轉移到了 xDS server 上了。

xDS 模式的使用

xDS 內容較多,又比較新,單獨開個章節介紹下 xDS 的使用

gRPC xDS 架構中出現了三個服務

我們可以使用 Envoy go-control-plane 庫來實現 xDS server,這部分的開發類似於 servicemesh,就不介紹太多了,如果位於 k8s 平臺內可以參考下 istio 中控制面的實現

gRPC server 需要自注冊或者託管到 k8s 平臺,如果託管到 k8s 則可以繼續參考 istio 中控制面的實現

因爲 xDS 也包含了服務發現的部分,因此對於 client 來說第一步需要先開發自定義的服務發現和負載均衡配置。幸運的是 gRPC 官方已經爲我們開發了對應實現,只需要引入包即可,在 init 階段會註冊 xDS 的解析器和負載均衡器

"google.golang.org/grpc/xds" // To install the xds resolvers and balancers.

隨後只需要把 gRPC client 連接到 xDs server 即可,這部分與非 xDS 並無不同。只是目標服務的地址的 URI scheme 爲xds

conn, err := grpc.Dial("xds:///localhost:50051", grpc.WithTransportCredentials(creds))
if err != nil {
  panic(err)
}

ctx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()

c := pb.NewOrderManagementClient(conn)
res, err := c.AddOrder(ctx, &order)

完整代碼可以參考:https://github.com/grpc/grpc-go/tree/master/examples/features/xds

自定義負載均衡器

自定義負載均衡器需要使用google.golang.org/grpc/balancer.Register提前註冊,此函數和服務發現一樣接受工廠函數

// Builder creates a balancer.
type Builder interface {
 // Build creates a new balancer with the ClientConn.
 Build(cc ClientConn, opts BuildOptions) Balancer
 // Name returns the name of balancers built by this builder.
 // It will be used to pick balancers (for example in service config).
 Name() string
}

Name()是負載均衡策略的名字

Build(...)需要返回負載均衡器

✨✨ cc ClientConn代表客戶端與服務端的連接,其擁有一系列函數可以讓我們更新鏈接的狀態

type Balancer interface {
 // UpdateClientConnState is called by gRPC when the state of the ClientConn
 // changes.  If the error returned is ErrBadResolverState, the ClientConn
 // will begin calling ResolveNow on the active name resolver with
 // exponential backoff until a subsequent call to UpdateClientConnState
 // returns a nil error.  Any other errors are currently ignored.
 UpdateClientConnState(ClientConnState) error
 // ResolverError is called by gRPC when the name resolver reports an error.
 ResolverError(error)
 // UpdateSubConnState is called by gRPC when the state of a SubConn
 // changes.
 UpdateSubConnState(SubConn, SubConnState)
 // Close closes the balancer. The balancer is not required to call
 // ClientConn.RemoveSubConn for its existing SubConns.
 Close()
}

負載均衡器需要實現一系列的函數用於 gRPC 在不同場景下調用

類 RR 算法負載均衡器

如果要實現一個類 round_robin 的負載均衡策略,gRPC 官方實現提供了一個baseBuilder,它已經實現了大部Balancer接口,可以大幅簡化了我們創建 RR 策略的邏輯。使用google.golang.org/grpc/balancer/base.NewBalancerBuilder創建負載均衡的工廠

func NewBalancerBuilder(name string, pb PickerBuilder, config Config) balancer.Builder
// PickerBuilder creates balancer.Picker.
type PickerBuilder interface {
 // Build returns a picker that will be used by gRPC to pick a SubConn.
 Build(info PickerBuildInfo) balancer.Picker
}
type Picker interface {
  // 子連接選擇
 Pick(info PickInfo) (PickResult, error)
}

於是藉助base.NewBalancerBuilder我們僅需要實現Picker一個函數即可實現類 RR 的負載均衡策略了

代碼

利用Picker接口來實現一個隨機選擇策略

# builder.go
package balancer

import (
 "google.golang.org/grpc/balancer"
 "google.golang.org/grpc/balancer/base"
)

var _ base.PickerBuilder = &Builder{}

type Builder struct {
}

func NewBalancerBuilder() balancer.Builder {
 return base.NewBalancerBuilder("random_picker"&Builder{}, base.Config{HealthCheck: true})
}

func (b *Builder) Build(info base.PickerBuildInfo) balancer.Picker {
 if len(info.ReadySCs) == 0 {
  return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
 }

 var scs []balancer.SubConn
 for subConn := range info.ReadySCs {
  scs = append(scs, subConn)
 }

 return &Picker{
  subConns: scs,
 }
}
// picker.go
package balancer

import (
 "math/rand"

 "google.golang.org/grpc/balancer"
)

var _ balancer.Picker = &Picker{}

type Picker struct {
 subConns []balancer.SubConn
}

func (p *Picker) Pick(info balancer.PickInfo) (balancer.PickResult, error) {
 index := rand.Intn(len(p.subConns))
 sc := p.subConns[index]
 return balancer.PickResult{SubConn: sc}, nil
}
#client.go
package main

import (
 "context"
 "log"

 bl "github.com/liangwt/note/grpc/name_resolver_lb_example/client/balancer"
 rs "github.com/liangwt/note/grpc/name_resolver_lb_example/client/resolver"
 pb "github.com/liangwt/note/grpc/name_resolver_lb_example/ecommerce"
 "google.golang.org/grpc"
 "google.golang.org/grpc/balancer"
 "google.golang.org/grpc/resolver"
)

func main() {
 resolver.Register(rs.NewResolverBuilder(map[string][]string{
  "cluster@callee"{
   "127.0.0.1:8009",
   "127.0.0.1:8010",
  },
 }))

 balancer.Register(bl.NewBalancerBuilder())

 conn, err := grpc.Dial("example:cluster@callee",
  grpc.WithInsecure(),
  grpc.WithDefaultServiceConfig(
   `{"loadBalancingPolicy":"random_picker"}`,
  ),
 )
 if err != nil {
  panic(err)
 }
 
  ....
}

參考資料

[1]

URI 語法: https://zh.wikipedia.org/wiki/%E7%BB%9F%E4%B8%80%E8%B5%84%E6%BA%90%E6%A0%87%E5%BF%97%E7%AC%A6

[2]

go-control-plane: https://github.com/envoyproxy/go-control-plane

[3]

gRPC Name Resolution: https://github.com/grpc/grpc/blob/master/doc/naming.md

[4]

Load Balancing in gRPC: https://github.com/grpc/grpc/blob/master/doc/load-balancing.md

[5]

https://github.com/grpc/grpc-go: https://github.com/grpc/grpc-go

[6]

gRPC Go 服務發現與負載均衡: https://blog.cong.moe/post/2021-03-06-grpc-go-discovery-lb/

[7]

[轉]gRPC 服務發現 & 負載均衡: https://colobu.com/2017/03/25/grpc-naming-and-load-balance/

[8]

淺淡 xDS 協議在 gRPC 中的應用: http://limeng.org/2020/03/08/xds-in-grpc.html

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