Skip to content

13案例:如何基于Conul给微服务添加服务注册与发现?

今天我和你分享的是如何基于 Consul 给微服务添加服务注册与发现的案例。

微服务架构按业务划分微服务的特点,使得原本聚合了大量业务模块的单体应用被划分为众多的微服务。而大量微服务的出现,势必会带来运维管理上的巨大挑战,于是服务注册与发现这类自动化策略应运而生。但是引入服务注册与发现就可能引入额外技术栈,增加系统总体的复杂性,比如会引入中心化的服务注册与发现中心这类基础组件。

在本课时,我们将基于 Consul 给微服务添加服务注册与发现的能力。首先,我们会基于 Kubernetes 搭建一个接近生产环境的 Consul 集群;接着再基于搭建好的 Consul 集群,为我们的微服务添加服务注册与发现的基本能力。

Consul 集群

在上一课时,我们分别介绍了 Consul、ZooKeeper 和 Etcd 这 3 种服务注册与发现组件,考虑到 Consul 开箱即用、专注于服务注册与发现的特点,我们就选取 Consul 作为后续实践使用的服务注册与发现中心组件。

Consul 集群中存在 ServerClient 两种角色节点,Server 中保存了整个集群的数据,而 Client 负责对本地的服务进行健康检查和转发请求到 Server 中,并且也保存有注册到本节点的服务实例数据。

关于 Server 节点,一般建议你部署 3 个或者 5 个节点,但并不是越多越好,因为这会增加数据同步的成本。Server 节点之间存在一个 Leader 和多个 Follower,通过 Raft 协议维护 Server 之间数据的强一致性。一个典型的 Consul 集群和微服务的部署方式如下:

Consul 集群和微服务部署图

在上述图片的 Consul 集群中,存在 3 个 Server 节点,它们分别部署在不同的服务节点上。我们假设 Node2 节点中的 Server 被选举为 Leader,那么其他节点上的 Server 则需要与其同步数据

在Node4、Node5 和 Node6 节点中分别部署了 Consul Client,各节点中的各个微服务通过它进行服务注册。Consul Client 会将注册信息通过 RPC 调用转发给 Consul Server,这些服务实例元数据会保存到 Server 中的各个节点,并通过 Raft 协议保证数据的强一致性;同时 Consul Client 会对注册到自身的微服务进行健康检查,并将检查到的服务状态同步到 Server 中。

其实服务实例也可以直接注册到 Consul Server,但是每个节点的注册服务实例数量存在上限,因为节点还负责定时对服务实例进行健康检查,因此在服务实例数量较多的时候,建议使用 Consul Client 分担 Consul Server 的处理工作。

当微服务间要发起远程调用时,比如位于 Node5 节点的 Service C 想要调用 Service B 服务,它将首先向节点上的 Consul Client 根据服务名请求 Service B 的服务实例信息列表,Consul Client 会把请求转发到 Consul Server 中,查询 Service B 的服务实例信息列表返回。接着 Service C 根据一定的负载均衡策略,从中选择合适的 Service B 的实例 IP 和端口发起远程调用。

接下来我们就基于 Kubernetes 搭建一个接近生产环境的 Consul 集群,包含 3 个 Server 节点和 1 个 Client 节点。考虑到 Pod 的意外重启会导致 Consul Server IP 的变化,我们首先为 Consul Server 声明一个 Service,consul-server-service.yaml 定义如下:

yaml
apiVersion: v1 
kind: Service 
metadata: 
  name: consul-server 
  labels: 
    name: consul-server 
spec: 
  selector: 
    name: consul-server 
  ports: 
    - name: http 
      port: 8500 
      targetPort: 8500 
    - name: https 
      port: 8443 
      targetPort: 8443 
    - name: rpc 
      port: 8400 
      targetPort: 8400 
    .... // 其他暴露的端口

Consul Server 对外暴露了诸多接口,包括响应 HTTP 和 HTTPS 请求的 8500 和 8433 端口等,Service 方式使得这些端口在集群内可通过 ClusterIP:Port 的方式访问。

当 Consul Server 所在的 Pod 重启后,新启动的 Consul Server 需要重新加入集群中,这就需要知道 Leader 节点 IP。对此,我们可以使用 Kubernetes 提供的 DNS 功能访问不同 Pod 的 Consul Server,并通过 StatefulSets Controller 管理 Consul Server,使得每个 Consul Server Pod 有固定的标识用于 DNS 解析。通过这样的方式能够使得 Consul Server 集群可以自动处理 Leader 选举和新节点加入的问题,充分利用 Kubernetes 的自动伸缩和调度能力。consul-server.yaml 配置如下:

yaml
apiVersion: apps/v1 
kind: StatefulSet 
metadata: 
  name: consul-server 
  labels: 
    name: consul-server 
spec: 
  serviceName: consul-server 
  selector: 
    matchLabels: 
      name: consul-server 
  replicas: 3 
  template: 
    metadata: 
      labels: 
        name: consul-server 
    spec: 
      terminationGracePeriodSeconds: 10 
      containers: 
        - name: consul 
          image: consul:latest 
          imagePullPolicy: IfNotPresent 
          args: 
            - "agent" 
            - "-server" 
            - "-bootstrap-expect=3" 
            - "-ui" 
            - "-data-dir=/consul/data" 
            - "-bind=0.0.0.0" 
            - "-client=0.0.0.0" 
            - "-advertise=$(POD_IP)" 
            - "-retry-join=consul-server-0.consul-server.$(NAMESPACE).svc.cluster.local" 
            - "-retry-join=consul-server-1.consul-server.$(NAMESPACE).svc.cluster.local" 
            - "-retry-join=consul-server-2.consul-server.$(NAMESPACE).svc.cluster.local" 
            - "-domain=cluster.local" 
            - "-disable-host-node-id" 
          env: 
            - name: POD_IP 
              valueFrom: 
                fieldRef: 
                  fieldPath: status.podIP 
            - name: NAMESPACE 
              valueFrom: 
                fieldRef: 
                  fieldPath: metadata.namespace 
          ports: 
            - containerPort: 8500 
              name: http 
            - containerPort: 8400 
              name: rpc 
            - containerPort: 8443 
              name: https-port 
           // ...其他端口

上述配置中指定了 Controller 为 StatefulSet,这使得被管理的 Pod 具备固定的命名规则,可用于 DNS 解析,它们的 Pod 名称分别为 consul-server-0、consul-server-1 和 consul-server-2。配置还通过 -retry-join 选项让新加入的节点逐一尝试加入每一个 Consul Server,直到发现真正的 Leader 节点并加入 Consul Server 集群中。

为了方便在 Kubernetes 集群外访问 Consul UI,可以通过 NodePort 暴露 Consul Server 的 8500 端口,如下 consul-server-http.yaml 配置所示:

yaml
apiVersion: v1 
kind: Service 
metadata: 
  name: consul-server-http 
spec: 
  selector: 
    name: consul-server 
  type: NodePort 
  ports: 
    - protocol: TCP 
      port: 8500 
      targetPort: 8500 
      nodePort: 30098 
      name: consul-server-tcp

依次通过 kubectl apply -f {yaml} 启动上述 3 个 yaml 配置后,即可在集群外通过 30098 端口访问 Consul UI,结果图如下:

Consul Server 部署效果图


从上图可以看到目前 Consul 集群中有 3 个 Consul Server,带小星星的 consul-server-0 为 Leader 节点。在前面介绍 Consul 集群的部署图时,为方便 Consul Client 对服务节点上的微服务进行管理,建议在每一个服务节点上部署 Consul Client,对此我们可以通过 DaemonSet Controller 的方式部署 Consul Client。

DaemonSet Controller 能够确保在集群所有的 Node 中或者指定的 Node 中都运行一个副本 Pod。consul-client.yaml 的配置如下:

yaml
apiVersion: apps/v1 
kind: DaemonSet 
metadata: 
 name: consul-client 
 labels: 
  name: consul-client 
spec: 
  selector: 
    matchLabels: 
      name: consul-client 
  template: 
    metadata: 
      labels: 
        name: consul-client 
    spec: 
      volumes: 
        - name: consul-data-dir 
          hostPath: 
            path: /data/consul/data 
            type: DirectoryOrCreate 
      containers: 
        - name: consul 
          image: consul:latest 
          imagePullPolicy: IfNotPresent 
          args: 
            - "agent" 
            - "-data-dir=/consul/data" 
            - "-bind=0.0.0.0" 
            - "-client=0.0.0.0" 
            - "-advertise=$(POD_IP)" 
            - "-retry-join=consul-server-0.consul-server.$(NAMESPACE).svc.cluster.local" 
            - "-retry-join=consul-server-1.consul-server.$(NAMESPACE).svc.cluster.local" 
            - "-retry-join=consul-server-2.consul-server.$(NAMESPACE).svc.cluster.local" 
            - "-domain=cluster.local" 
            - "-disable-host-node-id" 
          env: 
            - name: POD_IP 
              valueFrom: 
                fieldRef: 
                  fieldPath: status.podIP 
            - name: NAMESPACE 
              valueFrom: 
                fieldRef: 
                  fieldPath: metadata.namespace 
          lifecycle: 
            postStart: 
              exec: 
                command: 
                  - /bin/sh 
                  - -c 
                  - consul reload 
            preStop: 
              exec: 
                command: 
                  - /bin/sh 
                  - -c 
                  - consul leave 
          volumeMounts: 
            - name: consul-data-dir 
              mountPath: /consul/data 
          ports: 
            - containerPort: 8500 
              hostPort: 8500 
              name: http 
            - containerPort: 8400 
              name: rpc 
            - containerPort: 8443 
              name: https 
            // ... 其他端口

在上述配置中,我们指定 Controller 为 DaemonSet,并修改 Consul 的启动命令,去除 -server 等选项,使得 Consul 以 Client 的角色启动并加入集群中。除此之外,还通过 volumes 配置将 Consul Client 的数据目录 /consul/data 挂载到 Node 节点上,使得意外宕机的 Consul Client 重启时能够复用相同的 node-id 等元数据,避免导致 Consul 中出现同一个 IP 对应不同主机名的服务注册错误的情况。

运行上述的 yaml 配置文件后,就能在 Consul UI 中发现 Consul 集群中出现了 Consul Client。(由于这里我们搭建的 Kubernetes 集群只有一个 Node 节点,因此 Consul 集群中仅有一个 Consul Client。)

Consul Client 出现在集群




注册服务到 Consul

Consul 提供 HTTPDNS 两种方式访问服务注册与发现接口,我们接下来的实践主要是基于 HTTP API 进行的。

由于我们是通过 Consul Client 进行服务注册与发现,所以接下来我们会首先介绍 Consul Client 中提供的用于服务注册、服务注销和服务发现的 HTTP API,如下所示:

java
/v1/agent/service/register // 服务注册接口 
/v1/agent/service/deregister/${instanceId} // 服务注销接口 
/v1/health/service/${serviceName} // 服务发现接口

服务注册接口 用于服务启动成功后,服务实例将自身所属的服务名和服务元数据,包括服务实例 ID、服务 IP、服务端口等提交到 Consul Client 中完成服务注册。当服务关闭时,为了避免无效的请求,服务实例会调用服务注销接口 主动将自身服务实例数据从 Consul 中移除。服务发现接口用于在发起远程调用时根据服务名获取该服务可用的服务实例信息列表,然后调用方就可以使用一定的负载均衡策略选择某个服务实例发起远程调用,该接口会把查询请求转发到 Consul Server 处理。另外,还存在 /v1/agent/health/service/ 接口用于获取注册到本地 Consul Client 的可用服务实例信息列表。

需要提交到 Consul 的服务实例信息主要有以下这些:

go
// 服务实例信息结构体 
type InstanceInfo struct { 
	ID         string                     `json:"ID"`                // 服务实例ID 
	Service           string                     `json:"Service,omitempty"` // 服务发现时返回的服务名 
	Name              string                     `json:"Name"`              // 服务名 
	Tags              []string                   `json:"Tags,omitempty"`    // 标签,可用于进行服务过滤 
	Address           string                     `json:"Address"`           // 服务实例HOST 
	Port              int                        `json:"Port"`              // 服务实例端口 
	Meta              map[string]string          `json:"Meta,omitempty"`    // 元数据 
	EnableTagOverride bool                       `json:"EnableTagOverride"` // 是否允许标签覆盖 
	Check             `json:"Check,omitempty"`   // 健康检查相关配置 
	Weights           `json:"Weights,omitempty"` // 权重 
} 
type Check struct { 
	DeregisterCriticalServiceAfter string   `json:"DeregisterCriticalServiceAfter"` // 多久之后注销服务 
	Args                           []string `json:"Args,omitempty"`                 // 请求参数 
	HTTP                           string   `json:"HTTP"`                           // 健康检查地址 
	Interval                       string   `json:"Interval,omitempty"`             // Consul 被动回调检查间隔 
	TTL                            string   `json:"TTL,omitempty"`                  // 服务实例主动维持心跳间隔,与Interval只存其一 
}

这其中,ID 用来唯一标识服务实例,Name 代表服务实例归属的服务集群,Address、Port 表示服务的 IP 地址和监听端口,Check 用于指定健康检查的配置,Consul 中提供主动上报和被动回调两种方式维持心跳。

接下来我们以服务注册为例,演示服务实例如何注册自身数据到 Consul,discovery_client.go 中服务注册的代码如下所示:

go
func (consulClient *DiscoveryClient) Register(ctx context.Context, serviceName, instanceId, healthCheckUrl string, instanceHost string, instancePort int, meta map[string]string, weights *Weights) error { 
	instanceInfo := &InstanceInfo{ 
		ID:                instanceId, 
		Name:              serviceName, 
		Address:           instanceHost, 
		Port:              instancePort, 
		Meta:              meta, 
		EnableTagOverride: false, 
		Check: Check{ 
			DeregisterCriticalServiceAfter: "30s", 
			HTTP:                           "http://" + instanceHost + ":" + strconv.Itoa(instancePort) + healthCheckUrl, 
			Interval:                       "15s", 
		}, 
	} 
	if weights != nil{ 
		instanceInfo.Weights = *weights 
	}else { 
		instanceInfo.Weights = Weights{ 
			Passing: 10, 
			Warning: 1, 
		} 
	} 
	byteData, err := json.Marshal(instanceInfo) 
	if err != nil{ 
		log.Printf("json format err: %s", err) 
		return err 
	} 
	req, err := http.NewRequest("PUT", 
		"http://"+consulClient.host+":"+strconv.Itoa(consulClient.port)+"/v1/agent/service/register", 
		bytes.NewReader(byteData)) 
	if err != nil{ 
		return err 
	} 
	req.Header.Set("Content-Type", "application/json;charset=UTF-8") 
	client := http.Client{} 
	client.Timeout = time.Second * 2 
	resp, err := client.Do(req) 
	// 检查 HTTP 请求是否发送成功,判断是否注册成功 
	.... 
}

上述代码的关键在于将服务实例信息封装为 InstanceInfo,并通过 HTTP 请求访问 Consul Client 的 /v1/agent/service/register 接口,将服务实例信息提交 Consul 中。我们在 InstanceInfo 中设定了服务实例 ID、服务名、服务地址、服务端口等关键数据,并指定了健康检查的地址用于与 Consul 维持心跳。类似的,服务发现和服务注销的实现也是通过调用上面介绍的 HTTP API 完成,在此就不演示代码了。

微服务需要在准备就绪后,向服务注册与发现中心发起服务注册,并在服务关闭前及时从服务注册与发现中心注销自身服务实例信息,这些我们都可以在 main 函数中添加对应的行为,代码如下所示:

go
	// ... 通过命令行参数或者环境变量获取服务配置信息 
	flag.Parse() 
	client := discovery.NewDiscoveryClient(*consulAddr, *consulPort) 
	//... 省略 注册 endpoint,构建 transport 
	 
	go func() { 
		errChan <- http.ListenAndServe(":" + strconv.Itoa(*servicePort), handler) 
	}() 
	go func() { 
		// 监控系统信号,等待 ctrl + c 系统信号通知服务关闭 
		c := make(chan os.Signal, 1) 
		signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) 
		errChan <- fmt.Errorf("%s", <-c) 
	}() 
	instanceId := *serviceName + "-" + uuid.New().String() 
	err := client.Register(context.Background(), *serviceName, instanceId, "/health", *serviceAddr, *servicePort, nil, nil) 
	if err != nil{ 
		log.Printf("register service err : %s", err) 
		os.Exit(-1) 
	} 
	err = <-errChan 
	log.Printf("listen err : %s", err) 
	client.Deregister(context.Background(), instanceId)

在 main 函数中启动 http.ListenAndServe 监听对应的服务端口时,我们同时调用了 DiscoveryClient.Register 方法向 Consul 发起服务注册,并采用监控系统信号的方式,在服务关闭时调用 DiscoveryClient.Deregister 进行服务注销,以避免无效的请求发送到已关闭的服务实例。

为了保证 Consul Client 主动进行健康检查成功,我们还需要在 transport 层中定义 /health 接口用于响应 Consul Client 的调用,代码如下所示:

go
	r.Methods("GET").Path("/health").Handler(kithttp.NewServer( 
		endpoints.HealthCheckEndpoint, 
		decodeHealthCheckRequest, 
		encodeJSONResponse, 
		options..., 
	))

在 Kubernetes 部署微服务实例时,可以通过获取服务实例所在的 Pod IP 作为服务实例 IP 提交到 Consul,使用 Kubernetes 的 valueFrom 即可获取服务实例所在 Pod 的相关信息。部署 register 微服务的 Kubernetes 简单配置如下所示:

yaml
apiVersion: v1 
kind: Pod 
metadata: 
  name: register 
  labels: 
    name: register 
spec: 
  containers: 
    - name: register 
      image: register 
      ports: 
        - containerPort: 12312 
      imagePullPolicy: IfNotPresent 
      env: 
        - name: consulAddr 
          valueFrom: 
            fieldRef: 
              fieldPath: spec.nodeName 
        - name: serviceAddr 
          valueFrom: 
            fieldRef: 
              fieldPath: status.podIP

由于 Consul Client 部署在每一个 Node 节点中,我们可以直接获取 spec.nodeName(即 Pod 所在 Node 节点的主机名)作为 Consul Client 的地址传递给 Go 微服务,而 Go 微服务的 IP 地址即其所在 Pod 的 IP。在 Kubernetes 中启动该配置后即可在 Consul UI 中查看到该服务实例注册到 Consul 中,如图所示:

register 服务注册到 Consul


进入到 register 服务所在的 Pod,通过 curl 访问 /discovery/name?serviceName={serviceName} 即可根据服务名获取注册到 Consul 中的服务实例信息列表。

小结

在微服务架构中,服务注册与发现能够管理集群中大量动态变化的服务实例,有效提高服务治理的效率。

本课时我们主要介绍了如何结合 Consul 给 Go 微服务整合服务注册与发现能力。首先。我们借助 Kubernetes 搭建了具备 3 个 Consul Server 的 Consul 集群;接着,我们又基于 Consul Client 提供的 HTTP API 完成了 Go 微服务与 Consul 的服务注册与发现。

希望通过本课时能够加深你对 Consul 的理解,并掌握如何为 Go 微服务添加服务注册与发现能力,在下一课时我们将介绍更多关于 Go 微服务服务注册与发现的实践,比如基于 Service Mesh 进行服务注册与发现等。

最后,关于 Consul 和 Go 微服务的服务注册与发现,你有什么其他的见解?欢迎在评论区与我分享。