1. LB方案介绍
构建高可用、高性能的通信服务,通常采用服务注册与发现、负载均衡和容错处理等机制实现。根据负载均衡实现所在的位置不同,通常可分为以下三种解决方案:
1.1 集中式LB(Proxy代理模式)
在服务消费者和服务提供者之间有一个独立的LB,通常是专门的硬件设备如 F5,或者基于软件如 LVS,HAproxy等实现。LB上有所有服务的地址映射表,通常由运维配置注册,当服务消费方调用某个目标服务时,它向LB发起请求,由LB以某种策略,比如轮询(Round-Robin)做负载均衡后将请求转发到目标服务。LB一般具备健康检查能力,能自动摘除不健康的服务实例。
该方案主要问题:
- 单点问题,所有服务调用流量都经过LB,当服务数量和调用量大的时候,LB容易成为瓶颈,且一旦LB发生故障影响整个系统;
- 服务消费方、提供方之间增加了一级,有一定性能开销。
1.2 进程内LB(客户端负均衡)
针对第一个方案的不足,此方案将LB的功能集成到服务消费方进程里,也被称为软负载或者客户端负载方案。服务提供方启动时,首先将服务地址注册到服务注册表,同时定期报心跳到服务注册表以表明服务的存活状态,相当于健康检查,服务消费方要访问某个服务时,它通过内置的LB组件向服务注册表查询,同时缓存并定期刷新目标服务地址列表,然后以某种负载均衡策略选择一个目标服务地址,最后向目标服务发起请求。LB和服务发现能力被分散到每一个服务消费者的进程内部,同时服务消费方和服务提供方之间是直接调用,没有额外开销,性能比较好。
该方案主要问题:
- 开发成本,该方案将服务调用方集成到客户端的进程里头,如果有多种不同的语言栈,就要配合开发多种不同的客户端,有一定的研发和维护成本;
- 另外生产环境中,后续如果要对客户库进行升级,势必要求服务调用方修改代码并重新发布,升级较复杂。
1.3 独立LB进程(外部负载均衡服务(Sidecar))
该方案是针对第二种方案的不足而提出的一种折中方案,原理和第二种方案基本类似。不同之处是将LB和服务发现功能从进程内移出来,变成主机上的一个独立进程。主机上的一个或者多个服务要访问目标服务时,他们都通过同一主机上的独立LB进程做服务发现和负载均衡。该方案也是一种分布式方案没有单点问题,一个LB进程挂了只影响该主机上的服务调用方,服务调用方和LB之间是进程内调用性能好,同时该方案还简化了服务调用方,不需要为不同语言开发客户库,LB的升级不需要服务调用方改代码。
该方案主要问题:部署较复杂,环节多,出错调试排查问题不方便。
2. grpc服务发现与负载均衡实现
gRPC开源组件官方并未直接提供服务注册与发现的功能实现,但其设计文档已提供实现的思路,并在不同语言的gRPC代码API中已提供了命名解析和负载均衡接口供扩展。
其基本实现原理:
- 服务启动后gRPC客户端向命名服务器发出名称解析请求,名称将解析为一个或多个IP地址,每个IP地址标示它是服务器地址还是负载均衡器地址,以及标示要使用那个客户端负载均衡策略或服务配置。
- 客户端实例化负载均衡策略,如果解析返回的地址是负载均衡器地址,则客户端将使用grpclb策略,否则客户端使用服务配置请求的负载均衡策略。
- 负载均衡策略为每个服务器地址创建一个子通道(channel)。
- 当有rpc请求时,负载均衡策略决定那个子通道即grpc服务器将接收请求,当可用服务器为空时客户端的请求将被阻塞。
根据grpc官方提供的设计思路,基于进程内LB方案(即第2个案),结合分布式一致的组件(如Zookeeper、Etcd),可找到grpc服务发现和负载均衡的可行解决方案。
3. 核心代码实战
接下来以Go语言为例,简单介绍下基于Etcd3的关键代码实现。
3.1 服务注册实现: register.go
注册的核心逻辑就是将服务的启动IP和Port以name的形式注册到基于etcd的target中,这样当客户端去发现服务的时候以name的形式去请求target(etcd)获取IP和Port,这样就会将请求直接打到以这个IP和port的服务器上。
const schema = "etcdv3_resolver" func Register(target, service, host, port string, interval time.Duration, ttl int) error { // 以IP和Port作为etcd的value serviceValue := net.JoinHostPort(host, port) // 以schema+name(服务名称)+value作为etcd的key serviceKey := fmt.Sprintf("/%s/%s/%s", schema, service, serviceValue) // 获取endpoints(即etcd的nodehost:port列表)用于实例化etcd对象 var err error cli, err := clientv3.New(clientv3.Config{ Endpoints: strings.Split(target, ","), }) // 获取租约 resp, err := cli.Grant(context.TODO(), int64(ttl)) if err != nil { return err } // 将key和value存储到etcd中 if _, err := cli.Put(context.TODO(), serviceKey, serviceValue, clientv3.WithLease(resp.ID)); err != nil { return err } // 根据租约ID保持与etcd的存活探针 if _, err := cli.KeepAlive(context.TODO(), resp.ID); err != nil { return err } }
3.2 服务发现实现: watcher.go
服务发现核心逻辑就是根据客户端上传的name去etcd查询后端服务,查询到就把服务注册列表返回给grpc客户端,客户端根据负载均衡策略(轮循)直接发起rpc调用,不用夸网络。
func (r *Resolver) watch(prefix string) { // 根据name和schema拼接的前缀去匹配key,那么一定是一个列表,因为我们注册的key是schema+name+host:port 所以schema+name可以匹配多个key/value resp, err := r.cli.Get(context.Background(), prefix, clientv3.WithPrefix()) if err == nil { for i := range resp.Kvs { addrDict[string(resp.Kvs[i].Value)] = resolver.Address{Addr: string(resp.Kvs[i].Value)} } } //通知到grpc客户端地址列表 update := func() { addrList := make([]resolver.Address, 0, len(addrDict)) for _, v := range addrDict { addrList = append(addrList, v) } r.cc.UpdateState(resolver.State{Addresses: addrList}) } } update() //监听etcd注册上来的服务变化(新增,修改或者删除) rch := r.cli.Watch(context.Background(), prefix, clientv3.WithPrefix(), clientv3.WithPrevKV()) for n := range rch { for _, ev := range n.Events { switch ev.Type { case mvccpb.PUT: //新增或者修改服务 addrDict[string(ev.Kv.Key)] = resolver.Address{Addr: string(ev.Kv.Value)} case mvccpb.DELETE: //删除服务 delete(addrDict, string(ev.PrevKv.Key)) } } update() }
3.3 server进行注册: main.go
var ( serv = flag.String("service", "hello_service", "service name") host = flag.String("host", "localhost", "listening host") port = flag.String("port", "50001", "listening port") reg = flag.String("reg", "http://localhost:2379", "register etcd address") ) func main() { flag.Parse() lis, err := net.Listen("tcp", net.JoinHostPort(*host, *port)) if err != nil { panic(err) } //调用注册 将服务注册到etcd中 并且添加续约时间 err = grpclb.Register(*reg, *serv, *host, *port, time.Second*10, 15) if err != nil { panic(err) } }
3.4 client发现server: main.go
var ( svc = flag.String("service", "hello_service", "service name") reg = flag.String("reg", "http://localhost:2379", "register etcd address") ) func main() { flag.Parse() // 实例化name命名解析器 r := grpclb.NewResolver(*reg, *svc) resolver.Register(r) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) // 通过r.Scheme()+"://authority/"+*svc发现服务的IP+Port列表 // 设置负载均衡策略:轮循 roundrobin conn, err := grpc.DialContext(ctx, r.Scheme()+"://authority/"+*svc, grpc.WithInsecure(), grpc.WithBalancerName(roundrobin.Name), grpc.WithBlock()) cancel() if err != nil { panic(err) } ticker := time.NewTicker(1000 * time.Millisecond) for t := range ticker.C { client := pb.NewGreeterClient(conn) 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) } } }
3.5 运行
1. 以docker方式启动etcd
➜ ~ docker run -d\ -p 2379:2379 \ -p 2380:2380 \ --volume=${DATA_DIR}:/etcd-data \ --name etcd ${REGISTRY}:latest \ /usr/local/bin/etcd \ --data-dir=/etcd-data --name node1 \ --initial-advertise-peer-urls http://127.0.0.1:2380 --listen-peer-urls http://0.0.0.0:2380 \ --advertise-client-urls http://127.0.0.1:2379 --listen-client-urls http://0.0.0.0:2379 \ --initial-cluster node1=http://127.0.0.1:2380
2. 启动测试程序
分别启动服务端
终端1: go run -mod vendor cmd/svr/svr.go -port 50001 终端2: go run -mod vendor cmd/svr/svr.go -port 50002 终端3: go run -mod vendor cmd/svr/svr.go -port 50003
启动客户端
go run -mod vendor cmd/cli/cli.go
3. 结果
客户端响应:
可以看出来成功请求三次都是不同的服务,说明服务发现和负载均衡生效了。
服务端响应:
可以看出来三个server都响应了客户端请求,尚且只有一次,再次证明轮循负载均衡策略和服务注册生效了。
以上代码参考:https://github.com/wwcd/grpc-lb
4. 小结
这片文章是【微服务架构上篇】系列重点,因为服务注册和发现以及负载均衡策略是微服务架构中不可或缺的一环,只有彻底弄懂和掌握了此技术,才能在基于云原生微服务架构中得心应手,所以学好grpc+etcd+golang的技术栈走遍天下大厂都不怕!