微服务架构按业务划分微服务的特点,使得原本聚合了大量业务模块的单体应用被划分为众多的微服务。而大量微服务的出现,势必会带来运维管理上的巨大挑战,于是服务注册与发现这类自动化策略应运而生。但是引入服务注册与发现就可能引入额外技术栈,增加系统总体的复杂性,比如会引入中心化的服务注册与发现中心这类基础组件。
在本课时,我们将基于 Consul 给微服务添加服务注册与发现的能力。首先,我们会基于 Kubernetes 搭建一个接近生产环境的 Consul 集群;接着再基于搭建好的 Consul 集群,为我们的微服务添加服务注册与发现的基本能力。
在上一课时,我们分别介绍了 Consul、ZooKeeper 和 Etcd 这 3 种服务注册与发现组件,考虑到 Consul 开箱即用、专注于服务注册与发现的特点,我们就选取 Consul 作为后续实践使用的服务注册与发现中心组件。
Consul 集群中存在 Server 和 Client 两种角色节点,Server 中保存了整个集群的数据,而 Client 负责对本地的服务进行健康检查和转发请求到 Server 中,并且也保存有注册到本节点的服务实例数据。
关于 Server 节点,一般建议你部署 3 个或者 5 个节点,但并不是越多越好,因为这会增加数据同步的成本。Server 节点之间存在一个 Leader 和多个 Follower,通过 Raft 协议维护 Server 之间数据的强一致性。一个典型的 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 定义如下:
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 配置如下:
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 配置所示:
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 集群中有 3 个 Consul Server,带小星星的 consul-server-0 为 Leader 节点。在前面介绍 Consul 集群的部署图时,为方便 Consul Client 对服务节点上的微服务进行管理,建议在每一个服务节点上部署 Consul Client,对此我们可以通过 DaemonSet Controller 的方式部署 Consul Client。
DaemonSet Controller 能够确保在集群所有的 Node 中或者指定的 Node 中都运行一个副本 Pod。consul-client.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 提供 HTTP 和 DNS 两种方式访问服务注册与发现接口,我们接下来的实践主要是基于 HTTP API 进行的。
由于我们是通过 Consul Client 进行服务注册与发现,所以接下来我们会首先介绍 Consul Client 中提供的用于服务注册、服务注销和服务发现的 HTTP API,如下所示:
/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 的服务实例信息主要有以下这些:
// 服务实例信息结构体
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 中服务注册的代码如下所示:
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 函数中添加对应的行为,代码如下所示:
// ... 通过命令行参数或者环境变量获取服务配置信息
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 的调用,代码如下所示:
r.Methods("GET").Path("/health").Handler(kithttp.NewServer(
endpoints.HealthCheckEndpoint,
decodeHealthCheckRequest,
encodeJSONResponse,
options...,
))
在 Kubernetes 部署微服务实例时,可以通过获取服务实例所在的 Pod IP 作为服务实例 IP 提交到 Consul,使用 Kubernetes 的 valueFrom 即可获取服务实例所在 Pod 的相关信息。部署 register 微服务的 Kubernetes 简单配置如下所示:
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 服务所在的 Pod,通过 curl 访问 /discovery/name?serviceName={serviceName} 即可根据服务名获取注册到 Consul 中的服务实例信息列表。