前言
在后台服務開發中,高可用性是構建中核心且重要的一環。服務發現(Service discovery)和負載均衡(Load Balance)一直都是我關注的話題。今天來談一下我在實際中是如何理解及落地的。
負載均衡 && 服務發現
基礎
負載均衡 ,顧名思義,是通過某種手段將流量 / 請求分配到不通的服務器上去,保證后台的每個服務收到的請求都盡可能保持平衡
服務發現 ,就是指客戶端按照某種約定的方式主動去(注冊中心)尋找服務,然后再連接相應的服務
關於負載均衡的構建與實現,可以看下這幾篇文章:
服務發現概念
我們說的服務發現,一般理解為客戶端如何發現 (並連接到) 服務,這里一般包含三個組件:
- 服務消費者:一般指客戶端(可以是簡單的 TCP-Client 或者是 RPC-Client )
- 服務提供者:一般指服務提供方,如傳統服務,微服務等
- 服務注冊中心:用來存儲(Key-Value)服務提供者的服務,一般以 DNS/HTTP/RPC 等方式對外暴露接口
負載均衡概念
我們把 LB 看作一個組件,根據組件位置的不同,大致上分為三種:
集中式 LB(Proxy Model)
獨立的 LB, 可以是硬件實現,如 F5,或者是 nginx 這種內置 Proxy-pass 或者 upstream 功能的網關,亦或是 LVS/HAPROXY,之前也使用 DPDK 開發過類似的專用網關。
進程內 LB(Balancing-aware Client)
進程內 LB(集成到客戶端),此方案將 LB 的功能集成到服務消費方進程里,也被稱為軟負載或者客戶端負載方案。服務提供方啟動時,首先將服務地址注冊到服務注冊表,同時定期報心跳到服務注冊表以表明服務的存活狀態,相當於健康檢查,服務消費方要訪問某個服務時,它通過內置的 LB 組件向服務注冊表查詢,同時緩存並定期刷新目標服務地址列表,然后以某種負載均衡策略選擇一個目標服務地址,最后向目標服務發起請求。LB 和服務發現能力被分散到每一個服務消費者的進程內部,同時服務消費方和服務提供方之間是直接調用,沒有額外開銷,性能比較好。
獨立 LB 進程(External Load Balancing Service)
該方案是針對上一種方案的不足而提出的一種折中方案,原理和第二種方案基本類似。不同之處是將 LB 和服務發現功能從進程內移出來,變成主機上的一個獨立進程。主機上的一個或者多個服務要訪問目標服務時,他們都通過同一主機上的獨立 LB 進程做服務發現和負載均衡。該方案也是一種分布式方案沒有單點問題,一個 LB 進程掛了只影響該主機上的服務調用方,服務調用方和 LB 之間是進程內調用性能好,同時該方案還簡化了服務調用方,不需要為不同語言開發客戶庫,LB 的升級不需要服務調用方改代碼。 公司的 L5 是這種方式,每台機器上都安裝了 L5 的 agent,供其他服務調用。該方案主要問題:部署較復雜,環節多,出錯調試排查問題不方便。
gRPC 內置的方案
gRPC 的內置方案如下圖所示:
gRPC 在官網文檔中提供了實現 LB 的思路,並在不同語言的 gRPC 代碼 API 中已提供了命名解析和負載均衡接口供擴展。默認提供了 DNS-resolver 的實現,接口相當規范,實現起來也不復雜,只需要實現服務注冊(Registry)和服務監聽 + 解析(Watcher+Resolver)的邏輯就行了,這里簡單介紹其基本實現過程:
- 構建注冊中心,這里注冊中心一般要求具備分布式一致性(滿足 CAP 定理的 AP 或 CP)的高可用的組件集群,如 Zookeeper、Consul、Etcd 等
- 構建 gRPC 服務端的注冊邏輯,服務啟動后定時向注冊中心注冊自身的關鍵信息(一般開啟新的 groutine 來完成),至少包含 IP 和端口,其他可選信息,如自身的負載信息(CPU 和 Memory)、當前實時連接數等,這些輔助信息有助於幫助系統更好的執行 LB 算法
- gRPC 客戶端向注冊中心發出服務解析請求,注冊中心將請求中關聯的所有服務的信息返回給 gRPC 客戶端,客戶端與所有在線的服務建立起 HTTP2 長連接
- gRPC 客戶端發起 RPC 調用,根據 LB 均衡器中實現的負載均衡策略(gRPC 中默認提供的算法是 RoundRobin),選擇其中一 HTTP2 長連接進行通信,即 LB 策略決定哪個子通道 - 即哪個 gRPC 服務器將接收請求
gRPC 負載均衡的運行機制
gRPC 提供了負載均衡實現的用戶側接口,我們可以非常方便的定制化業務的負載均衡策略,為了理解 gRPC 的負載均衡的實現機制,后續博客中我會分析下 gRPC
實現負載均衡的代碼。
- Resolver
- 解析器,用於從注冊中心實時獲取當前服務端的列表,同步發送給 Balancer
- Balancer
- 平衡器,一是接收從 Resolver 發送的服務端列表,建立並維護(長)連接狀態;二是每次當 Client 發起 Rpc 調用時,按照一定算法從連接池中選擇一個連接進行 Rpc 調用
- Register
- 注冊,用於服務端初始化和在線時,將自己信息上報到注冊中心,主要信息有 Ip,端口等
負載均衡的算法及實現
在實踐中,如何選取負載均衡策略是一個很有趣的話題,例如 Nginx 的 upstream
機制中就有很多經典的 LB 策略,如帶權重的輪詢 Weight-RoundRobin,一般常用的負載均衡方法有如下幾種:
- RoundRobin(輪詢)
- Weight-RoundRobin(加權輪詢)
- 不同的后端服務器可能機器的配置和當前系統的負載並不相同,因此它們的抗壓能力也不相同。給配置高、負載低的機器配置更高的權重,而配置低、負載高的機器,給其分配較低的權重,降低其系統負載,加權輪詢能很好地處理這一問題,並將請求順序且按照權重分配到后端。
- Random(隨機)
- Weight-Random(加權隨機)
- 通過系統的隨機算法,根據后端服務器的列表隨機選取其中的一台服務器進行訪問
- 源地址哈希法
- 源地址哈希的思想是根據獲取客戶端的 IP 地址,通過哈希函數計算得到的一個數值,用該數值對服務器列表的大小進行取模運算,得到的結果便是客服端要訪問服務器的序號。采用源地址哈希法進行負載均衡,同一 IP 地址的客戶端,當后端服務器列表不變時,它每次都會映射到同一台后端服務器進行訪問
- 最小連接數法
- 最小連接數算法比較靈活和智能,由於后端服務器的配置不盡相同,對於請求的處理有快有慢,它是根據后端服務器當前的連接情況,動態地選取其中當前積壓連接數最少的一台服務器來處理當前的請求,盡可能地提高后端服務的利用效率,將負責合理地分流到每一台服務器
- 一致性哈希算法
- 常見的是
Ketama
算法,該算法是用來解決cache
失效導致的緩存穿透的問題的,當然也可以適用於 gRPC 長連接的場景
- 常見的是
gRPC 服務治理的優勢
在現網環境中,后端服務就是采用了 gRPC 與 Etcd 的服務治理方案,總結下有這么幾個優點;
- 采用了 gRPC 實現負載均衡策略,模塊之間通信采用長連接方式,避免每次 RPC 調用時新建連接的開銷,充分發揮
HTTP2
的優勢 - 擴容和縮容都及其方便,例如擴容,只要部署上服務,運行后,服務成功注冊到 Etcd 便大功告成
- 靈活的自定義的 LB 算法,使得后端壓力更為均衡
- 客戶端加入重試邏輯,使得網絡抖動情況下,可以通過重試連接上另外一台服務
Resolver 暴露的三個接口
前文說過,gRPC 內置的服務治理功能,對開發者暴露了服務發現的 interface{}
,resolver.Builder
和 resolver.ClientConn
和 resolver.Resolver
,相關代碼。開發者在實例化這三個接口之后,就可以實現從指定的 scheme 中獲取服務列表,通知 balancer 並與這些服務端建立 RPC 長連接。
- resolver.Builder
- resolver.ClientConn
- resolver.Resolver
- resolver.Builder
Builder
用於 gRPC 內部創建Resolver
接口的實現,但注意內部聲明的Build()
方法將接口ClientConn
作為參數傳入了,在前文的分析中,我們了解到ClientConn
結庫 是非常重要的結構,其成員conns map[*addrConn]struct{}
中維護了所有從注冊中心獲取到的服務端列表。
// Builder creates a resolver that will be used to watch name resolution updates.
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 BuildOption) (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
}
- resolver.ClientConn
ClientConn
接口中,UpdateState
方法需要傳入State
結構,NewAddress
方法需要傳入Address
結構,看代碼可以發現其中包含了Addresses []Address // Resolved addresses for the target
,可以看出是需要將服務發現得到的Address
對象列表告訴ClientConn
的對象。
// ClientConn contains the callbacks for resolver to notify any updates
// to the gRPC ClientConn.
//
// This interface is to be implemented by gRPC. Users should not need a
// brand new implementation of this interface. For the situations like
// testing, the new implementation should embed this interface. This allows
// gRPC to add new methods to this interface.
type ClientConn interface {
// UpdateState updates the state of the ClientConn appropriately.
UpdateState(State)
// NewAddress is called by resolver to notify ClientConn a new list
// of resolved addresses.
// The address list should be the complete list of resolved addresses.
//
// Deprecated: Use UpdateState instead.
NewAddress(addresses []Address)
// NewServiceConfig is called by resolver to notify ClientConn a new
// service config. The service config should be provided as a json string.
//
// Deprecated: Use UpdateState instead.
NewServiceConfig(serviceConfig string)
}
- resolver.Resolver
Resolver
提供了ResolveNow
用於被 gRPC 嘗試重新進行服務發現
// Resolver watches for the updates on the specified target.
// Updates include address updates and service config updates.
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(ResolveNowOption)
// Close closes the resolver.
Close()
}
梳理 Resolver 過程
通過這三個接口,再次梳理下 gRPC 的服務發現實現邏輯
- 通過
Builder.Build()
進行Reslover
的創建,在Build()
的過程中將服務發現的地址信息丟給ClientConn
用於內部連接創建(通過ClientConn.UpdateState()
實現)等邏輯; - 當
client
在Dial
時會根據target
解析的scheme
獲取對應的Builder
,代碼位置
func DialContext(ctx context.Context, target string, opts ...DialOption) (conn *ClientConn, err error) {
...
...
// Determine the resolver to use.
cc.parsedTarget = parseTarget(cc.target)
grpclog.Infof("parsed scheme: %q", cc.parsedTarget.Scheme)
resolverBuilder := cc.getResolver(cc.parsedTarget.Scheme) // 通過 scheme(名字) 獲取對應的 resolver
if resolverBuilder == nil {
// If resolver builder is still nil, the parsed target's scheme is
// not registered. Fallback to default resolver and set Endpoint to
// the original target.
grpclog.Infof("scheme %q not registered, fallback to default scheme", cc.parsedTarget.Scheme)
cc.parsedTarget = resolver.Target{
Scheme: resolver.GetDefaultScheme(),
Endpoint: target,
}
resolverBuilder = cc.getResolver(cc.parsedTarget.Scheme)
if resolverBuilder == nil {
return nil, fmt.Errorf("could not get resolver for default scheme: %q", cc.parsedTarget.Scheme)
}
}
...
...
// Build the resolver.
rWrapper, err := newCCResolverWrapper(cc, resolverBuilder) // 通過 gRPC 提供的 Wrapper,應用我們實現的 resolver 邏輯
if err != nil {
return nil, fmt.Errorf("failed to build resolver: %v", err)
}
cc.mu.Lock()
cc.resolverWrapper = rWrapper
cc.mu.Unlock()
...
...
}
- 當
Dial
成功會創建出結構體ClientConn
的對象 官方代碼位置(注意不是上面的ClientConn
接口),可以看到結構體ClientConn
內的成員resolverWrapper
又實現了接口ClientConn
的方法 官方代碼位置
func DialContext(ctx context.Context, target string, opts ...DialOption) (conn *ClientConn, err error) {
// 初始化 CC
cc := &ClientConn{
target: target,
csMgr: &connectivityStateManager{},
conns: make(map[*addrConn]struct{}),
dopts: defaultDialOptions(),
blockingpicker: newPickerWrapper(),
czData: new(channelzData),
firstResolveEvent: grpcsync.NewEvent(),
}
...
...
...
...
return cc, nil
}
- 當
resolverWrapper
被初始化時就會調用Build
方法 官方代碼位置,其中參數為接口ClientConn
傳入的是ccResolverWrapper
// newCCResolverWrapper uses the resolver.Builder to build a Resolver and
// returns a ccResolverWrapper object which wraps the newly built resolver.
func newCCResolverWrapper(cc *ClientConn, rb resolver.Builder) (*ccResolverWrapper, error) {
ccr := &ccResolverWrapper{
cc: cc,
done: grpcsync.NewEvent(),
}
var credsClone credentials.TransportCredentials
if creds := cc.dopts.copts.TransportCredentials; creds != nil {
credsClone = creds.Clone()
}
rbo := resolver.BuildOptions{
DisableServiceConfig: cc.dopts.disableServiceConfig,
DialCreds: credsClone,
CredsBundle: cc.dopts.copts.CredsBundle,
Dialer: cc.dopts.copts.Dialer,
}
var err error
// We need to hold the lock here while we assign to the ccr.resolver field
// to guard against a data race caused by the following code path,
// rb.Build-->ccr.ReportError-->ccr.poll-->ccr.resolveNow, would end up
// accessing ccr.resolver which is being assigned here.
ccr.resolverMu.Lock()
defer ccr.resolverMu.Unlock()
ccr.resolver, err = rb.Build(cc.parsedTarget, ccr, rbo)
if err != nil {
return nil, err
}
return ccr, nil
}
- 當用戶基於
Builder
的實現進行UpdateState
調用時,則會觸發結構體ClientConn
的updateResolverState
方法 官方代碼位置,updateResolverState
則會對傳入的Address
進行初始化等邏輯 官方代碼位置
func (cc *ClientConn) updateResolverState(s resolver.State, err error) error {
defer cc.firstResolveEvent.Fire()
cc.mu.Lock()
// Check if the ClientConn is already closed. Some fields (e.g.
// balancerWrapper) are set to nil when closing the ClientConn, and could
// cause nil pointer panic if we don't have this check.
if cc.conns == nil {
cc.mu.Unlock()
return nil
}
if err != nil {
// May need to apply the initial service config in case the resolver
// doesn't support service configs, or doesn't provide a service config
// with the new addresses.
cc.maybeApplyDefaultServiceConfig(nil)
if cc.balancerWrapper != nil {
cc.balancerWrapper.resolverError(err)
}
// No addresses are valid with err set; return early.
cc.mu.Unlock()
return balancer.ErrBadResolverState
}
var ret error
if cc.dopts.disableServiceConfig || s.ServiceConfig == nil {
cc.maybeApplyDefaultServiceConfig(s.Addresses)
// TODO: do we need to apply a failing LB policy if there is no
// default, per the error handling design?
} else {
if sc, ok := s.ServiceConfig.Config.(*ServiceConfig); s.ServiceConfig.Err == nil && ok {
cc.applyServiceConfigAndBalancer(sc, s.Addresses)
} else {
ret = balancer.ErrBadResolverState
if cc.balancerWrapper == nil {
var err error
if s.ServiceConfig.Err != nil {
err = status.Errorf(codes.Unavailable, "error parsing service config: %v", s.ServiceConfig.Err)
} else {
err = status.Errorf(codes.Unavailable, "illegal service config type: %T", s.ServiceConfig.Config)
}
cc.blockingpicker.updatePicker(base.NewErrPicker(err))
cc.csMgr.updateState(connectivity.TransientFailure)
cc.mu.Unlock()
return ret
}
}
}
var balCfg serviceconfig.LoadBalancingConfig
if cc.dopts.balancerBuilder == nil && cc.sc != nil && cc.sc.lbConfig != nil {
balCfg = cc.sc.lbConfig.cfg
}
cbn := cc.curBalancerName
bw := cc.balancerWrapper
cc.mu.Unlock()
if cbn != grpclbName {
// Filter any grpclb addresses since we don't have the grpclb balancer.
for i := 0; i <len(s.Addresses); {
if s.Addresses[i].Type == resolver.GRPCLB {
copy(s.Addresses[i:], s.Addresses[i+1:])
s.Addresses = s.Addresses[:len(s.Addresses)-1]
continue
}
i++
}
}
uccsErr := bw.updateClientConnState(&balancer.ClientConnState{ResolverState: s, BalancerConfig: balCfg})
if ret == nil {
ret = uccsErr // prefer ErrBadResolver state since any other error is
// currently meaningless to the caller.
}
return ret
}
- 至此整個服務發現過程就結束了。從中也可以看出 gRPC 官方提供的三個接口還是很靈活的,但也正因為靈活要實現稍微麻煩一些,而
Address
官方代碼位置 如果直接被業務拿來用於服務節點信息的描述結構則顯得有些過於簡單。
所以 warden
包裝了 gRPC 的整個服務發現實現邏輯,代碼分別位於 pkg/naming/naming.go
和 warden/resolver/resolver.go
,其中:
naming.go
內定義了用於描述業務實例的Instance
結構、用於服務注冊的Registry
接口、用於服務發現的Resolver
接口resolver.go
內實現了 gRPC 官方的resolver.Builder
和resolver.Resolver
接口,但也暴露了naming.go
內的naming.Builder
和naming.Resolver
接口