头图

利用etcd实现grpc的服务注册和服务发现

etcd

etcd是一个高可用的KV分布式存储系统,主要用于共享配置和服务发现。etcd使用go语言编写,并通过Raft一致性算法来处理日志复制以保证强一致性,k8s的底层也在使用etcd。

grpc

RPC,远程过程调用,是一个计算机通信协议。该协议允许运行一台计算机的程序调用另外一台计算机的子程序,就像调用本地程序一样,无需额外地为这个交互作用编程(准确来说,是无需关注细节)。 RPC是一种经典的CS模式,实现了一个通过发送请求-接收回应来进行信息交互的系统。gRPC是一种现代化的开源高性能RPC框架,可以运行在任意环境之中。最初由google进行开源,使用HTTP/2作为传输协议。

使用grpc, 我们可以一次性地在一个.proto文件中定义服务并使用任何支持它的语言去实现客户端和服务端,grpc可以帮助我们屏蔽不同语言和环境间通信的复杂性。

为什么要使用etcd?

系统中实现服务注册与发现所需的基本功能有:

  • 服务注册,同一service的所有节点注册到相同目录下,节点启动后将自己的信息注册到所属服务的目录中
  • 健康检查,服务节点定时发送心跳,注册到服务目录中的信息设置一个较短的TTL,运行正常的服务节点每隔一段时间会去更新信息的TTL
  • 服务发现,通过名称能查询到服务提供外部访问的IP和端口号。比如网关代理服务时能够及时的发现服务中新增节点、丢弃不可用的服务节点,同时各个服务间也能感知对方的存在

在分布式系统中,如果管理节点间的状态一直是一个难题。etcd是专门为集群环境的服务发现和服务注册而设计的,它提供了TTL失效、数据改变监视、多值、目录监听、分布式锁原子操作等功能,可以方便跟踪并管理集群节点的状态。

服务注册和服务发现的流程图

我们首先来简单说明一下利用etcd来实现服务组册和服务发现的原理,并描述一些简单的概念,其流程图可以用下面的示意图来描述:

image.png

  1. 上图的service A包含两个节点,服务在节点上启动后,会以service name + node ip作为唯一标识key,比如/service/a/114.128.45.117, node ip + port 作为值存储到etcd上
  2. 这些key都是带租约的, 需要定期去续租,一旦服务节点本身宕机,无法完成租约,它对应的key就会过期,客户端就无法从etcd上获得这个服务节点的信息
  3. 同时客户端也会利用etcd的watch功能监听以/service/a为前缀的所有key的变化

不同于nginx,lvs或者f5这些服务端的负载均衡策略,grpc采用的是客户端实现负载均衡。客户端会从可用的后端节点列表中,根据自己的负载均衡策略选择一个节点,然后直连到后端服务器上。

etcd sdk的naming组件提供以一个命名解析器(naming resolver),结合gRPC本身自带的RR轮询调度负载均衡器,可以让使用者能方便地搭建起一套服务注册/发现的负载均衡体系。

本文引用的源码版本为:grpc v1.40.0, etcdv3 v3.5.2

服务注册

etcd的sdk并没有提供统一的注册函数提供调用,那么我们怎么在新增服务节点之后,怎么把节点信息存储到etcd上并通知naming resolver呢?下面的代码是我们对服务注册过程实现了封装,以方便大家快速实践((metric的打点上报,可以忽略)。

创建用于服务注册/服务发现的agent


// Agent service discovery agent
// Responsible for service registration and service resolve
type Agent interface {
    // Register register a service. return a deregister func.
    // Should call the deregister func gracefully when program exit(signal notify)
    Register(serviceName string, IP string, port string) func()
    InitGRPCResolverBuilder()
}

// NewAgent create a service register/discovery agent
func NewAgent(etcdServers []string) Agent {
    client, err := etcdv3.NewClient(context.Background(), etcdServers, etcdv3.ClientOptions{
        DialTimeout:   time.Second * 3,
        DialKeepAlive: time.Second * 3,
    })
    if err != nil {
        log.Fatalf("etcd init failed. error=[%v]", err)
    }
    return &agent{
        cc: client,
    }
}

服务注册,以及返回服务下线时的注销函数

func (sd *agent) Register(serviceName string, IP string, port string) func() {
    metric.RequestCounter.WithLabelValues("Register").Inc()
    // Build the registrar.
    registrar := etcdv3.NewRegistrar(sd.cc, etcdv3.Service{
        Key:   fmt.Sprintf("%s/%s/%s:%s", prefix, serviceName, IP, port),
        Value: fmt.Sprintf("%s:%s", IP, port),
    })
    // Register our instance.
    registrar.Register()
    return func() {
        metric.RequestCounter.WithLabelValues("Deregister").Inc()
        registrar.Deregister()
    }
}

putSession中put方法,实现真正的服务注册

func (c *client) putWithSession(s Service) error {
    session, err := concurrency.NewSession(c.cli, concurrency.WithTTL(int(s.TTL.ttl.Seconds())), concurrency.WithContext(c.rctx))
    if err != nil {
        metric.ErrCounter.WithLabelValues("NewSession").Inc()
        log.Errorf("register with error=[%v]", err)
        return err
    }
    c.session = session
    c.leaseID = session.Lease()

    _, err = session.Client().Put(c.rctx, s.Key, s.Value, clientv3.WithLease(session.Lease()))
    if err != nil {
        metric.ErrCounter.WithLabelValues("Put").Inc()
        log.Errorf("register with error=[%v]", err)
        return err
    }
    return nil
}

Deregister中的delete方法,实现服务下线注销

func (c *client) Deregister(s Service) error {
    defer c.close()

    if s.Key == "" {
        return ErrNoKey
    }
    if _, err := c.cli.Delete(c.ctx, s.Key, clientv3.WithIgnoreLease()); err != nil {
        log.Infof("Deregister Key=[%s]", s.Key)
        metric.ErrCounter.WithLabelValues("Delete").Inc()
        return err
    }

    return nil
}

keepAlive

使用put方法完成服务注册后,我们还需要为服务节点定期续租,一般续租都是通过etcd租约中的KeepAlive方法。可以观察到,在创建agent的时候,我们已经设置了DialKeepAlive参数,etcd的sdk会帮助我们实现KeepAlive。

服务发现

如果有新的服务节点注册,或者原有的服务节点下线,客户端是怎么知道的呢?这就需要命名解析服务器Resolver来帮助我们了,Resolver的作用可以理解为:将一个携带信息的字符串映射为一组IP端口信息。

etcd ResolverBuilder实现了gprc builder接口

const (
    prefix = "services"
)

resolver.Register(etcdv3.NewEtcdResolverBuilder(sd.cc, prefix))

etcd ResolverBuilder的实现

type etcdResolverBuilder struct {
    client        Client
    servicePrefix string
}

// NewEtcdResolverBuilder etcd grpc resolver build
func NewEtcdResolverBuilder(client Client, servicePrefix string) resolver.Builder {
    return &etcdResolverBuilder{
        client:        client,
        servicePrefix: servicePrefix,
    }
}

func (b *etcdResolverBuilder) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
    r := &etcdResolver{
        target:        target,
        cc:            cc,
        client:        b.client,
        servicePrefix: b.servicePrefix,
        syncc:         make(chan struct{}),
        quitc:         make(chan struct{}),
    }
    go r.loop()
    return r, nil
}

func (*etcdResolverBuilder) Scheme() string { return "etcdv3" }

etcd的scheme name为etcdv3, grpc dail的时候会通过该scheme name去找到对应的etcdResolverBuilder,下面为grpc的source code:

// Determine the resolver to use.
    cc.parsedTarget = grpcutil.ParseTarget(cc.target, cc.dopts.copts.Dialer != nil)
    channelz.Infof(logger, cc.channelzID, "parsed scheme: %q", cc.parsedTarget.Scheme)
    resolverBuilder := cc.getResolver(cc.parsedTarget.Scheme)

利用协程序执行loop

WatchPrefix会一直监听target的变化,并对自己的维护的地址进行动态的增删:

// WatchPrefix implements the etcd Client interface.
func (c *client) WatchPrefix(prefix string, ch chan struct{}) {
    for {
        errch := make(chan error, 1)
        c.watch(prefix, ch, errch)

        select {
        case <-errch:
            time.Sleep(time.Second)
        case <-c.wctx.Done():
            return
        }
    }
}

func (c *client) watch(prefix string, ch chan struct{}, errch chan error) {
    ch <- struct{}{}
    wch := c.watcher.Watch(c.wctx, prefix, clientv3.WithPrefix(), clientv3.WithPrevKV())

    for wr := range wch {
        if wr.Canceled {
            metric.ErrCounter.WithLabelValues("Watch").Inc()
            log.Errorf("watch failure. error=[%v]", wr.Err())
            errch <- wr.Err()
            return
        }

        for _, e := range wr.Events {
            if e.Type == mvccpb.DELETE {
                log.Infof("[DELETE] key=[%s]", e.Kv.Key)
            } else if e.Type == mvccpb.PUT {
                log.Infof("[PUT] key=[%s], val=[%s]", e.Kv.Key, e.Kv.Value)
            }
        }
        metric.RequestCounter.WithLabelValues("WatchEventChange").Inc()
        ch <- struct{}{}
    }
}

负载均衡

前面我们有提到,gprc是在客户端实现负载均衡的策略,以决定访问具体server node。先来看看grpc source code中对于balancer接口的定义:

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

在grpc客户端和服务端之间建立连接(Dail)时可以用WithBalancerName方法,便于在DiapOption里面指定balancer组件。在这里,我们使用了round robin来作为我们负载均衡的调度算法:

opts = append(opts, grpc.WithInsecure(), grpc.WithBalancerName("round_robin"))
......
conn, err := grpc.Dial(URL, opts...)

参考

golang grpc之etcd服务注册发现
gRPC 服务注册发现及负载均衡的实现方案与源码解析
用etcd实现服务注册和发现

脂肪三尺,非一日之寒

0 声望
0 粉丝
0 条评论
推荐阅读
mysql间隙锁实战,记录一次有意思的线上问题
前言在记录这次线上问题之前,我们先来回顾一些基础知识。数据库系统的锁数据库系统使用锁是为了支持对共享资源进行访问,提供数据的完整性和一致性。锁类型InnoDB存储引擎中实现了如下两种标准的行级锁:共享锁...

CloudFish阅读 294

如何实现协同编辑 - 理解Operational Transformation
Operational Transformation(OT)是一个应用于协同编辑领域的并发控制和冲突解决系统,要解决的是“多个用户对同一个文本域的同一个版本进行编辑时如何处理”的问题。下文中简称冲突问题。

Garin1阅读 4.9k评论 2

写给go开发者的gRPC教程-通信模式
本篇为【写给go开发者的gRPC教程系列】第二篇第一篇:protobuf基础第二篇:通信模式上一篇介绍了如何编写 protobuf 的 idl,并使用 idl 生成了 gRPC 的代码,现在来看看如何编写客户端和服务端的代码Simple RPC (...

liangwt2阅读 991

封面图
Cas单点登录剖析
CAS(Central Authentication Service) 是 Yale 大学发起的构建 Web SSO 的 开源项目SSO 是什么?SSO-Single Sign On就是 单点登录 也就是 多个网站程序 统一到一个网址进行登录身份验证主要特点是:SSO 应用之间...

Eric2阅读 2.9k

写给go开发者的gRPC教程-protobuf基础
序列化协议。gRPC使用protobuf,首先使用protobuf定义服务,然后使用这个文件来生成客户端和服务端的代码。因为pb是跨语言的,因此即使服务端和客户端语言并不一致也是可以互相序列化和反序列化的

liangwt1阅读 976评论 1

封面图
ElasticSearch 必知必会 - 进阶篇
京东物流:康睿 姚再毅 李振 刘斌 王北永说明:以下全部均基于 ElasticSearch 8.1 版本一.跨集群检索 - ccr官网文档地址: [链接]跨集群检索的背景和意义跨集群检索定义跨集群检索环境搭建官网文档地址: [链接]...

京东云开发者2阅读 324

封面图
protocol-buffers namespace conflict
在运行grpc服务,加载*.pb.go时可能会报冲突错误,如文件名命名冲突:其实针对文件名冲突的错误处理开发者有移除过"文件冲突检测":[链接]后来发现有问题又加上了"文件冲突检测":[链接]

AVOli阅读 823

脂肪三尺,非一日之寒

0 声望
0 粉丝
宣传栏