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来实现服务组册和服务发现的原理,并描述一些简单的概念,其流程图可以用下面的示意图来描述:
- 上图的service A包含两个节点,服务在节点上启动后,会以service name + node ip作为唯一标识key,比如/service/a/114.128.45.117, node ip + port 作为值存储到etcd上
- 这些key都是带租约的, 需要定期去续租,一旦服务节点本身宕机,无法完成租约,它对应的key就会过期,客户端就无法从etcd上获得这个服务节点的信息
- 同时客户端也会利用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实现服务注册和发现
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。