基于etcd的服务发现与注册

744 阅读7分钟

1. LB方案介绍

构建高可用、高性能的通信服务,通常采用服务注册与发现、负载均衡和容错处理等机制实现。根据负载均衡实现所在的位置不同,通常可分为以下三种解决方案:

集中式LB(Proxy代理模式)

image.png

在服务消费者和服务提供者之间有一个独立的LB,通常是专门的硬件设备如 F5,或者基于软件如 LVS,HAproxy等实现。LB上有所有服务的地址映射表,通常由运维配置注册,当服务消费方调用某个目标服务时,它向LB发起请求,由LB以某种策略,比如轮询(Round-Robin)做负载均衡后将请求转发到目标服务。LB一般具备健康检查能力,能自动摘除不健康的服务实例。

该方案主要问题:

单点问题,所有服务调用流量都经过LB,当服务数量和调用量大的时候,LB容易成为瓶颈,且一旦LB发生故障影响整个系统;

服务消费方、提供方之间增加了一级,有一定性能开销。

进程内LB(客户端负均衡)

image.png

针对第一个方案的不足,此方案将LB的功能集成到服务消费方进程里,也被称为软负载或者客户端负载方案。服务提供方启动时,首先将服务地址注册到服务注册表,同时定期报心跳到服务注册表以表明服务的存活状态,相当于健康检查,服务消费方要访问某个服务时,它通过内置的LB组件向服务注册表查询,同时缓存并定期刷新目标服务地址列表,然后以某种负载均衡策略选择一个目标服务地址,最后向目标服务发起请求。LB和服务发现能力被分散到每一个服务消费者的进程内部,同时服务消费方和服务提供方之间是直接调用,没有额外开销,性能比较好。 该方案主要问题:

  1. 开发成本,该方案将服务调用方集成到客户端的进程里头,如果有多种不同的语言栈,就要配合开发多种不同的客户端,有一定的研发和维护成本;
  2. 另外生产环境中,后续如果要对客户库进行升级,势必要求服务调用方修改代码并重新发布,升级较复杂。

独立LB进程(外部负载均衡服务(Sidecar))

image.png

该方案是针对第二种方案的不足而提出的一种折中方案,原理和第二种方案基本类似。 不同之处是将LB和服务发现功能从进程内移出来,变成主机上的一个独立进程。主机上的一个或者多个服务要访问目标服务时,他们都通过同一主机上的独立LB进程做服务发现和负载均衡。该方案也是一种分布式方案没有单点问题,一个LB进程挂了只影响该主机上的服务调用方,服务调用方和LB之间是进程内调用性能好,同时该方案还简化了服务调用方,不需要为不同语言开发客户库,LB的升级不需要服务调用方改代码。

该方案主要问题:部署较复杂,环节多,出错调试排查问题不方便。

2. grpc服务发现与负载均衡实现

gRPC开源组件官方并未直接提供服务注册与发现的功能实现,但其设计文档已提供实现的思路,并在不同语言的gRPC代码API中已提供了命名解析和负载均衡接口供扩展。

image.png

其基本实现原理:

服务启动后gRPC客户端向命名服务器发出名称解析请求,名称将解析为一个或多个IP地址,每个IP地址标示它是服务器地址还是负载均衡器地址,以及标示要使用那个客户端负载均衡策略或服务配置。

客户端实例化负载均衡策略,如果解析返回的地址是负载均衡器地址,则客户端将使用grpclb策略,否则客户端使用服务配置请求的负载均衡策略。

负载均衡策略为每个服务器地址创建一个子通道(channel)。

当有rpc请求时,负载均衡策略决定那个子通道即grpc服务器将接收请求,当可用服务器为空时客户端的请求将被阻塞。

根据grpc官方提供的设计思路,基于进程内LB方案(即第2个案),结合分布式一致的组件(如Zookeeper、Etcd),可找到grpc服务发现和负载均衡的可行解决方案。

3. 核心代码实战

服务注册实现: 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
 }
}

服务发现实现: watcher.go

服务发现核心逻辑就是根据客户端上传的name去etcd查询后端服务,查询到就把服务注册的时候的那个value返回给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()
 }

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)
 }
}

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)
  }
 }
}

4.运行

以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

启动测试程序

分别启动服务端

终端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

结果

客户端响应:

image.png

可以看出来成功请求三次都是不同的服务,说明服务发现和负载均衡生效了。

服务端响应:

image.png

image.png

image.png

可以看出来三个server都响应了客户端请求,尚且只有一次,再次证明轮循负载均衡策略和服务注册生效了