三、微服务:Etcd服务注册与发现

1、引言

这里服务的发现与注册使用Etcd,Etcd是CoreOS团队于2013年6月发起的开源项目,它的目标是构建一个高可用的分布式键值(key-value)数据库。Etcd内部采用raft协议作为一致性算法,etcd基于Go语言实现。

开原地址:Etcd

服务的发现与注册需要解决以下问题:

  • 服务IP与端口的确定方式。
  • 服务注册与发现。
  • 服务下线
  • 服务健康监测。
  • 节点加入或退出,如何通知订阅者变化。
  • 查看应用的订阅列表,发布列表,以及订阅节点。

2、ip and port

在微服务的系统应用中,服务的实例个数是动态变化的,各个实例动态化分配网络地址和端口,为了弹性扩容,快速部署,多机部署,需要自动化识别ip与port。

2.1 ip 确认

  • 手动配置:可以使用,但是生产环境下无法做到水平扩容,多机部署,部署一台就需要去手动配置一台,运维成本较大。
  • 遍历网卡:可在生产环境中使用,实现自动化适配。推荐这种方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func GetLocalIP() (string,error){
addrs,err := net.InterfaceAddrs()
if err != nil {
log.Println(err)
return "",err
}
for _,addr:=range addrs{
if ipnet,ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback(){
if ipnet.IP.To4() != nil {
return ipnet.IP.String(),nil
}
}
}
panic("unable to determine locla ip")
}

2.2 port

在微服务中,直接使用配置文件中配置好的端口就可以。

3、服务注册

服务注册就是服务启动后向注册中心(Etcd)登记自己的IP与端口信息,服务的消费方通过查看登记信息,可以直接找到对应的服务,并发起请求。

注册模式有两种:

  • 客户端注册
  • 服务端注册

3.1、客户端注册

服务启动后,主动去注册。服务停止,主动去注销信息。服务注册应该在服务完全成功启动后在发生注册。通过检测对应端口是否已经处于监控状态了,就可以判断该服务是否成功启动。

3.2、服务端注册

同样,服务成功启动后去注册。但是不是由自己去注册,统一交给一个独立的注册服务去完成。服务启动后,通过一种机制通知注册服务,让其去帮自己完成注册。这个注册服务一定要保证高可用,否则,整个系统的注册服务就无法使用。

3.3、clientv3服务注册

使用Etcd客户端clientv3,操作。通过put方法完成注册。通过get获取对应注册信息,并获取覆盖之前的数据。

文档: clientv3

3.3.1 注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import (
"context"
"fmt"
"go.etcd.io/etcd/clientv3"
"log"
"time"
)

func main() {
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"127.0.0.1:2379"},
DialTimeout: 3 * time.Second,
})
defer cli.Close()
if err != nil {
log.Fatal(err)
}
rsp,err := cli.Put(context.TODO(),"/test/b","something",clientv3.WithPrevKV())
if err != nil {
fmt.Println(err)
}
fmt.Println(rsp)
}

3.3.2 小节

同理,删除可以直接调用Delete方法更多api,可查阅文档。clientv3文档,

这里推荐一个Etcd的UI管理端etcdkeeper

4、服务发现

服务发现就是去注册中心查询指定信息。连接服务,完成对应的请求。

发现模式:

  • 客户端发现
  • 服务端发现

4.1 客户端发现

客户端发现指客户端直接连接注册中心,获取服务信息,自己实现负载均衡,使用一种负载均衡策略发起请求。优势可以定制化发现策略与负载均衡策略,劣势也很明显,每一个客户端都需要实现对应的服务发现和负载均衡。

4.2 服务端发现

使用reverse proxy,在proxy层实现服务发现与负载均衡。后面利用Envoy设置一个反向代理。优势是服务发现与负载均衡对客户端来说完全透明。缺陷是这里需要维护一个高可用的反向代理,并且支持负载均衡。

4.3、clientv3服务发现

获取服务数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func GetInfo()  {
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"127.0.0.1:2379"},
DialTimeout: 3 * time.Second,
})

defer cli.Close()
if err != nil {
log.Fatal(err)
}
//设置OpOption,前缀查询
rsp,err := cli.Get(context.TODO(),"/services/book/",clientv3.WithPrefix())
if err != nil {
fmt.Println(err)
}

for _,kv := range rsp.Kvs{
fmt.Println("key=",string(kv.Key),"|","value=",string(kv.Value))
}
}

5、 服务下线

​ 基本上注册中心都是有健康检查的功能的,应用停止服务后,会自动检测剔除服务,但是不应该依靠健康检测。所以我们应该主动去注销。需要注意不应该直接注销,先将权重修改为0,不会再有服务请求,调用注册中心注销接口,进行注销。

5.1、clientv3服务注销

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func Logout()  {
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"127.0.0.1:2379"},
DialTimeout: 3 * time.Second,
})

defer cli.Close()
if err != nil {
log.Fatal(err)
}

rsp,err := cli.Delete(context.TODO(),"/services/book/127.0.0.1:2379")
if err != nil {
log.Println(err)
}
log.Println(rsp.PrevKvs)
}

6、健康监测

​ 基本上健康检查分为两种,客户端心跳检测,服务端主动检测。

6.1、客户端心跳

  • 维持长链接,定期发送心跳包,心跳可以是TCP,也可以是HTTP的形式。

客户端心跳检测的更偏向于链路是否通,所以服务端的服务台可能会异常。

6.2、服务端检测

  • 注册中心调用服务发布者接口,通过返回结果直接判断健康。
  • 通过脚本定时检测。

服务端检测会比较准确,但是很难做到通用性,毕竟接口不同。而且还需要保证注册中心所在网络与接口发布者所在网络是互通的。

6.3、小结

具体使用哪种方式,可以根据自己的业务场景而定。

6.4、Etcd健康监测

​ 用户可以在etcd中注册服务,并且对注册的服务设置key TTL,定时保持服务的心跳以达到监控健康状态的效果。需要维护一个TTL(V3 使用 lease实现),类似于心跳。

​ 申请lease租约,设置服务生存周期TTL,让key支持自动过期,在服务正常的状态下,通过KeepAlive定期去续租,避免过期。这里还需要考虑一个异常场景,极有可能在Put或者Keepalive时Lease已经过期,所以需要进行容错处理,分配新的Lease进行重试。

官方demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func test()  {
cli, err := clientv3.New(clientv3.Config{
Endpoints: []string{"127.0.0.1:2379"},
DialTimeout: 3 * time.Second,
})
if err != nil {
log.Fatal(err)
}
defer cli.Close()

resp, err := cli.Grant(context.TODO(), 5)
if err != nil {
log.Fatal(err)
}
_, err = cli.Put(context.TODO(), "/services/book/127.0.0.1:8088", "bar", clientv3.WithLease(resp.ID))
if err != nil {
log.Fatal(err)
}
// the key 'foo' will be kept forever
ch, err := cli.KeepAlive(context.TODO(), resp.ID)
if err != nil {
log.Fatal(err)
}
ka := <-ch
fmt.Println("ttl:", ka.TTL)
}

同样也可以使用KeepAliveOnce续租一次,在一个for循环中定时续租。

7、变化通知

7.1 Etcd watch

watch的作用就是在新服务改变(重新注册,下线等)后, 告知各个服务执行相应的逻辑(重新连接新服务,报警等).

比如我们注册一个key,并且设置TTL为10s,10s后key自动删除。

1
2
3
resp, _:= cli.Grant(context.TODO(), 10)
ctx, _:= context.WithTimeout(context.Background(), 10*time.Second)
rsp,err:=cli.Put(ctx,"/services/book/127.0.0.1:8088","127.0.0.1:8088",clientv3.WithLease(resp.ID))

设置watch,/services/book/下发生变化会发出通知。根据Evevt执行对应逻辑,报警,重连,等等。

1
2
3
4
5
6
7
8
9
10
ch := cli.Watch(context.TODO(), "/services/book/", clientv3.WithPrefix())
for {
log.Print("rev")
select {
case c := <-ch:
for _, e := range c.Events {
log.Printf("%+v", e)
}
}
}

8、总结

在微服务中,服务发现与注册至关重要,并且很多设计还需要根据业务来确定。这里也是大量用Etcd举例。Etcd是Kubernetes集群中的一个十分重要的组件,用于保存集群所有的网络配置和对象的状态信息.很多公司在生产环境中都在使用Etcd,如果微服务技术栈都是使用go,很推荐Etcd作为基础组件。

8.1、参考文献

1.etcd-io

2.Etcd:从应用场景到实现原理的全方位解读

3.Etcd 使用小记

4.深入学习Etcd

5.Etcd v3客户端用法

6ZooKeeper vs. Doozer vs. Etcd