掌门教育微服务体系 Solar | 阿里巴巴 Nacos 企业级落地中篇

头图.png

联席作者:吴毅挺 任浩军 童子龙
郑重鸣谢:Nacos – 彦林,Spring Cloud Alibaba – 小马哥、洛夜,Nacos 社区 – 张龙(pader)、春少(chuntaojun)

相关文章推荐:

前言

在高速发展的时候,公司规模越来越大,老师人数越来越多,这时候公司不能铺太多人去做运营与服务,必须提高每个人效,这就需要技术驱动。因此掌门教育转变成一家技术驱动型的公司,如果被迫成为一家靠资金驱动的公司就活不下去了。

— 张翼(掌门教育创始人兼 CEO)

掌门教育自 2014 年正式转型在线教育以来,秉承“让教育共享智能,让学习高效快乐”的宗旨和愿景,经历云计算、大数据、人工智能、 AR / VR / MR 以及现今最火的 5G ,一直坚持用科技赋能教育。掌门教育的业务近几年得到了快速发展,特别是今年的疫情,使在线教育成为了新的风口,也给掌门教育新的机遇。

随着业务规模进一步扩大,流量进一步暴增,微服务数目进一步增长,使老的微服务体系所采用的注册中心 Eureka 不堪重负,同时 Spring Cloud 体系已经演进到第二代,第一代的 Eureka 注册中心已经不大适合现在的业务逻辑和规模,同时它目前被 Spring Cloud 官方置于维护模式,将不再向前发展。如何选择一个更为优秀和适用的注册中心,这个课题就摆在了掌门人的面前。经过对 Alibaba Nacos 、HashiCorp Consul等开源注册中心做了深入的调研和比较,最终选定 Alibaba Nacos 做微服务体系 Solar 中的新注册中心。

背景故事

两次 Eureka 引起业务服务大面积崩溃后,虽然通过升级硬件和优化配置参数的方式得以解决,Eureka 服务器目前运行平稳,但我们依旧担心此类事故在未来会再次发生,最终选择落地 Alibaba Nacos 作为掌门教育的新注册中心。

Nacos 开发篇

Nacos Eureka Sync 方案演进

① Sync 官方方案

经过研究,我们采取了官方的 Nacos Eureka Sync 方案,在小范围试用了一下,效果良好,但一部署到 FAT 环境后,发现根本不行,一台同步服务器无法抗住将近 660 个服务(非实例数)的频繁心跳,同时该方案不具备高可用特点。

② Sync 高可用一致性 Hash + Zookeeper 方案

既然一台不行,那么就多几台,但如何做高可用呢?

我们率先想到的是一致性 Hash 方式。当一台或者几台同步服务器挂掉后,采用 Zookeeper 临时节点的 Watch 机制监听同步服务器挂掉情况,通知剩余同步服务器执行 reHash ,挂掉服务的工作由剩余的同步服务器来承担。通过一致性 Hash 实现被同步的业务服务列表的平均分配,基于对业务服务名的二进制转换作为 Hash 的 Key 实现一致性 Hash 的算法。我们自研了这套算法,发现平均分配的很不理想,第一时间怀疑是否算法有问题,于是找来 Kafka 自带的算法(见 Utils.murmur2 ),发现效果依旧不理想,原因还是业务服务名的本身分布就是不平均的,于是又回到自研算法上进行了优化,基本达到预期,下文会具体讲到。但说实话,直到现在依旧无法做到非常良好的绝对平均。

③ Sync 高可用主备 + Zookeeper 方案

这个方案是个小插曲,当一台同步服务器挂掉后,由它的“备”顶上,当然主备切换也是基于 Zookeeper 临时节点的 Watch 机制来实现的。后面讨论下来,主备方案,机器的成本很高,实现也不如一致性 Hash 优雅,最后没采用。

④ Sync 高可用一致性 Hash + Etcd 方案

折腾了这么几次后,发现同步业务服务列表是持久化在数据库,同步服务器挂掉后 reHash 通知机制是由 Zookeeper 来负责,两者能否可以合并到一个中间件上以降低成本?于是我们想到了 Etcd 方案,即通过它实现同步业务服务列表持久化 + 业务服务列表增减的通知 + 同步服务器挂掉后 reHash 通知。至此方案最终确定,即两个注册中心( Eureka 和 Nacos )的双向同步方案,通过第三个注册中心( Etcd )来做桥梁。

⑤ Sync 业务服务名列表定时更新优化方案

解决了一致性 Hash 的问题后,还有一个潜在风险,即官方方案每次定时同步业务服务的时候,都会去读取全量业务服务名列表,对于业务服务数较少的场景应该没问题,但对于我们这种场景下,这么频繁的全量去拉业务服务列表,会不会对 Nacos 服务器的性能有所冲击呢?接下去我们对此做了优化,取消全量定时读取业务服务名列表,通过 DevOps 的发布系统平台实施判断,如果是迁移过来的业务服务或者新上 Nacos 的业务服务,由发布平台统一调用 Nacos 接口来增加新的待同步业务服务 Job,当该业务服务全部迁移完毕后,在官方同步界面上删除该同步业务服务 Job 即可。

⑥ Sync 服务器两次扩容

方案实现后,上了 FAT 环境上后没发现问题(此环境,很多业务服务只部署一个实例),而在 PROD 环境上发现存在双向同步丢心跳的问题,原因是同步服务器来不及执行排队的心跳线程,导致 Nacos 服务器无法及时收到心跳而把业务服务踢下来。我们从 8 台 4C8G 同步服务器扩容到 12 台,情况好了很多,但观察下来,还是存在一天内存在一些业务服务丢失心跳的情况,于是我们再次从 12 台 4C8G 同步服务器扩容到 20 台,情况得到了大幅改善,但依旧存在某个同步服务器上个位数丢失心跳的情况,观察下来,那台同步服务器承受的某几个业务服务的实例数特别多的情况,我们在那台同步服务器调整了最大同步线程数,该问题得到了修复。我们将继续观察,如果该问题仍旧复现,不排除升级机器配置到 8C16G 来确保 PROD 环境的绝对安全。

至此,经过 2 个月左右的努力付出,Eureka 和 Nacos 同步运行稳定, PROD 环境上同步将近 660 个服务(非实例数),情况良好。

非常重要的提醒:一致性 Hash 的虚拟节点数,在所有的 Nacos Sync Server 上必须保持一致,否则会导致一部分业务服务同步的时候会被遗漏。

Nacos Eureka Sync 落地实践

① Nacos Eureka Sync 目标原则

  • 注册中心迁移目标
    • 过程并非一蹴而就的,业务服务逐步迁移的过程要保证线上调用不受影响,例如, A 业务服务注册到 Eureka 上, B 业务服务迁移到 Nacos ,A 业务服务和 B 业务服务的互相调用必须正常;
    • 过程必须保证双注册中心都存在这两个业务服务,并且目标注册中心的业务服务实例必须与源注册中心的业务服务实例数目和状态保持实时严格一致。
  • 注册中心迁移原则
    • 一个业务服务只能往一个注册中心注册,不能同时双向注册;
    • 一个业务服务无论注册到 Eureka 或者 Nacos,最终结果都是等效的;
    • 一个业务服务在绝大多数情况下,一般只存在一个同步任务,如果是注册到 Eureka 的业务服务需要同步到 Nacos ,那就有一个 Eureka -> Nacos 的同步任务,反之亦然。在平滑迁移中,一个业务服务一部分实例在 Eureka 上,另一部分实例在 Nacos 上,那么会产生两个双向同步的任务;
    • 一个业务服务的同步方向,是根据业务服务实例元数据( Metadata )的标记 syncSource 来决定。

② Nacos Eureka Sync 问题痛点

  • Nacos Eureka Sync 同步节点需要代理业务服务实例和 Nacos Server 间的心跳上报。Nacos Eureka Sync 将心跳上报请求放入队列,以固定线程消费,一个同步业务服务节点处理的服务实例数超过一定的阈值会造成业务服务实例的心跳发送不及时,从而造成业务服务实例的意外丢失。
  • Nacos Eureka Sync 节点宕机,上面处理的心跳任务会全部丢失,会造成线上调用大面积失败,后果不堪设想。
  • Nacos Eureka Sync 已经开始工作的时候,从 Eureka 或者 Nacos 上,新上线或者下线一个业务服务(非实例),都需要让 Nacos Eureka Sync 实时感知。

③ Nacos Eureka Sync 架构思想

1.png

  • 从各个注册中心获取业务服务列表,初始化业务服务同步任务列表,并持久化到 Etcd 集群中;
  • 后续迁移过程增量业务服务通过 API 接口持久化到 Etcd 集群中,业务服务迁移过程整合 DevOps 发布平台。整个迁移过程全自动化,规避人为操作造成的遗漏;
  • 同步服务订阅 Etcd 集群获取任务列表,并监听同步集群的节点状态;
  • 同步服务根据存活节点的一致性 Hash 算法,找到处理任务节点,后端接口通过 SLB 负载均衡,删除任务指令轮询到的节点。如果是自己处理任务则移除心跳,否则找到处理节点,代理出去;
  • 同步服务监听源注册中心每个业务服务实例状态,将正常的业务服务实例同步到目标注册中心,保证双方注册中心的业务服务实例状态实时同步;
  • 业务服务所有实例从 Eureka 到 Nacos 后,需要业务部门通知基础架构部手动从 Nacos Eureka Sync 同步界面摘除该同步任务。

④ Nacos Eureka Sync 方案实现

2.png

基于官方的 Nacos Sync 做任务分片和集群高可用,目标是为了支持大规模的注册集群迁移,并保障在节点宕机时,其它节点能快速响应,转移故障。技术点如下,文中只列出部分源码或者以伪代码表示:

详细代码,请参考:https://github.com/zhangmen-tech/nacos

服务一致性 Hash 分片路由

  • 根据如图 1 多集群部署,为每个节点设置可配置的虚拟节点数,使其在 Hash 环上能均匀分布;
// 虚拟节点配置
sync.consistent.hash.replicas = 1000;

// 存储虚拟节点
SortedMap<Integer, T> circle = new TreeMap<Integer, T>();
// 循环添加所有节点到容器,构建Hash环
replicas for loop {
    // 为每个物理节点设置虚拟节点
    String nodeStr = node.toString().concat("##").concat(Integer.toString(replica));
    // 根据算法计算出虚拟节点的Hash值
    int hashcode = getHash(nodeStr);
    // 将虚拟节点放入Hash环中
    circle.put(hashcode, node);
}

// 异步监听节点存活状态
etcdManager.watchEtcdKeyAsync(REGISTER_WORKER_PATH, true, response -> {
    for (WatchEvent event : response.getEvents()) {
    // 删除事件,从内存中剔除此节点及Hash中虚拟节点
    if (event.getEventType().equals(WatchEvent.EventType.DELETE)) {
        String key = Optional.ofNullable(event.getKeyValue().getKey()).map(bs -> bs.toString(Charsets.UTF_8)).orElse(StringUtils.EMPTY);
        //获取Etcd中心跳丢失的节点
        String[] ks = key.split(SLASH); 
        log.info("{} lost heart beat", ks[3]);
        // 自身节点不做判断
        if (!IPUtils.getIpAddress().equalsIgnoreCase(ks[3])) {
            // 监听心跳丢失,更显存货节点缓存,删除Hash环上节点
            nodeCaches.remove(ks[3]);
            try {
                // 心跳丢失,清除etcd上该节点的处理任务
                manager.deleteEtcdValueByKey(PER_WORKER_PROCESS_SERVICE.concat(SLASH).concat(ks[3]), true);
            } catch (InterruptedException e) {
                log.error("clear {} process service failed,{}", ks[3], e);
            } catch (ExecutionException e) {
                log.error("clear {} process service failed,{}", ks[3], e);
            }
        }
    }
}
  • 根据业务服务名的 FNV1_32_HASH 算法计算每个业务服务的哈希值,计算该 Hash 值顺时针最近的节点,将任务代理到该节点。
// 计算任务的Hash值
int hash = getHash(key.toString());
if (!circle.containsKey(hash)) {
    SortedMap<Integer, T> tailMap = circle.tailMap(hash);
    // 找到顺势针最近节点
    hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
}

// 得到Hash环中的节点位置
circle.get(hash); 

// 判断任务是否自己的处理节点
if (syncShardingProxy.isProcessNode(taskDO.getServiceName())) {
    //如果任务属于该节点,则进行心跳同步处理
    processTask(Task);
}

// 删除心跳同步任务
if (TaskStatusEnum.DELETE.getCode().equals(taskUpdateRequest.getTaskStatus())) {
    // 通过Etcd存活节点的一致性Hash算法,获取此任务所在的处理节点
    Node processNode = syncShardingProxy.fetchProcessNode(Task);
    if (processNode.isMyself()) {
        // 如果是自己的同步任务,发布删除心跳事件
        eventBus.post(new DeleteTaskEvent(taskDO));
    } else {
        // 如果是其他节点,则通过Http代理到此节点处理
        httpClientProxy.deleteTask(targetUrl,task);
    }
}

同步节点宕机故障转移

  • 节点监听:监听其它节点存活状态,配置 Etcd 集群租约 TTL , TTL 内至少发送 5 个续约心跳以保证一旦出现网络波动避免造成节点丢失;
// 心跳TTL配置
sync.etcd.register.ttl = 30;

// 获取租约TTL配置
String ttls = environment.getProperty(ETCD_BEAT_TTL);
long ttl = NumberUtils.toLong(ttls);

// 获取租约ID
long leaseId = client.getLeaseClient().grant(ttl).get().getID();
PutOption option = PutOption.newBuilder().withLeaseId(leaseId).withPrevKV().build();
client.getKVClient().put(ByteSequence.from(key, UTF_8), ByteSequence.from(value, UTF_8), option).get();
long delay = ttl / 6;

// 定时续约
scheduledExecutorService.schedule(new BeatTask(leaseId, delay), delay, TimeUnit.SECONDS);

// 续约任务
private class BeatTask implements Runnable {
    long leaseId;
    long delay;

    public BeatTask(long leaseId, long delay) {
        this.leaseId = leaseId;
        this.delay = delay;
    }

    public void run() {
        client.getLeaseClient().keepAliveOnce(leaseId);
        scheduledExecutorService.schedule(new BeatTask(this.leaseId, this.delay), delay, TimeUnit.SECONDS);
    }
}
  • 节点宕机:其中某个节点宕机,其任务转移到其它节点,因为有虚拟节点的缘故,所以此节点的任务会均衡 ReSharding 到其它节点,那么,集群在任何时候,任务处理都是分片均衡的,如图 2 中, B 节点宕机, ##1 、 ##2 虚拟节点的任务会分别转移到 C 和 A 节点,这样避免一个节点承担宕机节点的所有任务造成剩余节点连续雪崩;
  • 节点恢复:如图 3,节点的虚拟节点重新添加到 Hash 环中, Sharding 规则变更,恢复的节点会根据新的 Hash 环规则承担其它节点的一部分任务。心跳任务一旦在节点产生都不会自动消失,这时需要清理其它节点的多余任务(即重新分配给复苏节点的任务),给其它节点减负(这一步非常关键,不然也可能会引发集群的连续雪崩),保障集群恢复到最初正常任务同步状态;
// 找到此节点处理的心跳同步任务
Map<String, FinishedTask> finishedTaskMap = skyWalkerCacheServices.getFinishedTaskMap();

// 存储非此节点处理任务
Map<String, FinishedTask> unBelongTaskMap = Maps.newHashMap();

// 找到集群复苏后,Rehash后不是此节点处理的任务
if (!shardingEtcdProxy.isProcessNode(taskDO.getServiceName()) && TaskStatusEnum.SYNC.getCode().equals(taskDO.getTaskStatus())) {
    unBelongTaskMap.put(operationId, entry.getValue());
}

unBelongTaskMap for loop {
    // 删除多余的节点同步
    specialSyncEventBus.unsubscribe(taskDO);

    // 删除多余的节点处理任务数
    proxy.deleteEtcdValueByKey(PER_WORKER_PROCESS_SERVICE.concat(SLASH).concat(IPUtils.getIpAddress()).concat(SLASH).concat(taskDO.getServiceName()), false);

    // 根据不同的同步类型,删除多余的节点心跳
    if (ClusterTypeEnum.EUREKA.getCode().equalsIgnoreCase(clusterDO.getClusterType())) {
        syncToNacosService.deleteHeartBeat(taskDO);
    }

    if (ClusterTypeEnum.NACOS.getCode().equalsIgnoreCase(clusterDO.getClusterType())) {
        syncToEurekaService.deleteHeartBeat(taskDO);
    }

    // 删除多余的finish任务
    finishedTaskMap.remove(val.getKey());
}
  • 节点容灾:如果 Etcd 集群连接不上,则存活节点从配置文件中获取,集群正常运作,但是会失去容灾能力。
// 配置所有处理节点的机器IP,用于构建Hash环
sync.worker.address = ip1, ip2, ip3;

// 从配置文件获取所有处理任务节点IP
List<String> ips = getWorkerIps();
ConsistentHash<String> consistentHash = new ConsistentHash(replicas, ips);

// 如果从Etcd中获取不到当前处理节点,则构建Hash环用配置文件中的IP列表,且列表不会动态变化    
if (CollectionUtils.isNotEmpty(nodeCaches)) {
    consistentHash = new ConsistentHash(replicas, nodeCaches);
}

return consistentHash;

Nacos Eureka Sync 保障手段

① Nacos Eureka Sync 同步界面

从如下界面可以保证,从 Eureka 或者 Nacos 上,新上线或者下线一个业务服务(非实例),都能让 Nacos Eureka Sync 实时感知。但我们做了更进一层的智能化和自动化:

  • 新增同步:结合 DevOps 发布平台,当一个业务服务(非实例)新上线的时候,智能判断它是从哪个注册中心上线的,然后回调 Nacos Eureka Sync 接口,自动添加同步接口,例如,A 业务服务注册到 Eureka 上,DevOps 发布平台会自动添加它的 Eureka -> Nacos 的同步任务,反之亦然。当然从如下界面的操作也可实现该功能;
  • 删除同步:由于 DevOps 发布平台无法判断一个业务服务(非实例)下线,或者已经迁移到另一个注册中心,已经全部完毕(有同学会反问,可以判断的,即查看那个业务服务的实例数是否是零为标准,但我们应该考虑,实例数为零在网络故障的时候也会发生,即心跳全部丢失,所以这个判断依据是不严谨的),交由业务人员来判断,同时配合钉钉机器人告警提醒,由基础架构部同学从如下界面的操作实现该功能;

3.png

② Nacos Eureka Sync Etcd 监控

从如下界面可以监控到,业务服务列表是否在同步服务的集群上呈现一致性 Hash 均衡分布。

4.png

③ Nacos Eureka Sync 告警

5.jpg

  • 业务服务同步完毕告警

6.png

Nacos Eureka Sync 升级演练

  • 7 月某天晚上 10 点开始, FAT 环境进行演练,通过自动化运维工具 Ansible 两次执行一键升级和回滚均没问题;
  • 晚上 11 点 30 开始,执行灾难性操作,观察智能恢复状况, 9 台 Nacos Eureka Sync 挂掉 3 台的操作,只丢失一个实例,但 5 分钟后恢复(经调查,问题定位在 Eureka 上某个业务服务实例状态异常);
  • 晚上 11 点 45 开始,继续挂掉 2 台,只剩 4 台,故障转移,同步正常;
  • 晚上 11 点 52 开始,恢复 2 台,Nacos Eureka Sync 集群重新均衡 ReHash ,同步正常;
  • 晚上 11 点 55 开始,全部恢复,Nacos Eureka Sync 集群重新均衡 ReHash ,同步正常;
  • 12 点 14 分,极限灾难演练, 9 台挂掉 8 台,剩 1 台也能抗住,故障转移,同步正常;
  • 凌晨 12 点 22 分,升级 UAT 环境顺利;
  • 凌晨 1 点 22,升级 PROD 环境顺利;
  • 容灾恢复中的 ReHash 时间小于 1 分钟,即 Nacos Eureka Sync 服务大面积故障发生时,恢复时间小于 1 分钟。

作者介绍

  • 吴毅挺,掌门技术副总裁,负责技术中台和少儿技术团队。曾就职于百度、eBay 、携程,曾任携程高级研发总监,负责从零打造携程私有云、容器云、桌面云和 PaaS 平台。
  • 任浩军,掌门基础架构部负责人。曾就职于平安银行、万达、惠普,曾负责平安银行平台架构部 PaaS 平台 Halo 基础服务框架研发。10 多年开源经历,Github ID:@HaojunRen,Nepxion 开源社区创始人,Nacos Group Member,Spring Cloud Alibaba & Nacos & Sentinel & OpenTracing Committer。

参与 Nacos 落地的基础架构部成员,包括:

  • 童子龙,张彬彬,廖梦鸽,张金星,胡振建,谢璐,谢庆芳,伊安娜

阿里巴巴云原生关注微服务、Serverless、容器、Service Mesh 等技术领域、聚焦云原生流行技术趋势、云原生大规模的落地实践,做最懂云原生开发者的公众号。”

K8S中文社区微信公众号

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址