头图

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实现服务注册和发现


CloudFish
0 声望0 粉丝

脂肪三尺,非一日之寒