# 6 服务注册

# 6.1 术语

  • 注册中心 registry
  • 服务 service
  • 服务元数据 service metadata
  • 服务提供方 provider
  • 服务消费方 consumer
  • 名字 name,标示应用唯一性的名字
  • 地址 ip
  • 端口 port

# 6.2 背景

当我们的服务规模数量比较小的时候,我们通常都会在应用中使用配置文件去调用服务。例如某个consumer需要通过provider获取用户信息。我们可以将这个行为拆解为三个步骤

  • 人工将provider的ip、port信息填入到consumer的配置
  • consumer根据配置里的provider name,获取其ip port
  • 调用provider的ip port的用户方法,拿到用户数据。 img_3.png

但是当集群规模达到一定量级之后,如果仍然采用这种配置方式去寻找服务,会让研发陷入到无尽的配置中,并且这种人工配置是不可靠的,会因为配置错误,导致服务不可访问,影响线上服务的稳定性。

为了减少这种繁琐的人工配置,因此我们需要Consumer能够动态的感知到Provider的地址。如果Provider在启动的时候将自己的名字、ip、port写入到中间件,那么Consumer就可以通过该中间件获取到Provider信息。这种能够提供注册服务信息、获取服务信息的中间件,我们称之为注册中心。缺图。

目前服务注册业界有多种方式,如下所示:

  • Provider通过框架的sdk,将name、ip、port写入到注册中心,consumer访问provider,通过name,获取ip port,例如ego
  • 通过边车模式,拿到Provider的name、ip、port写入到注册中心,consumer访问provider,通过name,获取ip port,例如istio
  • 通过边车模式,将name、ip写入到注册中心,dns关联上ip,consumer访问provider,通过name,获取ip。例如k8s的service

# 6.3 服务注册原理

在微服务架构下,主要有三种角色:服务提供者(RPC Server)、服务消费者(RPC Client)和服务注册中心(Registry),三者的交互关系请看下面这张图。

  • RPC Server 提供服务,在启动时,根据服务的编译和配置信息,向 Registry 注册服务,并向 Registry 定期发送心跳汇报存活状态。
  • RPC Client 调用服务,在启动时,根据配置文件的信息,向 Registry 订阅服务,把 Registry 返回的服务节点列表缓存在本地内存中,并与 RPC Sever 建立连接。
  • 当 RPC Server 节点发生变更时,Registry 会同步变更,RPC Client 感知后会刷新本地内存中缓存的服务节点列表。
  • RPC Client 从本地缓存的服务节点列表中,基于负载均衡算法选择一台 RPC Sever 发起调用。

img_4.png

# 6.4 服务注册概念

我们刚才讲到Consumer能够通过Provider的name获取他的ip port信息,这些信息确定一个服务的基本情况是远远不够的。我们通常会在注册信息写入以下信息:

名称 英文 示例 类型
环境 env dev 环境变量
地区 region beijing 环境变量
可用区 zone zone1 环境变量
地址 ip 192.168.1.1 环境变量
端口 port 8080 环境变量
协议 scheme gRPC 配置变量
权重 weight 100 配置变量
部署组 deployment red 配置变量
框架版本号 frameVersion 2.0 编译变量
应用版本号 appVersion ahkfasgasdf 编译变量
编译时间 buildTime 2020-10-14 10:00:00 编译变量
启动时间 startTime 2020-10-14 11:00:00 编译变量

通过这些信息,我们能够很方便的获得Provider的基本情况,并对他进行改变。例如可以调节流量比例、调度流量区域、灰度版本、排查应用的版本、编译时间、启动时间。

# 6.5 服务注册最小实现

本节我们主要以beego2的实现方式讲解微服务注册。

  • 服务信息
    • key: name
    • value: ip port
  • 服务注册
    • Grant 创建一个租期
    • Put 根据name、写入value
    • Keepalive 与注册中心保持连接
  • 服务注销
    • Delete 删除name

# 6.5.1 注册服务

我们先来看下服务注册的代码,如下所示

func (er *Registry) Register(ctx context.Context, serverName string, addr string, opts ...RegistryOptions) (err error) {
	var upBytes []byte
	info := resolver.Address{
		Addr:       addr,
		ServerName: serverName,
		Attributes: nil,
	}
	
    // 服务信息
	if upBytes, err = json.Marshal(info); err != nil {
		return status.Error(codes.InvalidArgument, err.Error())
	}
	
    // 设置超时
	ctx, cancel := context.WithTimeout(context.TODO(), resolverTimeOut)
	er.cancel = cancel
    
    // 设置选项
	rgOpt := RegistryOption{TTL: DefaultRegInfTTL}
	for _, opt := range opts {
		opt(&rgOpt)
	}
    // 名字
	key := "/" + serverName + "/" + addr
	
    // 创建租期
	lsRsp, err := er.lsCli.Grant(ctx, int64(rgOpt.TTL/time.Second))
	if err != nil {
		return err
	}
	etcdOpts := []clientv3.OpOption{clientv3.WithLease(lsRsp.ID)}
	
    // 写入数据
    _, err = er.cli.KV.Put(ctx, key, string(upBytes), etcdOpts...)
	if err != nil {
		return err
	}
    
    // 设置keepalive    
	lsRspChan, err := er.lsCli.KeepAlive(context.TODO(), lsRsp.ID)
	if err != nil {
		return err
	}
	go func() {
		for {
			_, ok := <-lsRspChan
			if !ok {
				grpclog.Fatalf("%v keepalive channel is closing", key)
				break
			}
		}
	}()
	return nil
}
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

在gRPC的 resolver.Address里定义里三个属性,当注册类型为 resolver.Backend的时候。其解释如下所示:

  • Address 服务地址,用于拨号的地址
  • ServerName 服务名称,用于标示某个业务的服务名称
  • Attributes 元数据信息,例如编译时间、启动时间、机房等信息

通过这三个属性,我们可以定义一个服务的基本情况。然后我们可以根据该服务的名字+ip+port作为唯一key,服务的三个属性json编码后作为value值。写入到注册中心,同时在启动一个goroutine,启动keepalive,定时写入租期。

我们熟悉了框架的代码后,我们再来编写下业务方的注册代码,如下所示

func main() {
	log.SetHeader(`{"time":"${time_rfc3339}","level":"${level}","file":"${short_file}","line":"${line}"}`) 	// 设置日志格式
	log.SetLevel(log.DEBUG) // 全局日志级别
	log.Infof("server start, pid = %d", os.Getpid())

	cc, err := clientv3.New(clientv3.Config{
		Endpoints:        []string{"127.0.0.1:2379"}, // etcd节点ip
		AutoSyncInterval: Duration("60s"), // 自动同步etcd的member节点
		DialTimeout:      Duration("1s"), // 拨号超时时间
	})
	if err != nil {
		panic(err)
	}

	var servOpts []grpc.ServerOption

	app := mygrpc.NewApp(
		mygrpc.WithAddress("127.0.0.1:4000"), // 设置服务Address
		mygrpc.WithRegistry(mygrpc.NewRegisty(cc)),  // 设置服务注册中心
		mygrpc.WithServerName("micro"),          // 设置服务名称
		mygrpc.WithGRPCServOption(servOpts),         // 设置服务属性
	)
	app.Register(pb.RegisterGreeterServer, &Hello{})
	app.Start()
	log.Info("handle end")
}

func Duration(str string) time.Duration {
	dur, err := time.ParseDuration(str)
	if err != nil {
		panic(err)
	}
	return dur
}

type Hello struct{}

// SayHello implements helloworld.GreeterServer
func (s *Hello) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
	log.Infof("receive req : %v", *in)
	return &pb.HelloReply{Message: "Hello " + in.Name}, nil
}
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

在业务注册代码里,我们首先要创建一个注册中心的实例,我们需要设置三个etcd最基本的属性,节点ip、同步etcd的member list的时间和拨号超时时间。然后我们将注册中心的实例、服务Address、服务名称、服务属性 设置到gRPC的服务注册里。启动服务后,可以看到以下启动信息。 img_5.png 我们在访问下etcd里的信息。可以看到key是 /micro/127.0.0.1:4000 ,value是我们刚才写入的三个属性信息。

/micro/127.0.0.1:4000
{"Addr":"127.0.0.1:4000","ServerName":"micro","Attributes":null,"Type":0,"Metadata":null}
1
2
上次更新: 4/1/2021, 10:42:15 PM