寫給Go開發(fā)者的gRPC教程-服務(wù)發(fā)現(xiàn)與負(fù)載均衡
對于一個客戶端創(chuàng)建請求的過程
conn, err := grpc.Dial("example:8009", grpc.WithInsecure())
if err != nil {
panic(err)
}
- gRPC客戶端通過服務(wù)發(fā)現(xiàn)解析請求,將名稱解析為一個或多個IP地址,以及服務(wù)配置
- 客戶端使用上一步的服務(wù)配置、ip列表、實例化負(fù)載均衡策略
- 負(fù)載均衡策略為每個服務(wù)器地址創(chuàng)建一個子通道(channel),并監(jiān)測每一個子通道狀態(tài)
- 當(dāng)有rpc請求時,負(fù)載均衡策略決定那個子通道即gRPC服務(wù)器將接收請求,當(dāng)可用服務(wù)器為空時客戶端的請求將被阻塞
gRPC官方提供了基本的服務(wù)發(fā)現(xiàn)和負(fù)載均衡邏輯,并提供了接口供擴展用于開發(fā)自定義的服務(wù)發(fā)現(xiàn)與負(fù)載均衡
服務(wù)發(fā)現(xiàn)
用通俗易懂的方式來解釋下什么是服務(wù)發(fā)現(xiàn)。通常情況下客戶端需要知道服務(wù)端的IP+端口號才能建立連接,但服務(wù)端的IP和端口號并不是那么容易記憶。還有更重要的,在云部署的環(huán)境中,服務(wù)端的IP和端口可能隨時會發(fā)生變化。
所以我們可以給某一個服務(wù)起一個名字,客戶端通過名字創(chuàng)建與服務(wù)端的連接,客戶端底層使用服務(wù)發(fā)現(xiàn)系統(tǒng),解析這個名字來獲取真正的IP和端口,并在服務(wù)端的IP和端口發(fā)生變化時,重新建立連接。這樣的系統(tǒng)通常也會被叫做name-system(名字服務(wù))
gRPC 中的默認(rèn)name-system是 DNS,同時在客戶端以插件形式提供了自定義name-system的機制。
名字格式
gRPC采用的名字格式遵循的RFC 3986中定義的URI語法
scheme:[//[user[:password]@]host[:port]][/path][?query][#fragment]
例如
URI示例
gRPC關(guān)注其中兩部分
- URI scheme:第一個:前面的標(biāo)識。對于gRPC客戶端來說,它指示要使用的服務(wù)發(fā)現(xiàn)解析器,如果未指定前綴或方案未知,則默認(rèn)使用DNS方案
- URI path:指示要解析的名字
大部分gRPC實現(xiàn)默認(rèn)支持以下的URI schemes
- dns:[//authority/]host[:port] -- DNS (default)
- unix:path, unix://absolute_path -- Unix domain sockets (Unix systems only)
- unix-abstract:abstract_path -- Unix domain socket in abstract namespace (Unix systems only)
服務(wù)的服務(wù)注冊
如果gRPC服務(wù)端的地址是靜態(tài)的,可以在客戶端服務(wù)發(fā)現(xiàn)時直接解析為靜態(tài)的地址
如果gRPC服務(wù)端的地址是動態(tài)的,可以有兩種選擇
- 自注冊:當(dāng)gRPC的服務(wù)啟動后,向一個集中的注冊中心進行注冊
- 平臺的服務(wù)發(fā)現(xiàn):使用k8s平臺時,平臺會感知gPRC實例的變化
關(guān)于服務(wù)注冊這里不在做更多介紹了
客戶端的服務(wù)發(fā)現(xiàn)
自定義服務(wù)發(fā)現(xiàn)需要在客戶端啟動前,注冊一個服務(wù)解析器(Resolve)
Golang中使用google.golang.org/grpc/resolver.Register(resolver.Builder)注冊,這個函數(shù)不是直接接收一個解析器,而是使用工廠模式接收一個解析器的構(gòu)造器
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(...)需要返回一個服務(wù)發(fā)現(xiàn)解析器google.golang.org/grpc/resolver.Resolver
??cc ClientConn代表客戶端與服務(wù)端的連接,其擁有的cc.UpdateState(State) error可以讓我們更新鏈接的狀態(tài)
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 嘗試再一次對域名進行解析,在個人實踐中,服務(wù)端進程掛掉會觸發(fā)該調(diào)用
ResolveNow(ResolveNowOptions)
// Close closes the resolver.
// 資源釋放。
Close()
}
解析器需要有能力從注冊中心獲取解析結(jié)果,并更新客戶端中連接(cc ClientConn)的信息。還可以持續(xù)watch一個名字的解析結(jié)果,實時的更新客戶端中連接的信息
- 解析出來的地址列表(包含ip和port)
- 針對連接的服務(wù)配置,如負(fù)載均衡等,來想覆蓋全局的負(fù)責(zé)均衡配置
- 每一個地址可以包含一系列的屬性(kv),他們可以用來支持后續(xù)的負(fù)載均衡策略
示例代碼
gRPC resolver 原理
?? 在 init() 階段時
- 創(chuàng)建并注冊Builder 實例,將其注冊到 grpc 內(nèi)部的 resolveBuilder 表中(其實是一個全局 map,key 為協(xié)議名;value 為構(gòu)造的 resolveBuilder)
?? 客戶端啟動時通過自定義Dail()方法構(gòu)造grpc.ClientConn單例
- grpc.DialContext() 方法內(nèi)部解析 URI,分析協(xié)議類型,并從 resolveBuilder 表中查找協(xié)議對應(yīng)的 resolverBuilder
- 將地址作為 Target 、conn單例作為resolver.ClientConn參數(shù)調(diào)用 resolver.Build 方法實例化出 Resolver
- 用戶 Resolver實現(xiàn)中調(diào)用 cc.UpdateState 傳入 State.Addresses 地址,gRPC使用這個地址建立連接,傳入State.ServiceConfig,gRPC使用這份服務(wù)配置覆蓋默認(rèn)配置
name-reslover原理
代碼
# 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() {
// 在靜態(tài)路由表中查詢此 Endpoint 對應(yīng) 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,
// 設(shè)置負(fù)載均衡策略為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)
}
// ...
}
負(fù)載均衡
同樣來通俗易懂的解釋下什么負(fù)載均衡。為了提高系統(tǒng)的負(fù)載能力和穩(wěn)定性,我們的服務(wù)端往往具有多臺服務(wù)器,負(fù)載均衡的目的就是希望請求能分散到不同的服務(wù)器,從服務(wù)器列表中選擇一臺服務(wù)器的算法就是負(fù)載均衡的策略,常見的輪循、加權(quán)輪詢等
負(fù)載均衡器要在多臺服務(wù)器之間選擇,所以通常情況下負(fù)載均衡器是具備服務(wù)發(fā)現(xiàn)的能力的
根據(jù)負(fù)載均衡實現(xiàn)所在的位置不同,通??煞譃橐韵氯N解決方案:
1、集中式負(fù)載均衡(Proxy Model)
在客戶端和服務(wù)端之間有一個獨立的LB,通常是專門的硬件設(shè)備如 F5,或者基于軟件如 LVS,HAproxy,Nginx等實現(xiàn)。LB使用負(fù)載均衡策略將請求轉(zhuǎn)發(fā)到目標(biāo)服務(wù)
因為所有服務(wù)調(diào)用流量都經(jīng)過LB,當(dāng)服務(wù)數(shù)量和調(diào)用量大的時候,LB容易成為瓶頸;一旦LB發(fā)生故障影響整個系統(tǒng);客戶端、服務(wù)端之間增加了一級,有一定性能開銷
集中式負(fù)載均衡
2、客戶端負(fù)載均衡(Balancing-aware Client)
客戶端負(fù)載將LB的功能集成到客戶端進程里,然后使用負(fù)載均衡策略選擇一個目標(biāo)服務(wù)地址,向目標(biāo)服務(wù)發(fā)起請求。LB能力被分散到每一個服務(wù)消費者的進程內(nèi)部,同時服務(wù)消費方和服務(wù)提供方之間是直接調(diào)用,沒有額外開銷,性能比較好。
但如果有多種不同的語言棧,就要配合開發(fā)多種不同的客戶端,有一定的研發(fā)和維護成本;后續(xù)如果要對客戶庫進行升級,勢必要求服務(wù)調(diào)用方修改代碼并重新發(fā)布,升級較復(fù)雜。
客戶端負(fù)載均衡
3、獨立負(fù)載均衡進程(External Load Balancing Service)
將LB從進程內(nèi)移出來,變成主機上的一個獨立進程。主機上的一個或者多個服務(wù)要訪問目標(biāo)服務(wù)時,他們都通過同一主機上的獨立LB進程做負(fù)載均衡
此方案有兩種模式
第一種是直接由LB進行轉(zhuǎn)發(fā)請求,被稱為sidecar方案
第二種是從LB獲取到IP后依舊由客戶端發(fā)起請求,gRPC曾經(jīng)支持過此方案叫l(wèi)ookaside方案,目前已廢棄
該方案也是一種分布式方案沒有單點問題,一個LB進程掛了只影響該主機上的客戶端;客戶端和LB之間是本地調(diào)用調(diào)用性能好;同時該方案還簡化了客戶端,不需要為不同語言開發(fā)客戶庫,LB的升級不需要服務(wù)調(diào)用方改代碼。該方案主要問題:部署較復(fù)雜,環(huán)節(jié)多,出錯調(diào)試排查問題不方便
獨立負(fù)載均衡進程
gRPC的負(fù)載均衡
上文介紹的三種負(fù)載均衡方式,集中式負(fù)載均衡和gRPC無關(guān),屬于外部的基礎(chǔ)設(shè)施,因此我們不再介紹
gRPC中的負(fù)載平衡是以每次調(diào)用為基礎(chǔ),而不是以每個連接為基礎(chǔ)。換句話說,即使所有的請求都來自一個客戶端,它仍能在所有的服務(wù)器上實現(xiàn)負(fù)載平衡
gRPC目前內(nèi)置四種策略
?? pick_first:默認(rèn)策略,選擇第一個
?? round_robin:輪詢
使用默認(rèn)的負(fù)載均衡器很簡單,只需要在建立連接的時候指定負(fù)載均衡策略即可。
?? 注意
舊版本gRPC使用 grpc.WithBalancerName("round_robin"),已經(jīng)被廢棄,使用grpc.WithDefaultServiceConfig。
grpc.WithDefaultServiceConfig可以被上文服務(wù)發(fā)現(xiàn)中提到的cc.UpdateState(State) error覆蓋配置
conn, err := grpc.Dial("example:cluster@callee",
grpc.WithInsecure(),
grpc.WithDefaultServiceConfig(
`{"loadBalancingPolicy":"round_robin"}`,
),
)
?? grpclb:已廢棄
它屬于上文介紹的負(fù)載均衡中獨立負(fù)載均衡進程第二種。不必直接在客戶端中添加新的LB策略,而只實現(xiàn)諸如round-robin之類的簡單算法,任何更復(fù)雜的算法都將由lookaside負(fù)載平衡器提供
gRPC的grpclb方案
?? xDS
如果接觸過servicemesh那么對xDS并不會陌生,xDS 本身是Envoy中的概念,現(xiàn)在已經(jīng)發(fā)展為用于配置各種數(shù)據(jù)平面軟件的標(biāo)準(zhǔn),最新版本的gRPC已經(jīng)支持xDS。不同于單純的負(fù)載均衡策略,xDS在gRPC包含了服務(wù)發(fā)現(xiàn)和負(fù)載均衡的概念
這里簡單的理解下xDS,本質(zhì)上xDS就是一個標(biāo)準(zhǔn)的協(xié)議
它規(guī)定了xDS客戶端和xDS服務(wù)端的交互流程,即API調(diào)用的順序
我們在xDS 服務(wù)端實現(xiàn)服務(wù)發(fā)現(xiàn),配置負(fù)載均衡策略等等,支持xDS的客戶端連接到xDS 服務(wù)端并通過xDS api來獲取各種需要的數(shù)據(jù)和配置
xDS主要應(yīng)用于servicemesh中,在mesh中由sidecar連接到xDS server進行數(shù)據(jù)交互,同時由sidecar來控制流量的分發(fā)。也就是上文提到的獨立負(fù)載均衡進程的第一種模式
servicemesh的負(fù)載均衡
gRPC使用xDS是一種無proxy的客戶端負(fù)載均衡方案。對比Mesh方案,性能更好。我們把servicemesh的負(fù)載均衡和grpc的xds負(fù)載均衡放在一起感受下區(qū)別
?? 這意味著,grpclb廢棄后,gRPC內(nèi)置的pick_first、round_robin、xDS三種模式都屬于客戶端的負(fù)載均衡模式。pick_first、round_robin是單純的負(fù)載均衡策略;xDS包含了服務(wù)發(fā)現(xiàn)等一系列能力,并且還在不斷拓展中,而我們控制gRPC的行為也被轉(zhuǎn)移到了xDS server上了。
xDS模式的使用
xDS內(nèi)容較多,又比較新,單獨開個章節(jié)介紹下xDS的使用
gRPC xDS架構(gòu)中出現(xiàn)了三個服務(wù)
- xDS server:它是我們的控制面
- gRPC client:它既是gRPC服務(wù)的客戶端,同時也是xDS的客戶端
- gRPC server:它是gRPC服務(wù)的服務(wù)端,它需要具備被xDS server發(fā)現(xiàn)的能力
我們可以使用 Envoy go-control-plane庫來實現(xiàn)xDS server,這部分的開發(fā)類似于servicemesh,就不介紹太多了,如果位于k8s平臺內(nèi)可以參考下istio中控制面的實現(xiàn)
gRPC server需要自注冊或者托管到k8s平臺,如果托管到k8s則可以繼續(xù)參考istio中控制面的實現(xiàn)
因為xDS也包含了服務(wù)發(fā)現(xiàn)的部分,因此對于client來說第一步需要先開發(fā)自定義的服務(wù)發(fā)現(xiàn)和負(fù)載均衡配置。幸運的是gRPC官方已經(jīng)為我們開發(fā)了對應(yīng)實現(xiàn),只需要引入包即可,在init階段會注冊xDS的解析器和負(fù)載均衡器
_ "google.golang.org/grpc/xds" // To install the xds resolvers and balancers.
隨后只需要把gRPC client連接到xDs server即可,這部分與非xDS并無不同。只是目標(biāo)服務(wù)的地址的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
自定義負(fù)載均衡器
自定義負(fù)載均衡器需要使用google.golang.org/grpc/balancer.Register提前注冊,此函數(shù)和服務(wù)發(fā)現(xiàn)一樣接受工廠函數(shù)
// 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()是負(fù)載均衡策略的名字
? Build(...)需要返回負(fù)載均衡器
?? cc ClientConn代表客戶端與服務(wù)端的連接,其擁有一系列函數(shù)可以讓我們更新鏈接的狀態(tài)
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()
}
負(fù)載均衡器需要實現(xiàn)一系列的函數(shù)用于gRPC在不同場景下調(diào)用
類RR算法負(fù)載均衡器
如果要實現(xiàn)一個類round_robin的負(fù)載均衡策略,gRPC官方實現(xiàn)提供了一個baseBuilder,它已經(jīng)實現(xiàn)了大部Balancer接口,可以大幅簡化了我們創(chuàng)建RR策略的邏輯。使用google.golang.org/grpc/balancer/base.NewBalancerBuilder創(chuàng)建負(fù)載均衡的工廠
func NewBalancerBuilder(name string, pb PickerBuilder, config Config) balancer.Builder
- name string負(fù)載均衡器的名字
- pb PickerBuilder是我們要實現(xiàn)的負(fù)載均衡策略邏輯的地方
- config Config可以配置是否要進行健康檢查
// 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我們僅需要實現(xiàn)Picker一個函數(shù)即可實現(xiàn)類RR的負(fù)載均衡策略了
代碼
利用Picker接口來實現(xiàn)一個隨機選擇策略
# 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 服務(wù)發(fā)現(xiàn)與負(fù)載均衡: https://blog.cong.moe/post/2021-03-06-grpc-go-discovery-lb/
[7][轉(zhuǎn)]gRPC服務(wù)發(fā)現(xiàn)&負(fù)載均衡: https://colobu.com/2017/03/25/grpc-naming-and-load-balance/
[8]淺淡 xDS 協(xié)議在 gRPC 中的應(yīng)用: http://limeng.org/2020/03/08/xds-in-grpc.html