每次调用负载均衡
值得注意的是,gRPC 内的负载均衡是在每个调用的基础上发生的,而不是基于每个连接的。
换句话说,即使所有请求都来自单个客户端,我们仍然希望它们在所有 服务器 之间进行负载均衡。
负载均衡的方法
代理模型(集中式LB(Proxy Model))
使用代理提供可靠的可信客户端,可以向负载均衡系统报告负载。代理通常需要更多资源才能运行,因为它们具有 RPC 请求和响应的临时副本。
此模型还会增加 RPC 的延迟。在考虑诸如存储之类的请求重型服务时,代理模型被认为是低效的。
客户端负载均衡方式(进程内LB(Balancing-aware Client))
这个较重的客户端将更多的负载均衡逻辑放在客户端中。例如,客户端可以包含许多用于从列表中选择服务器的负载均衡策略(循环,随机等)。在此模型中,服务器列表将在客户端中静态配置,由名称解析系统提供,外部负载均衡器等。在任何情况下,客户端都负责从列表中选择首选服务器。
这种方法的缺点之一是以多种语言和/或客户端版本编写和维护负载均衡策略。这些策略可能相当复杂。一些算法还需要客户端到服务器通信,因此除了为用户请求发送 RPC 之外,客户端还需要更重,以支持其他 RPC 来获取运行状况或负载信息。
它还会使客户端的代码大大复杂化:新设计隐藏了多层负载均衡的复杂性,并将其作为服务器的简单列表呈现给客户端。
外部负载均衡服务(独立 LB 进程(External Load Balancing Service))
客户端负载均衡代码保持简单和可移植,实现用于服务器选择的众所周知的算法(例如,循环)。 复杂的负载均衡算法由负载均衡器提供。 客户端依赖负载均衡器来提供负载均衡配置以及客户端应向其发送请求的服务器列表。 平衡器根据需要更新服务器列表以平衡负载以及处理服务器不可用或健康问题。 负载均衡器将做出任何必要的复杂决策并通知客户。 负载均衡器可以与后端服务器通信以收集负载和健康信息。
GRPC服务发现及负载均衡
负载均衡策略适用于命名解析和与服务器的连接之间的 gRPC 客户端工作流。
设计文档:https://github.com/grpc/grpc/blob/master/doc/load-balancing.md
gRPC 开源组件官方并未直接提供服务注册与发现的功能实现,但其设计文档已提供实现的思路,并在不同语言的 gRPC 代码 API 中已提供了命名解析和负载均衡接口供扩展。
以下是它的工作原理:
- 启动时,gRPC 客户端会为服务器名称发出 名称解析 请求。该名称将解析为一个或多个 IP 地址,每个 IP 地址将指示它是服务器地址还是负载均衡器地址,以及指示要使用哪个客户端负载均衡策略的 服务配置 (例如,round_robin 或 grpclb)。
- 客户端实例化负载均衡策略。
- 注意:如果解析程序返回的任何一个地址是均衡器地址,则无论服务配置请求了什么负载均衡策略,客户端都将使用 grpclb 策略。否则,客户端将使用服务配置请求的负载均衡策略。如果服务配置未请求负载均衡策略,则客户端将默认使用选择第一个可用服务器地址的策略。
- 负载均衡策略为每个服务器地址创建一个子通道。
- 对于除 grpclb 之外的所有策略,这意味着解析器返回的每个地址都有一个子通道。请注意,这些策略会忽略解析程序返回的任何均衡器地址。
- 对于grpclb策略,工作流程如下: a. 该策略打开一个流到解析器返回的平衡器地址之一。它要求平衡器将服务器地址用于客户端最初请求的服务器名称(即,最初传递给名称解析器的服务器名称)。 + 注意:在 grpclb 策略中,解析器返回的非平衡器地址用作后备,以防在启动 LB 策略时无法联系到平衡器。 b. 如果负载均衡器的配置需要该信息,则负载均衡器指向客户端的 gRPC 服务器可以向负载均衡器报告负载。 c. 负载均衡器将服务器列表返回给 gRPC 客户端的 grpclb 策略。然后,grpclb 策略将为列表中的每个服务器创建一个子通道。
- 对于发送的每个 RPC ,负载平衡策略决定应将 RPC 发送到哪个子通道(即哪个服务器)。
- 对于 grpclb 策略,客户端将按负载均衡器返回的顺序向服务器发送请求。如果服务器列表为空,则调用将阻塞,直到收到非空的调用。
轮训
proto
syntax = "proto3";
package helloworld;
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
register
package resolver
import (
"context"
"fmt"
"log"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
)
type Register struct {
key string
client3 *clientv3.Client
serverAddress string
stop chan bool
interval time.Duration
leaseTime int64
}
func NewRegister(key string, client3 *clientv3.Client, serverAddress string) *Register {
return &Register{
key: fmt.Sprintf("/%s/%s/", schema, key),
serverAddress: serverAddress,
client3: client3,
interval: 60 * time.Second,
leaseTime: 70,
stop: make(chan bool, 1),
}
}
func (r *Register) Regist() {
lgs, err := r.client3.Grant(context.TODO(), r.leaseTime)
if nil != err {
panic(err)
}
if _, err := r.client3.Put(context.TODO(), r.key, r.serverAddress, clientv3.WithLease(lgs.ID)); nil != err {
panic(err)
}
ch, err := r.client3.KeepAlive(context.TODO(), lgs.ID)
if err != nil {
panic(err)
}
go func() {
for {
ka := <-ch
fmt.Println("ttl:", ka.TTL)
}
}()
}
func (r *Register) GetServerAddress() string {
return r.serverAddress
}
func (r *Register) UnRegist() {
r.stop <- true
if _, err := r.client3.Delete(context.TODO(), r.key); nil != err {
panic(err)
} else {
log.Printf("%s UnReg Sucess", r.key)
}
}
resolver
package resolver
import (
"context"
"fmt"
"strings"
clientv3 "go.etcd.io/etcd/client/v3"
"google.golang.org/grpc/resolver"
)
const schema = "etcdv3_resolver"
// resolver is the implementaion of resolver.Resolve resolver.Builder
type Resolver struct {
target string
service string
cli *clientv3.Client
cc resolver.ClientConn
}
func NewResolver(target string, service string) resolver.Builder {
return &Resolver{target: target, service: service}
}
// Scheme return etcdv3 schema
func (r *Resolver) Scheme() string {
return schema
}
// ResolveNow 立马做一次解析
func (r *Resolver) ResolveNow(rn resolver.ResolveNowOptions) {
// 与watch像似,没监控的逻辑
addrDict := make(map[string]resolver.Address)
update := func() {
addrList := make([]resolver.Address, 0, len(addrDict))
for _, v := range addrDict {
addrList = append(addrList, v)
}
_ = r.cc.UpdateState(resolver.State{Addresses: addrList})
}
// 按前缀取出键值对,并更新r.cc
resp, err := r.cli.Get(context.Background(), prefix, clientv3.WithPrefix())
if err == nil {
for i := range resp.Kvs {
addrDict[string(resp.Kvs[i].Key)] = resolver.Address{Addr: string(resp.Kvs[i].Value)}
}
}
update()
}
// Close
func (r *Resolver) Close() {
}
// Build to resolver.Resolver
func (r *Resolver) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
var err error
r.cli, err = clientv3.New(clientv3.Config{
Endpoints: strings.Split(r.target, ","),
})
if err != nil {
return nil, fmt.Errorf("grpclb: create clientv3 client failed: %v", err)
}
r.cc = cc
go r.watch(fmt.Sprintf("/%s/%s/", schema, r.service))
return r, nil
}
// 监听服务注册的变化
func (r *Resolver) watch(prefix string) {
addrDict := make(map[string]resolver.Address)
update := func() {
addrList := make([]resolver.Address, 0, len(addrDict))
for _, v := range addrDict {
addrList = append(addrList, v)
}
_ = r.cc.UpdateState(resolver.State{Addresses: addrList})
}
// 按前缀取出键值对,并更新r.cc
resp, err := r.cli.Get(context.Background(), prefix, clientv3.WithPrefix())
if err == nil {
for i := range resp.Kvs {
addrDict[string(resp.Kvs[i].Key)] = resolver.Address{Addr: string(resp.Kvs[i].Value)}
}
}
update()
// 监听服务注册的变化,并更新r.cc
rch := r.cli.Watch(context.Background(), prefix, clientv3.WithPrefix(), clientv3.WithPrevKV())
for n := range rch {
for _, ev := range n.Events {
switch ev.Type {
case clientv3.EventTypePut:
addrDict[string(ev.Kv.Key)] = resolver.Address{Addr: string(ev.Kv.Value)}
case clientv3.EventTypeDelete:
delete(addrDict, string(ev.PrevKv.Key))
}
}
update()
}
}
client
package main
import (
resolver2 "commons/grpclb/resolver"
"context"
"flag"
"fmt"
"strconv"
"time"
pb "commons/grpclb/proto"
"github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/balancer/roundrobin"
"google.golang.org/grpc/resolver"
)
var (
svc = flag.String("service", "hello_service", "service name")
reg = flag.String("reg", "http://127.0.0.1:2379", "register etcd address")
)
func main() {
flag.Parse()
r := resolver2.NewResolver(*reg, *svc)
resolver.Register(r)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// https://github.com/grpc/grpc/blob/master/doc/naming.md
// The gRPC client library will use the specified scheme to pick the right resolver plugin and pass it the fully qualified name string.
DefaultServiceConfig := fmt.Sprintf(`{"LoadBalancingPolicy": "%s"}`, roundrobin.Name)
// authority是自己随便起的,不是必须的,但是r.Scheme()+"://authority/"+*svc这种格式是必须的
conn, err := grpc.DialContext(ctx, r.Scheme()+"://authority/"+*svc, grpc.WithInsecure(), grpc.WithDefaultServiceConfig(DefaultServiceConfig), grpc.WithBlock())
if err != nil {
panic(err)
}
ticker := time.NewTicker(100 * time.Millisecond)
client := pb.NewGreeterClient(conn)
for t := range ticker.C {
resp, err := client.SayHello(context.Background(), &pb.HelloRequest{Name: "world " + strconv.Itoa(t.Second())})
if err == nil {
logrus.Infof("%v: Reply is %s\n", t, resp.Message)
} else {
logrus.Error(err)
}
}
}
server
package main
import (
"commons/grpclb/resolver"
"context"
"flag"
"log"
"net"
"os"
"os/signal"
"strings"
"syscall"
"time"
clientv3 "go.etcd.io/etcd/client/v3"
"google.golang.org/grpc"
pb "commons/grpclb/proto"
"github.com/sirupsen/logrus"
)
const DialTimeout = time.Second * 5
var (
serv = flag.String("service", "hello_service", "service name")
host = flag.String("host", "localhost", "listening host")
port = flag.String("port", "50000", "listening port")
endpoints = flag.String("reg", "127.0.0.1:2379", "register etcd address")
)
func init() {
flag.Parse()
}
// 程序停止去掉etcd里的信息
func deadNotify(reg *resolver.Register) {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT, syscall.SIGKILL, syscall.SIGHUP, syscall.SIGQUIT)
go func() {
log.Printf("signal.Notify %v", <-ch)
reg.UnRegist()
os.Exit(1)
}()
}
func main() {
// 端口监听
lis, err := net.Listen("tcp", net.JoinHostPort(*host, *port))
if err != nil {
panic(err)
}
defer lis.Close()
// 初始化etcd连接
client3, err := clientv3.New(clientv3.Config{
Endpoints: strings.Split(*endpoints, ","),
DialTimeout: DialTimeout,
})
if err != nil {
panic(err)
}
defer client3.Close()
reg := resolver.NewRegister(*serv+"/"+net.JoinHostPort(*host, *port), client3, net.JoinHostPort(*host, *port))
reg.Regist() //服务注册到etcd
deadNotify(reg) //取消注册
logrus.Infof("starting hello service at %s", *port)
s := grpc.NewServer()
pb.RegisterGreeterServer(s, &server{})
_ = s.Serve(lis)
}
// server is used to implement helloworld.GreeterServer.
type server struct{}
// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
logrus.Infof("%v: Receive is %s\n", time.Now(), in.Name)
time.Sleep(time.Second * 5)
return &pb.HelloReply{Message: "Hello " + in.Name + " from " + net.JoinHostPort(*host, *port)}, nil
}
当流量进入5000的服务,此时我们使用kill -9 的方式干掉他,会收到如下的错误
由于kill -9 无法被进程捕获, 此时etcd里的节点信息是没有发生变化的
但是注意没有,由于etcd里的节点信息没发生变化,我们的resolver是无法更新节点信息的,但看之后的请求,并没继续走到5000这个节点上
挂掉的节点怎么移除etcd?
首选我们做了信号监听,但像kill -9 , 机器挂掉,oom等我们是捕获不到的
func deadNotify(reg *resolver.Register) {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT, syscall.SIGKILL, syscall.SIGHUP, syscall.SIGQUIT)
go func() {
log.Printf("signal.Notify %v", <-ch)
reg.UnRegist()
os.Exit(1)
}()
}
那我们可以用两个时间点一直做轮询,比如我设置key的有效期为10s,但过5s我们就去续约一次,这样就算节点挂掉,在etcd里最多也只有10s的生命周期,同理坏节点在grpc内置的connectPool里最多生存10s。
这里没必要写这么麻烦,用上面的KeepAlive是一样的,它会帮我们自动续期
func (r *Register) Regist() {
lgs, err := r.client3.Grant(context.TODO(), r.leaseTime)
if nil != err {
panic(err)
}
if _, err := r.client3.Put(context.TODO(), r.key, r.serverAddress, clientv3.WithLease(lgs.ID)); nil != err {
panic(err)
}
go func() {
c := time.Tick(1 * time.Second)
for range c {
if _, err := r.client3.KeepAliveOnce(context.TODO(), lgs.ID); nil != err {
panic(err)
}
}
}()
}
grpc会什么不会将流量打到etcd里的坏节点?
要测试出效果,我们首先要增加每次租约时长,我们调整为2h
首先我们要知道,连接建立好后,会放在balance的subConnect下,grpc内置多个策略,它们之间会有差异,这里我们讨论的是roundrobin
由上图,我们看到了两个节点都处于 Ready 状态,根据设计规则,最后承担发送的数据的链接,都必须是通过Picker拿到的链接,我们可以看下roundrobin
// Name is the name of round_robin balancer.
const Name = "round_robin"
var logger = grpclog.Component("roundrobin")
// newBuilder creates a new roundrobin balancer builder.
func newBuilder() balancer.Builder {
return base.NewBalancerBuilder(Name, &rrPickerBuilder{}, base.Config{HealthCheck: true})
}
func init() {
balancer.Register(newBuilder())
}
type rrPickerBuilder struct{}
func (*rrPickerBuilder) Build(info base.PickerBuildInfo) balancer.Picker {
logger.Infof("roundrobinPicker: newPicker called with info: %v", info)
if len(info.ReadySCs) == 0 {
return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
}
var scs []balancer.SubConn
for sc := range info.ReadySCs {
scs = append(scs, sc)
}
return &rrPicker{
subConns: scs,
// Start at a random index, as the same RR balancer rebuilds a new
// picker when SubConn states change, and we don't want to apply excess
// load to the first server in the list.
next: grpcrand.Intn(len(scs)),
}
}
type rrPicker struct {
// subConns is the snapshot of the roundrobin balancer when this picker was
// created. The slice is immutable. Each Get() will do a round robin
// selection from it and return the selected SubConn.
subConns []balancer.SubConn
mu sync.Mutex
next int
}
func (p *rrPicker) Pick(balancer.PickInfo) (balancer.PickResult, error) {
p.mu.Lock()
sc := p.subConns[p.next]
p.next = (p.next + 1) % len(p.subConns)
p.mu.Unlock()
return balancer.PickResult{SubConn: sc}, nil
}
�整个代码不多,比较清晰,rrPickerBuilder 会 更新 rrPicker里的subConns,而 Pick从subConns轮询取一个值
而rrPickerBuilder 保证了 只有是有效连接才会放入 rrPicker
那么问题来了,肯定是需要有监听,才有感知到连接的状态哇,在新建balancerWrapper时,提供了watcher
func newCCBalancerWrapper(cc *ClientConn, b balancer.Builder, bopts balancer.BuildOptions) *ccBalancerWrapper {
ccb := &ccBalancerWrapper{
cc: cc,
updateCh: buffer.NewUnbounded(),
closed: grpcsync.NewEvent(),
done: grpcsync.NewEvent(),
subConns: make(map[*acBalancerWrapper]struct{}),
}
go ccb.watcher()
ccb.balancer = b.Build(ccb, bopts)
return ccb
}
func (ccb *ccBalancerWrapper) watcher() {
for {
select {
case t := <-ccb.updateCh.Get():
ccb.updateCh.Load()
if ccb.closed.HasFired() {
break
}
switch u := t.(type) {
case *scStateUpdate:
ccb.balancerMu.Lock()
// 这里会去具体的Balancer,例如我们的例子是baseBalancer
// 然后走一下逻辑,再去调用pickerBuilder.Build()
ccb.balancer.UpdateSubConnState(u.sc, balancer.SubConnState{ConnectivityState: u.state, ConnectionError: u.err})
ccb.balancerMu.Unlock()
case *acBalancerWrapper:
ccb.mu.Lock()
if ccb.subConns != nil {
delete(ccb.subConns, u)
ccb.cc.removeAddrConn(u.getAddrConn(), errConnDrain)
}
ccb.mu.Unlock()
default:
logger.Errorf("ccBalancerWrapper.watcher: unknown update %+v, type %T", t, t)
}
case <-ccb.closed.Done():
}
if ccb.closed.HasFired() {
...
}
}
}
ps:处于Connecting的链接,会一直的尝试建立链接