背景

最近负责着手把公司跑在阿里云 ECS 上的 Spring Cloud 微服务迁移到 k8s,为保证平滑顺畅,仍需 k8s 中保留 Eureka 体系,直到所有服务都跑在 k8s 后,才会着手考虑去 Eureka。

实施过程可初略分为试水和全面推广两个阶段:

  1. 试水阶段,迁移一些独立的周边服务到 k8s 观察运行情况。选择一部分机器作为 Worker Node 受 k8s 管理,余下部分则不受 k8s 管理。默认情况下,k8s Pod 网络到 ECS 网络为单向畅通,不受管理部分的 ECS 无法通过 ip 直接访问 k8s Pod 网段。
  2. 全面推广阶段,所有主机均受 k8s 管理,k8s 网络到 ECS 网络双通。

针对试水阶段网络单通的情况,可在 k8s 中独立部署一套 Eureka 集群,并将 ECS Eureka 集群单向注册到 k8s Eureka 集群,保证 k8s 实例可以访问 ECS 实例即可。因为试水阶段我们只选择独立的周边服务部署,因此可以容忍 ECS 实例不能访问 k8s 实例。

概要图,省略服务层级和 k8s Service、Pod 等细节

完成试水后,可以将所有 ECS 节点作为 Worker Node 纳入 k8s 管理范围,这时 ECS 网络和 k8s Pod 网络互通,可将 Eureka 切换为双向注册。所有迁移完成之后,下线原 ECS Eureka 集群即可。

双 Eureka 集群的方式不仅可保证迁移平滑,也可实现试水期两边应用的相对隔离。

PS:高端爱折腾的同学可以采用手工或脚本配置 ip table 的方式,保证不受管理部分的 ECS 可以通过 ip 直接访问 k8s Pod 网段。这种方案很早就能开启双向注册,可与全面推广阶段无缝衔接。

下文通过示例讲述 ECS 集群单向注册 k8s 集群的配置设计以及如何解决可能遇到问题。

Eureka 配置设计

首先在 /etc/hosts 文件增加如下 DNS 解析规则

1
2
3
4
127.0.0.1 ecs-peer1
127.0.0.1 ecs-peer2
127.0.0.1 k8s-peer1
127.0.0.1 k8s-peer2

下面的 yaml 配置展示了如何模拟将 ECS Eureka 集群单向注册到 k8s 集群,有几个配置需要说明

  • eureka.server.enable-self-preservation=false,关闭了 k8s Eureka 集群的自我保护功能(原因将在下文说明)
  • eureka.client.fetch-registry=false, 关闭了 ECS Eureka 启动的应用列表获取,如果开启,ECS Eureka 启动时将从 k8s Eureka server 中获取应用列表, k8s 实例会注册到 ECS Eureka,由于网络不通,会导致 ECS 实例访问出错
 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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
---
server:
  port: 8761
spring:
  profiles: k8s-peer1
eureka:
  instance:
    hostname: k8s-peer1
    appname: k8s-eureka
  client:
    serviceUrl:
      k8s-zone: http://k8s-peer1:8761/eureka/,http://k8s-peer2:8762/eureka/
    availability-zones:
      shanghai: k8s-zone
    region: shanghai
  server:
    enable-self-preservation: false
---
server:
  port: 8762
spring:
  profiles: k8s-peer2
eureka:
  instance:
    hostname: k8s-peer2
    appname: k8s-eureka
  client:
    serviceUrl:
      k8s-zone: http://k8s-peer1:8761/eureka/,http://k8s-peer2:8762/eureka/
    availability-zones:
      shanghai: k8s-zone
    region: shanghai
  server:
    enable-self-preservation: false
---
server:
  port: 8763
spring:
  profiles: ecs-peer1
eureka:
  instance:
    hostname: ecs-peer1
    appname: ecs-eureka
  client:
    fetch-registry: false
    serviceUrl:
      ecs-zone: http://ecs-peer1:8763/eureka/,http://ecs-peer2:8764/eureka/
      k8s-zone: http://k8s-peer1:8761/eureka/,http://k8s-peer2:8762/eureka/
    availability-zones:
      shanghai: ecs-zone,k8s-zone
    region: shanghai
---
server:
  port: 8764
spring:
  profiles: ecs-peer2
eureka:
  instance:
    hostname: ecs-peer2
    appname: ecs-eureka
  client:
    fetch-registry: false
    serviceUrl:
      ecs-zone: http://ecs-peer1:8763/eureka/,http://ecs-peer2:8764/eureka/
      k8s-zone: http://k8s-peer1:8761/eureka/,http://k8s-peer2:8762/eureka/
    availability-zones:
      shanghai: ecs-zone,k8s-zone
    region: shanghai

确认你已经在 Eureka server 项目目录,开 4 个控制台依次使用如下命令启动所有 Eureka

1
2
3
4
mvn spring-boot:run -Dspring-boot.run.arguments=--spring.profiles.active=k8s-peer1
mvn spring-boot:run -Dspring-boot.run.arguments=--spring.profiles.active=k8s-peer2
mvn spring-boot:run -Dspring-boot.run.arguments=--spring.profiles.active=ecs-peer1
mvn spring-boot:run -Dspring-boot.run.arguments=--spring.profiles.active=ecs-peer2

访问 k8s-peer1:8761 或者 k8s-peer2:8762,可以看到 Eureka 实例 ecs-peer1:8763 和 ecs-peer2:8764 注册到了 k8s-zone

访问 ecs-peer1:8763 或者 ecs-peer2:8764,k8s-zone 但 Eureka 并未注册到 ecs-zone

再通过 ecs-peer1:8763 注册应用 ecs-to-k8s-app,再通过 k8s-peer1:8761 注册应用 k8s-only-app,发现 ecs-to-k8s-app 注册信息已被同步至 k8s-zone,但 k8s-only-app 注册信息并未被同步至 ecs-zone

k8s-peer1:8761 注册信息

ecs-peer1:8763 注册信息

如进入全面推广阶段,打算切为双向注册,类似地,只需将 k8s Eureka 实例配置改为如下即可

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
port: 8761
spring:
  profiles: k8s-peer1
eureka:
  instance:
    hostname: k8s-peer1
    appname: k8s-eureka
  client:
    serviceUrl:
      ecs-zone: http://ecs-peer1:8763/eureka/,http://ecs-peer2:8764/eureka/
      k8s-zone: http://k8s-peer1:8761/eureka/,http://k8s-peer2:8762/eureka/
    availability-zones:
      shanghai: k8s-zone, ecs-zone
    region: shanghai
  server:
    enable-self-preservation: false

理解 Eureka 集群复制原理

Eureka 会选取 eureka.client.availability-zones 声明的所有节点作为同步节点列表(在上面,我通过这种方式实现了单向注册)。

Eureka 集群复制代码在 package com.netflix.eureka.registry, class PeerAwareInstanceRegistryImpl,以实例注册为例

 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
public void register(final InstanceInfo info, final boolean isReplication) {
    int leaseDuration = Lease.DEFAULT_DURATION_IN_SECS;
    if (info.getLeaseInfo() != null && info.getLeaseInfo().getDurationInSecs() > 0) {
      leaseDuration = info.getLeaseInfo().getDurationInSecs();
    }
    super.register(info, leaseDuration, isReplication);
    replicateToPeers(Action.Register, info.getAppName(), info.getId(), info, null, isReplication);
}

private void replicateToPeers(Action action, String appName, String id,
                                  InstanceInfo info /* optional */,
                                  InstanceStatus newStatus /* optional */, boolean isReplication) {
    Stopwatch tracer = action.getTimer().start();
    try {
        if (isReplication) {
            numberOfReplicationsLastMin.increment();
        }
        // If it is a replication already, do not replicate again as this will create a poison replication
        // 如果是其他节点信息同步,处理结束
        if (peerEurekaNodes == Collections.EMPTY_LIST || isReplication) {
            return;
        }
        // 如果是应用信息更新,同步注册信息至其他节点
        for (final PeerEurekaNode node : peerEurekaNodes.getPeerEurekaNodes()) {
            // If the url represents this host, do not replicate to yourself.
            if (peerEurekaNodes.isThisMyUrl(node.getServiceUrl())) {
                continue;
            }
            replicateInstanceActionsToPeers(action, appName, id, info, newStatus, node);
        }
    } finally {
        tracer.stop();
    }
}

Eureka server 实例通过 register 同时处理应用注册 (isReplication=false) 和 peer 注册同步 (isReplication=true)。如果是应用注册,在本地注册成功后,PeerAwareInstanceRegistryImpl 会将注册信息同步到集群其他节点;如果是其他节点的注册同步,那么在本地注册成功之后,即结束流程。

这样设计的考量是:如果允许 replication 信息反复传递,那么只要任意注册一个应用,稍过一段时间,所有 Eureka 节点的网卡都会被打爆。

因此,在跨集群复制中,不要试图将多个节点放置在同一域名,再通过统一域名传递注册信息,务必在配置中逐个声明所有需要同步的节点

在上图中,app 信息更新到 ecs-zone 的 eureka-1 中,随之会被复制到 ecs-zone 的 eureka-2 和 k8s-zone 的 eureka-1,但不会被复制到 k8s-zone 的 eureka-2,最终会导致 k8s-zone 中的某个 Eureka 实例缺少大量注册应用。

同理,其他操作,如续租 (renew), 下线 (cancel) 都是类似的。值得一提的是,Eureka 的设计思想是 AP,所以过期服务淘汰(evict) 并不会同步至其他节点。

Eureka cluster in k8s

任意 Eureka 节点均需知道其他节点的地址,并通过固定地址维续通信,于是 Eureka 集群在 k8s 中的适宜部署为 StatefuleSet。在 StatefulSet yaml 中,只需要将固定 IP 替换称固定域名即可,这里省去该部分配置。

值得注意的是,这里使用了多个 Service 暴露底层的 Eureka 实例,原因都写在注释中,StatefulSet 需要根据 Service 声明生成节点域名,如果 StatefulSet 名为 eureka,那么节点固定域名就是 eureka-0.eureka-svc, eureka-1.eureka-svc,…, eureka-n.eureka-svc。

上文提到,Eureka 节点之间的信息同步不能统一域名负载,因此不能简单在 eureka-svc 上层放一个 Ingress 暴露给 ECS Eureka 注册,而是需要通过单独的 Service 逐个挑选 Eureka 实例(结合 StatefulSet label),再通过 NodePort 或者 Ingress 逐个暴露。

 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
---
# headless Service, 绑定到 StatefulSet 使用
apiVersion: v1
kind: Service
metadata:
  name: eureka-svc
spec:
  clusterIP: None
  ports:
    - port: 8761
      targetPort: 8761
  selector:
    app: eureka
---
# 与 Pod 一一对应,供 ECS eureka 注册
apiVersion: v1
kind: Service
metadata:
  name: eureka-0-svc
spec:
  type: NodePort
  ports:
    - nodePort: 30761
      port: 8761
      targetPort: 8761
      protocol: TCP
  selector:
    app: eureka
    "statefulset.kubernetes.io/pod-name": eureka-0
---
# 与 Pod 一一对应,供 ECS eureka 注册
apiVersion: v1
kind: Service
metadata:
  name: eureka-1-svc
spec:
  type: NodePort
  ports:
    - nodePort: 30762
      port: 8761
      targetPort: 8761
      protocol: TCP
  selector:
    app: eureka
    "statefulset.kubernetes.io/pod-name": eureka-1

看到这里,你应该已经明白如何实现 k8s Eureka 与外部 Eureka 信息同步了。

为什么关闭 Eureka 自我保护

Eureka 自我保护 (eureka-self-preservation-renewal) 功能,旨在出现 network partition 时,禁用 evict 机制,不再淘汰服务实例,这里有很好的介绍文章

默认情况下,如果 Eureka 没收到超过 85% 实例的续租信息,自我保护就会开启。

迁移早期,k8s Eureka 集群中的大部分实例均来自 ECS 集群。但凡发生点网络抖动,或者 ECS Eureka 重启,就会触发 k8s Eureka 自我保护。结果就是 k8s Eureka 存在大量过期实例,一致性特差,服务调用失败频发。因此笔者在实践中关闭了自我保护。