阿杜 2022-11-29T09:10:16+00:00 1294057873@qq.com 技术成长过程中的一些思考 2022-11-29T19:10:31+00:00 duyanghao http://duyanghao.github.io/technology-development-think 前言

前段时间在公司内做了一次《技术成长之路》的分享,总结了我个人在职业发展过程中对技术的一些思考,对外版PPT已经放在kubernetes-reading-notes首页,本文主要对这次分享做一个文字性的描述,仅供参考

自我介绍

本人于2016年毕业以校招生的身份进入腾讯,并在腾讯负责云原生方面的组件以及架构开发,并在去年来到联易融公司负责边缘计算这块的产品设计以及研发工作

由于我个人对开源十分热衷,非常喜欢在社区分享一些自己的技术积累和想法,因此在过去几年的工作时间里我先后参加了几场社区分享以及直播,包括:K8s&云原生技术开放日(深圳站)- harbor企业级方案设计与落地实践腾讯云十年乘风破浪直播 - Kubernetes集群高可用&备份还原概述全球边缘计算大会(深圳站) - 联易融边缘计算在供应链金融领域的应用

另外,我也有幸参与了腾讯开源边缘容器框架SuperEdge的贡献工作。同时为了学习Kubernetes以及后台架构,我开源了kubernetes-reading-notes,希望对深入学习云原生以及边缘计算的同学有所帮助

接下来我将从优秀技术人员具备的能力、技术成长中的核心关注点以及技术人员的综合素质等方面展开介绍,并最后给出一些工作的其它思考

优秀技术人员具备的能力

我们可以从一个项目出发来讨论一个优秀的技术人员所应该具备的能力

快速且合理的技术选型能力

当我们接受到一个新的项目时,首先要做的就是对该项目涉及的技术进行一个快速且合理的选型分析,可以从技术路线以及开源选型两方面进行:

  • 技术路线:纯开源 vs 二次定制 vs 纯自研

  • 开源选型:针对某一类别或者领域的开源项目进行对比,并选择出功能场景最合适的项目

复杂问题的技术拆解能力

在技术选型后,我们下一步需要考虑的就是如何将整个项目可执行化,通俗地讲也就是如何分工,因此需要对项目进行技术拆分,将一个大且复杂的问题拆分成若干个小且简单的问题,再对拆分后的问题以此类推不断拆分,最终形成若干个相对独立且容易解决的技术组件或者功能。这种方法借鉴了二分法的算法思想:

优秀的框架设计封装能力

在真正开始编写代码之前需要进行很重要的一个步骤,就是对技术拆分后的组件或者功能进行框架设计,所谓‘代码未动,文档先行’,好的框架以及文档对后续代码开发以及维护将事半功倍

在进行框架设计时,我们需要从可扩展性、可维护性以及稳定性等多个维度进行综合考虑,这样才能设计出比较优雅的架构,而优雅的框架会使得软件开发规范化、效能化以及规模化等

高质量的代码编写能力

在设计了一个好的框架后,接下来就是”真刀真枪“的动手写代码了,这部分也是一个程序员能力的最直接体现,好的编程水平体现在如下几个方面:

全面周到的技术取舍能力

在项目进展过程中难免会遇到一些技术难题,这些问题不是那么容易解决,或者是需要投入足够的人力、时间比较优雅地解决,或者是通过在性能、一致性等方面的折中妥协上快速解决,“鱼与熊掌不可兼得”,这个时候就需要在开发成本与技术优雅性之间权衡,找到一个平衡点,既可以一定程度解决问题满足需求,又不至于在不必要的场景下’自嗨‘投入过多

准确的问题定位和修复能力

软件维护占据整个软件生命周期成本的75%,包括:修复bug、适配、调优、安全防护等。而一个人之所以厉害,很大程度在于他能解决别人不能解决的问题,这也是我进入腾讯时我的导师跟我说的一句话,受益终身

技术成长核心关注点

上面我从比较抽象的层面介绍了我认为的一个优秀的技术人员所需要具备的6种能力,缺一不可。下面我将更加具体的介绍技术成长过程中的核心关注点,这些东西都是我们日常可以具体执行的,养成好的技术习惯对职场发展至关重要,同时也是程序员自我修养的直接体现

内容修炼:计算机三大核心技术

我非常认同我大学恩师的一句话 ,“纵观计算机整个发展历史,逃脱不了三大核心技术:硬件I/0、编译器以及操作系统“。了解了硬件,你就知道CPU架构是什么样的,什么是寄存器,它又有什么用?实模式与保护模式的区别是什么?CPU是如何执行指令的?;了解了编译器,你就知道一个用高级语言编写的代码如何一步步通过编译、链接(静态链接、动态链接),然后加载到内存中运行的;了解了操作系统,你就打通了上层代码与下层硬件,知道程序是如何通过操作系统的管理有条不紊地在CPU上运行。而我根据自己的读书经验也推荐了几本比较合适的书籍供大家参考学习

需要特别说明的是,尽管CPU、编译器以及操作系统在我们日常的工作中看起来似乎没有用到,但是在一些关键的问题上还是能发挥决定性的作用;同时也是美国卡脖子的关键所在,“三大核心技术强则国强”,工作除了满足我们的生活需求,我始终认为国内程序员要有理想和抱负,不甘于落后于美国

外功修炼:培养良好的编码规范与习惯

内功修炼决定了我们对计算机的理解有多深,而外功修炼决定了我们代码有多‘漂亮’,我总结了三方面的编码规范与习惯:

1、培养规范的命名习惯

变量命名是开发代码中遇到最多的情况,好的命名应该满足如下四方面的要求:驼峰命名(基于Go的规范)、顾名思义、特殊字符以及活用缩写

2、培养结构清晰、可扩展的代码组织习惯

好的代码组织结构应该达到一目了然的效果,功能尽可能清晰,方便扩展。而自从云原生领域以Kubernetes为核心构建生态之后,诸多云原生的项目都参考Kubernetes的目录结构构建(当然Kubernetes本身也因为其极佳的可扩展性广为流行):

3、培养可读性强的注释以及文档编写习惯

好的文档以及注释有助于理解项目以及代码逻辑,而不管是注释、git commit还是项目README我们都应该写的尽可能详细,这样可读性才会强:

进阶修炼:培养良好的编码素养

外功修炼固然重要,但是仅仅如此还是不够的,如果我们想往更高阶发展,还需要更加重视编码素养的培养,下面将从10个方面展开介绍:

1、培养代码测试的习惯,是程序员的基本素养

在交付功能代码或者接口之前我们需要对自己负责的模块进行系统测试,整体来说测试的形式不限,根据情况不同灵活变动,但需遵守几个原则:

  • 核心逻辑全覆盖

  • 关键路径性能压测

  • 交付前接口自测

2、培养抽象封装函数接口的习惯,提高代码可复用性

函数封装是程序员的必备技能,好的函数封装能使代码精简,可读性好,另外也便于复用。函数封装抽象程度不限,但是总体来说需要遵循几个原则:

  • 函数功能单一 do one thing, and do it well.

  • 代码超过2/3个地方使用,函数封装

  • 公共代码封装

  • 函数不超过100行(特殊情况除外)

3、 培养添加代码容错机制的习惯,提高代码可靠性

Anything that can go wrong will go wrong.

任何可能出错的地方都会出错

                  —Murphy's law

根据墨菲定律,我们需要在代码容易出错的地方添加容错机制,提高代码的可靠性与稳定性。考虑最坏的情况编写代码,这样写出来的代码才会足够健壮:

4、培养扩展接口的设计习惯,提高代码可扩展性

程序的可扩展性取决于接口是否具备可扩展性,不管是存储、计算还是调度。总体来说,无侵入的接口扩展方式可以分为两种:内嵌插件以及独立服务

内嵌插件是编写扩展的插件实现指定的接口,然后与项目一起打包运行,这种方式最终是形成一个二进制文件,运行效率以及部署效率高:

而独立服务通过gRPC等远程调用的方式执行按照指定规范扩展实现的第三方插件,插件与核心项目独立维护迭代,这样扩展的插件不会影响主体项目的发展和合并。相比而言,这种方式保证了核心项目的精简与快速迭代,因此更加优雅。而Kubernetes的存储扩展接口就是由上述内嵌插件模式转变为基于独立服务模式的CSI

5、培养简短精悍的编码风格,提高代码可读性

不好的代码千奇百怪,但好的代码一定是简短精悍,同时具备极高的可读性:

6、培养关键路径多用缓存、算法优化的习惯,提高性能

在程序的某些关键性能环节,我们需要利用算法、缓存以及增量思维来解决。对于算法来说,基本上只有面试的时候会强调重视,工作中很少需要用到,也就是我们常常调侃的“面试造火箭,工作拧螺丝”,但实际上在一些关键的性能环节上,这些经典的算法往往可以发挥举足轻重的作用:

而对于分布式的应用来说,单体的性能优化可能不会起到决定性的作用,这个时候就需要善用缓存以及增量思维来解决问题了:

7、培养多核多线程/协程编码思维,提高并发性

现代服务器的计算资源非常充足,基本都是多核多线程,如何编写程序充分利用这些资源就是一个很大的问题。基于这种前提,我们要培养自己的多线程/协程编码思维,提高程序的并发性:

8、面向云原生编程,适应分布式云计算架构

在上云的大背景下,云原生,DevOps已经不单单是运维人员需要关注的问题,开发以及架构设计人员应该学习熟悉云原生的技术形态和理念,充分拥抱云原生,并基于云原生编码,适应分布式的云计算架构。这里我总结了四种具体执行的思路:

1)面向微服务拆分应用

将应用按照功能模块进行合理拆分,使得拆分后的每一个服务都尽可能独立,这样做的优点有很多:敏捷、弹性、开发效率高、可扩展、易于部署以及开放。但是切记不要为了微服务而微服务,将服务拆分的过于细,这样不仅不能提高开发效率,反而会降低部署以及运维效率:

2)声明式API

尝试使用声明式API来编写程序,系统会更加健壮:

3)横向扩展应用

可以将一个单体的服务分离出无状态的服务以及有状态的服务,无状态的服务可方便水平横向扩展,而有状态的服务可单独维护扩展,通过这样实现状态分离,最大程度提高服务的吞吐率:

4)云原生高可用

Kubernetes本身提供了若干种高可用的实现方式,归类如下:

  • 无状态的服务可采用多副本的形式部署,并利用节点亲和性使副本不会部署在同一个工作节点上,从而实现一定程度的高可用,这是最简单且最实用的一种方式

  • 有状态的服务则相对较为复杂,可利用Kubernetes内置的分布式锁,采用Leader-候选者模式实现高可用;而对于高可用相对复杂且变化较多的数据库服务,则需要采用不同的数据库高可用原理,例如:Redis的一主二从三哨兵模式 以及 Etcd的Raft算法

9、培养规范的开源项目管理习惯,更好地拥抱世界

闭源的软件写的再天花乱坠却始终无法共享给社区的其他人看,这在我看来就是一种技术浪费。相比而言开源意味着开放,共享,也能更好地激发程序员内心的成就感,简而言之,技术成就感来源于开源认可度

而为了更好地拥抱世界,我们需要了解开源的基本运作模式,这里面就包括:如何贡献代码?开源项目版本如何管理?如何给项目提Bug以及建议?如何维护项目PR?等

10、培养抽象封装框架的能力

最后也是最高阶的能力,就是学会如何抽象封装框架。而要培养这方面的能力,就必须多读优秀的框架,例如:容器编排标准Kubernetes、云原生分布式存储Etcd,边缘容器框架SuperEdge以及分布式计算引擎Spark等;同时在多读优秀项目的基础上,我们要多模仿,在实践中使用,这样久而久之能力就上去了。

当你努力追赶大牛时,某一时刻你也会变成大牛!!!

技术人员综合素质

除了上述提到的硬实力以外,技术人员还需要培养一些软实力,全面提高自己的综合素质

会画架构

所谓’一图胜千言‘,一张好的架构图比大段大段的文字性描述更加直接清晰,且信息量更多,更容易让人理解和接受

不会画架构的架构师,不是好的架构师

能写文章、材料

能把自己理解的说清楚也是一种能力,包括:

拥抱变化、技术开放

尝试抱着开放的心态接纳新的技术,并在实践中不断融入新的技术,学以致用

主动学习,终生学习

培养指导基层技术人员

在个人成长到一定阶段后就会面临如何培养指导基层技术员工,而针对不同阶段以及级别的工程师指导方式也不尽相同,这里我根据自己的经验进行了总结:

技术影响

普通人靠平台增加影响,大牛靠成果影响平台乃至世界

其它一些思考

最后,在讲完技术成长的一些思考后,我也想给出一些个人工作的思考和总结:

培养好的职场习惯

保持好的学习习惯

关于技术转型的思考

最后一点零散的建议

总结

本文从我个人的工作经验出发,思考了优秀技术人员应该具备的能力、技术成长过程中的核心关注点以及技术人员的综合素质,最后给出了一些工作上的思考和建议。希望通过这篇文章能引起读者的思考,对他们的职业发展有所帮助

]]>
边缘计算技术在联易融蜂知网银机器人中的应用 2022-11-25T20:10:31+00:00 duyanghao http://duyanghao.github.io/edge-aicfo 前言

边缘计算,起初是为了弥补传统集中式云计算在面对“低时延”、“高带宽”、“安全与隐私保护”以及“弹性敏捷部署”等场景时表现不足应运而生。

在财资管理领域,边缘计算主要在靠近数据产生的源头部署计算节点,提供网络、计算、存储、应用等核心能力为一体的开放平台。为企业财务数据信息化提供便利,满足企业在敏捷联接、实时业务、数据优化、安全与隐私保护等方面的关键需求。

那么,边缘计算技术在财资管理领域的具体实现形式是什么样的?落地应用场景和价值有多大?能为企业的成本、效率带来哪些变化?本文将一一为您解答。

边缘计算:拓展财资管理业务应用新形态

边缘计算,通俗理解即在网络的“边缘”更快地处理和分析数据,并缩短将数据交付到请求源所花费的时间,具有超低延时、高可靠性、隐私保护的特点。

网银机器人是边缘计算技术在财资管理中的应用形式之一。在联易融的技术落地应用中,边缘计算重点解决蜂知网银机器人(点击蓝字了解更多蜂知网银机器人产品详情)这一产品的运行、维护和数据安全三大问题。

在交付模式上,传统厂商的交付模式为“物理PC部署+单个机器人运行+本地维护”。在边缘计算技术的加持下,联易融蜂知网银机器人升级为轻量化交付模式,即“微PC盒子+软硬一体化集成方案+多机器人运行+远程维护”,客户无需额外提供PC部署,而是由联易融提供边缘计算+盒子一体化集成方案,部署时能够支持多个网银机器人同时运行,实现远程维护。

边缘计算是蜂知网银机器人的技术底座。边缘计算通过小型(迷你)服务器、PC(个人电脑)、服务器、云服务集群等方式进行部署,为蜂知网银机器人提供干净、隔离的运行环境,当边缘计算的底层技术架构搭建好了以后,便可以通过管理台启动网银机器人程序,执行下载银行单据的任务。

边缘计算就像是一艘轮船,蜂知网银机器人类似于已经在码头装箱好的集装箱,边缘计算为网银机器人提供了高效传输、稳定运行的能力。

边缘计算:实现网银机器人应用降本增效

边缘计算在蜂知网银机器人中的应用价值主要有以下几个方面:

1、边缘计算使得网银机器人整体任务执行成功率大幅提升

第一,在没有使用边缘计算之前,由于网银机器人对外部环境依赖性较强,进而导致程序整体运行时存在高不稳定性和高不确定性的“双高问题”。

第二,由于客户运行环境不同导致网银机器人软件版本有所差异,因此需要维护大量软件分支版本,维护成本较高。

边缘计算平台为网银机器人提供了标准化的软件环境。由于统一了客户运行环境,程序运行的稳定性得到了很好的保障,网银机器人整体任务的成功率也大幅提升;同时只需要维护特定环境的分支版本,大大降低了软件维护成本。

2、边缘计算将网银机器人整体任务执行时间缩短了50%

在没有使用边缘计算之前,由于各家银行网银环境之间相互不隔离,执行网银任务时共享计算资源、互相干扰,整个流程执行速度缓慢。而边缘计算技术则实现了不同网银环境之间的有效隔离,各家网银在各自单独的轨道上独自运行,真正意义上实现了网银全流程提速,执行时间缩短了50%。

3、边缘计算实现企业数据隔离,大大提高企业用户隐私安全性

目前基于边缘计算的运行模式,主要从两方面确保数据安全:一、物理隔离层面,每个企业客户的软件和数据库均部署在本地的办公环境中,在物理层面做到了不同企业客户之间数据隔离不可见;二、蜂知网银机器人全程使用联易融定制的Win10操作环境,有效避免了木马等第三方病毒对客户敏感信息的截获,充分保障了企业客户的数据隐私安全。

4、蜂知网银机器人软件版本发布时间由几天缩短到几分钟,极大提高发布效率

第一,边缘计算实现了控制面板对分散USB集线器的集中式管理,便于整体UKEY资源的调度运维;

第二,目前基于边缘计算平台可实现一键服务部署更新功能,本地维护镜像,全国推送版本,并支持区域化版本管理,大大缩短了软件发布更新的时间;同时通过边缘计算平台还可实现远程线上监控运维,极大提高运维效率。

5、解耦软硬件,为蜂知网银机器人的大规模应用打好底层基础设施

通过边缘虚拟化技术实现了UKEY的软硬件解耦,目前网银机器人计算资源可与USB集线器分离;同时基础环境创新,摆脱了Windows系统和底层硬件的束缚。不论是Linux还是Windows系统,不论是PC(个人电脑)、服务器还是虚拟机都可实现应用无感知化:统一开发、统一配置、统一发布以及统一运维管理。

通过上述两部分技术可实现蜂知网银SaaS服务的横向扩展以及企业客户本地化服务的纵向扩展。

以上,就是边缘计算技术在联易融蜂知网银机器人中的具体应用实践,未来,随着边缘计算+RPA技术进一步的创新和发展,联易融蜂知网银机器人将会带给客户更多更完备的产品体验,让我们敬请期待。

]]>
边缘计算杂谈 2022-11-25T19:10:31+00:00 duyanghao http://duyanghao.github.io/edge-computing 前言

近年来,国家已出台了许多有关边缘计算的相应政策,在政企倡导的“上云、用数、赋智”的推动下,边缘计算的技术应用显得尤为重要。

随着信息化水平的不断提升,以及在产业发展的推动下,边缘计算已成为推动数字化经济发展的新动能,联易融在边缘计算以及云计算的技术能力上日渐成熟,云边协同或将成为未来发展的新动向。

本期大咖说,我们将邀请到了联易融边缘计算的负责人,杜杨浩,来与我们一同分享一下边缘计算在供应链金融领域的实践与应用

Q1:可以请您用3个关键词来形容一下边缘计算的技术特征吗?它的技术原理是怎么样的?

边缘计算是云计算靠近用户数据源的延伸,它的技术特征主要包括:低时延、高可靠、数据安全与隐私。

边缘计算是随着5G商用的推进,以及大视频、大数据、物联网等业务的蓬勃发展诞生的新兴技术,我们可以用“章鱼”的例子来简单明了的理解边缘计算的原理:那作为无脊椎动物中智商最高的一种动物,章鱼拥有巨量的神经元,但60%分布在章鱼的八条腿上,脑部只有40%神经元。所谓的用“腿”思考说的就是章鱼,而边缘计算想做的就是用“腿”思考,让数据的处理从中心下放到章鱼的腿上,也就是网络的边缘节点上。

边缘计算技术涉及很多技术领域,比如:云原生、物联网、密码学、通信等。利用边缘计算可以解决传统集中式云计算所无法满足的“低时延”,“高带宽”,“安全与隐私保护”以及“弹性敏捷部署”等方面的需求。

Q2:边缘计算如何推动供应链金融的发展?能给大家介绍一下联易融在这个领域的应用吗?

蜂网边缘计算平台目前在公司的应用场景主要包括:轻量化组件输出解决方案、智慧工厂以及绿色金融可信数据采集。

我们知道联易融是一家供应链金融科技解决方案提供商,在多年的市场打磨下,联易融在供应链金融领域积累了很多轻量化能力,依靠蜂网边缘计算平台可以将这些轻量化能力输出到其它公司,探索更多的合作伙伴,构建供应链金融生态联盟。像智能中登以及财资管理就是上述轻量化能力的典型代表,尤其是在财资网银流水项目中,联易融蜂网边缘团队开创性地解决了Windows全系列系统边缘化以及Windows应用跨系统兼容性技术难题,实现财资网银流水自动化技术的大规模推广。

同时,联易融联合生态伙伴公司合作推出“智慧工厂管家”产品,提供软硬件一体的一揽子解决方案,解决制造业工厂在智能绿色转型中遇到的能耗管理、环保监测、设备监控、安全生产、大屏与移动端展示等复杂问题,并具备:标准化实施改造、架构安全可靠,监管要求达标,综合成本低廉以及联易融独有的优惠融资渠道等多种优势。

另外,为了响应国家“碳中和”战略目标,联易融推出绿色供应链金融产品“绿色E链”。蜂网边缘计算平台作为“绿色E链”可信数据采集来源,负责各应用场景物联网数据的抓取、转换、分析以及处理,帮助“绿色E链”解决供应链上绿色资产识别和碳足迹测算等核心问题,促进供应链进行绿色转型和绿色发展。

Q3:云计算和边缘计算似乎有很强的关联性,您如何理解他们之间的关系呢?

首先我们要明确,边缘计算是云计算在靠近用户数据边缘侧能力的延伸,边缘计算一定不是孤立于云计算单独存在,脱离了云计算,边缘计算就失去了意义,变成了区域性的云计算了。边缘计算的核心是云边协同,云、边、端共同协作,解决传统云计算无法满足的业务实时性、智能化和隐私保护等方面的需求。

边缘计算在靠近用户数据源侧提供了轻量级别的计算能力,只能满足一部分有限的计算需求;而云计算提供了丰富的集中化计算资源,能满足大规模的数据存储、分析、处理需求,所以通常情况下我们用边缘计算做区域性处理,用云计算做集中性大数据处理。

Q4:边缘计算在业界有哪些应用场景呢?

边缘计算主要的应用形态包括边缘云以及近场计算。对于边缘云,业界的应用场景包括像:云游戏/云视频/云课堂,直播/VR/AR,CDN,5G MEC等;对于近场计算,应用场景则更加丰富,像包括:智慧工厂、智慧矿山、智慧能源、智慧交通、智慧医疗、智慧农业,智慧物流等等,涉及:工业、能源、交通、通信、供应链金融等多个行业。

Q5:您如何看待边缘技术的未来发展?接下来联易融在边缘计算技术的落地上,有哪些计划?

根据 Gartner 发布的年度新兴技术成熟度曲线,边缘计算已经从“触发期”进入“期望膨胀期”,是未来科技发展的一大重要趋势,同时伴随数字化转型浪潮的快速推进,边缘计算赋能千行百业的时代已经到来。

我们知道,4G网络创造了全新的移动互联网时代,随着5G/6G网络的普及,未来将会属于“万物互联”的物联网时代,那么在那个时候,现在很多行业在用户消费习惯以及经营模式上将会产生颠覆性的改变,而边缘计算作为物联网时代关键的基础性技术,将会发挥巨大的作用,也必将与云计算、大数据等技术一样成为大家普遍认知的基础性技术。

目前联易融蜂网边缘计算平台已经通过信通院《边缘容器》的测评,同时在同步构建基于“云、管、边、端”四层架构的物联网边缘计算平台。未来联易融将重点探索边缘计算在物联网金融、绿色金融、绿色供应链以及智慧物联网等领域的技术落地。

]]>
联易融蜂网边缘计算平台 2022-01-24T19:10:31+00:00 duyanghao http://duyanghao.github.io/beeedge 蜂网边缘计算平台

联易融蜂网边缘计算平台(Linklogis BeeEdge Computing Platform)是联易融管理边缘节点、设备与应用的技术服务平台。蜂网平台1.0.0版本于9月份上线,旨在通过云原生、边缘计算等核心技术快速地将存储、人工智能、区块链、大数据等云端计算能力扩展至边缘节点以及设备,就近提供计算和智能服务,并实现云边端应用的数字化、自动化和智能化统一管理,满足客户对业务实时性、智能化和隐私保护等方面的需求。

边缘计算概念简介

边缘计算的起源可以追溯到上世纪90年代,Akamai公司提出了内容分发网络(Content Delivery Network, CDN)的概念,通过在地理位置上更接近用户的位置引入网络节点,以缓存的方式实现图像、视频等内容的高速传输。2006年亚马逊首次提出“弹性计算云(Elastic Compute Cloud)”的概念,在计算、可视化和存储等方面开启了许多新的机遇。2009年CMU的Satyanarayanan教授引入了cloudlet作为边缘计算的早期示例形式,展示了一种双层架构,第一层称为云(高延迟),第二层称为cloudlet(低延迟),后者即是广泛分散的互联网基础设施组件,其计算周期和存储资源可以被附近的移动设备利用。此后,随着边缘计算模型的深入研究,学业界和工业界相继提出了移动边缘计算或多接入边缘计算(Mobile Edge Computing, Multi-access Edge Computing)。根据 Gartner 发布的年度新兴技术成熟度曲线,边缘计算已经从“触发期”进入“期望膨胀期”,是未来科技发展的一大重要趋势。

总的来说,边缘计算是为应用开发者和服务提供商在网络的边缘侧提供云服务和IT环境服务的一种分布式计算概念,边缘计算着重要解决的问题,是传统云计算(或者说是中央计算)模式下存在的高延迟、网络不稳定、低带宽以及安全隐私问题。对5G和物联网而言,边缘计算技术取得突破,意味着许多控制将通过本地设备实现而无需交由云端,处理过程将在本地完成,将大大提升处理效率,减轻云端的负荷。

蜂网边缘计算平台架构

蜂网边缘计算平台采用了业界普遍的云-边-端三层架构体系。位于最上层的边缘云管理平台提供集群创建、节点管理、应用治理、云边隧道等功能,实现边缘应用的全生命周期管控。其中基础云服务依托联易融强大的云原生能力,为边缘云管理平台提供了良好的平台和服务支撑。位于中间层的边缘节点服务旨在实现应用无感知边缘化,以及提供边端云原生、可扩展、易维护、安全可靠的基础设施,目前可兼容X86、ARM架构,同时支持CentOS、Ubuntu及Windows系列操作系统。位于最底层的“端”为设备层,可提供边缘设备的接入以及管理能力,如传感器、执行器以及各种智能设备等。

边缘计算典型落地场景

智慧仓储

智慧仓储能有效应对制造业复杂的货物进出管理业务,提升货物流转效率和库存周转率,大大降低企业库存积压和资金占用,让仓储管理更加准确、简单以及高效。通过边缘计算可以将智慧仓储解决方案沉降至业务现场,数据模型定期更新并下沉,在线或离线建立资产与人员之间的关系,从而提升企业的精细化管理水平并实现仓库管理的数字化转型。

智慧网点

在传统银行网点向智慧网点的转型过程中,边缘计算与5G、云计算、人工智能等技术的深度融合,必将爆发巨大的创新潜力,催生新型的服务模式和金融产品,从而全面提升服务水平。基于边缘计算“终端-边缘节点-云”三层链路,将智能推理从云端下沉到边缘节点。其中包括摄像头、互动大屏等终端设备负责感知和采集用户行为及周围环境的数据,边缘节点基于云端下发的模型对用户和环境变化进行推理、决策,云端则进行复杂的模型训练和持续优化。基于上述闭环,可实现客户身份识别、流量监控、用户行为分析、轨迹跟踪、热点区域分析、网点设备智能管控、抵质押物全流程跟踪、金库智慧化管理等创新应用。例如对于进入网点的客户,可通过智能摄像头识别用户身份,在边缘节点可智能推测用户偏好,实时推送至客户经理,实现智能引导、个性化产品或服务的精准推荐,有效提升服务质量和客户体验。基于上述基础技术框架,可扩展应用到银行风控、运营、营销、安防等方方面面的智能化。

智能监控

在园区、住宅、商超等视频监控场景下,通过边缘计算将AI能力下沉,对本地摄像头产生的视频进行预处理,可以实时感知异常事件,实现事前预防预判,事中监控指挥,事后回溯取证等业务优势。

智能制造

随着工业制造步入智能化时代,许多企业原有的生产工艺和自动化、智能化水平已经无法匹配下游客户对于产品质量的个性化需求。通过边缘计算可以实现大量不同类型工业生产设备的智能化生命周期管理,同时生产过程自动化、智能化,实现企业节能、降本、增效。

AIoT

AIoT结合边缘计算和人工智能技术,在边缘侧进行实时、小数据处理、AI模型推理,实现智能检测;数据回传到云端进行长周期、大数据量处理,有效地解决AI在行业应用面临的海量数据处理、实时响应、数据安全等问题,为AI在更多行业应用落地奠定基础。

边缘计算与公司业务

边缘计算作为5G+物联网时代的关键技术,在上述的智慧仓储、智慧网点、智能监控、智能制造以及AIoT等场景下都会有广泛的应用。那具体到联易融以及供应链金融科技赛道,边缘计算又将发挥怎样的作用呢?下面介绍一下蜂网边缘计算平台在联易融的一些业务探索和思考。

智能中登

在多年的市场打磨下,联易融在供应链金融领域积累了很多轻量化能力,依靠蜂网边缘计算平台可以将这些轻量化能力输出到其它公司,探索更多的合作伙伴,构建供应链金融生态联盟。

智能中登是上述轻量化能力的典型代表。作为联易融的明星科技产品,利用领先的自动化+AI技术,大大提高了资产证券化中必不可少的资产核查和登记环节的自动化效率。对于合规及数据隐私要求较高的客户,蜂网边缘计算平台助力智能中登本地化,提供一键部署、一键更新的优质服务体验,并在保证客户隐私数据不出本地的前提下,也能安全接受SaaS云端推送的指令、模型。通过蜂网边缘计算平台,智能中登产品大大提高了产品竞争力和部署灵活性,同时也减少了客户的部署和运维成本。

财资管理

AI-CFO Mini是联易融推出的一款针对中小型企业客户的财务管理SaaS产品,利用自动化+AI技术,助力企业完成财务自动化、线上化转型。对于网络策略为只出不入,安全性要求较高的客户,蜂网边缘计算平台通过云边通信加密隧道帮助AI-CFO Mini打通业务流程,在无需客户改变网络策略的前提下也可进行AI-CFO Mini与银行前置机的通信,单向网络+加密隧道+转换器加解密的组合方案也大大提升了产品的整体技术架构安全等级。

边缘计算+区块链

区块链作为一项新型信息处理技术,其在表示价值、传递信任方面具有天然的优势,是“价值互联网”的核心要素。 边缘计算是5G系统针对低时延、高安全性、大带宽、汇聚流量大、海量物联、高可靠特定应用场景的新型解决方案。 5G时代,区块链与边缘计算的结合,将助力联易融面向供应链金融产业开拓to-B新市场。目前探索的合作模式主要会是整合区块链节点作为边缘计算节点进行集中管控,提高整个区块链底层架构的灵活性和管理效率。同时利用边缘计算云边网络方案解决多云跨地域部署模式下区块链节点的通信问题。

边缘计算+AI/大数据

通过边缘计算平台可以将联易融在供应链金融领域积累的强大AI/大数据SaaS能力延伸到边缘,通过支持视频智能分析、NLP文字识别、OCR图像识别、大数据流处理等能力,就近提供实时智能边缘服务,最终为供应链金融客户提供完整的云边协同的一体化解决方案。

蜂网边缘计算平台未来规划

供应链金融作为实体经济和金融的连接器,一直是国家大力支持的产业。而随着 5G 商用的推进,以及大视频、大数据、物联网等业务的蓬勃发展,边缘计算有望成为补充联易融在ABCD(AI、Blockchain、Cloud Computing、Big Data)科技能力输出上的“E”(Edge Computing)赛道,更好地支撑联易融推动供应链金融的数字化、智能化转型。

目前蜂网边缘计算平台正在参与信通院《基于云边协同的边缘容器技术要求》的认证,同时团队也积累了一些技术创新,已提交一篇软著以及6篇专利。未来也将会更多地参与到边缘社区与协议的建设中,增加联易融边缘计算的科技品牌影响力。值得特别提出的是,经过不懈的努力,目前蜂网团队已成功攻坚Windows系列操作系统兼容性难题,并将大规模应用到财资管理产品中,助力公司SME Tech条线实现百万获客。

在2022年,蜂网业务布局将聚焦在企业数字化转型和“碳中和”两个领域。依靠公司数字仓单抓手,与市场上智能仓储解决方案提供商以及上游设备厂商合作,为大型仓储企业提供整体智能仓储+数字仓单融资平台解决方案;同时,结合AI/大数据做算法层,依托边缘计算下发到设备端以及边缘端,联合上游厂商提供硬件,最终提供营销、运营、风控、安保等方面的银行智慧网点解决方案,助力银行数字化转型;另外,蜂网平台将会大力拓展工业端设备的接入和管理能力,目前也已经与一些物联网企业进行了合作洽谈,通过采集能耗、环保、设备维保等工业数据,与已有的工商大数据、司法大数据结合,上层做应用创新,孵化绿色金融、绿色供应链、智慧物联网等新型业务场景。

]]>
SuperEdge云边隧道network-tunnel深入剖析 2021-03-12T19:10:31+00:00 duyanghao http://duyanghao.github.io/superedge-tunnel Table of Contents

前言

云边隧道主要用于代理云端访问边缘节点组件的请求,解决云端无法直接访问边缘节点的问题(边缘节点没有暴露在公网中)

架构图如下所示:

img

实现原理为:

  • 边缘节点上tunnel-edge主动连接云端tunnel-cloud service,tunnel-cloud service根据负载均衡策略将请求转到tunnel-cloud的具体pod上
  • tunnel-edge与tunnel-cloud建立grpc连接后,tunnel-cloud会把自身的podIp和tunnel-edge所在节点的nodeName的映射写入DNS(tunnel dns)。grpc连接断开之后,tunnel-cloud会删除相关podIp和节点名的映射

而整个请求的代理转发流程如下:

  • apiserver或者其它云端的应用访问边缘节点上的kubelet或者其它应用时,tunnel-dns通过DNS劫持(将host中的节点名解析为tunnel-cloud的podIp)把请求转发到tunnel-cloud的pod上
  • tunnel-cloud根据节点名把请求信息转发到节点名对应的与tunnel-edge建立的grpc连接上
  • tunnel-edge根据接收的请求信息请求边缘节点上的应用

tunnel配置及数据结构

type Tunnel struct {
	TunnlMode *TunnelMode `toml:"mode"`
}

type TunnelMode struct {
	Cloud *TunnelCloud `toml:"cloud"`
	EDGE  *TunnelEdge  `toml:"edge"`
}

TunnelCloud代表云端配置而TunnelEdge代表边端配置,下面依次介绍:

type TunnelCloud struct {
	Https  *HttpsServer      `toml:"https"`
	Stream *StreamCloud      `toml:"stream"`
	Tcp    map[string]string `toml:"tcp"`
}

type HttpsServer struct {
	Cert string            `toml:"cert"`
	Key  string            `toml:"key"`
	Addr map[string]string `toml:"addr"`
}

type StreamCloud struct {
	Server *StreamServer `toml:"server"`
	Dns    *Dns          `toml:"dns"`
}

type StreamServer struct {
	TokenFile    string `toml:"tokenfile"`
	Key          string `toml:"key"`
	Cert         string `toml:"cert"`
	GrpcPort     int    `toml:"grpcport"`
	LogPort      int    `toml:"logport"`
	ChannelzAddr string `toml:"channelzaddr"`
}

type Dns struct {
	Configmap string `toml:"configmap"`
	Hosts     string `toml:"hosts"`
	Service   string `toml:"service"`
	Debug     bool   `toml:"debug"`
}

type TunnelEdge struct {
	Https      *HttpsClient `toml:"https"`
	StreamEdge StreamEdge   `toml:"stream"`
}

type HttpsClient struct {
	Cert string `toml:"cert"`
	Key  string `toml:"key"`
}

type StreamEdge struct {
	Client *StreamClient `toml:"client"`
}

type StreamClient struct {
	Token        string `toml:"token"`
	Cert         string `toml:"cert"`
	Dns          string `toml:"dns"`
	ServerName   string `toml:"servername"`
	LogPort      int    `toml:"logport"`
	ChannelzAddr string `toml:"channelzaddr"`
}

TunnelCloud包含如下结构:

  • HttpsServer:云端tunnel证书,key以及Addr map(key表示云端tunnel https代理监听端口,而value表示边端tunnel需要访问的https服务监听地址(kubelet监听地址:127.0.0.1:10250)
  • StreamCloud:包括StreamServer以及Dns配置:
    • StreamServer:包括云端tunnel grpc服务证书,key,以及监听端口
    • Dns:包括了云端coredns相关信息:
      • Configmap:云端coredns host plugin使用的挂载configmap,其中存放有云端tunnel ip以及边缘节点名映射列表
      • Hosts:云端tunnel对coredns host plugin使用的configmap的本地挂载文件
      • Service:云端tunnel service名称
      • Debug: 默认值false,true为本地调试模式,不更新本tunnel连接的边缘节点映射到configmap
  • Tcp:包括了云端tunnel tcp监听地址以及边缘节点某进程的tcp监听地址

TunnelEdge包含如下结构:

  • HttpsClient:包括边缘节点https进程的证书,key
  • StreamEdge:包括了云端tunnel service的dns以及服务访问地址和端口ServerName

在介绍完tunnel的配置后,下面介绍tunnel使用的内部数据结构(github.com/superedge/superedge/pkg/tunnel/context):

1、StreamMsg

StreamMsg为云边grpc隧道传输的消息数据格式:

message StreamMsg {
    string node = 1;
    string category = 2;
    string type = 3;
    string topic = 4;
    bytes data = 5;
    string addr = 6;
}
  • node:表示边缘节点名称
  • category:消息所属的模块(stream、https或tls)
  • type:消息类型
  • topic:消息的唯一标示
  • data:消息数据内容
  • addr:边缘端请求的server的地址

2、conn

type conn struct {
	uid string
	ch  chan *proto.StreamMsg
}

conn表示tunnel grpc隧道上的连接(包括tcp以及https代理):

  • uid:表示conn uid
  • ch:StreamMsg消息传递的管道

3、connContext

type connContext struct {
	conns    map[string]*conn
	connLock sync.RWMutex
}

connContext表示tcp和https模块代理转发的所有连接,其中conns key为conn uid,value为conn

4、node

type node struct {
	name      string
	ch        chan *proto.StreamMsg
	conns     *[]string
	connsLock sync.RWMutex
}

node表示边缘节点相关连接信息:

  • name:边缘节点名称
  • ch:消息传输的管道
  • conns:保存转发到本节点所有的https和tcp分配的连接的uuid。当节点断连之后,通过数组内保存的uuid,向转发的所有的https和tcp连接发送断开的msg,通知对端关闭连接

5、nodeContext

type nodeContext struct {
	nodes    map[string]*node
	nodeLock sync.RWMutex
}

nodeContext表示本tunnel上所有连接的相关节点信息,其中nodes key为边缘节点名称,value为node

nodeContext和connContext都是做连接的管理,但是节点的grpc长连接的和上层转发请求的连接(tcp和https)的生命周期是不相同的,因此需要分开管理

6、TcpConn

type TcpConn struct {
	Conn     net.Conn
	uid      string
	stopChan chan struct{}
	Type     string
	C        context.Conn
	n        context.Node
	Addr     string
	once     sync.Once
}

TcpConn为tcp代理模块封装的数据结构,代表了grpc隧道上的一个tcp代理连接:

  • Conn:云端组件与云端tunnel的底层tcp连接 or 边端tunnel与边端组件的底层tcp连接
  • uid:TcpConn唯一标识
  • Type:TcpConn类型
  • C:TcpConn使用的context.Conn
  • n:TcpConn对应的context.Node
  • Addr:边缘服务tcp监听地址及端口

7、HttpsMsg

type HttpsMsg struct {
	StatusCode  int               `json:"status_code"`
	HttpsStatus string            `json:"https_status"`
	HttpBody    []byte            `json:"http_body"`
	Header      map[string]string `json:"header"`
	Method      string            `json:"method"`
}

HttpsMsg为https代理消息传输中转结构:

  • StatusCode:http response返回码
  • HttpsStatus:https连接状态
  • HttpBody:http 请求 or 回应 Body
  • Header:http 请求 or 回应 报头
  • Method:http请求Method

在介绍完tunnel核心配置和数据结构后,下面开始分析源码

tunnel源码分析

首先启动函数中会进行若干初始化:

func NewTunnelCommand() *cobra.Command {
	option := options.NewTunnelOption()
	cmd := &cobra.Command{
		Use: "tunnel",
		Run: func(cmd *cobra.Command, args []string) {
			verflag.PrintAndExitIfRequested()

			klog.Infof("Versions: %#v\n", version.Get())
			util.PrintFlags(cmd.Flags())

			err := conf.InitConf(*option.TunnelMode, *option.TunnelConf)
			if err != nil {
				klog.Info("tunnel failed to load configuration file !")
				return
			}
			InitModules(*option.TunnelMode)
			stream.InitStream(*option.TunnelMode)
			tcp.InitTcp()
			https.InitHttps()
			LoadModules(*option.TunnelMode)
			ShutDown()
		},
	}
	fs := cmd.Flags()
	namedFlagSets := option.Addflag()
	for _, f := range namedFlagSets.FlagSets {
		fs.AddFlagSet(f)
	}
	return cmd
}

下面分别介绍:

  • stream.InitStream
func InitStream(mode string) {
	if mode == util.CLOUD {
		if !conf.TunnelConf.TunnlMode.Cloud.Stream.Dns.Debug {
			err := connect.InitDNS()
			if err != nil {
				klog.Errorf("init client-go fail err = %v", err)
				return
			}
		}
		err := token.InitTokenCache(conf.TunnelConf.TunnlMode.Cloud.Stream.Server.TokenFile)
		if err != nil {
			klog.Error("Error loading token file !")
		}
	} else {
		err := connect.InitToken(os.Getenv(util.NODE_NAME_ENV), conf.TunnelConf.TunnlMode.EDGE.StreamEdge.Client.Token)
		if err != nil {
			klog.Errorf("initialize the edge node token err = %v", err)
			return
		}
	}
	model.Register(&Stream{})
	klog.Infof("init module: %s success !", util.STREAM)
}

InitStream首先判断tunnel是云端还是边缘,对于云端会执行InitDNS初始化coredns host plugins configmap刷新相关配置:

func InitDNS() error {
	coreDns = &CoreDns{
		Update: make(chan struct{}),
	}
	coreDns.PodIp = os.Getenv(util.POD_IP_ENV)
	klog.Infof("endpoint of the proxycloud pod = %s ", coreDns.PodIp)
	config, err := rest.InClusterConfig()
	if err != nil {
		klog.Errorf("client-go get inclusterconfig  fail err = %v", err)
		return err
	}
	clientset, err := kubernetes.NewForConfig(config)
	if err != nil {
		klog.Errorf("get client fail err = %v", err)
		return err
	}
	coreDns.ClientSet = clientset
	coreDns.Namespace = os.Getenv(util.POD_NAMESPACE_ENV)
	return nil
}

coreDns.PodIp初始化为云端tunnel pod ip;coredns.Namespace初始化为云端tunnel pod所属namespace;同时根据kubeconfig创建kubeclient(inCluster模式)

而对于边端则会执行InitToken初始化clientToken,包括边缘节点名称以及通信携带的token

// github.com/superedge/superedge/pkg/tunnel/proxy/stream/streammng/connect/streaminterceptor.go
var clientToken string
...
func InitToken(nodeName, tk string) error {
	var err error
	clientToken, err = token.GetTonken(nodeName, tk)
	klog.Infof("stream clinet token nodename = %s token = %s", nodeName, tk)
	if err != nil {
		klog.Error("client get token fail !")
	}
	return err
}

最后会注册stream模块(grpc连接隧道)

  • tcp.InitTcp:注册了Tcp代理模块(建立在grpc隧道上)
  • https.InitHttps:注册了https代理模块(建立在grpc隧道上)
  • LoadModules:加载各模块,会执行上述已注册模块的Start函数 ```go func LoadModules(mode string) { modules := GetModules() for n, m := range modules { context.GetContext().AddModule(n) klog.Infof(“starting module:%s”, m.Name()) m.Start(mode) klog.Infof(“start module:%s success !”, m.Name()) }

}


如下分别介绍stream,tcpProxy以及https模块的Start函数:

1、stream(grpc云边隧道)

```go
func (stream *Stream) Start(mode string) {
	context.GetContext().RegisterHandler(util.STREAM_HEART_BEAT, util.STREAM, streammsg.HeartbeatHandler)
	var channelzAddr string
	if mode == util.CLOUD {
		go connect.StartServer()
		if !conf.TunnelConf.TunnlMode.Cloud.Stream.Dns.Debug {
			go connect.SynCorefile()
		}
		channelzAddr = conf.TunnelConf.TunnlMode.Cloud.Stream.Server.ChannelzAddr
	} else {
		go connect.StartSendClient()
		channelzAddr = conf.TunnelConf.TunnlMode.EDGE.StreamEdge.Client.ChannelzAddr
	}

	go connect.StartLogServer(mode)

	go connect.StartChannelzServer(channelzAddr)
}

首先调用RegisterHandler注册心跳消息处理函数HeartbeatHandler,其中util.STREAM以及util.STREAM_HEART_BEAT分别对应StreamMsg的category以及type字段

如果tunnel位于云端,则启动grpc server并监听StreamServer.GrpcPort,如下:

func StartServer() {
	creds, err := credentials.NewServerTLSFromFile(conf.TunnelConf.TunnlMode.Cloud.Stream.Server.Cert, conf.TunnelConf.TunnlMode.Cloud.Stream.Server.Key)
	if err != nil {
		klog.Errorf("failed to create credentials: %v", err)
		return
	}
	opts := []grpc.ServerOption{grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp), grpc.StreamInterceptor(ServerStreamInterceptor), grpc.Creds(creds)}
	s := grpc.NewServer(opts...)
	proto.RegisterStreamServer(s, &stream.Server{})

	lis, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(conf.TunnelConf.TunnlMode.Cloud.Stream.Server.GrpcPort))
	klog.Infof("the https server of the cloud tunnel  listen on %s", "0.0.0.0:"+strconv.Itoa(conf.TunnelConf.TunnlMode.Cloud.Stream.Server.GrpcPort))
	if err != nil {
		klog.Fatalf("failed to listen: %v", err)
		return
	}
	if err := s.Serve(lis); err != nil {
		klog.Fatalf("failed to serve: %v", err)
		return
	}
}

之后会调用SynCorefile执行同步coredns host plugins configmap刷新逻辑,每隔一分钟执行一次checkHosts,如下:

func SynCorefile() {
	for {
		klog.V(8).Infof("connected node total = %d nodes = %v", len(context.GetContext().GetNodes()), context.GetContext().GetNodes())
		err := coreDns.checkHosts()
		if err != nil {
			klog.Errorf("failed to synchronize hosts periodically err = %v", err)
		}
		time.Sleep(60 * time.Second)
	}
}

而checkHosts负责configmap具体的刷新操作:

func (dns *CoreDns) checkHosts() error {
	nodes, flag := parseHosts()
	if !flag {
		return nil
	}
	var hostsBuffer bytes.Buffer
	for k, v := range nodes {
		hostsBuffer.WriteString(v)
		hostsBuffer.WriteString("    ")
		hostsBuffer.WriteString(k)
		hostsBuffer.WriteString("\n")
	}
	cm, err := dns.ClientSet.CoreV1().ConfigMaps(dns.Namespace).Get(cctx.TODO(), conf.TunnelConf.TunnlMode.Cloud.Stream.Dns.Configmap, metav1.GetOptions{})
	if err != nil {
		klog.Errorf("get configmap fail err = %v", err)
		return err
	}
	if hostsBuffer.Len() != 0 {
		cm.Data[util.COREFILE_HOSTS_FILE] = hostsBuffer.String()
	} else {
		cm.Data[util.COREFILE_HOSTS_FILE] = ""
	}
	_, err = dns.ClientSet.CoreV1().ConfigMaps(dns.Namespace).Update(cctx.TODO(), cm, metav1.UpdateOptions{})
	if err != nil {
		klog.Errorf("update configmap fail err = %v", err)
		return err
	}
	klog.Infof("update configmap success!")
	return nil
}

首先调用parseHosts获取所有云端tunnel连接的边缘节点名称以及对应云端tunnel pod ip映射列表(并更新本tunnel连接的边缘节点映射列表),然后写入hostsBuffer(tunnel pod ip nodeName形式),如果有变化则将这个内容覆盖写入configmap并更新:

img

另外,这里云端tunnel引入configmap本地挂载文件的目的是:优化托管模式下众多集群同时同步coredns时的性能

而如果tunnel位于边端,则会调用StartSendClient进行隧道的打通:

func StartSendClient() {
	conn, clictx, cancle, err := StartClient()
	if err != nil {
		klog.Error("edge start client error !")
		klog.Flush()
		os.Exit(1)
	}
	streamConn = conn
	defer func() {
		conn.Close()
		cancle()
	}()

	go func(monitor *grpc.ClientConn) {
		mcount := 0
		for {
			if conn.GetState() == connectivity.Ready {
				mcount = 0
			} else {
				mcount += 1
			}
			klog.V(8).Infof("grpc connection status = %s count = %v", conn.GetState(), mcount)
			if mcount >= util.TIMEOUT_EXIT {
				klog.Error("grpc connection rebuild timed out, container exited !")
				klog.Flush()
				os.Exit(1)
			}
			klog.V(8).Infof("grpc connection status of node = %v", conn.GetState())
			time.Sleep(1 * time.Second)
		}
	}(conn)
	running := true
	count := 0
	for running {
		if conn.GetState() == connectivity.Ready {
			cli := proto.NewStreamClient(conn)
			stream.Send(cli, clictx)
			count = 0
		}
		count += 1
		klog.V(8).Infof("node connection status = %s count = %v", conn.GetState(), count)
		time.Sleep(1 * time.Second)
		if count >= util.TIMEOUT_EXIT {
			klog.Error("the streamClient retrying to establish a connection timed out and the container exited !")
			klog.Flush()
			os.Exit(1)
		}
	}
}

首先调用StartClient根据云端tunnel域名构建证书,对云端tunnel服务地址调用grpc.Dial创建grpc连接,并返回grpc.ClientConn

func StartClient() (*grpc.ClientConn, ctx.Context, ctx.CancelFunc, error) {
	creds, err := credentials.NewClientTLSFromFile(conf.TunnelConf.TunnlMode.EDGE.StreamEdge.Client.Cert, conf.TunnelConf.TunnlMode.EDGE.StreamEdge.Client.Dns)
	if err != nil {
		klog.Errorf("failed to load credentials: %v", err)
		return nil, nil, nil, err
	}
	opts := []grpc.DialOption{grpc.WithKeepaliveParams(kacp), grpc.WithStreamInterceptor(ClientStreamInterceptor), grpc.WithTransportCredentials(creds)}
	conn, err := grpc.Dial(conf.TunnelConf.TunnlMode.EDGE.StreamEdge.Client.ServerName, opts...)
	if err != nil {
		klog.Error("edge start client fail !")
		return nil, nil, nil, err
	}
	clictx, cancle := ctx.WithTimeout(ctx.Background(), time.Duration(math.MaxInt64))
	return conn, clictx, cancle, nil
}

在调用grpc.Dial时会传递grpc.WithStreamInterceptor(ClientStreamInterceptor) DialOption,将ClientStreamInterceptor作为StreamClientInterceptor传递给grpc.ClientConn

之后等待grpc连接状态变为Ready,然后执行Send函数。streamClient.TunnelStreaming调用StreamClientInterceptor返回wrappedClientStream对象

func ClientStreamInterceptor(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
	var credsConfigured bool
	for _, o := range opts {
		_, ok := o.(*grpc.PerRPCCredsCallOption)
		if ok {
			credsConfigured = true
		}
	}
	if !credsConfigured {
		opts = append(opts, grpc.PerRPCCredentials(oauth.NewOauthAccess(&oauth2.Token{
			AccessToken: clientToken,
		})))
	}
	s, err := streamer(ctx, desc, cc, method, opts...)
	if err != nil {
		return nil, err
	}
	return newClientWrappedStream(s), nil
}

func newClientWrappedStream(s grpc.ClientStream) grpc.ClientStream {
	return &wrappedClientStream{s, false}
}

type wrappedClientStream struct {
	grpc.ClientStream
	restart bool
}

func Send(client proto.StreamClient, clictx ctx.Context) {
	stream, err := client.TunnelStreaming(clictx)
	if err != nil {
		klog.Error("EDGE-SEND fetch stream failed !")
		return
	}
	klog.Info("streamClient created successfully")
	errChan := make(chan error, 2)
	go func(send proto.Stream_TunnelStreamingClient, sc chan error) {
		sendErr := send.SendMsg(nil)
		if sendErr != nil {
			klog.Errorf("streamClient failed to send message err = %v", sendErr)
		}
		sc <- sendErr
	}(stream, errChan)

	go func(recv proto.Stream_TunnelStreamingClient, rc chan error) {
		recvErr := recv.RecvMsg(nil)
		if recvErr != nil {
			klog.Errorf("streamClient failed to receive message err = %v", recvErr)
		}
		rc <- recvErr
	}(stream, errChan)

	e := <-errChan
	klog.Errorf("the stream of streamClient is disconnected err = %v", e)
	err = stream.CloseSend()
	if err != nil {
		klog.Errorf("failed to close stream send err: %v", err)
	}
}

ClientStreamInterceptor会将边缘节点名称以及token构造成oauth2.Token.AccessToken进行认证传递,并构建wrappedClientStream

stream.Send会并发调用wrappedClientStream.SendMsg以及wrappedClientStream.RecvMsg分别用于边端tunnel发送以及接受,并阻塞等待

注意:tunnel edge向tunnel cloud注册节点信息是在建立grpc stream时,而不是创建grpc.connClient的时候

整个过程如下图所示:

img

SendMsg会起goroutine每隔1分钟构建心跳StreamMsg,并通过node.ch传递。同时不断从node.ch中获取StreamMsg,并调用ClientStream.SendMsg发送StreamMsg给云端tunnel

func (w *wrappedClientStream) SendMsg(m interface{}) error {
	if m != nil {
		return w.ClientStream.SendMsg(m)
	}
	nodeName := os.Getenv(util.NODE_NAME_ENV)
	node := ctx.GetContext().AddNode(nodeName)
	klog.Infof("node added successfully node = %s", nodeName)
	stopHeartbeat := make(chan struct{}, 1)
	defer func() {
		stopHeartbeat <- struct{}{}
		ctx.GetContext().RemoveNode(nodeName)
		klog.Infof("node removed successfully node = %s", nodeName)
	}()
	go func(hnode ctx.Node, hw *wrappedClientStream, heartbeatStop chan struct{}) {
		count := 0
		for {
			select {
			case <-time.After(60 * time.Second):
				if w.restart {
					klog.Errorf("streamClient failed to receive heartbeat message count:%v", count)
					if count >= 1 {
						klog.Error("streamClient receiving heartbeat timeout, container exits")
						klog.Flush()
						os.Exit(1)
					}
					hnode.Send2Node(&proto.StreamMsg{
						Node:     os.Getenv(util.NODE_NAME_ENV),
						Category: util.STREAM,
						Type:     util.CLOSED,
					})
					count += 1
				} else {
					hnode.Send2Node(&proto.StreamMsg{
						Node:     os.Getenv(util.NODE_NAME_ENV),
						Category: util.STREAM,
						Type:     util.STREAM_HEART_BEAT,
						Topic:    os.Getenv(util.NODE_NAME_ENV) + util.STREAM_HEART_BEAT,
					})
					klog.V(8).Info("streamClient send heartbeat message")
					w.restart = true
					count = 0
				}
			case <-heartbeatStop:
				klog.Error("streamClient exits heartbeat sending")
				return
			}
		}
	}(node, w, stopHeartbeat)
	for {
		msg := <-node.NodeRecv()
		if msg.Category == util.STREAM && msg.Type == util.CLOSED {
			klog.Error("streamClient turns off message sending")
			return fmt.Errorf("streamClient stops sending messages to server node: %s", os.Getenv(util.NODE_NAME_ENV))
		}
		klog.V(8).Infof("streamClinet starts to send messages to the server node: %s uuid: %s", msg.Node, msg.Topic)
		err := w.ClientStream.SendMsg(msg)
		if err != nil {
			klog.Errorf("streamClient failed to send message err = %v", err)
			return err
		}
		klog.V(8).Infof("streamClinet successfully send a message to the server node: %s uuid: %s", msg.Node, msg.Topic)
	}
}

而RecvMsg会一直接受云端tunnel的StreamMsg,如果StreamMsg为心跳消息,则重置restart参数,使得边端tunnel继续发送心跳;若为其它类型消息,则调用该消息对应的处理函数进行操作:

func (w *wrappedClientStream) RecvMsg(m interface{}) error {
	if m != nil {
		return w.ClientStream.RecvMsg(m)
	}
	for {
		msg := &proto.StreamMsg{}
		err := w.ClientStream.RecvMsg(msg)
		if err != nil {
			klog.Error("streamClient failed to receive message")
			node := ctx.GetContext().GetNode(os.Getenv(util.NODE_NAME_ENV))
			if node != nil {
				node.Send2Node(&proto.StreamMsg{
					Node:     os.Getenv(util.NODE_NAME_ENV),
					Category: util.STREAM,
					Type:     util.CLOSED,
				})
			}
			return err
		}
		klog.V(8).Infof("streamClient recv msg node: %s uuid: %s", msg.Node, msg.Topic)
		if msg.Category == util.STREAM && msg.Type == util.STREAM_HEART_BEAT {
			klog.V(8).Info("streamClient received heartbeat message")
			w.restart = false
			continue
		}
		ctx.GetContext().Handler(msg, msg.Type, msg.Category)
	}
}

相应的,在初始化云端tunnel时,会将grpc.StreamInterceptor(ServerStreamInterceptor)构建成grpc ServerOption,并将ServerStreamInterceptor作为StreamServerInterceptor传递给grpc.Server:

func StartServer() {
	creds, err := credentials.NewServerTLSFromFile(conf.TunnelConf.TunnlMode.Cloud.Stream.Server.Cert, conf.TunnelConf.TunnlMode.Cloud.Stream.Server.Key)
	if err != nil {
		klog.Errorf("failed to create credentials: %v", err)
		return
	}
	opts := []grpc.ServerOption{grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp), grpc.StreamInterceptor(ServerStreamInterceptor), grpc.Creds(creds)}
	s := grpc.NewServer(opts...)
	proto.RegisterStreamServer(s, &stream.Server{})

	lis, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(conf.TunnelConf.TunnlMode.Cloud.Stream.Server.GrpcPort))
	klog.Infof("the https server of the cloud tunnel  listen on %s", "0.0.0.0:"+strconv.Itoa(conf.TunnelConf.TunnlMode.Cloud.Stream.Server.GrpcPort))
	if err != nil {
		klog.Fatalf("failed to listen: %v", err)
		return
	}
	if err := s.Serve(lis); err != nil {
		klog.Fatalf("failed to serve: %v", err)
		return
	}
}

云端grpc服务在接受到边端tunnel请求(建立stream流)时,会调用ServerStreamInterceptor,而ServerStreamInterceptor会从grpc metadata中解析出此grpc连接对应的边缘节点名和token,并对该token进行校验,然后根据节点名构建wrappedServerStream作为与该边缘节点通信的处理对象(每个边缘节点对应一个处理对象),handler函数会调用stream.TunnelStreaming,并将wrappedServerStream传递给它(wrappedServerStream实现了proto.Stream_TunnelStreamingServer接口)

func ServerStreamInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
	klog.Info("start verifying the token !")
	md, ok := metadata.FromIncomingContext(ss.Context())
	if !ok {
		klog.Error("missing metadata")
		return ErrMissingMetadata
	}
	if len(md["authorization"]) < 1 {
		klog.Errorf("failed to obtain token")
		return fmt.Errorf("failed to obtain token")
	}
	tk := strings.TrimPrefix(md["authorization"][0], "Bearer ")
	auth, err := token.ParseToken(tk)
	if err != nil {
		klog.Error("token deserialization failed !")
		return err
	}
	if auth.Token != token.GetTokenFromCache(auth.NodeName) {
		klog.Errorf("invalid token node = %s", auth.NodeName)
		return ErrInvalidToken
	}
	klog.Infof("token verification successful node = %s", auth.NodeName)
	err = handler(srv, newServerWrappedStream(ss, auth.NodeName))
	if err != nil {
		ctx.GetContext().RemoveNode(auth.NodeName)
		klog.Errorf("node disconnected node = %s err = %v", auth.NodeName, err)
	}
	return err
}

func newServerWrappedStream(s grpc.ServerStream, node string) grpc.ServerStream {
	return &wrappedServerStream{s, node}
}

type wrappedServerStream struct {
	grpc.ServerStream
	node string
}

func ParseToken(token string) (*Token, error) {
	rtoken := &Token{}
	err := json.Unmarshal([]byte(token), rtoken)
	if err != nil {
		return rtoken, err
	}
	return rtoken, nil
}

而当TunnelStreaming方法退出时,就会执ServerStreamInterceptor移除节点的逻辑ctx.GetContext().RemoveNode

TunnelStreaming会并发调用wrappedServerStream.SendMsg以及wrappedServerStream.RecvMsg分别用于云端tunnel发送以及接受,并阻塞等待:

type Server struct{}

func (s *Server) TunnelStreaming(stream proto.Stream_TunnelStreamingServer) error {
	errChan := make(chan error, 2)

	go func(sendStream proto.Stream_TunnelStreamingServer, sendChan chan error) {
		sendErr := sendStream.SendMsg(nil)
		if sendErr != nil {
			klog.Errorf("streamServer failed to send message err = %v", sendErr)
		}
		sendChan <- sendErr
	}(stream, errChan)

	go func(recvStream proto.Stream_TunnelStreamingServer, recvChan chan error) {
		recvErr := stream.RecvMsg(nil)
		if recvErr != nil {
			klog.Errorf("streamServer failed to receive message err = %v", recvErr)
		}
		recvChan <- recvErr
	}(stream, errChan)

	e := <-errChan
	klog.Errorf("the stream of streamServer is disconnected err = %v", e)
	return e
}

SendMsg会从wrappedServerStream对应边缘节点node中接受StreamMsg,并调用ServerStream.SendMsg发送该消息给边缘tunnel

func (w *wrappedServerStream) SendMsg(m interface{}) error {
	if m != nil {
		return w.ServerStream.SendMsg(m)
	}
	node := ctx.GetContext().AddNode(w.node)
	klog.Infof("node added successfully node = %s", node.GetName())
	defer klog.Infof("streamServer no longer sends messages to edge node: %s", w.node)
	for {
		msg := <-node.NodeRecv()
		if msg.Category == util.STREAM && msg.Type == util.CLOSED {
			klog.Error("streamServer turns off message sending")
			return fmt.Errorf("streamServer stops sending messages to node: %s", w.node)
		}
		klog.V(8).Infof("streamServer starts to send messages to the client node: %s uuid: %s", msg.Node, msg.Topic)
		err := w.ServerStream.SendMsg(msg)
		if err != nil {
			klog.Errorf("streamServer failed to send a message to the edge node: %s", w.node)
			return err
		}
		klog.V(8).Infof("StreamServer sends a message to the client successfully node: %s uuid: %s", msg.Node, msg.Topic)
	}
}

而RecvMsg会不断接受来自边缘tunnel的StreamMsg,并调用该消息对应的处理函数进行操作,例如心跳消息对应HeartbeatHandler:

func (w *wrappedServerStream) RecvMsg(m interface{}) error {
	if m != nil {
		return w.ServerStream.RecvMsg(m)
	}
	defer klog.V(8).Infof("streamServer no longer receives messages from edge node: %s", w.node)
	for {
		msg := &proto.StreamMsg{}
		err := w.ServerStream.RecvMsg(msg)
		klog.V(8).Infof("streamServer receives messages node: %s ", w.node)
		if err != nil {
			klog.Errorf("streamServer failed to receive a message to the edge node: %s", w.node)
			node := ctx.GetContext().GetNode(w.node)
			if node != nil {
				node.Send2Node(&proto.StreamMsg{
					Node:     w.node,
					Category: util.STREAM,
					Type:     util.CLOSED,
				})
			}
			return err
		}
		klog.V(8).Infof("streamServer received the message successfully node: %s uuid: %s", msg.Node, msg.Topic)
		ctx.GetContext().Handler(msg, msg.Type, msg.Category)
	}
}

...
func HeartbeatHandler(msg *proto.StreamMsg) error {
	node := context.GetContext().GetNode(msg.Node)
	if node == nil {
		klog.Errorf("failed to send heartbeat to edge node node: %s", msg.Node)
		return fmt.Errorf("failed to send heartbeat to edge node node: %s", msg.Node)
	}
	node.Send2Node(msg)
	return nil
}

HeartbeatHandler会从msg.Node中获取边缘节点对应node,然后将该StreamMsg传递给node.ch,同时前面分析的wrappedServerStream.SendMsg会从该node.ch接受心跳StreamMsg并发送给边端

总结stream(grpc隧道)如下:

  • stream模块负责建立grpc连接以及通信(云边隧道)
  • 边缘节点上tunnel-edge主动连接云端tunnel-cloud service,tunnel-cloud service根据负载均衡策略将请求转到tunnel-cloud的具体pod上
  • tunnel-edge与tunnel-cloud建立grpc连接后,tunnel-cloud会把自身的podIp和tunnel-edge所在节点的nodeName的映射写入DNS(tunnel dns)。grpc连接断开之后,tunnel-cloud会删除相关podIp和节点名的映射
  • 边端tunnel会利用边缘节点名以及token构建grpc连接,而云端tunnel会通过认证信息解析grpc连接对应的边缘节点,并对每个边缘节点分别构建一个wrappedServerStream进行处理(同一个云端tunnel可以处理多个边缘节点tunnel的连接)
  • 云端tunnel每隔一分钟向coredns host plugins对应configmap同步一次边缘节点名以及tunnel pod ip的映射(并更新本tunnel连接的边缘节点映射列表);另外,引入configmap本地挂载文件优化了托管模式下众多集群同时同步coredns时的性能
  • 边端tunnel每隔一分钟会向云端tunnel发送代表该节点正常的心跳StreamMsg,而云端tunnel在接受到该心跳后会进行回应,并循环往复这个过程(心跳是为了探测grpc stream流是否正常)
  • StreamMsg包括心跳,tcp代理以及https请求等不同类型消息;同时云端tunnel通过context.node区分与不同边缘节点grpc的连接隧道

2、tcpProxy(tcp代理)

tcpProxy模块负责在多集群管理中建立云端与边缘的一条tcp代理隧道:

func (tcp *TcpProxy) Start(mode string) {
	context.GetContext().RegisterHandler(util.TCP_BACKEND, tcp.Name(), tcpmsg.BackendHandler)
	context.GetContext().RegisterHandler(util.TCP_FRONTEND, tcp.Name(), tcpmsg.FrontendHandler)
	context.GetContext().RegisterHandler(util.TCP_CONTROL, tcp.Name(), tcpmsg.ControlHandler)
	if mode == util.CLOUD {
		for front, backend := range conf.TunnelConf.TunnlMode.Cloud.Tcp {
			go func(front, backend string) {
				ln, err := net.Listen("tcp", front)
				if err != nil {
					klog.Errorf("cloud proxy start %s fail ,error = %s", front, err)
					return
				}
				defer ln.Close()
				klog.Infof("the tcp server of the cloud tunnel listen on %s\n", front)
				for {
					rawConn, err := ln.Accept()
					if err != nil {
						klog.Errorf("cloud proxy accept error!")
						return
					}
					nodes := context.GetContext().GetNodes()
					if len(nodes) == 0 {
						rawConn.Close()
						klog.Errorf("len(nodes)==0")
						continue
					}
					uuid := uuid.NewV4().String()
					node := nodes[0]
					fp := tcpmng.NewTcpConn(uuid, backend, node)
					fp.Conn = rawConn
					fp.Type = util.TCP_FRONTEND
					go fp.Write()
					go fp.Read()
				}
			}(front, backend)
		}
	}
}

Start函数首先注册了三种消息的处理函数:

  • category为TCP_BACKEND,type为tcp,对应处理函数为tcpmsg.BackendHandler
  • category为TCP_FRONTEND,type为tcp,对应处理函数为tcpmsg.FrontendHandler
  • category为TCP_CONTROL,type为tcp,对应处理函数为tcpmsg.ControlHandler

然后在云端监听TunnelConf.TunnlMode.Cloud.Tcp参数key端口,并在接受到云端组件的请求后获取边缘节点列表中的第一个节点构建TcpConn:

func NewTcpConn(uuid, addr, node string) *TcpConn {
	tcp := &TcpConn{
		uid:      uuid,
		stopChan: make(chan struct{}, 1),
		C:        context.GetContext().AddConn(uuid),
		Addr:     addr,
		n:        context.GetContext().AddNode(node),
	}
	tcp.n.BindNode(uuid)
	return tcp
}

func (edge *node) BindNode(uuid string) {
	edge.connsLock.Lock()
	if edge.conns == nil {
		edge.conns = &[]string{uuid}
	}
	edge.connsLock.Unlock()
}

这里会利用uuid创建context.conn,同时将该conn与node绑定;将TcpConn的type设置为TCP_FRONTEND,同时addr设置为边缘节点服务tcp监听地址以及端口,并异步执行TcpConn Read以及Write函数:

func (tcp *TcpConn) Read() {
	running := true
	for running {
		select {
		case <-tcp.stopChan:
			klog.Info("Disconnect tcp and stop receiving !")
			tcp.Conn.Close()
			running = false
		default:
			size := 32 * 1024
			if l, ok := interface{}(tcp.Conn).(*io.LimitedReader); ok && int64(size) > l.N {
				if l.N < 1 {
					size = 1
				} else {
					size = int(l.N)
				}
			}
			buf := make([]byte, size)
			n, err := tcp.Conn.Read(buf)
			if err != nil {
				klog.Errorf("conn read fail,err = %s ", err)
				tcp.cleanUp()
				break
			}
			tcp.n.Send2Node(&proto.StreamMsg{
				Category: util.TCP,
				Type:     tcp.Type,
				Topic:    tcp.uid,
				Data:     buf[0:n],
				Addr:     tcp.Addr,
				Node:     tcp.n.GetName(),
			})
			if err != nil {
				klog.Errorf("tcp conn failed to send a message to the node err = %v", err)
				running = false
				break
			}
		}
	}
}

tcp.Read会从云端组件与云端tunnel建立的tcp连接中不断读取数据,并构造StreamMsg:

  • category:util.TCP
  • Type:TCP_FRONTEND
  • Topic:tcp.uid
  • data:云端组件发送给云端tunnel的数据
  • addr:边缘节点tcp代理服务监听地址以及端口
  • node:边缘节点名称

从前面的分析可以知道在调用Send2Node后,stream SendMsg会从node.ch中获取该StreamMsg,并发送给边端tunnel

边端在接受到该StreamMsg后,会执行对应的处理函数,也即tcpmsg.FrontendHandler:

func FrontendHandler(msg *proto.StreamMsg) error {
	c := context.GetContext().GetConn(msg.Topic)
	if c != nil {
		c.Send2Conn(msg)
		return nil
	}
	tp := tcpmng.NewTcpConn(msg.Topic, msg.Addr, msg.Node)
	tp.Type = util.TCP_BACKEND
	tp.C.Send2Conn(msg)
	tcpAddr, err := net.ResolveTCPAddr("tcp", tp.Addr)
	if err != nil {
		klog.Error("edeg proxy resolve addr fail !")
		return err
	}
	conn, err := net.DialTCP("tcp", nil, tcpAddr)
	if err != nil {
		klog.Error("edge proxy connect fail!")
		return err
	}
	tp.Conn = conn
	go tp.Read()
	go tp.Write()
	return nil
}

FrontendHandler首先根据msg.Topic,msg.Addr以及msg.Node构建TcpConn,然后利用msg.Addr与边端的代理服务建立tcp连接,并将该连接赋值给TcpConn.Conn,因此边端tunnel创建出的TcpConn各字段含义如下:

  • uid:云端tunnel TcpConn对应的uid
  • Conn:边端tunnel与边端代理服务建立的tcp连接
  • Type:TCP_BACKEND
  • C:云端TcpConn对应的uid建立的context.conn
  • n:边缘节点名称
  • addr:边缘节点代理服务tcp监听地址及端口

之后会异步执行该TcpConn的Read以及Write函数,由于这里对StreamMsg执行了Send2Conn,因此会触发Write操作,如下:

func (tcp *TcpConn) Write() {
	running := true
	for running {
		select {
		case msg := <-tcp.C.ConnRecv():
			if msg.Type == util.TCP_CONTROL {
				tcp.cleanUp()
				break
			}
			_, err := tcp.Conn.Write(msg.Data)
			if err != nil {
				klog.Errorf("write conn fail err = %v", err)
				tcp.cleanUp()
				break
			}
		case <-tcp.stopChan:
			klog.Info("disconnect tcp and stop sending !")
			tcp.Conn.Close()
			tcp.closeOpposite()
			running = false
		}
	}
}

Write函数会从conn.ch中接收StreamMsg,并将msg.data利用tcp socket发送给边端代理服务。从而实现云端tunnel tcp代理的功能,数据流如下:

云端组件 -> 云端tunnel -> 边端tunnel -> 边端服务 

如果边端服务有回应,则Read函数会从tcp连接中读取回应数据并构建StreamMsg发送给云端tunnel

而云端tunnel在接收到回应StreamMsg后,会调用tcpmsg.BackendHandler进行处理:

func BackendHandler(msg *proto.StreamMsg) error {
	conn := context.GetContext().GetConn(msg.Topic)
	if conn == nil {
		klog.Errorf("trace_id = %s the stream module failed to distribute the side message module = %s type = %s", msg.Topic, msg.Category, msg.Type)
		return fmt.Errorf("trace_id = %s the stream module failed to distribute the side message module = %s type = %s ", msg.Topic, msg.Category, msg.Type)
	}
	conn.Send2Conn(msg)
	return nil
}

BackendHandler会根据msg.Topic(conn uid)获取conn,并调用conn.Send2Conn发送StreamMsg,而TcpConn.Write会接受该消息,并通过云端tunnel与云端组件建立的tcp连接将msg.data发送给云端组件,数据流如下:

边端服务 -> 边端tunnel -> 云端tunnel -> 云端组件 

架构如图所示:

img

总结如下:

  • tcp模块负责在多集群管理中建立云端与边端的tcp代理
  • 当云端组件与云端tunnel tcp代理建立连接时,云端tunnel会选择它所管理的边缘节点列表中第一个节点以及边端代理服务地址端口 创建代表tcp代理的结构体TcpConn,并从云端组件与云端tunnel建立的tcp连接中接受以及发送数据,之后转发给边端tunnel;边端tunnel在初次接受到云端tunnel发送的消息时,会与边端代理服务建立连接,并传输数据
  • 通过context.conn在tunnel grpc隧道与tcp代理之间中转StreamMsg。并区分各tcp代理连接

3、https(https代理)

https模块负责建立云边的https代理,将云端组件(例如:kube-apiserver)的https请求转发给边端服务(例如:kubelet)

func (https *Https) Start(mode string) {
	context.GetContext().RegisterHandler(util.CONNECTING, util.HTTPS, httpsmsg.ConnectingHandler)
	context.GetContext().RegisterHandler(util.CONNECTED, util.HTTPS, httpsmsg.ConnectedAndTransmission)
	context.GetContext().RegisterHandler(util.CLOSED, util.HTTPS, httpsmsg.ConnectedAndTransmission)
	context.GetContext().RegisterHandler(util.TRANSNMISSION, util.HTTPS, httpsmsg.ConnectedAndTransmission)
	if mode == util.CLOUD {
		go httpsmng.StartServer()
	}
}

Start函数首先注册了四种消息的处理函数:

  • category为https,type为CONNECTING,对应处理函数为httpsmsg.ConnectingHandler
  • category为https,type为CONNECTED,对应处理函数为httpsmsg.ConnectedAndTransmission
  • category为https,type为TRANSNMISSION,对应处理函数为httpsmsg.ConnectedAndTransmission
  • category为https,type为CLOSED,对应处理函数为httpsmsg.ConnectedAndTransmission

并在云端调用StartServer启动服务:

func StartServer() {
	cert, err := tls.LoadX509KeyPair(conf.TunnelConf.TunnlMode.Cloud.Https.Cert, conf.TunnelConf.TunnlMode.Cloud.Https.Key)
	if err != nil {
		klog.Errorf("client load cert fail certpath = %s keypath = %s \n", conf.TunnelConf.TunnlMode.Cloud.Https.Cert, conf.TunnelConf.TunnlMode.Cloud.Https.Key)
		return
	}
	config := &tls.Config{
		Certificates:       []tls.Certificate{cert},
		InsecureSkipVerify: true,
	}
	for k := range conf.TunnelConf.TunnlMode.Cloud.Https.Addr {
		serverHandler := &ServerHandler{
			port: k,
		}
		s := &http.Server{
			Addr:      "0.0.0.0:" + k,
			Handler:   serverHandler,
			TLSConfig: config,
		}
		klog.Infof("the https server of the cloud tunnel listen on %s", s.Addr)
		go func(server *http.Server) {
			err = s.ListenAndServeTLS("", "")
			if err != nil {
				klog.Errorf("server start fail,add = %s err = %v", s.Addr, err)
			}
		}(s)
	}
}

这里云端tunnel会将ServerHandler设置为http.Server的handler,并监听TunnelConf.TunnlMode.Cloud.Https.Addr key端口(10250)。ServerHandler处理逻辑如下:

func (serverHandler *ServerHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
	var nodeName string
	nodeinfo := strings.Split(request.Host, ":")
	if context.GetContext().NodeIsExist(nodeinfo[0]) {
		nodeName = nodeinfo[0]
	} else {
		nodeName = request.TLS.ServerName
	}
	node := context.GetContext().GetNode(nodeName)
	if node == nil {
		fmt.Fprintf(writer, "edge node disconnected node = %s", nodeinfo[0])
		return
	}
	uid := uuid.NewV4().String()
	node.BindNode(uid)
	conn := context.GetContext().AddConn(uid)

	requestBody, err := ioutil.ReadAll(request.Body)
	if err != nil {
		klog.Errorf("traceid = %s read request body fail err = %v ", uid, err)
		fmt.Fprintf(writer, "traceid = %s read request body fail err = %v ", uid, err)
		return
	}
	httpmsg := &HttpsMsg{
		HttpsStatus: util.CONNECTING,
		Header:      make(map[string]string),
		Method:      request.Method,
		HttpBody:    requestBody,
	}
	for k, v := range request.Header {
		for _, vv := range v {
			httpmsg.Header[k] = vv
		}
	}
	bmsg := httpmsg.Serialization()
	if len(bmsg) == 0 {
		klog.Errorf("traceid = %s httpsmsg serialization failed err = %v req = %v serverName = %s", uid, err, request, request.TLS.ServerName)
		fmt.Fprintf(writer, "traceid = %s httpsmsg serialization failed err = %v", uid, err)
		return
	}
	node.Send2Node(&proto.StreamMsg{
		Node:     nodeName,
		Category: util.HTTPS,
		Type:     util.CONNECTING,
		Topic:    uid,
		Data:     bmsg,
		Addr:     "https://" + conf.TunnelConf.TunnlMode.Cloud.Https.Addr[serverHandler.port] + request.URL.String(),
	})
	if err != nil {
		klog.Errorf("traceid = %s httpsServer send request msg failed err = %v", uid, err)
		fmt.Fprintf(writer, "traceid = %s httpsServer send request msg failed err = %v", uid, err)
		return
	}
	resp := <-conn.ConnRecv()
	rmsg, err := Deserialization(resp.Data)
	if err != nil {
		klog.Errorf("traceid = %s httpsmag deserialization failed err = %v", uid, err)
		fmt.Fprintf(writer, "traceid = %s httpsmag deserialization failed err = %v", uid, err)
		return
	}
	node.Send2Node(&proto.StreamMsg{
		Node:     nodeName,
		Category: util.HTTPS,
		Type:     util.CONNECTED,
		Topic:    uid,
	})
	if err != nil {
		klog.Errorf("traceid = %s httpsServer send confirm msg failed err = %v", uid, err)
		fmt.Fprintf(writer, "traceid = %s httpsServer send confirm msg failed err = %v", uid, err)
		return
	}
	if rmsg.StatusCode != http.StatusSwitchingProtocols {
		handleServerHttp(rmsg, writer, request, node, conn)
	} else {
		handleServerSwitchingProtocols(writer, node, conn)
	}
}

当云端组件向云端tunnel发送https请求时,serverHandler会首先从request.Host字段解析节点名,若先建立TLS连接,然后在连接中写入http的request对象,此时的request.Host可以不设置,则需要从request.TLS.ServerName解析节点名,这里解释一下这样做的原因:

由于apiserver或者其它组件本来要访问的对象是边缘节点上的某个服务,通过coredns DNS劫持后,会将host中的节点名解析为tunnel-cloud的podIp,但是host以及request.TLS.ServerName依旧保持不变,因此可以通过解析这两个字段得出要访问的边缘节点名称

之后读取request.Body以及request.Header构建HttpsMsg结构体,并序列化。之后创建StreamMsg,各字段含义如下:

  • Node:边缘节点名
  • Category:util.HTTPS
  • Type:util.CONNECTING
  • Topic:conn uid
  • Data:序列化后的HttpsMsg
  • Addr:边缘节点https服务访问URL

之后通过Send2Node传递该StreamMsg,而stream SendMsg会接受该消息并发送给对应边缘节点

边缘节点会接受该消息,并执行上述注册的httpsmsg.ConnectingHandler函数:

func ConnectingHandler(msg *proto.StreamMsg) error {
	go httpsmng.Request(msg)
	return nil
}

func Request(msg *proto.StreamMsg) {
	httpConn, err := getHttpConn(msg)
	if err != nil {
		klog.Errorf("traceid = %s failed to get httpclient httpConn err = %v", msg.Topic, err)
		return
	}
	rawResponse := bytes.NewBuffer(make([]byte, 0, util.MaxResponseSize))
	rawResponse.Reset()
	respReader := bufio.NewReader(io.TeeReader(httpConn, rawResponse))
	resp, err := http.ReadResponse(respReader, nil)
	if err != nil {
		klog.Errorf("traceid = %s httpsclient read response failed err = %v", msg.Topic, err)
		return
	}

	bodyMsg := HttpsMsg{
		StatusCode:  resp.StatusCode,
		HttpsStatus: util.CONNECTED,
		Header:      make(map[string]string),
	}
	for k, v := range resp.Header {
		for _, vv := range v {
			bodyMsg.Header[k] = vv
		}
	}
	msgData := bodyMsg.Serialization()
	if len(msgData) == 0 {
		klog.Errorf("traceid = %s httpsclient httpsmsg serialization failed", msg.Topic)
		return
	}
	node := context.GetContext().GetNode(msg.Node)
	if node == nil {
		klog.Errorf("traceid = %s httpClient failed to get node", msg.Topic)
		return
	}
	node.Send2Node(&proto.StreamMsg{
		Node:     msg.Node,
		Category: msg.Category,
		Type:     util.CONNECTED,
		Topic:    msg.Topic,
		Data:     msgData,
	})
	conn := context.GetContext().AddConn(msg.Topic)
	node.BindNode(msg.Topic)
	confirm := true
	for confirm {
		confirmMsg := <-conn.ConnRecv()
		if confirmMsg.Type == util.CONNECTED {
			confirm = false
		}
	}
	if resp.StatusCode != http.StatusSwitchingProtocols {
		handleClientHttp(resp, rawResponse, httpConn, msg, node, conn)
	} else {
		handleClientSwitchingProtocols(httpConn, rawResponse, msg, node, conn)
	}
}

func getHttpConn(msg *proto.StreamMsg) (net.Conn, error) {
	cert, err := tls.LoadX509KeyPair(conf.TunnelConf.TunnlMode.EDGE.Https.Cert, conf.TunnelConf.TunnlMode.EDGE.Https.Key)
	if err != nil {
		klog.Errorf("tranceid = %s httpsclient load cert fail certpath = %s keypath = %s", msg.Topic, conf.TunnelConf.TunnlMode.EDGE.Https.Cert, conf.TunnelConf.TunnlMode.EDGE.Https.Key)
		return nil, err
	}
	requestMsg, err := Deserialization(msg.Data)
	if err != nil {
		klog.Errorf("traceid = %s httpsclient deserialization failed err = %v", msg.Topic, err)
		return nil, err
	}
	request, err := http.NewRequest(requestMsg.Method, msg.Addr, bytes.NewBuffer(requestMsg.HttpBody))
	if err != nil {
		klog.Errorf("traceid = %s httpsclient get request fail err = %v", msg.Topic, err)
		return nil, err
	}
	for k, v := range requestMsg.Header {
		request.Header.Add(k, v)
	}
	conn, err := tls.Dial("tcp", request.Host, &tls.Config{
		Certificates:       []tls.Certificate{cert},
		InsecureSkipVerify: true,
	})
	if err != nil {
		klog.Errorf("traceid = %s httpsclient request failed err = %v", msg.Topic, err)
		return nil, err
	}
	err = request.Write(conn)
	if err != nil {
		klog.Errorf("traceid = %s https clinet request failed to write conn err = %v", msg.Topic, err)
		return nil, err
	}
	return conn, nil
}

ConnectingHandler会调用Request对该StreamMsg进行处理。Reqeust首先通过getHttpConn发起对StreamMsg.Addr也即边缘节点https服务的https请求,请求内容复用了云端组件对云端tunnel的请求(method,header,body等)。并返回了建立的tls连接

之后通过tls连接创建了respReader,并调用http.ReadResponse读取resp.StatusCode以及resp.Header构建出HttpsMsg(HttpsStatus为util.CONNECTED),序列化后作为StreamMsg的数据部分发送给云端tunnel

云端tunnel在接受到该StreamMsg后,会调用ConnectedAndTransmission进行处理:

func ConnectedAndTransmission(msg *proto.StreamMsg) error {
	conn := context.GetContext().GetConn(msg.Topic)
	if conn == nil {
		klog.Errorf("trace_id = %s the stream module failed to distribute the side message module = %s type = %s", msg.Topic, msg.Category, msg.Type)
		return fmt.Errorf("trace_id = %s the stream module failed to distribute the side message module = %s type = %s", msg.Topic, msg.Category, msg.Type)
	}
	conn.Send2Conn(msg)
	return nil
}

通过msg.Topic(conn uid)获取conn,并通过Send2Conn将消息塞到该conn对应的管道中

而ServerHandler之前在发送完CONNECTING的HttpsMsg后就一直处于阻塞等待conn的管道中:

func (serverHandler *ServerHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
    ...
	node.Send2Node(&proto.StreamMsg{
		Node:     nodeName,
		Category: util.HTTPS,
		Type:     util.CONNECTING,
		Topic:    uid,
		Data:     bmsg,
		Addr:     "https://" + conf.TunnelConf.TunnlMode.Cloud.Https.Addr[serverHandler.port] + request.URL.String(),
	})
	if err != nil {
		klog.Errorf("traceid = %s httpsServer send request msg failed err = %v", uid, err)
		fmt.Fprintf(writer, "traceid = %s httpsServer send request msg failed err = %v", uid, err)
		return
	}
	resp := <-conn.ConnRecv()
	rmsg, err := Deserialization(resp.Data)
	if err != nil {
		klog.Errorf("traceid = %s httpsmag deserialization failed err = %v", uid, err)
		fmt.Fprintf(writer, "traceid = %s httpsmag deserialization failed err = %v", uid, err)
		return
	}
	node.Send2Node(&proto.StreamMsg{
		Node:     nodeName,
		Category: util.HTTPS,
		Type:     util.CONNECTED,
		Topic:    uid,
	})
	if err != nil {
		klog.Errorf("traceid = %s httpsServer send confirm msg failed err = %v", uid, err)
		fmt.Fprintf(writer, "traceid = %s httpsServer send confirm msg failed err = %v", uid, err)
		return
	}
    ...
}

在接受到来自边端tunnel的CONNECTED回应之后,会继续发送CONNECTED StreamMsg给云端tunnel,而边端tunnel这个时候一直等待接受来自云端tunnel的CONNECTED StreamMsg:

func Request(msg *proto.StreamMsg) {
    ...
	node.Send2Node(&proto.StreamMsg{
		Node:     msg.Node,
		Category: msg.Category,
		Type:     util.CONNECTED,
		Topic:    msg.Topic,
		Data:     msgData,
	})
	conn := context.GetContext().AddConn(msg.Topic)
	node.BindNode(msg.Topic)
	confirm := true
	for confirm {
		confirmMsg := <-conn.ConnRecv()
		if confirmMsg.Type == util.CONNECTED {
			confirm = false
		}
	}
	if resp.StatusCode != http.StatusSwitchingProtocols {
		handleClientHttp(resp, rawResponse, httpConn, msg, node, conn)
	} else {
		handleClientSwitchingProtocols(httpConn, rawResponse, msg, node, conn)
	}
}

在接受到云端的CONNECTED消息之后,认为https代理成功建立。并继续执行handleClientHttp or handleClientSwitchingProtocols进行数据传输,这里只分析handleClientHttp非协议提升下的数据传输过程,如下:

func handleClientHttp(resp *http.Response, rawResponse *bytes.Buffer, httpConn net.Conn, msg *proto.StreamMsg, node context.Node, conn context.Conn) {
	readCh := make(chan *proto.StreamMsg, util.MSG_CHANNEL_CAP)
	stop := make(chan struct{})
	go func(read chan *proto.StreamMsg, response *http.Response, buf *bytes.Buffer, stopRead chan struct{}) {
		rrunning := true
		for rrunning {
			bbody := make([]byte, util.MaxResponseSize)
			n, err := response.Body.Read(bbody)
			respMsg := &proto.StreamMsg{
				Node:     msg.Node,
				Category: msg.Category,
				Type:     util.CONNECTED,
				Topic:    msg.Topic,
				Data:     bbody[:n],
			}
			if err != nil {
				if err == io.EOF {
					klog.V(4).Infof("traceid = %s httpsclient read fail err = %v", msg.Topic, err)
				} else {
					klog.Errorf("traceid = %s httpsclient read fail err = %v", msg.Topic, err)
				}
				rrunning = false
				respMsg.Type = util.CLOSED
			} else {
				respMsg.Type = util.TRANSNMISSION
				buf.Reset()
			}
			read <- respMsg
		}
		<-stop
		close(read)
	}(readCh, resp, rawResponse, stop)
	running := true
	for running {
		select {
		case cloudMsg := <-conn.ConnRecv():
			if cloudMsg.Type == util.CLOSED {
				klog.Infof("traceid = %s httpsclient receive close msg", msg.Topic)
				httpConn.Close()
				stop <- struct{}{}
			}
		case respMsg := <-readCh:
			if respMsg == nil {
				running = false
				break
			}
			node.Send2Node(respMsg)
			if respMsg.Type == util.CLOSED {
				stop <- struct{}{}
				klog.V(4).Infof("traceid = %s httpsclient read fail !", msg.Topic)
				running = false
			}

		}
	}
	node.UnbindNode(conn.GetUid())
	context.GetContext().RemoveConn(conn.GetUid())
}

这里handleClientHttp会一直尝试读取来自边端组件的数据包,并构建成TRANSNMISSION类型的StreamMsg发送给云端tunnel,云端tunnel在接受到该消息后会执行ConnectedAndTransmission,并将该消息塞到代表https代理请求的conn管道中

而云端tunnel在发送完CONNECTED消息之后,会继续执行handleServerHttp(非协议提升) or handleServerSwitchingProtocols(协议提升)处理https数据传输,这里只分析非协议提升下的数据传输:

func handleServerHttp(rmsg *HttpsMsg, writer http.ResponseWriter, request *http.Request, node context.Node, conn context.Conn) {
	for k, v := range rmsg.Header {
		writer.Header().Add(k, v)
	}
	flusher, ok := writer.(http.Flusher)
	if ok {
		running := true
		for running {
			select {
			case <-request.Context().Done():
				klog.Infof("traceid = %s httpServer context close! ", conn.GetUid())
				node.Send2Node(&proto.StreamMsg{
					Node:     node.GetName(),
					Category: util.HTTPS,
					Type:     util.CLOSED,
					Topic:    conn.GetUid(),
				})
				running = false
			case msg := <-conn.ConnRecv():
				if msg.Data != nil && len(msg.Data) != 0 {
					_, err := writer.Write(msg.Data)
					if err != nil {
						klog.Errorf("traceid = %s httpsServer write data failed err = %v", conn.GetUid(), err)
					}
					flusher.Flush()
				}
				if msg.Type == util.CLOSED {
					running = false
					break
				}
			}
		}
	}
	context.GetContext().RemoveConn(conn.GetUid())
}

handleServerHttp在接受到StreamMsg后,会将msg.Data,也即边端组件的数据包,发送给云端组件。整个数据流是单向的由边端向云端传送,如下所示:

img

而对于类似kubectl exec的请求,数据流是双向的,此时边端组件(kubelet)会返回StatusCode为101的回包,标示协议提升,之后云端tunnel以及边端tunnel会分别切到handleServerSwitchingProtocols以及handleClientSwitchingProtocols对https底层连接进行读取和写入,完成数据流的双向传输

架构如下所示:

img

总结

  • tunnel配置包括云端以及边端配置;tunnel数据结构如下:
    • StreamMsg:云边grpc隧道传输的消息数据格式
    • conn:tunnel grpc隧道上的连接(包括tcp以及https代理)
    • connContext:tunnel grpc上所有连接,其中conns key为conn uid,value为conn
    • node:边缘节点相关连接信息
    • nodeContext:tunnel上所有连接的相关节点信息,其中nodes key为边缘节点名称,value为node
    • TcpConn:tcp代理模块封装的数据结构,代表了grpc隧道上的一个tcp代理连接
    • HttpsMsg:https消息传输中转结构
  • tunnel首先会执行初始化注册各模块,然后分别执行如下模块:
    • stream(grpc隧道):stream模块负责建立grpc连接以及通信(云边隧道)
      • 边缘节点上tunnel-edge主动连接云端tunnel-cloud service,tunnel-cloud service根据负载均衡策略将请求转到tunnel-cloud的具体pod上
      • tunnel-edge与tunnel-cloud建立grpc连接后,tunnel-cloud会把自身的podIp和tunnel-edge所在节点的nodeName的映射写入DNS(tunnel dns)。grpc连接断开之后,tunnel-cloud会删除相关podIp和节点名的映射
      • 边端tunnel会利用边缘节点名以及token构建grpc连接,而云端tunnel会通过认证信息解析grpc连接对应的边缘节点,并对每个边缘节点分别构建一个wrappedServerStream进行处理(同一个云端tunnel可以处理多个边缘节点tunnel的连接)
      • 边端tunnel每隔一分钟会向云端tunnel发送代表该节点正常的心跳StreamMsg,而云端tunnel在接受到该心跳后会进行回应,并循环往复这个过程(心跳是为了探测grpc stream流是否正常)
      • 云端tunnel每隔一分钟向coredns host plugins对应configmap同步一次边缘节点名以及tunnel pod ip的映射(并更新本tunnel连接的边缘节点映射列表);另外,引入configmap本地挂载文件优化了托管模式下众多集群同时同步coredns时的性能
      • StreamMsg包括心跳,tcp代理以及https请求等不同类型消息;同时云端tunnel通过context.node区分与不同边缘节点grpc的连接隧道
    • tcp(tcp代理):负责在多集群管理中建立云端与边端的tcp代理
      • 当云端组件与云端tunnel tcp代理建立连接时,云端tunnel会选择它所管理的边缘节点列表中第一个节点以及边端代理服务地址端口,创建代表tcp代理的结构体TcpConn,并从云端组件与云端tunnel建立的tcp连接中接受以及发送数据,之后转发给边端tunnel;边端tunnel在初次接受到云端tunnel发送的消息时,会与边端代理服务建立连接,并传输数据
      • 通过context.conn在tunnel grpc隧道与tcp代理之间中转StreamMsg。并区分各tcp代理连接
    • https(https代理):负责建立云边https代理(eg:云端kube-apiserver <-> 边端kubelet),并传输数据
      • 作用与tcp代理类似,不同的是云端tunnel会读取云端组件https请求中携带的边缘节点名,并尝试建立与该边缘节点的https代理;而不是像tcp代理一样随机选择第一个边缘节点
      • 整个请求的代理转发流程如下:
        • apiserver或者其它云端的应用访问边缘节点上的kubelet或者其它应用时,tunnel-dns通过DNS劫持(将host中的节点名解析为tunnel-cloud的podIp)把请求转发到tunnel-cloud的pod上
        • tunnel-cloud根据节点名把请求信息转发到节点名对应的与tunnel-edge建立的grpc连接上
        • tunnel-edge根据接收的请求信息请求边缘节点上的应用
      • 通过context.conn在tunnel grpc隧道与https代理之间中转StreamMsg。并区分各https代理连接

展望

  • 支持更多的网络协议(已支持https和tcp)
  • 支持云端访问边缘节点业务pod server
  • 多个边缘节点同时加入集群时,多副本云端tunnel pod对coredns host plguins对应configmap更新冲突解决

Refs

]]>
SuperEdge分布式健康检查深度剖析——edge-health-admission 2021-03-08T19:10:31+00:00 duyanghao http://duyanghao.github.io/superedge-edge-health-admission 前言

SuperEdge分布式健康检查功能由边端的edge-health-daemon以及云端的edge-health-admission组成:

  • edge-health-daemon:对同区域边缘节点执行分布式健康检查,并向apiserver发送健康状态投票结果(给node打annotation)
  • edge-health-admission:不断根据node edge-health annotation调整kube-controller-manager设置的node taint(去掉NoExecute taint)以及endpoints(将失联节点上的pods从endpoint subsets notReadyAddresses移到addresses中),从而实现云端和边端共同决定节点状态

整体架构如下所示:

img

之所以创建edge-health-admission云端组件,是因为当云边断连时,kube-controller-manager会执行如下操作:

  • 失联的节点被置为ConditionUnknown状态,并被添加NoSchedule和NoExecute的taints
  • 失联的节点上的pod从Service的Endpoint列表中移除

当edge-health-daemon在边端根据健康检查判断节点状态正常时,会更新node:去掉NoExecute taint。但是在node成功更新之后又会被kube-controller-manager给刷回去(再次添加NoExecute taint),因此必须添加Kubernetes mutating admission webhook也即edge-health-admission,将kube-controller-manager对node api resource的更改做调整,最终实现分布式健康检查效果

本文将基于我对edge-health的重构PR Refactor edge-health and admission webhook for a better maintainability and extendibility 分析edge-health-admission组件,在深入源码之前先介绍一下Kubernetes Admission Controllers

An admission controller is a piece of code that intercepts requests to the Kubernetes API server prior to persistence of the object, but after the request is authenticated and authorized. The controllers consist of the list below, are compiled into the kube-apiserver binary, and may only be configured by the cluster administrator. In that list, there are two special controllers: MutatingAdmissionWebhook and ValidatingAdmissionWebhook. These execute the mutating and validating (respectively) admission control webhooks which are configured in the API.

Kubernetes Admission Controllers是kube-apiserver处理api请求的某个环节,用于在api请求认证&鉴权之后,对象持久化之前进行调用,对请求进行校验或者修改(or both)

Kubernetes Admission Controllers包括多种admission,大多数都内嵌在kube-apiserver代码中了。其中MutatingAdmissionWebhook以及ValidatingAdmissionWebhook controller比较特殊,它们分别会调用外部构造的mutating admission control webhooks以及validating admission control webhooks

Admission webhooks are HTTP callbacks that receive admission requests and do something with them. You can define two types of admission webhooks, validating admission webhook and mutating admission webhook. Mutating admission webhooks are invoked first, and can modify objects sent to the API server to enforce custom defaults. After all object modifications are complete, and after the incoming object is validated by the API server, validating admission webhooks are invoked and can reject requests to enforce custom policies.

Admission Webhooks是一个HTTP回调服务,接受AdmissionReview请求并进行处理,按照处理方式的不同,可以将Admission Webhooks分类如下:

  • validating admission webhook:通过ValidatingWebhookConfiguration配置,会对api请求进行准入校验,但是不能修改请求对象
  • mutating admission webhook:通过MutatingWebhookConfiguration配置,会对api请求进行准入校验以及修改请求对象

两种类型的webhooks都需要定义如下Matching requests字段:

  • admissionReviewVersions:定义了apiserver所支持的AdmissionReview api resoure的版本列表(API servers send the first AdmissionReview version in the admissionReviewVersions list they support)
  • name:webhook名称(如果一个WebhookConfiguration中定义了多个webhooks,需要保证名称的唯一性)
  • clientConfig:定义了webhook server的访问地址(url or service)以及CA bundle(optionally include a custom CA bundle to use to verify the TLS connection)
  • namespaceSelector:限定了匹配请求资源的命名空间labelSelector
  • objectSelector:限定了匹配请求资源本身的labelSelector
  • rules:限定了匹配请求的operations,apiGroups,apiVersions,resources以及resource scope,如下:
    • operations:规定了请求操作列表(Can be “CREATE”, “UPDATE”, “DELETE”, “CONNECT”, or “*” to match all.)
    • apiGroups:规定了请求资源的API groups列表(“” is the core API group. “*” matches all API groups.)
    • apiVersions:规定了请求资源的API versions列表(“*” matches all API versions.)
    • resources:规定了请求资源类型(node, deployment and etc)
    • scope:规定了请求资源的范围(Cluster,Namespaced or *)
  • timeoutSeconds:规定了webhook回应的超时时间,如果超时了,根据failurePolicy进行处理
  • failurePolicy:规定了apiserver对admission webhook请求失败的处理策略:
    • Ignore:means that an error calling the webhook is ignored and the API request is allowed to continue.
    • Fail:means that an error calling the webhook causes the admission to fail and the API request to be rejected.
  • matchPolicy:规定了rules如何匹配到来的api请求,如下:
    • Exact:完全匹配rules列表限制
    • Equivalent:如果修改请求资源(apiserver可以实现对象在不同版本的转化)可以转化为能够配置rules列表限制,则认为该请求匹配,可以发送给admission webhook
  • reinvocationPolicy:In v1.15+, to allow mutating admission plugins to observe changes made by other plugins, built-in mutating admission plugins are re-run if a mutating webhook modifies an object, and mutating webhooks can specify a reinvocationPolicy to control whether they are reinvoked as well.
    • Never: the webhook must not be called more than once in a single admission evaluation
    • IfNeeded: the webhook may be called again as part of the admission evaluation if the object being admitted is modified by other admission plugins after the initial webhook call.
  • Side effects:某些webhooks除了修改AdmissionReview的内容外,还会连带修改其它的资源(“side effects”)。而sideEffects指示了Webhooks是否具有”side effects”,取值如下:
    • None: calling the webhook will have no side effects.
    • NoneOnDryRun: calling the webhook will possibly have side effects, but if a request with dryRun: true is sent to the webhook, the webhook will suppress the side effects (the webhook is dryRun-aware).

这里给出edge-health-admission对应的MutatingWebhookConfiguration作为参考示例:

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: edge-health-admission
webhooks:
  - admissionReviewVersions:
      - v1
    clientConfig:
      caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNwRENDQVl3Q0NRQ2RaL0w2akZSSkdqQU5CZ2txaGtpRzl3MEJBUXNGQURBVU1SSXdFQVlEVlFRRERBbFgKYVhObE1tTWdRMEV3SGhjTk1qQXdOekU0TURRek9ERTNXaGNOTkRjeE1qQTBNRFF6T0RFM1dqQVVNUkl3RUFZRApWUVFEREFsWGFYTmxNbU1nUTBFd2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUUNSCnhHT2hrODlvVkRHZklyVDBrYVkwajdJQVJGZ2NlVVFmVldSZVhVcjh5eEVOQkF6ZnJNVVZyOWlCNmEwR2VFL3cKZzdVdW8vQWtwUEgrbzNQNjFxdWYrTkg1UDBEWHBUd1pmWU56VWtyaUVja3FOSkYzL2liV0o1WGpFZUZSZWpidgpST1V1VEZabmNWOVRaeTJISVF2UzhTRzRBTWJHVmptQXlDMStLODBKdDI3QUl4YmdndmVVTW8xWFNHYnRxOXlJCmM3Zk1QTXJMSHhaOUl5aTZla3BwMnJrNVdpeU5YbXZhSVA4SmZMaEdnTU56YlJaS1RtL0ZKdDdyV0dhQ1orNXgKV0kxRGJYQ2MyWWhmbThqU1BqZ3NNQTlaNURONDU5ellJSkVhSTFHeFI3MlhaUVFMTm8zdE5jd3IzVlQxVlpiTgo1cmhHQlVaTFlrMERtd25vWTBCekFnTUJBQUV3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUhuUDJibnJBcWlWCjYzWkpMVzM0UWFDMnRreVFScTNVSUtWR3RVZHFobWRVQ0I1SXRoSUlleUdVRVdqVExpc3BDQzVZRHh4YVdrQjUKTUxTYTlUY0s3SkNOdkdJQUdQSDlILzRaeXRIRW10aFhiR1hJQ3FEVUVmSUVwVy9ObUgvcnBPQUxhYlRvSUVzeQpVNWZPUy9PVVZUM3ZoSldlRjdPblpIOWpnYk1SZG9zVElhaHdQdTEzZEtZMi8zcEtxRW1Cd1JkbXBvTExGbW9MCmVTUFQ4SjREZExGRkh2QWJKalFVbjhKQTZjOHUrMzZJZDIrWE1sTGRZYTdnTnhvZTExQTl6eFJQczRXdlpiMnQKUXZpbHZTbkFWb0ZUSVozSlpjRXVWQXllNFNRY1dKc3FLMlM0UER1VkNFdlg0SmRCRlA2NFhvU08zM3pXaWhtLworMXg3OXZHMUpFcz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
      service:
        namespace: kube-system
        name: edge-health-admission
        path: /node-taint
    failurePolicy: Ignore
    matchPolicy: Exact
    name: node-taint.k8s.io
    namespaceSelector: {}
    objectSelector: {}
    reinvocationPolicy: Never
    rules:
      - apiGroups:
          - '*'
        apiVersions:
          - '*'
        operations:
          - UPDATE
        resources:
          - nodes
        scope: '*'
    sideEffects: None
    timeoutSeconds: 5
  - admissionReviewVersions:
      - v1
    clientConfig:
      caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNwRENDQVl3Q0NRQ2RaL0w2akZSSkdqQU5CZ2txaGtpRzl3MEJBUXNGQURBVU1SSXdFQVlEVlFRRERBbFgKYVhObE1tTWdRMEV3SGhjTk1qQXdOekU0TURRek9ERTNXaGNOTkRjeE1qQTBNRFF6T0RFM1dqQVVNUkl3RUFZRApWUVFEREFsWGFYTmxNbU1nUTBFd2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUUNSCnhHT2hrODlvVkRHZklyVDBrYVkwajdJQVJGZ2NlVVFmVldSZVhVcjh5eEVOQkF6ZnJNVVZyOWlCNmEwR2VFL3cKZzdVdW8vQWtwUEgrbzNQNjFxdWYrTkg1UDBEWHBUd1pmWU56VWtyaUVja3FOSkYzL2liV0o1WGpFZUZSZWpidgpST1V1VEZabmNWOVRaeTJISVF2UzhTRzRBTWJHVmptQXlDMStLODBKdDI3QUl4YmdndmVVTW8xWFNHYnRxOXlJCmM3Zk1QTXJMSHhaOUl5aTZla3BwMnJrNVdpeU5YbXZhSVA4SmZMaEdnTU56YlJaS1RtL0ZKdDdyV0dhQ1orNXgKV0kxRGJYQ2MyWWhmbThqU1BqZ3NNQTlaNURONDU5ellJSkVhSTFHeFI3MlhaUVFMTm8zdE5jd3IzVlQxVlpiTgo1cmhHQlVaTFlrMERtd25vWTBCekFnTUJBQUV3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUhuUDJibnJBcWlWCjYzWkpMVzM0UWFDMnRreVFScTNVSUtWR3RVZHFobWRVQ0I1SXRoSUlleUdVRVdqVExpc3BDQzVZRHh4YVdrQjUKTUxTYTlUY0s3SkNOdkdJQUdQSDlILzRaeXRIRW10aFhiR1hJQ3FEVUVmSUVwVy9ObUgvcnBPQUxhYlRvSUVzeQpVNWZPUy9PVVZUM3ZoSldlRjdPblpIOWpnYk1SZG9zVElhaHdQdTEzZEtZMi8zcEtxRW1Cd1JkbXBvTExGbW9MCmVTUFQ4SjREZExGRkh2QWJKalFVbjhKQTZjOHUrMzZJZDIrWE1sTGRZYTdnTnhvZTExQTl6eFJQczRXdlpiMnQKUXZpbHZTbkFWb0ZUSVozSlpjRXVWQXllNFNRY1dKc3FLMlM0UER1VkNFdlg0SmRCRlA2NFhvU08zM3pXaWhtLworMXg3OXZHMUpFcz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
      service:
        namespace: kube-system
        name: edge-health-admission
        path: /endpoint
    failurePolicy: Ignore
    matchPolicy: Exact
    name: endpoint.k8s.io
    namespaceSelector: {}
    objectSelector: {}
    reinvocationPolicy: Never
    rules:
      - apiGroups:
          - '*'
        apiVersions:
          - '*'
        operations:
          - UPDATE
        resources:
          - endpoints
        scope: '*'
    sideEffects: None
    timeoutSeconds: 5

kube-apiserver会发送AdmissionReview(apiGroup: admission.k8s.io,apiVersion:v1 or v1beta1)给Webhooks,并封装成JSON格式,示例如下:

# This example shows the data contained in an AdmissionReview object for a request to update the scale subresource of an apps/v1 Deployment
{
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "request": {
    # Random uid uniquely identifying this admission call
    "uid": "705ab4f5-6393-11e8-b7cc-42010a800002",

    # Fully-qualified group/version/kind of the incoming object
    "kind": {"group":"autoscaling","version":"v1","kind":"Scale"},
    # Fully-qualified group/version/kind of the resource being modified
    "resource": {"group":"apps","version":"v1","resource":"deployments"},
    # subresource, if the request is to a subresource
    "subResource": "scale",

    # Fully-qualified group/version/kind of the incoming object in the original request to the API server.
    # This only differs from `kind` if the webhook specified `matchPolicy: Equivalent` and the
    # original request to the API server was converted to a version the webhook registered for.
    "requestKind": {"group":"autoscaling","version":"v1","kind":"Scale"},
    # Fully-qualified group/version/kind of the resource being modified in the original request to the API server.
    # This only differs from `resource` if the webhook specified `matchPolicy: Equivalent` and the
    # original request to the API server was converted to a version the webhook registered for.
    "requestResource": {"group":"apps","version":"v1","resource":"deployments"},
    # subresource, if the request is to a subresource
    # This only differs from `subResource` if the webhook specified `matchPolicy: Equivalent` and the
    # original request to the API server was converted to a version the webhook registered for.
    "requestSubResource": "scale",

    # Name of the resource being modified
    "name": "my-deployment",
    # Namespace of the resource being modified, if the resource is namespaced (or is a Namespace object)
    "namespace": "my-namespace",

    # operation can be CREATE, UPDATE, DELETE, or CONNECT
    "operation": "UPDATE",

    "userInfo": {
      # Username of the authenticated user making the request to the API server
      "username": "admin",
      # UID of the authenticated user making the request to the API server
      "uid": "014fbff9a07c",
      # Group memberships of the authenticated user making the request to the API server
      "groups": ["system:authenticated","my-admin-group"],
      # Arbitrary extra info associated with the user making the request to the API server.
      # This is populated by the API server authentication layer and should be included
      # if any SubjectAccessReview checks are performed by the webhook.
      "extra": {
        "some-key":["some-value1", "some-value2"]
      }
    },

    # object is the new object being admitted.
    # It is null for DELETE operations.
    "object": {"apiVersion":"autoscaling/v1","kind":"Scale",...},
    # oldObject is the existing object.
    # It is null for CREATE and CONNECT operations.
    "oldObject": {"apiVersion":"autoscaling/v1","kind":"Scale",...},
    # options contains the options for the operation being admitted, like meta.k8s.io/v1 CreateOptions, UpdateOptions, or DeleteOptions.
    # It is null for CONNECT operations.
    "options": {"apiVersion":"meta.k8s.io/v1","kind":"UpdateOptions",...},

    # dryRun indicates the API request is running in dry run mode and will not be persisted.
    # Webhooks with side effects should avoid actuating those side effects when dryRun is true.
    # See http://k8s.io/docs/reference/using-api/api-concepts/#make-a-dry-run-request for more details.
    "dryRun": false
  }
}

而Webhooks需要向kube-apiserver回应具有相同版本的AdmissionReview,并封装成JSON格式,包含如下关键字段:

  • uid:拷贝发送给webhooks的AdmissionReview request.uid字段
  • allowed:true表示准许;false表示不准许
  • status:当不准许请求时,可以通过status给出相关原因(http code and message)
  • patch:base64编码,包含mutating admission webhook对请求对象的一系列JSON patch操作
  • patchType:目前只支持JSONPatch类型

示例如下:

# a webhook response to add that label would be:
{
  "apiVersion": "admission.k8s.io/v1",
  "kind": "AdmissionReview",
  "response": {
    "uid": "<value from request.uid>",
    "allowed": true,
    "patchType": "JSONPatch",
    "patch": "W3sib3AiOiAiYWRkIiwgInBhdGgiOiAiL3NwZWMvcmVwbGljYXMiLCAidmFsdWUiOiAzfV0="
  }
}

edge-health-admission实际上就是一个mutating admission webhook,选择性地对endpoints以及node UPDATE请求进行修改,下面将详细分析其原理

edge-health-admission源码分析

edge-health-admission完全参考官方示例编写,如下是监听入口:

func (eha *EdgeHealthAdmission) Run(stopCh <-chan struct{}) {
	if !cache.WaitForNamedCacheSync("edge-health-admission", stopCh, eha.cfg.NodeInformer.Informer().HasSynced) {
		return
	}

	http.HandleFunc("/node-taint", eha.serveNodeTaint)
	http.HandleFunc("/endpoint", eha.serveEndpoint)
	server := &http.Server{
		Addr: eha.cfg.Addr,
	}

	go func() {
		if err := server.ListenAndServeTLS(eha.cfg.CertFile, eha.cfg.KeyFile); err != http.ErrServerClosed {
			klog.Fatalf("ListenAndServeTLS err %+v", err)
		}
	}()

	for {
		select {
		case <-stopCh:
			ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
			defer cancel()
			if err := server.Shutdown(ctx); err != nil {
				klog.Errorf("Server: program exit, server exit error %+v", err)
			}
			return
		default:
		}
	}
}

这里会注册两种路由处理函数:

  • node-taint:对应处理函数serveNodeTaint,负责对node UPDATE请求进行更改
  • endpoint:对应处理函数serveEndpoint,负责对endpoints UPDATE请求进行更改

而这两个函数都会调用serve函数,如下:

// serve handles the http portion of a request prior to handing to an admit function
func serve(w http.ResponseWriter, r *http.Request, admit admitFunc) {
	var body []byte
	if r.Body != nil {
		if data, err := ioutil.ReadAll(r.Body); err == nil {
			body = data
		}
	}

	// verify the content type is accurate
	contentType := r.Header.Get("Content-Type")
	if contentType != "application/json" {
		klog.Errorf("contentType=%s, expect application/json", contentType)
		return
	}

	klog.V(4).Info(fmt.Sprintf("handling request: %s", body))

	// The AdmissionReview that was sent to the webhook
	requestedAdmissionReview := admissionv1.AdmissionReview{}

	// The AdmissionReview that will be returned
	responseAdmissionReview := admissionv1.AdmissionReview{}

	deserializer := codecs.UniversalDeserializer()
	if _, _, err := deserializer.Decode(body, nil, &requestedAdmissionReview); err != nil {
		klog.Error(err)
		responseAdmissionReview.Response = toAdmissionResponse(err)
	} else {
		// pass to admitFunc
		responseAdmissionReview.Response = admit(requestedAdmissionReview)
	}

	// Return the same UID
	responseAdmissionReview.Response.UID = requestedAdmissionReview.Request.UID

	klog.V(4).Info(fmt.Sprintf("sending response: %+v", responseAdmissionReview.Response))

	respBytes, err := json.Marshal(responseAdmissionReview)
	if err != nil {
		klog.Error(err)
	}
	if _, err := w.Write(respBytes); err != nil {
		klog.Error(err)
	}
}

serve逻辑如下所示:

  • 解析request.Body为AdmissionReview对象,并赋值给requestedAdmissionReview
  • 对AdmissionReview对象执行admit函数,并赋值给回responseAdmissionReview
  • 设置responseAdmissionReview.Response.UID为请求的AdmissionReview.Request.UID

其中serveNodeTaint以及serveEndpoint对应的admit函数分别为:mutateNodeTaint以及mutateEndpoint,下面依次分析:

1、mutateNodeTaint

mutateNodeTaint会对node UPDATE请求按照分布式健康检查结果进行修改:

func (eha *EdgeHealthAdmission) mutateNodeTaint(ar admissionv1.AdmissionReview) *admissionv1.AdmissionResponse {
	klog.V(4).Info("mutating node taint")
	nodeResource := metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "nodes"}
	if ar.Request.Resource != nodeResource {
		klog.Errorf("expect resource to be %s", nodeResource)
		return nil
	}

	var node corev1.Node
	deserializer := codecs.UniversalDeserializer()
	if _, _, err := deserializer.Decode(ar.Request.Object.Raw, nil, &node); err != nil {
		klog.Error(err)
		return toAdmissionResponse(err)
	}

	reviewResponse := admissionv1.AdmissionResponse{}
	reviewResponse.Allowed = true

	if index, condition := util.GetNodeCondition(&node.Status, v1.NodeReady); index != -1 && condition.Status == v1.ConditionUnknown {
		if node.Annotations != nil {
			var patches []*patch
			if healthy, existed := node.Annotations[common.NodeHealthAnnotation]; existed && healthy == common.NodeHealthAnnotationPros {
				if index, existed := util.TaintExistsPosition(node.Spec.Taints, common.UnreachableNoExecuteTaint); existed {
					patches = append(patches, &patch{
						OP:   "remove",
						Path: fmt.Sprintf("/spec/taints/%d", index),
					})
					klog.V(4).Infof("UnreachableNoExecuteTaint: remove %d taints %s", index, node.Spec.Taints[index])
				}
			}
			if len(patches) > 0 {
				patchBytes, _ := json.Marshal(patches)
				reviewResponse.Patch = patchBytes
				pt := admissionv1.PatchTypeJSONPatch
				reviewResponse.PatchType = &pt
			}
		}
	}

	return &reviewResponse
}

主体逻辑如下:

  • 检查AdmissionReview.Request.Resource是否为node资源的group/version/kind
  • 将AdmissionReview.Request.Object.Raw转化为node对象
  • 设置AdmissionReview.Response.Allowed为true,表示无论如何都准许该请求
  • 执行协助边端健康检查核心逻辑:在节点处于ConditionUnknown状态且分布式健康检查结果为正常的情况下,若节点存在NoExecute(node.kubernetes.io/unreachable) taint,则将其移除

总的来说,mutateNodeTaint的作用就是:不断修正被kube-controller-manager更新的节点状态,去掉NoExecute(node.kubernetes.io/unreachable) taint,让节点不会被驱逐

2、mutateEndpoint

mutateEndpoint会对endpoints UPDATE请求按照分布式健康检查结果进行修改:

func (eha *EdgeHealthAdmission) mutateEndpoint(ar admissionv1.AdmissionReview) *admissionv1.AdmissionResponse {
	klog.V(4).Info("mutating endpoint")
	endpointResource := metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "endpoints"}
	if ar.Request.Resource != endpointResource {
		klog.Errorf("expect resource to be %s", endpointResource)
		return nil
	}

	var endpoint corev1.Endpoints
	deserializer := codecs.UniversalDeserializer()
	if _, _, err := deserializer.Decode(ar.Request.Object.Raw, nil, &endpoint); err != nil {
		klog.Error(err)
		return toAdmissionResponse(err)
	}

	reviewResponse := admissionv1.AdmissionResponse{}
	reviewResponse.Allowed = true

	for epSubsetIndex, epSubset := range endpoint.Subsets {
		for notReadyAddrIndex, EndpointAddress := range epSubset.NotReadyAddresses {
			if node, err := eha.nodeLister.Get(*EndpointAddress.NodeName); err == nil {
				if index, condition := util.GetNodeCondition(&node.Status, v1.NodeReady); index != -1 && condition.Status == v1.ConditionUnknown {
					if node.Annotations != nil {
						var patches []*patch
						if healthy, existed := node.Annotations[common.NodeHealthAnnotation]; existed && healthy == common.NodeHealthAnnotationPros {
							// TODO: handle readiness probes failure
							// Remove address on node from endpoint notReadyAddresses
							patches = append(patches, &patch{
								OP:   "remove",
								Path: fmt.Sprintf("/subsets/%d/notReadyAddresses/%d", epSubsetIndex, notReadyAddrIndex),
							})

							// Add address on node to endpoint readyAddresses
							TargetRef := map[string]interface{}{}
							TargetRef["kind"] = EndpointAddress.TargetRef.Kind
							TargetRef["namespace"] = EndpointAddress.TargetRef.Namespace
							TargetRef["name"] = EndpointAddress.TargetRef.Name
							TargetRef["uid"] = EndpointAddress.TargetRef.UID
							TargetRef["apiVersion"] = EndpointAddress.TargetRef.APIVersion
							TargetRef["resourceVersion"] = EndpointAddress.TargetRef.ResourceVersion
							TargetRef["fieldPath"] = EndpointAddress.TargetRef.FieldPath

							patches = append(patches, &patch{
								OP:   "add",
								Path: fmt.Sprintf("/subsets/%d/addresses/0", epSubsetIndex),
								Value: map[string]interface{}{
									"ip":        EndpointAddress.IP,
									"hostname":  EndpointAddress.Hostname,
									"nodeName":  EndpointAddress.NodeName,
									"targetRef": TargetRef,
								},
							})

							if len(patches) != 0 {
								patchBytes, _ := json.Marshal(patches)
								reviewResponse.Patch = patchBytes
								pt := admissionv1.PatchTypeJSONPatch
								reviewResponse.PatchType = &pt
							}
						}
					}
				}
			} else {
				klog.Errorf("Get pod's node err %+v", err)
			}
		}

	}

	return &reviewResponse
}

主体逻辑如下:

  • 检查AdmissionReview.Request.Resource是否为endpoints资源的group/version/kind
  • 将AdmissionReview.Request.Object.Raw转化为endpoints对象
  • 设置AdmissionReview.Response.Allowed为true,表示无论如何都准许该请求
  • 遍历endpoints.Subset.NotReadyAddresses,如果EndpointAddress所在节点处于ConditionUnknown状态且分布式健康检查结果为正常,则将该EndpointAddress从endpoints.Subset.NotReadyAddresses移到endpoints.Subset.Addresses

总的来说,mutateEndpoint的作用就是:不断修正被kube-controller-manager更新的endpoints状态,将分布式健康检查正常节点上的负载从endpoints.Subset.NotReadyAddresses移到endpoints.Subset.Addresses中,让服务依旧可用

总结

  • SuperEdge分布式健康检查功能由边端的edge-health-daemon以及云端的edge-health-admission组成:
    • edge-health-daemon:对同区域边缘节点执行分布式健康检查,并向apiserver发送健康状态投票结果(给node打annotation)
    • edge-health-admission:不断根据node edge-health annotation调整kube-controller-manager设置的node taint(去掉NoExecute taint)以及endpoints(将失联节点上的pods从endpoint subsets notReadyAddresses移到addresses中),从而实现云端和边端共同决定节点状态
  • 之所以创建edge-health-admission云端组件,是因为当云边断连时,kube-controller-manager会将失联的节点置为ConditionUnknown状态,并添加NoSchedule和NoExecute的taints;同时失联的节点上的pod从Service的Endpoint列表中移除。当edge-health-daemon在边端根据健康检查判断节点状态正常时,会更新node:去掉NoExecute taint。但是在node成功更新之后又会被kube-controller-manager给刷回去(再次添加NoExecute taint),因此必须添加Kubernetes mutating admission webhook也即edge-health-admission,将kube-controller-manager对node api resource的更改做调整,最终实现分布式健康检查效果
  • Kubernetes Admission Controllers是kube-apiserver处理api请求的某个环节,用于在api请求认证&鉴权之后,对象持久化之前进行调用,对请求进行校验或者修改(or both);包括多种admission,大多数都内嵌在kube-apiserver代码中了。其中MutatingAdmissionWebhook以及ValidatingAdmissionWebhook controller比较特殊,它们分别会调用外部构造的mutating admission control webhooks以及validating admission control webhooks
  • Admission Webhooks是一个HTTP回调服务,接受AdmissionReview请求并进行处理,按照处理方式的不同,可以将Admission Webhooks分类如下:
    • validating admission webhook:通过ValidatingWebhookConfiguration配置,会对api请求进行准入校验,但是不能修改请求对象
    • mutating admission webhook:通过MutatingWebhookConfiguration配置,会对api请求进行准入校验以及修改请求对象
  • kube-apiserver会发送AdmissionReview(apiGroup: admission.k8s.io,apiVersion:v1 or v1beta1)给Webhooks,并封装成JSON格式;而Webhooks需要向kube-apiserver回应具有相同版本的AdmissionReview,并封装成JSON格式,包含如下关键字段:
    • uid:拷贝发送给webhooks的AdmissionReview request.uid字段
    • allowed:true表示准许;false表示不准许
    • status:当不准许请求时,可以通过status给出相关原因(http code and message)
    • patch:base64编码,包含mutating admission webhook对请求对象的一系列JSON patch操作
    • patchType:目前只支持JSONPatch类型
  • edge-health-admission实际上就是一个mutating admission webhook,选择性地对endpoints以及node UPDATE请求进行修改,包含如下处理逻辑:
    • mutateNodeTaint:不断修正被kube-controller-manager更新的节点状态,去掉NoExecute(node.kubernetes.io/unreachable) taint,让节点不会被驱逐
    • mutateEndpoint:不断修正被kube-controller-manager更新的endpoints状态,将分布式健康检查正常节点上的负载从endpoints.Subset.NotReadyAddresses移到endpoints.Subset.Addresses中,让服务依旧可用
]]>
SuperEdge拓扑感知深度剖析 2021-03-03T19:10:31+00:00 duyanghao http://duyanghao.github.io/superedge-application-grid-wrapper 前言

SuperEdge service group利用application-grid-wrapper实现拓扑感知,完成了同一个nodeunit内服务的闭环访问

在深入分析application-grid-wrapper之前,这里先简单介绍一下社区Kubernetes原生支持的拓扑感知特性

Kubernetes service topology awareness特性于v1.17发布alpha版本,用于实现路由拓扑以及就近访问特性。用户需要在service中添加topologyKeys字段标示拓扑key类型,只有具有相同拓扑域的endpoint会被访问到,目前有三种topologyKeys可供选择:

  • “kubernetes.io/hostname”:访问本节点内(kubernetes.io/hostname label value相同)的endpoint,如果没有则service访问失败
  • “topology.kubernetes.io/zone”:访问相同zone域内(topology.kubernetes.io/zone label value相同)的endpoint,如果没有则service访问失败
  • “topology.kubernetes.io/region”:访问相同region域内(topology.kubernetes.io/region label value相同)的endpoint,如果没有则service访问失败

除了单独填写如上某一个拓扑key之外,还可以将这些key构造成列表进行填写,例如:["kubernetes.io/hostname", "topology.kubernetes.io/zone", "topology.kubernetes.io/region"],这表示:优先访问本节点内的endpoint;如果不存在,则访问同一个zone内的endpoint;如果再不存在,则访问同一个region内的endpoint,如果都不存在则访问失败

另外,还可以在列表最后(只能最后一项)添加”*“表示:如果前面拓扑域都失败,则访问任何有效的endpoint,也即没有限制拓扑了,示例如下:

# A Service that prefers node local, zonal, then regional endpoints but falls back to cluster wide endpoints.
apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  selector:
    app: my-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 9376
  topologyKeys:
    - "kubernetes.io/hostname"
    - "topology.kubernetes.io/zone"
    - "topology.kubernetes.io/region"
    - "*"

而service group实现的拓扑感知和社区对比,有如下区别:

  • service group拓扑key可以自定义,也即为gridUniqKey,使用起来更加灵活;而社区实现目前只有三种选择:”kubernetes.io/hostname”,”topology.kubernetes.io/zone”以及”topology.kubernetes.io/region”
  • service group只能填写一个拓扑key,也即只能访问本拓扑域内有效的endpoint,无法访问其它拓扑域的endpoint;而社区可以通过topologyKey列表以及”*“实现其它备选拓扑域endpoint的访问

service group实现的拓扑感知,service配置如下:

# A Service that only prefers node zone1al endpoints.
apiVersion: v1
kind: Service
metadata:
  annotations:
    topologyKeys: '["zone1"]'
  labels:
    superedge.io/grid-selector: servicegrid-demo
  name: servicegrid-demo-svc
spec:
  ports:
  - port: 80
    protocol: TCP
    targetPort: 8080
  selector:
    appGrid: echo

在介绍完service group实现的拓扑感知后,我们深入到源码分析实现细节。同样的,这里以一个使用示例开始分析:

# step1: labels edge nodes
$ kubectl  get nodes
NAME    STATUS   ROLES    AGE   VERSION
node0   Ready    <none>   16d   v1.16.7
node1   Ready    <none>   16d   v1.16.7
node2   Ready    <none>   16d   v1.16.7
# nodeunit1(nodegroup and servicegroup zone1)
$ kubectl --kubeconfig config label nodes node0 zone1=nodeunit1  
# nodeunit2(nodegroup and servicegroup zone1)
$ kubectl --kubeconfig config label nodes node1 zone1=nodeunit2
$ kubectl --kubeconfig config label nodes node2 zone1=nodeunit2

...

# step3: deploy echo ServiceGrid
$ cat <<EOF | kubectl --kubeconfig config apply -f -
apiVersion: superedge.io/v1
kind: ServiceGrid
metadata:
  name: servicegrid-demo
  namespace: default
spec:
  gridUniqKey: zone1
  template:
    selector:
      appGrid: echo
    ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
EOF
servicegrid.superedge.io/servicegrid-demo created
# note that there is only one relevant service generated
$ kubectl  get svc
NAME                   TYPE        CLUSTER-IP        EXTERNAL-IP   PORT(S)   AGE
kubernetes             ClusterIP   192.168.0.1       <none>        443/TCP   16d
servicegrid-demo-svc   ClusterIP   192.168.6.139     <none>        80/TCP    10m

# step4: access servicegrid-demo-svc(service topology and closed-looped)
# execute on node0
$ curl 192.168.6.139|grep "node name"
        node name:      node0
# execute on node1 and node2
$ curl 192.168.6.139|grep "node name"
        node name:      node2
$ curl 192.168.6.139|grep "node name"
        node name:      node1        

在创建完ServiceGrid CR后,ServiceGrid Controller负责根据ServiceGrid产生对应的service(包含由serviceGrid.Spec.GridUniqKey构成的topologyKeys annotations);而application-grid-wrapper根据service实现拓扑感知,下面依次分析

ServiceGrid Controller分析

ServiceGrid Controller逻辑和DeploymentGrid Controller整体一致,如下:

  • 1、创建并维护service group需要的若干CRDs(包括:ServiceGrid)
  • 2、监听ServiceGrid event,并填充ServiceGrid到工作队列中;循环从队列中取出ServiceGrid进行解析,创建并且维护对应的service
  • 3、监听service event,并将相关的ServiceGrid塞到工作队列中进行上述处理,协助上述逻辑达到整体reconcile逻辑

注意这里区别于DeploymentGrid Controller:

  • 一个ServiceGrid对象只产生一个service
  • 只需额外监听service event,无需监听node事件。因为node的CRUD与ServiceGrid无关
  • ServiceGrid对应产生的service,命名为:{ServiceGrid}-svc
func (sgc *ServiceGridController) syncServiceGrid(key string) error {
	startTime := time.Now()
	klog.V(4).Infof("Started syncing service grid %q (%v)", key, startTime)
	defer func() {
		klog.V(4).Infof("Finished syncing service grid %q (%v)", key, time.Since(startTime))
	}()

	namespace, name, err := cache.SplitMetaNamespaceKey(key)
	if err != nil {
		return err
	}

	sg, err := sgc.svcGridLister.ServiceGrids(namespace).Get(name)
	if errors.IsNotFound(err) {
		klog.V(2).Infof("service grid %v has been deleted", key)
		return nil
	}
	if err != nil {
		return err
	}

	if sg.Spec.GridUniqKey == "" {
		sgc.eventRecorder.Eventf(sg, corev1.EventTypeWarning, "Empty", "This service grid has an empty grid key")
		return nil
	}

	// get service workload list of this grid
	svcList, err := sgc.getServiceForGrid(sg)
	if err != nil {
		return err
	}

	if sg.DeletionTimestamp != nil {
		return nil
	}

	// sync service grid relevant services workload
	return sgc.reconcile(sg, svcList)
}

func (sgc *ServiceGridController) getServiceForGrid(sg *crdv1.ServiceGrid) ([]*corev1.Service, error) {
	svcList, err := sgc.svcLister.Services(sg.Namespace).List(labels.Everything())
	if err != nil {
		return nil, err
	}

	labelSelector, err := common.GetDefaultSelector(sg.Name)
	if err != nil {
		return nil, err
	}
	canAdoptFunc := controller.RecheckDeletionTimestamp(func() (metav1.Object, error) {
		fresh, err := sgc.crdClient.SuperedgeV1().ServiceGrids(sg.Namespace).Get(context.TODO(), sg.Name, metav1.GetOptions{})
		if err != nil {
			return nil, err
		}
		if fresh.UID != sg.UID {
			return nil, fmt.Errorf("orignal service grid %v/%v is gone: got uid %v, wanted %v", sg.Namespace,
				sg.Name, fresh.UID, sg.UID)
		}
		return fresh, nil
	})

	cm := controller.NewServiceControllerRefManager(sgc.svcClient, sg, labelSelector, util.ControllerKind, canAdoptFunc)
	return cm.ClaimService(svcList)
}

func (sgc *ServiceGridController) reconcile(g *crdv1.ServiceGrid, svcList []*corev1.Service) error {
	var (
		adds    []*corev1.Service
		updates []*corev1.Service
		deletes []*corev1.Service
	)

	sgTargetSvcName := util.GetServiceName(g)
	isExistingSvc := false
	for _, svc := range svcList {
		if svc.Name == sgTargetSvcName {
			isExistingSvc = true
			template := util.KeepConsistence(g, svc)
			if !apiequality.Semantic.DeepEqual(template, svc) {
				updates = append(updates, template)
			}
		} else {
			deletes = append(deletes, svc)
		}
	}

	if !isExistingSvc {
		adds = append(adds, util.CreateService(g))
	}

	return sgc.syncService(adds, updates, deletes)
}

func CreateService(sg *crdv1.ServiceGrid) *corev1.Service {
	svc := &corev1.Service{
		ObjectMeta: metav1.ObjectMeta{
			Name:      GetServiceName(sg),
			Namespace: sg.Namespace,
			// Append existed ServiceGrid labels to service to be created
			Labels: func() map[string]string {
				if sg.Labels != nil {
					newLabels := sg.Labels
					newLabels[common.GridSelectorName] = sg.Name
					newLabels[common.GridSelectorUniqKeyName] = sg.Spec.GridUniqKey
					return newLabels
				} else {
					return map[string]string{
						common.GridSelectorName:        sg.Name,
						common.GridSelectorUniqKeyName: sg.Spec.GridUniqKey,
					}
				}
			}(),
			Annotations: make(map[string]string),
		},
		Spec: sg.Spec.Template,
	}

	keys := make([]string, 1)
	keys[0] = sg.Spec.GridUniqKey
	keyData, _ := json.Marshal(keys)
	svc.Annotations[common.TopologyAnnotationsKey] = string(keyData)

	return svc
}

由于逻辑与DeploymentGrid类似,这里不展开细节,重点关注application-grid-wrapper部分

application-grid-wrapper分析

在ServiceGrid Controller创建完service之后,application-grid-wrapper的作用就开始启动了:

apiVersion: v1
kind: Service
metadata:
  annotations:
    topologyKeys: '["zone1"]'
  creationTimestamp: "2021-03-03T07:33:30Z"
  labels:
    superedge.io/grid-selector: servicegrid-demo
  name: servicegrid-demo-svc
  namespace: default
  ownerReferences:
  - apiVersion: superedge.io/v1
    blockOwnerDeletion: true
    controller: true
    kind: ServiceGrid
    name: servicegrid-demo
    uid: 78c74d3c-72ac-4e68-8c79-f1396af5a581
  resourceVersion: "127987090"
  selfLink: /api/v1/namespaces/default/services/servicegrid-demo-svc
  uid: 8130ba7b-c27e-4c3a-8ceb-4f6dd0178dfc
spec:
  clusterIP: 192.168.161.1
  ports:
  - port: 80
    protocol: TCP
    targetPort: 8080
  selector:
    appGrid: echo
  sessionAffinity: None
  type: ClusterIP
status:
  loadBalancer: {}

为了实现Kubernetes零侵入,需要在kube-proxy与apiserver通信之间添加一层wrapper,架构如下:

img

调用链路如下:

kube-proxy -> application-grid-wrapper -> lite-apiserver -> kube-apiserver

因此application-grid-wrapper会起服务,接受来自kube-proxy的请求,如下:

func (s *interceptorServer) Run(debug bool, bindAddress string, insecure bool, caFile, certFile, keyFile string) error {
    ...
	klog.Infof("Start to run interceptor server")
	/* filter
	 */
	server := &http.Server{Addr: bindAddress, Handler: s.buildFilterChains(debug)}

	if insecure {
		return server.ListenAndServe()
	}
    ...
	server.TLSConfig = tlsConfig
	return server.ListenAndServeTLS("", "")
}

func (s *interceptorServer) buildFilterChains(debug bool) http.Handler {
	handler := http.Handler(http.NewServeMux())

	handler = s.interceptEndpointsRequest(handler)
	handler = s.interceptServiceRequest(handler)
	handler = s.interceptEventRequest(handler)
	handler = s.interceptNodeRequest(handler)
	handler = s.logger(handler)

	if debug {
		handler = s.debugger(handler)
	}

	return handler
}

这里会首先创建interceptorServer,然后注册处理函数,由外到内依次如下:

  • debug:接受debug请求,返回wrapper pprof运行信息
  • logger:打印请求日志
  • node:接受kube-proxy node GET(/api/v1/nodes/{node})请求,并返回node信息
  • event:接受kube-proxy events POST(/events)请求,并将请求转发给lite-apiserver
    func (s *interceptorServer) interceptEventRequest(handler http.Handler) http.Handler {
      return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
          if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/events") {
              handler.ServeHTTP(w, r)
              return
          }
    
          targetURL, _ := url.Parse(s.restConfig.Host)
          reverseProxy := httputil.NewSingleHostReverseProxy(targetURL)
          reverseProxy.Transport, _ = rest.TransportFor(s.restConfig)
          reverseProxy.ServeHTTP(w, r)
      })
    }
    
  • service:接受kube-proxy service List&Watch(/api/v1/services)请求,并根据storageCache内容返回(GetServices)
  • endpoint:接受kube-proxy endpoint List&Watch(/api/v1/endpoints)请求,并根据storageCache内容返回(GetEndpoints)

下面先重点分析cache部分的逻辑,然后再回过头来分析具体的http handler List&Watch处理逻辑

wrapper为了实现拓扑感知,自己维护了一个cache,包括:node,service,endpoint。可以看到在setupInformers中注册了这三类资源的处理函数:

type storageCache struct {
	// hostName is the nodeName of node which application-grid-wrapper deploys on
	hostName         string
	wrapperInCluster bool

	// mu lock protect the following map structure
	mu           sync.RWMutex
	servicesMap  map[types.NamespacedName]*serviceContainer
	endpointsMap map[types.NamespacedName]*endpointsContainer
	nodesMap     map[types.NamespacedName]*nodeContainer

	// service watch channel
	serviceChan chan<- watch.Event
	// endpoints watch channel
	endpointsChan chan<- watch.Event
}
...
func NewStorageCache(hostName string, wrapperInCluster bool, serviceNotifier, endpointsNotifier chan watch.Event) *storageCache {
	msc := &storageCache{
		hostName:         hostName,
		wrapperInCluster: wrapperInCluster,
		servicesMap:      make(map[types.NamespacedName]*serviceContainer),
		endpointsMap:     make(map[types.NamespacedName]*endpointsContainer),
		nodesMap:         make(map[types.NamespacedName]*nodeContainer),
		serviceChan:      serviceNotifier,
		endpointsChan:    endpointsNotifier,
	}

	return msc
}
...
func (s *interceptorServer) Run(debug bool, bindAddress string, insecure bool, caFile, certFile, keyFile string) error {
    ...
	if err := s.setupInformers(ctx.Done()); err != nil {
		return err
	}

	klog.Infof("Start to run interceptor server")
	/* filter
	 */
	server := &http.Server{Addr: bindAddress, Handler: s.buildFilterChains(debug)}
    ...
	return server.ListenAndServeTLS("", "")
}

func (s *interceptorServer) setupInformers(stop <-chan struct{}) error {
	klog.Infof("Start to run service and endpoints informers")
	noProxyName, err := labels.NewRequirement(apis.LabelServiceProxyName, selection.DoesNotExist, nil)
	if err != nil {
		klog.Errorf("can't parse proxy label, %v", err)
		return err
	}

	noHeadlessEndpoints, err := labels.NewRequirement(v1.IsHeadlessService, selection.DoesNotExist, nil)
	if err != nil {
		klog.Errorf("can't parse headless label, %v", err)
		return err
	}

	labelSelector := labels.NewSelector()
	labelSelector = labelSelector.Add(*noProxyName, *noHeadlessEndpoints)

	resyncPeriod := time.Minute * 5
	client := kubernetes.NewForConfigOrDie(s.restConfig)
	nodeInformerFactory := informers.NewSharedInformerFactory(client, resyncPeriod)
	informerFactory := informers.NewSharedInformerFactoryWithOptions(client, resyncPeriod,
		informers.WithTweakListOptions(func(options *metav1.ListOptions) {
			options.LabelSelector = labelSelector.String()
		}))

	nodeInformer := nodeInformerFactory.Core().V1().Nodes().Informer()
	serviceInformer := informerFactory.Core().V1().Services().Informer()
	endpointsInformer := informerFactory.Core().V1().Endpoints().Informer()

	/*
	 */
	nodeInformer.AddEventHandlerWithResyncPeriod(s.cache.NodeEventHandler(), resyncPeriod)
	serviceInformer.AddEventHandlerWithResyncPeriod(s.cache.ServiceEventHandler(), resyncPeriod)
	endpointsInformer.AddEventHandlerWithResyncPeriod(s.cache.EndpointsEventHandler(), resyncPeriod)

	go nodeInformer.Run(stop)
	go serviceInformer.Run(stop)
	go endpointsInformer.Run(stop)

	if !cache.WaitForNamedCacheSync("node", stop,
		nodeInformer.HasSynced,
		serviceInformer.HasSynced,
		endpointsInformer.HasSynced) {
		return fmt.Errorf("can't sync informers")
	}

	return nil
}

func (sc *storageCache) NodeEventHandler() cache.ResourceEventHandler {
	return &nodeHandler{cache: sc}
}

func (sc *storageCache) ServiceEventHandler() cache.ResourceEventHandler {
	return &serviceHandler{cache: sc}
}

func (sc *storageCache) EndpointsEventHandler() cache.ResourceEventHandler {
	return &endpointsHandler{cache: sc}
}

这里依次分析NodeEventHandler,ServiceEventHandler以及EndpointsEventHandler,如下:

1、NodeEventHandler

NodeEventHandler负责监听node资源相关event,并将node以及node Labels添加到storageCache.nodesMap中(key为nodeName,value为node以及node labels)

func (nh *nodeHandler) add(node *v1.Node) {
	sc := nh.cache

	sc.mu.Lock()

	nodeKey := types.NamespacedName{Namespace: node.Namespace, Name: node.Name}
	klog.Infof("Adding node %v", nodeKey)
	sc.nodesMap[nodeKey] = &nodeContainer{
		node:   node,
		labels: node.Labels,
	}
	// update endpoints
	changedEps := sc.rebuildEndpointsMap()

	sc.mu.Unlock()

	for _, eps := range changedEps {
		sc.endpointsChan <- eps
	}
}

func (nh *nodeHandler) update(node *v1.Node) {
	sc := nh.cache

	sc.mu.Lock()

	nodeKey := types.NamespacedName{Namespace: node.Namespace, Name: node.Name}
	klog.Infof("Updating node %v", nodeKey)
	nodeContainer, found := sc.nodesMap[nodeKey]
	if !found {
		sc.mu.Unlock()
		klog.Errorf("Updating non-existed node %v", nodeKey)
		return
	}

	nodeContainer.node = node
	// return directly when labels of node stay unchanged
	if reflect.DeepEqual(node.Labels, nodeContainer.labels) {
		sc.mu.Unlock()
		return
	}
	nodeContainer.labels = node.Labels

	// update endpoints
	changedEps := sc.rebuildEndpointsMap()

	sc.mu.Unlock()

	for _, eps := range changedEps {
		sc.endpointsChan <- eps
	}
}
...

同时由于node的改变会影响endpoint,因此会调用rebuildEndpointsMap刷新storageCache.endpointsMap

// rebuildEndpointsMap updates all endpoints stored in storageCache.endpointsMap dynamically and constructs relevant modified events
func (sc *storageCache) rebuildEndpointsMap() []watch.Event {
	evts := make([]watch.Event, 0)
	for name, endpointsContainer := range sc.endpointsMap {
		newEps := pruneEndpoints(sc.hostName, sc.nodesMap, sc.servicesMap, endpointsContainer.endpoints, sc.wrapperInCluster)
		if apiequality.Semantic.DeepEqual(newEps, endpointsContainer.modified) {
			continue
		}
		sc.endpointsMap[name].modified = newEps
		evts = append(evts, watch.Event{
			Type:   watch.Modified,
			Object: newEps,
		})
	}
	return evts
}

rebuildEndpointsMap是cache的核心函数,同时也是拓扑感知的算法实现:

// pruneEndpoints filters endpoints using serviceTopology rules combined by services topologyKeys and node labels
func pruneEndpoints(hostName string,
	nodes map[types.NamespacedName]*nodeContainer,
	services map[types.NamespacedName]*serviceContainer,
	eps *v1.Endpoints, wrapperInCluster bool) *v1.Endpoints {

	epsKey := types.NamespacedName{Namespace: eps.Namespace, Name: eps.Name}

	if wrapperInCluster {
		eps = genLocalEndpoints(eps)
	}

	// dangling endpoints
	svc, ok := services[epsKey]
	if !ok {
		klog.V(4).Infof("Dangling endpoints %s, %+#v", eps.Name, eps.Subsets)
		return eps
	}

	// normal service
	if len(svc.keys) == 0 {
		klog.V(4).Infof("Normal endpoints %s, %+#v", eps.Name, eps.Subsets)
		return eps
	}

	// topology endpoints
	newEps := eps.DeepCopy()
	for si := range newEps.Subsets {
		subnet := &newEps.Subsets[si]
		subnet.Addresses = filterConcernedAddresses(svc.keys, hostName, nodes, subnet.Addresses)
		subnet.NotReadyAddresses = filterConcernedAddresses(svc.keys, hostName, nodes, subnet.NotReadyAddresses)
	}
	klog.V(4).Infof("Topology endpoints %s: subnets from %+#v to %+#v", eps.Name, eps.Subsets, newEps.Subsets)

	return newEps
}

// filterConcernedAddresses aims to filter out endpoints addresses within the same node unit
func filterConcernedAddresses(topologyKeys []string, hostName string, nodes map[types.NamespacedName]*nodeContainer,
	addresses []v1.EndpointAddress) []v1.EndpointAddress {
	hostNode, found := nodes[types.NamespacedName{Name: hostName}]
	if !found {
		return nil
	}

	filteredEndpointAddresses := make([]v1.EndpointAddress, 0)
	for i := range addresses {
		addr := addresses[i]
		if nodeName := addr.NodeName; nodeName != nil {
			epsNode, found := nodes[types.NamespacedName{Name: *nodeName}]
			if !found {
				continue
			}
			if hasIntersectionLabel(topologyKeys, hostNode.labels, epsNode.labels) {
				filteredEndpointAddresses = append(filteredEndpointAddresses, addr)
			}
		}
	}

	return filteredEndpointAddresses
}

func hasIntersectionLabel(keys []string, n1, n2 map[string]string) bool {
	if n1 == nil || n2 == nil {
		return false
	}

	for _, key := range keys {
		val1, v1found := n1[key]
		val2, v2found := n2[key]

		if v1found && v2found && val1 == val2 {
			return true
		}
	}

	return false
}

算法逻辑如下:

  • 判断endpoint是否为default kubernetes service,如果是,则将该endpoint转化为wrapper所在边缘节点的lite-apiserver地址(127.0.0.1)和端口(51003)
apiVersion: v1
kind: Endpoints
metadata:
  annotations:
    superedge.io/local-endpoint: 127.0.0.1
    superedge.io/local-port: "51003"
  name: kubernetes
  namespace: default
subsets:
- addresses:
  - ip: 172.31.0.60
  ports:
  - name: https
    port: xxx
    protocol: TCP
func genLocalEndpoints(eps *v1.Endpoints) *v1.Endpoints {
	if eps.Namespace != metav1.NamespaceDefault || eps.Name != MasterEndpointName {
		return eps
	}

	klog.V(4).Infof("begin to gen local ep %v", eps)
	ipAddress, e := eps.Annotations[EdgeLocalEndpoint]
	if !e {
		return eps
	}

	portStr, e := eps.Annotations[EdgeLocalPort]
	if !e {
		return eps
	}

	klog.V(4).Infof("get local endpoint %s:%s", ipAddress, portStr)
	port, err := strconv.ParseInt(portStr, 10, 32)
	if err != nil {
		klog.Errorf("parse int %s err %v", portStr, err)
		return eps
	}

	ip := net.ParseIP(ipAddress)
	if ip == nil {
		klog.Warningf("parse ip %s nil", ipAddress)
		return eps
	}

	nep := eps.DeepCopy()
	nep.Subsets = []v1.EndpointSubset{
		{
			Addresses: []v1.EndpointAddress{
				{
					IP: ipAddress,
				},
			},
			Ports: []v1.EndpointPort{
				{
					Protocol: v1.ProtocolTCP,
					Port:     int32(port),
					Name:     "https",
				},
			},
		},
	}

	klog.V(4).Infof("gen new endpoint complete %v", nep)
	return nep
}

这样做的目的是使边缘节点上的服务采用集群内(InCluster)方式访问的apiserver为本地的lite-apiserver,而不是云端的apiserver

  • 从storageCache.servicesMap cache中根据endpoint名称(namespace/name)取出对应service,如果该service没有topologyKeys则无需做拓扑转化(非service group)
func getTopologyKeys(objectMeta *metav1.ObjectMeta) []string {
	if !hasTopologyKey(objectMeta) {
		return nil
	}

	var keys []string
	keyData := objectMeta.Annotations[TopologyAnnotationsKey]
	if err := json.Unmarshal([]byte(keyData), &keys); err != nil {
		klog.Errorf("can't parse topology keys %s, %v", keyData, err)
		return nil
	}

	return keys
}
  • 调用filterConcernedAddresses过滤endpoint.Subsets Addresses以及NotReadyAddresses,只保留同一个service topologyKeys中的endpoint
// filterConcernedAddresses aims to filter out endpoints addresses within the same node unit
func filterConcernedAddresses(topologyKeys []string, hostName string, nodes map[types.NamespacedName]*nodeContainer,
	addresses []v1.EndpointAddress) []v1.EndpointAddress {
	hostNode, found := nodes[types.NamespacedName{Name: hostName}]
	if !found {
		return nil
	}

	filteredEndpointAddresses := make([]v1.EndpointAddress, 0)
	for i := range addresses {
		addr := addresses[i]
		if nodeName := addr.NodeName; nodeName != nil {
			epsNode, found := nodes[types.NamespacedName{Name: *nodeName}]
			if !found {
				continue
			}
			if hasIntersectionLabel(topologyKeys, hostNode.labels, epsNode.labels) {
				filteredEndpointAddresses = append(filteredEndpointAddresses, addr)
			}
		}
	}

	return filteredEndpointAddresses
}

func hasIntersectionLabel(keys []string, n1, n2 map[string]string) bool {
	if n1 == nil || n2 == nil {
		return false
	}

	for _, key := range keys {
		val1, v1found := n1[key]
		val2, v2found := n2[key]

		if v1found && v2found && val1 == val2 {
			return true
		}
	}

	return false
}

注意:如果wrapper所在边缘节点没有service topologyKeys标签,则也无法访问该service

回到rebuildEndpointsMap,在调用pruneEndpoints刷新了同一个拓扑域内的endpoint后,会将修改后的endpoints赋值给storageCache.endpointsMap[endpoint].modified(该字段记录了拓扑感知后修改的endpoints)

func (nh *nodeHandler) add(node *v1.Node) {
	sc := nh.cache

	sc.mu.Lock()

	nodeKey := types.NamespacedName{Namespace: node.Namespace, Name: node.Name}
	klog.Infof("Adding node %v", nodeKey)
	sc.nodesMap[nodeKey] = &nodeContainer{
		node:   node,
		labels: node.Labels,
	}
	// update endpoints
	changedEps := sc.rebuildEndpointsMap()

	sc.mu.Unlock()

	for _, eps := range changedEps {
		sc.endpointsChan <- eps
	}
}

// rebuildEndpointsMap updates all endpoints stored in storageCache.endpointsMap dynamically and constructs relevant modified events
func (sc *storageCache) rebuildEndpointsMap() []watch.Event {
	evts := make([]watch.Event, 0)
	for name, endpointsContainer := range sc.endpointsMap {
		newEps := pruneEndpoints(sc.hostName, sc.nodesMap, sc.servicesMap, endpointsContainer.endpoints, sc.wrapperInCluster)
		if apiequality.Semantic.DeepEqual(newEps, endpointsContainer.modified) {
			continue
		}
		sc.endpointsMap[name].modified = newEps
		evts = append(evts, watch.Event{
			Type:   watch.Modified,
			Object: newEps,
		})
	}
	return evts
}

另外,如果endpoints(拓扑感知后修改的endpoints)发生改变,会构建watch event,传递给endpoints handler(interceptEndpointsRequest)处理

2、ServiceEventHandler

storageCache.servicesMap结构体key为service名称(namespace/name),value为serviceContainer,包含如下数据:

  • svc:service对象
  • keys:service topologyKeys

对于service资源的改动,这里用Update event说明:

func (sh *serviceHandler) update(service *v1.Service) {
	sc := sh.cache

	sc.mu.Lock()
	serviceKey := types.NamespacedName{Namespace: service.Namespace, Name: service.Name}
	klog.Infof("Updating service %v", serviceKey)
	newTopologyKeys := getTopologyKeys(&service.ObjectMeta)
	serviceContainer, found := sc.servicesMap[serviceKey]
	if !found {
		sc.mu.Unlock()
		klog.Errorf("update non-existed service, %v", serviceKey)
		return
	}

	sc.serviceChan <- watch.Event{
		Type:   watch.Modified,
		Object: service,
	}

	serviceContainer.svc = service
	// return directly when topologyKeys of service stay unchanged
	if reflect.DeepEqual(serviceContainer.keys, newTopologyKeys) {
		sc.mu.Unlock()
		return
	}

	serviceContainer.keys = newTopologyKeys

	// update endpoints
	changedEps := sc.rebuildEndpointsMap()
	sc.mu.Unlock()

	for _, eps := range changedEps {
		sc.endpointsChan <- eps
	}
}

逻辑如下:

  • 获取service topologyKeys
  • 构建service event.Modified event
  • 比较service topologyKeys与已经存在的是否有差异
  • 如果有差异则更新topologyKeys,且调用rebuildEndpointsMap刷新该service对应的endpoints,如果endpoints发生变化,则构建endpoints watch event,传递给endpoints handler(interceptEndpointsRequest)处理

3、EndpointsEventHandler

storageCache.endpointsMap结构体key为endpoints名称(namespace/name),value为endpointsContainer,包含如下数据:

  • endpoints:拓扑修改前的endpoints
  • modified:拓扑修改后的endpoints

对于endpoints资源的改动,这里用Update event说明:

func (eh *endpointsHandler) update(endpoints *v1.Endpoints) {
	sc := eh.cache

	sc.mu.Lock()
	endpointsKey := types.NamespacedName{Namespace: endpoints.Namespace, Name: endpoints.Name}
	klog.Infof("Updating endpoints %v", endpointsKey)

	endpointsContainer, found := sc.endpointsMap[endpointsKey]
	if !found {
		sc.mu.Unlock()
		klog.Errorf("Updating non-existed endpoints %v", endpointsKey)
		return
	}
	endpointsContainer.endpoints = endpoints
	newEps := pruneEndpoints(sc.hostName, sc.nodesMap, sc.servicesMap, endpoints, sc.wrapperInCluster)
	changed := !apiequality.Semantic.DeepEqual(endpointsContainer.modified, newEps)
	if changed {
		endpointsContainer.modified = newEps
	}
	sc.mu.Unlock()

	if changed {
		sc.endpointsChan <- watch.Event{
			Type:   watch.Modified,
			Object: newEps,
		}
	}
}

逻辑如下:

  • 更新endpointsContainer.endpoint为新的endpoints对象
  • 调用pruneEndpoints获取拓扑刷新后的endpoints
  • 比较endpointsContainer.modified与新刷新后的endpoints
  • 如果有差异则更新endpointsContainer.modified,则构建endpoints watch event,传递给endpoints handler(interceptEndpointsRequest)处理

在分析完NodeEventHandler,ServiceEventHandler以及EndpointsEventHandler之后,我们回到具体的http handler List&Watch处理逻辑上,这里以endpoints为例:

func (s *interceptorServer) interceptEndpointsRequest(handler http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, "/api/v1/endpoints") {
			handler.ServeHTTP(w, r)
			return
		}

		queries := r.URL.Query()
		acceptType := r.Header.Get("Accept")
		info, found := s.parseAccept(acceptType, s.mediaSerializer)
		if !found {
			klog.Errorf("can't find %s serializer", acceptType)
			w.WriteHeader(http.StatusBadRequest)
			return
		}

		encoder := scheme.Codecs.EncoderForVersion(info.Serializer, v1.SchemeGroupVersion)
		// list request
		if queries.Get("watch") == "" {
			w.Header().Set("Content-Type", info.MediaType)
			allEndpoints := s.cache.GetEndpoints()
			epsItems := make([]v1.Endpoints, 0, len(allEndpoints))
			for _, eps := range allEndpoints {
				epsItems = append(epsItems, *eps)
			}

			epsList := &v1.EndpointsList{
				Items: epsItems,
			}

			err := encoder.Encode(epsList, w)
			if err != nil {
				klog.Errorf("can't marshal endpoints list, %v", err)
				w.WriteHeader(http.StatusInternalServerError)
				return
			}

			return
		}

		// watch request
		timeoutSecondsStr := r.URL.Query().Get("timeoutSeconds")
		timeout := time.Minute
		if timeoutSecondsStr != "" {
			timeout, _ = time.ParseDuration(fmt.Sprintf("%ss", timeoutSecondsStr))
		}

		timer := time.NewTimer(timeout)
		defer timer.Stop()

		flusher, ok := w.(http.Flusher)
		if !ok {
			klog.Errorf("unable to start watch - can't get http.Flusher: %#v", w)
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}

		e := restclientwatch.NewEncoder(
			streaming.NewEncoder(info.StreamSerializer.Framer.NewFrameWriter(w),
				scheme.Codecs.EncoderForVersion(info.StreamSerializer, v1.SchemeGroupVersion)),
			encoder)
		if info.MediaType == runtime.ContentTypeProtobuf {
			w.Header().Set("Content-Type", runtime.ContentTypeProtobuf+";stream=watch")
		} else {
			w.Header().Set("Content-Type", runtime.ContentTypeJSON)
		}
		w.Header().Set("Transfer-Encoding", "chunked")
		w.WriteHeader(http.StatusOK)
		flusher.Flush()
		for {
			select {
			case <-r.Context().Done():
				return
			case <-timer.C:
				return
			case evt := <-s.endpointsWatchCh:
				klog.V(4).Infof("Send endpoint watch event: %+#v", evt)
				err := e.Encode(&evt)
				if err != nil {
					klog.Errorf("can't encode watch event, %v", err)
					return
				}

				if len(s.endpointsWatchCh) == 0 {
					flusher.Flush()
				}
			}
		}
	})
}

逻辑如下:

  • 如果为List请求,则调用GetEndpoints获取拓扑修改后的endpoints列表,并返回
func (sc *storageCache) GetEndpoints() []*v1.Endpoints {
	sc.mu.RLock()
	defer sc.mu.RUnlock()

	epList := make([]*v1.Endpoints, 0, len(sc.endpointsMap))
	for _, v := range sc.endpointsMap {
		epList = append(epList, v.modified)
	}
	return epList
}
  • 如果为Watch请求,则不断从storageCache.endpointsWatchCh管道中接受watch event,并返回

interceptServiceRequest逻辑与interceptEndpointsRequest一致,这里不再赘述

总结

  • SuperEdge service group利用application-grid-wrapper实现拓扑感知,完成了同一个nodeunit内服务的闭环访问
  • service group实现的拓扑感知和Kubernetes社区原生实现对比,有如下区别:
    • service group拓扑key可以自定义,也即为gridUniqKey,使用起来更加灵活;而社区实现目前只有三种选择:”kubernetes.io/hostname”,”topology.kubernetes.io/zone”以及”topology.kubernetes.io/region”
    • service group只能填写一个拓扑key,也即只能访问本拓扑域内有效的endpoint,无法访问其它拓扑域的endpoint;而社区可以通过topologyKey列表以及”*“实现其它备选拓扑域endpoint的访问
  • ServiceGrid Controller负责根据ServiceGrid产生对应的service(包含由serviceGrid.Spec.GridUniqKey构成的topologyKeys annotations),逻辑和DeploymentGrid Controller整体一致,如下:
    • 创建并维护service group需要的若干CRDs(包括:ServiceGrid)
    • 监听ServiceGrid event,并填充ServiceGrid到工作队列中;循环从队列中取出ServiceGrid进行解析,创建并且维护对应的service
    • 监听service event,并将相关的ServiceGrid塞到工作队列中进行上述处理,协助上述逻辑达到整体reconcile逻辑
  • 为了实现Kubernetes零侵入,需要在kube-proxy与apiserver通信之间添加一层wrapper,调用链路如下:kube-proxy -> application-grid-wrapper -> lite-apiserver -> kube-apiserver
  • application-grid-wrapper是一个http server,接受来自kube-proxy的请求,同时维护一个资源缓存,处理函数由外到内依次如下:
    • debug:接受debug请求,返回wrapper pprof运行信息
    • logger:打印请求日志
    • node:接受kube-proxy node GET(/api/v1/nodes/{node})请求,并返回node信息
    • event:接受kube-proxy events POST(/events)请求,并将请求转发给lite-apiserver
    • service:接受kube-proxy service List&Watch(/api/v1/services)请求,并根据storageCache内容返回(GetServices)
    • endpoint:接受kube-proxy endpoint List&Watch(/api/v1/endpoints)请求,并根据storageCache内容返回(GetEndpoints)
  • wrapper为了实现拓扑感知,维护了一个资源cache,包括:node,service,endpoint,同时注册了相关event处理函数。核心拓扑算法逻辑为:调用filterConcernedAddresses过滤endpoint.Subsets Addresses以及NotReadyAddresses,只保留同一个service topologyKeys中的endpoint。另外,如果wrapper所在边缘节点没有service topologyKeys标签,则也无法访问该service
  • wrapper接受来自kube-proxy对endpoints以及service的List&Watch请求,以endpoints为例:如果为List请求,则调用GetEndpoints获取拓扑修改后的endpoints列表,并返回;如果为Watch请求,则不断从storageCache.endpointsWatchCh管道中接受watch event,并返回。service逻辑与endpoints一致

展望

目前SuperEdge service group实现的拓扑算法功能更加灵活方便,如何处理与Kubernetes社区service topology awareness之间的关系值得探索,建议将SuperEdge拓扑算法推到社区

Refs

]]>
SuperEdge分布式健康检查深度剖析——edge-health-daemon 2021-03-01T19:10:31+00:00 duyanghao http://duyanghao.github.io/superedge-edge-health-daemon 前言

边缘计算场景下,边缘节点与云端的网络环境十分复杂,连接并不可靠,在原生Kubernetes集群中,会造成apiserver和节点连接的中断,节点状态的异常,最终导致pod的驱逐和endpoint的缺失,造成服务的中断和波动,具体来说原生Kubernetes处理如下:

  • 失联的节点被置为ConditionUnknown状态,并被添加NoSchedule和NoExecute的taints
  • 失联的节点上的pod被驱逐,并在其他节点上进行重建
  • 失联的节点上的pod从Service的Endpoint列表中移除

因此,边缘计算场景仅仅依赖边端和apiserver的连接情况是不足以判断节点是否异常的,会因为网络的不可靠造成误判,影响正常服务。而相较于云端和边缘端的连接,显然边端节点之间的连接更为稳定,具有一定的参考价值,因此superedge提出了边缘分布式健康检查机制。该机制中节点状态判定除了要考虑apiserver的因素外,还引入了节点的评估因素,进而对节点进行更为全面的状态判断。通过这个功能,能够避免由于云边网络不可靠造成的大量的pod迁移和重建,保证服务的稳定

具体来说,主要通过如下三个层面增强节点状态判断的准确性:

  • 每个节点定期探测其他节点健康状态
  • 集群内所有节点定期投票决定各节点的状态
  • 云端和边端节点共同决定节点状态

而分布式健康检查最终的判断处理如下:

img

edge-health-daemon源码分析

下面将基于我对edge-health的重构PR Refactor edge-health and admission webhook for a better maintainability and extendibility 进行分析,在深入源码之前先介绍一下分布式健康检查的实现原理,其架构图如下所示:

img

Kubernetes每个node在kube-node-lease namespace下会对应一个Lease object,kubelet每隔node-status-update-frequency时间(默认10s)会更新对应node的Lease object

node-controller会每隔node-monitor-period时间(默认5s)检查Lease object是否更新,如果超过node-monitor-grace-period时间(默认40s)没有发生过更新,则认为这个node不健康,会更新NodeStatus(ConditionUnknown)

而当节点心跳超时(ConditionUnknown)之后,node controller会给该node添加如下taints:

spec:
  ...
  taints:
  - effect: NoSchedule
    key: node.kubernetes.io/unreachable
    timeAdded: "2020-07-02T03:50:47Z"
  - effect: NoExecute
    key: node.kubernetes.io/unreachable
    timeAdded: "2020-07-02T03:50:53Z"

同时,endpoint controller会从endpoint backend中踢掉该母机上的所有pod

对于打上NoSchedule taint的母机,Scheduler不会调度新的负载在该node上了;而对于打上NoExecute(node.kubernetes.io/unreachable) taint的母机,node controller会在节点心跳超时之后一段时间(默认5mins)驱逐该节点上的pod

分布式健康检查边端的edge-health-daemon组件会对同区域边缘节点执行分布式健康检查,并向apiserver发送健康状态投票结果(给node打annotation)

此外,为了实现在云边断连且分布式健康检查状态正常的情况下:

  • 失联的节点上的pod不会从Service的Endpoint列表中移除
  • 失联的节点上的pod不会被驱逐

还需要在云端运行edge-health-admission(Kubernetes mutating admission webhook),不断根据node edge-health annotation调整kube-controller-manager设置的node taint(去掉NoExecute taint)以及endpoints(将失联节点上的pods从endpoint subsets notReadyAddresses移到addresses中),从而实现云端和边端共同决定节点状态

本章将主要介绍edge-health-daemon原理,如下为edge-health-daemon的相关数据结构:

type EdgeHealthMetadata struct {
	*NodeMetadata
	*CheckMetadata
}

type NodeMetadata struct {
	NodeList []v1.Node
	sync.RWMutex
}

type CheckMetadata struct {
	CheckInfo            map[string]map[string]CheckDetail // Checker ip:{Checked ip:Check detail}
	CheckPluginScoreInfo map[string]map[string]float64     // Checked ip:{Plugin name:Check score}
	sync.RWMutex
}

type CheckDetail struct {
	Normal bool
	Time   time.Time
}

type CommunInfo struct {
	SourceIP    string                 // ClientIP,Checker ip
	CheckDetail map[string]CheckDetail // Checked ip:Check detail
	Hmac        string
}

含义如下:

  • NodeMetadata:为了实现分区域分布式健康检查机制而维护的边缘节点cache,其中包含该区域内的所有边缘节点列表NodeList
  • CheckMetadata:存放健康检查的结果,具体来说包括两个数据结构:
    • CheckPluginScoreInfo:为Checked ip:{Plugin name:Check score}组织形式。第一级key表示:被检查的ip;第二级key表示:检查插件的名称;value表示:检查分数
    • CheckInfo:为Checker ip:{Checked ip:Check detail}组织形式。第一级key表示:执行检查的ip;第二级key表示:被检查的ip;value表示检查结果CheckDetail
  • CheckDetail:代表健康检查的结果
    • Normal:Normal为true表示检查结果正常;false表示异常
    • Time:表示得出该结果时的时间,用于结果有效性的判断(超过一段时间没有更新的结果将无效)
  • CommunInfo:边缘节点向其它节点发送健康检查结果时使用的数据,其中包括:
    • SourceIP:表示执行检查的ip
    • CheckDetail:为Checked ip:Check detail组织形式,包含被检查的ip以及检查结果
    • Hmac:SourceIP以及CheckDetail进行hmac得到,用于边缘节点通信过程中判断传输数据的有效性(是否被篡改)

edge-health-daemon主体逻辑包括四部分功能:

  • SyncNodeList:根据边缘节点所在的zone刷新node cache,同时更新CheckMetadata相关数据
  • ExecuteCheck:对每个边缘节点执行若干种类的健康检查插件(ping,kubelet等),并将各插件检查分数汇总,根据用户设置的基准线得出节点是否健康的结果
  • Commun:将本节点对其它各节点健康检查的结果发送给其它节点
  • Vote:对所有节点健康检查的结果分类,如果某个节点被大多数(>1/2)节点判定为正常,则对该节点添加superedgehealth/node-health:true annotation,表明该节点分布式健康检查结果为正常;否则,对该节点添加superedgehealth/node-health:false annotation,表明该节点分布式健康检查结果为异常

下面依次对上述功能进行源码分析:

1、SyncNodeList

SyncNodeList每隔HealthCheckPeriod秒(health-check-period选项)执行一次,会按照如下情况分类刷新node cache:

  • 如果kube-system namespace下不存在名为edge-health-zone-config的configmap,则没有开启多地域探测,因此会获取所有边缘节点列表并刷新node cache
  • 否则,如果edge-health-zone-config的configmap数据部分TaintZoneAdmission为false,则没有开启多地域探测,因此会获取所有边缘节点列表并刷新node cache
  • 如果TaintZoneAdmission为true,且node有”superedgehealth/topology-zone”标签(标示区域),则获取”superedgehealth/topology-zone” label value相同的节点列表并刷新node cache
  • 如果node没有”superedgehealth/topology-zone” label,则只会将边缘节点本身添加到分布式健康检查节点列表中并刷新node cache(only itself)
func (ehd *EdgeHealthDaemon) SyncNodeList() {
	// Only sync nodes when self-located found
	var host *v1.Node
	if host = ehd.metadata.GetNodeByName(ehd.cfg.Node.HostName); host == nil {
		klog.Errorf("Self-hostname %s not found", ehd.cfg.Node.HostName)
		return
	}

	// Filter cloud nodes and retain edge ones
	masterRequirement, err := labels.NewRequirement(common.MasterLabel, selection.DoesNotExist, []string{})
	if err != nil {
		klog.Errorf("New masterRequirement failed %+v", err)
		return
	}
	masterSelector := labels.NewSelector()
	masterSelector = masterSelector.Add(*masterRequirement)

	if mrc, err := ehd.cmLister.ConfigMaps(metav1.NamespaceSystem).Get(common.TaintZoneConfigMap); err != nil {
		if apierrors.IsNotFound(err) { // multi-region configmap not found
			if NodeList, err := ehd.nodeLister.List(masterSelector); err != nil {
				klog.Errorf("Multi-region configmap not found and get nodes err %+v", err)
				return
			} else {
				ehd.metadata.SetByNodeList(NodeList)
			}
		} else {
			klog.Errorf("Get multi-region configmap err %+v", err)
			return
		}
	} else { // multi-region configmap found
		mrcv := mrc.Data[common.TaintZoneConfigMapKey]
		klog.V(4).Infof("Multi-region value is %s", mrcv)
		if mrcv == "false" { // close multi-region check
			if NodeList, err := ehd.nodeLister.List(masterSelector); err != nil {
				klog.Errorf("Multi-region configmap exist but disabled and get nodes err %+v", err)
				return
			} else {
				ehd.metadata.SetByNodeList(NodeList)
			}
		} else { // open multi-region check
			if hostZone, existed := host.Labels[common.TopologyZone]; existed {
				klog.V(4).Infof("Host %s has HostZone %s", host.Name, hostZone)
				zoneRequirement, err := labels.NewRequirement(common.TopologyZone, selection.Equals, []string{hostZone})
				if err != nil {
					klog.Errorf("New masterZoneRequirement failed: %+v", err)
					return
				}
				masterZoneSelector := labels.NewSelector()
				masterZoneSelector = masterZoneSelector.Add(*masterRequirement, *zoneRequirement)
				if nodeList, err := ehd.nodeLister.List(masterZoneSelector); err != nil {
					klog.Errorf("TopologyZone label for hostname %s but get nodes err: %+v", host.Name, err)
					return
				} else {
					ehd.metadata.SetByNodeList(nodeList)
				}
			} else { // Only check itself if there is no TopologyZone label
				klog.V(4).Infof("Only check itself since there is no TopologyZone label for hostname %s", host.Name)
				ehd.metadata.SetByNodeList([]*v1.Node{host})
			}
		}
	}

	// Init check plugin score
	ipList := make(map[string]struct{})
	for _, node := range ehd.metadata.Copy() {
		for _, addr := range node.Status.Addresses {
			if addr.Type == v1.NodeInternalIP {
				ipList[addr.Address] = struct{}{}
				ehd.metadata.InitCheckPluginScore(addr.Address)
			}
		}
	}

	// Delete redundant check plugin score
	for _, checkedIp := range ehd.metadata.CopyCheckedIp() {
		if _, existed := ipList[checkedIp]; !existed {
			ehd.metadata.DeleteCheckPluginScore(checkedIp)
		}
	}

	// Delete redundant check info
	for checkerIp := range ehd.metadata.CopyAll() {
		if _, existed := ipList[checkerIp]; !existed {
			ehd.metadata.DeleteByIp(ehd.cfg.Node.LocalIp, checkerIp)
		}
	}

	klog.V(4).Infof("SyncNodeList check info %+v successfully", ehd.metadata)
}

...
func (cm *CheckMetadata) DeleteByIp(localIp, ip string) {
	cm.Lock()
	defer cm.Unlock()
	delete(cm.CheckInfo[localIp], ip)
	delete(cm.CheckInfo, ip)
}

在按照如上逻辑更新node cache之后,会初始化CheckMetadata.CheckPluginScoreInfo,将节点ip赋值给CheckPluginScoreInfo key(Checked ip:被检查的ip)

另外,会删除CheckMetadata.CheckPluginScoreInfo以及CheckMetadata.CheckInfo中多余的items(不属于该边缘节点检查范围)

2、ExecuteCheck

ExecuteCheck也是每隔HealthCheckPeriod秒(health-check-period选项)执行一次,会对每个边缘节点执行若干种类的健康检查插件(ping,kubelet等),并将各插件检查分数汇总,根据用户设置的基准线HealthCheckScoreLine(health-check-scoreline选项)得出节点是否健康的结果

func (ehd *EdgeHealthDaemon) ExecuteCheck() {
	util.ParallelizeUntil(context.TODO(), 16, len(ehd.checkPlugin.Plugins), func(index int) {
		ehd.checkPlugin.Plugins[index].CheckExecute(ehd.metadata.CheckMetadata)
	})
	klog.V(4).Infof("CheckPluginScoreInfo is %+v after health check", ehd.metadata.CheckPluginScoreInfo)

	for checkedIp, pluginScores := range ehd.metadata.CopyCheckPluginScore() {
		totalScore := 0.0
		for _, score := range pluginScores {
			totalScore += score
		}
		if totalScore >= ehd.cfg.Check.HealthCheckScoreLine {
			ehd.metadata.SetByCheckDetail(ehd.cfg.Node.LocalIp, checkedIp, metadata.CheckDetail{Normal: true})
		} else {
			ehd.metadata.SetByCheckDetail(ehd.cfg.Node.LocalIp, checkedIp, metadata.CheckDetail{Normal: false})
		}
	}
	klog.V(4).Infof("CheckInfo is %+v after health check", ehd.metadata.CheckInfo)
}

这里会调用ParallelizeUntil并发执行各检查插件,edge-health目前支持ping以及kubelet两种检查插件,在checkplugin目录(github.com/superedge/superedge/pkg/edge-health/checkplugin),通过Register注册到PluginInfo单例(plugin列表)中,如下:

// TODO: handle flag parse errors
func (pcp *PingCheckPlugin) Set(s string) error {
	var err error
	for _, para := range strings.Split(s, ",") {
		if len(para) == 0 {
			continue
		}
		arr := strings.Split(para, "=")
		trimKey := strings.TrimSpace(arr[0])
		switch trimKey {
		case "timeout":
			timeout, _ := strconv.Atoi(strings.TrimSpace(arr[1]))
			pcp.HealthCheckoutTimeOut = timeout
		case "retries":
			retries, _ := strconv.Atoi(strings.TrimSpace(arr[1]))
			pcp.HealthCheckRetries = retries
		case "weight":
			weight, _ := strconv.ParseFloat(strings.TrimSpace(arr[1]), 64)
			pcp.Weight = weight
		case "port":
			port, _ := strconv.Atoi(strings.TrimSpace(arr[1]))
			pcp.Port = port
		}
	}
	PluginInfo = NewPlugin()
	PluginInfo.Register(pcp)
	return err
}

func (p *Plugin) Register(plugin CheckPlugin) {
	p.Plugins = append(p.Plugins, plugin)
	klog.V(4).Info("Register check plugin: %+v", plugin)
}


...
var (
	PluginOnce sync.Once
	PluginInfo Plugin
)

type Plugin struct {
	Plugins []CheckPlugin
}

func NewPlugin() Plugin {
	PluginOnce.Do(func() {
		PluginInfo = Plugin{
			Plugins: []CheckPlugin{},
		}
	})
	return PluginInfo
}

每种插件具体执行健康检查的逻辑封装在CheckExecute中,这里以ping plugin为例:

// github.com/superedge/superedge/pkg/edge-health/checkplugin/pingcheck.go
func (pcp *PingCheckPlugin) CheckExecute(checkMetadata *metadata.CheckMetadata) {
	copyCheckedIp := checkMetadata.CopyCheckedIp()
	util.ParallelizeUntil(context.TODO(), 16, len(copyCheckedIp), func(index int) {
		checkedIp := copyCheckedIp[index]
		var err error
		for i := 0; i < pcp.HealthCheckRetries; i++ {
			if _, err := net.DialTimeout("tcp", checkedIp+":"+strconv.Itoa(pcp.Port), time.Duration(pcp.HealthCheckoutTimeOut)*time.Second); err == nil {
				break
			}
		}
		if err == nil {
			klog.V(4).Infof("Edge ping health check plugin %s for ip %s succeed", pcp.Name(), checkedIp)
			checkMetadata.SetByPluginScore(checkedIp, pcp.Name(), pcp.GetWeight(), common.CheckScoreMax)
		} else {
			klog.Warning("Edge ping health check plugin %s for ip %s failed, possible reason %s", pcp.Name(), checkedIp, err.Error())
			checkMetadata.SetByPluginScore(checkedIp, pcp.Name(), pcp.GetWeight(), common.CheckScoreMin)
		}
	})
}

// CheckPluginScoreInfo relevant functions
func (cm *CheckMetadata) SetByPluginScore(checkedIp, pluginName string, weight float64, score int) {
	cm.Lock()
	defer cm.Unlock()

	if _, existed := cm.CheckPluginScoreInfo[checkedIp]; !existed {
		cm.CheckPluginScoreInfo[checkedIp] = make(map[string]float64)
	}
	cm.CheckPluginScoreInfo[checkedIp][pluginName] = float64(score) * weight
}

CheckExecute会对同区域每个节点执行ping探测(net.DialTimeout),如果失败,则给该节点打CheckScoreMin分(0);否则,打CheckScoreMax分(100)

每种检查插件会有一个Weight参数,表示了该检查插件分数的权重值,所有权重参数之和应该为1,对应基准分数线HealthCheckScoreLine范围0-100。因此这里在设置分数时,会乘以权重

回到ExecuteCheck函数,在调用各插件执行健康检查得出权重分数(CheckPluginScoreInfo)后,还需要将该分数与基准线HealthCheckScoreLine对比:如果高于(>=)分数线,则认为该节点本次检查正常;否则异常

func (ehd *EdgeHealthDaemon) ExecuteCheck() {
	util.ParallelizeUntil(context.TODO(), 16, len(ehd.checkPlugin.Plugins), func(index int) {
		ehd.checkPlugin.Plugins[index].CheckExecute(ehd.metadata.CheckMetadata)
	})
	klog.V(4).Infof("CheckPluginScoreInfo is %+v after health check", ehd.metadata.CheckPluginScoreInfo)

	for checkedIp, pluginScores := range ehd.metadata.CopyCheckPluginScore() {
		totalScore := 0.0
		for _, score := range pluginScores {
			totalScore += score
		}
		if totalScore >= ehd.cfg.Check.HealthCheckScoreLine {
			ehd.metadata.SetByCheckDetail(ehd.cfg.Node.LocalIp, checkedIp, metadata.CheckDetail{Normal: true})
		} else {
			ehd.metadata.SetByCheckDetail(ehd.cfg.Node.LocalIp, checkedIp, metadata.CheckDetail{Normal: false})
		}
	}
	klog.V(4).Infof("CheckInfo is %+v after health check", ehd.metadata.CheckInfo)
}

3、Commun

在对同区域各边缘节点执行健康检查后,需要将检查的结果传递给其它各节点,这也就是commun模块负责的事情:

func (ehd *EdgeHealthDaemon) Run(stopCh <-chan struct{}) {
	// Execute edge health prepare and check
	ehd.PrepareAndCheck(stopCh)

	// Execute vote
	vote := vote.NewVoteEdge(&ehd.cfg.Vote)
	go vote.Vote(ehd.metadata, ehd.cfg.Kubeclient, ehd.cfg.Node.LocalIp, stopCh)

	// Execute communication
	communEdge := commun.NewCommunEdge(&ehd.cfg.Commun)
	communEdge.Commun(ehd.metadata.CheckMetadata, ehd.cmLister, ehd.cfg.Node.LocalIp, stopCh)

	<-stopCh
}

既然是互相传递结果给其它节点,则必然会有接受和发送模块:

func (c *CommunEdge) Commun(checkMetadata *metadata.CheckMetadata, cmLister corelisters.ConfigMapLister, localIp string, stopCh <-chan struct{}) {
	go c.communReceive(checkMetadata, cmLister, stopCh)
	wait.Until(func() {
		c.communSend(checkMetadata, cmLister, localIp)
	}, time.Duration(c.CommunPeriod)*time.Second, stopCh)
}

其中communSend负责向其它节点发送本节点对它们的检查结果;而communReceive负责接受其它边缘节点的检查结果。下面依次分析:

func (c *CommunEdge) communSend(checkMetadata *metadata.CheckMetadata, cmLister corelisters.ConfigMapLister, localIp string) {
	copyLocalCheckDetail := checkMetadata.CopyLocal(localIp)
	var checkedIps []string
	for checkedIp := range copyLocalCheckDetail {
		checkedIps = append(checkedIps, checkedIp)
	}
	util.ParallelizeUntil(context.TODO(), 16, len(checkedIps), func(index int) {
		// Only send commun information to other edge nodes(excluding itself)
		dstIp := checkedIps[index]
		if dstIp == localIp {
			return
		}
		// Send commun information
		communInfo := metadata.CommunInfo{SourceIP: localIp, CheckDetail: copyLocalCheckDetail}
		if hmac, err := util.GenerateHmac(communInfo, cmLister); err != nil {
			log.Errorf("communSend: generateHmac err %+v", err)
			return
		} else {
			communInfo.Hmac = hmac
		}
		commonInfoBytes, err := json.Marshal(communInfo)
		if err != nil {
			log.Errorf("communSend: json.Marshal commun info err %+v", err)
			return
		}
		commonInfoReader := bytes.NewReader(commonInfoBytes)
		for i := 0; i < c.CommunRetries; i++ {
			req, err := http.NewRequest("PUT", "http://"+dstIp+":"+strconv.Itoa(c.CommunServerPort)+"/result", commonInfoReader)
			if err != nil {
				log.Errorf("communSend: NewRequest for remote edge node %s err %+v", dstIp, err)
				continue
			}
			if err = util.DoRequestAndDiscard(c.client, req); err != nil {
				log.Errorf("communSend: DoRequestAndDiscard for remote edge node %s err %+v", dstIp, err)
			} else {
				log.V(4).Infof("communSend: put commun info %+v to remote edge node %s successfully", communInfo, dstIp)
				break
			}
		}
	})
}

发送逻辑如下:

  • 构建CommunInfo结构体,包括:
    • SourceIP:表示执行检查的ip
    • CheckDetail:为Checked ip:Check detail组织形式,包含被检查的ip以及检查结果
  • 调用GenerateHmac构建Hmac:实际上是以kube-system下的hmac-config configmap hmackey字段为key,对SourceIP以及CheckDetail进行hmac得到,用于判断传输数据的有效性(是否被篡改)
func GenerateHmac(communInfo metadata.CommunInfo, cmLister corelisters.ConfigMapLister) (string, error) {
	addrBytes, err := json.Marshal(communInfo.SourceIP)
	if err != nil {
		return "", err
	}
	detailBytes, _ := json.Marshal(communInfo.CheckDetail)
	if err != nil {
		return "", err
	}
	hmacBefore := string(addrBytes) + string(detailBytes)
	if hmacConf, err := cmLister.ConfigMaps(metav1.NamespaceSystem).Get(common.HmacConfig); err != nil {
		return "", err
	} else {
		return GetHmacCode(hmacBefore, hmacConf.Data[common.HmacKey])
	}
}

func GetHmacCode(s, key string) (string, error) {
	h := hmac.New(sha256.New, []byte(key))
	if _, err := io.WriteString(h, s); err != nil {
		return "", err
	}
	return fmt.Sprintf("%x", h.Sum(nil)), nil
}
  • 发送上述构建的CommunInfo给其它边缘节点(DoRequestAndDiscard)

communReceive逻辑也很清晰:

// TODO: support changeable server listen port
func (c *CommunEdge) communReceive(checkMetadata *metadata.CheckMetadata, cmLister corelisters.ConfigMapLister, stopCh <-chan struct{}) {
	svr := &http.Server{Addr: ":" + strconv.Itoa(c.CommunServerPort)}
	svr.ReadTimeout = time.Duration(c.CommunTimeout) * time.Second
	svr.WriteTimeout = time.Duration(c.CommunTimeout) * time.Second
	http.HandleFunc("/debug/flags/v", pkgutil.UpdateLogLevel)
	http.HandleFunc("/result", func(w http.ResponseWriter, r *http.Request) {
		var communInfo metadata.CommunInfo
		if r.Body == nil {
			http.Error(w, "Invalid commun information", http.StatusBadRequest)
			return
		}

		err := json.NewDecoder(r.Body).Decode(&communInfo)
		if err != nil {
			http.Error(w, fmt.Sprintf("Invalid commun information %+v", err), http.StatusBadRequest)
			return
		}
		log.V(4).Infof("Received common information from %s : %+v", communInfo.SourceIP, communInfo.CheckDetail)

		if _, err := io.WriteString(w, "Received!\n"); err != nil {
			log.Errorf("communReceive: send response err %+v", err)
			http.Error(w, fmt.Sprintf("Send response err %+v", err), http.StatusInternalServerError)
			return
		}
		if hmac, err := util.GenerateHmac(communInfo, cmLister); err != nil {
			log.Errorf("communReceive: server GenerateHmac err %+v", err)
			http.Error(w, fmt.Sprintf("GenerateHmac err %+v", err), http.StatusInternalServerError)
			return
		} else {
			if hmac != communInfo.Hmac {
				log.Errorf("communReceive: Hmac not equal, hmac is %s but received commun info hmac is %s", hmac, communInfo.Hmac)
				http.Error(w, "Hmac not match", http.StatusForbidden)
				return
			}
		}
		log.V(4).Infof("communReceive: Hmac match")

		checkMetadata.SetByCommunInfo(communInfo)
		log.V(4).Infof("After communicate, check info is %+v", checkMetadata.CheckInfo)
	})

	go func() {
		if err := svr.ListenAndServe(); err != http.ErrServerClosed {
			log.Fatalf("Server: exit with error %+v", err)
		}
	}()

	for {
		select {
		case <-stopCh:
			ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
			defer cancel()
			if err := svr.Shutdown(ctx); err != nil {
				log.Errorf("Server: program exit, server exit error %+v", err)
			}
			return
		default:
		}
	}
}

负责接受其它边缘节点的检查结果,并写入自身检查结果CheckInfo,流程如下:

  • 通过/result路由接受请求,并将请求内容解析成CommunInfo
  • 对CommunInfo执行GenerateHmac获取hmac值,并与CommunInfo.Hmac字段进行对比,检查接受数据的有效性
  • 最后将CommunInfo检查结果写入CheckInfo,注意:CheckDetail.Time设置为写入时的时间
    // CheckInfo relevant functions
    func (cm *CheckMetadata) SetByCommunInfo(c CommunInfo) {
      cm.Lock()
      defer cm.Unlock()
    
      if _, existed := cm.CheckInfo[c.SourceIP]; !existed {
          cm.CheckInfo[c.SourceIP] = make(map[string]CheckDetail)
      }
      for k, detail := range c.CheckDetail {
          // Update time to local timestamp since different machines have different ones
          detail.Time = time.Now()
          c.CheckDetail[k] = detail
      }
      cm.CheckInfo[c.SourceIP] = c.CheckDetail
    }
    
  • 最后在接受到stopCh信号时,通过svr.Shutdown平滑关闭服务

4、Vote

在接受到其它节点的健康检查结果后,vote模块会对结果进行统计得出最终判决,并向apiserver报告:

func (v *VoteEdge) Vote(edgeHealthMetadata *metadata.EdgeHealthMetadata, kubeclient clientset.Interface,
	localIp string, stopCh <-chan struct{}) {
	go wait.Until(func() {
		v.vote(edgeHealthMetadata, kubeclient, localIp, stopCh)
	}, time.Duration(v.VotePeriod)*time.Second, stopCh)
}

首先根据检查结果统计出状态正常以及异常的节点列表:

type votePair struct {
	pros int
	cons int
}

...
var (
    prosVoteIpList, consVoteIpList []string
    // Init votePair since cannot assign to struct field voteCountMap[checkedIp].pros in map
    vp votePair
)
voteCountMap := make(map[string]votePair) // {"127.0.0.1":{"pros":1,"cons":2}}
copyCheckInfo := edgeHealthMetadata.CopyAll()
// Note that voteThreshold should be calculated by checked instead of checker
// since checked represents the total valid edge health nodes while checker may contain partly ones.
voteThreshold := (edgeHealthMetadata.GetCheckedIpLen() + 1) / 2
for _, checkedDetails := range copyCheckInfo {
    for checkedIp, checkedDetail := range checkedDetails {
        if !time.Now().After(checkedDetail.Time.Add(time.Duration(v.VoteTimeout) * time.Second)) {
            if _, existed := voteCountMap[checkedIp]; !existed {
                voteCountMap[checkedIp] = votePair{0, 0}
            }
            vp = voteCountMap[checkedIp]
            if checkedDetail.Normal {
                vp.pros++
                if vp.pros >= voteThreshold {
                    prosVoteIpList = append(prosVoteIpList, checkedIp)
                }
            } else {
                vp.cons++
                if vp.cons >= voteThreshold {
                    consVoteIpList = append(consVoteIpList, checkedIp)
                }
            }
            voteCountMap[checkedIp] = vp
        }
    }
}
log.V(4).Infof("Vote: voteCountMap is %+v", voteCountMap)
...

其中状态判断的逻辑如下:

  • 如果超过一半(>)的节点对该节点的检查结果为正常,则认为该节点状态正常(注意时间差在VoteTimeout内)
  • 如果超过一半(>)的节点对该节点的检查结果为异常,则认为该节点状态异常(注意时间差在VoteTimeout内)
  • 除开上述情况,认为节点状态判断无效,对这些节点不做任何处理(可能存在脑裂的情况)

对状态正常的节点做如下处理:

...
// Handle prosVoteIpList
util.ParallelizeUntil(context.TODO(), 16, len(prosVoteIpList), func(index int) {
    if node := edgeHealthMetadata.GetNodeByAddr(prosVoteIpList[index]); node != nil {
        log.V(4).Infof("Vote: vote pros to edge node %s begin ...", node.Name)
        nodeCopy := node.DeepCopy()
        needUpdated := false
        if nodeCopy.Annotations == nil {
            nodeCopy.Annotations = map[string]string{
                common.NodeHealthAnnotation: common.NodeHealthAnnotationPros,
            }
            needUpdated = true
        } else {
            if healthy, existed := nodeCopy.Annotations[common.NodeHealthAnnotation]; existed {
                if healthy != common.NodeHealthAnnotationPros {
                    nodeCopy.Annotations[common.NodeHealthAnnotation] = common.NodeHealthAnnotationPros
                    needUpdated = true
                }
            } else {
                nodeCopy.Annotations[common.NodeHealthAnnotation] = common.NodeHealthAnnotationPros
                needUpdated = true
            }
        }
        if index, existed := admissionutil.TaintExistsPosition(nodeCopy.Spec.Taints, common.UnreachableNoExecuteTaint); existed {
            nodeCopy.Spec.Taints = append(nodeCopy.Spec.Taints[:index], nodeCopy.Spec.Taints[index+1:]...)
            needUpdated = true
        }
        if needUpdated {
            if _, err := kubeclient.CoreV1().Nodes().Update(context.TODO(), nodeCopy, metav1.UpdateOptions{}); err != nil {
                log.Errorf("Vote: update pros vote to edge node %s error %+v ", nodeCopy.Name, err)
            } else {
                log.V(2).Infof("Vote: update pros vote to edge node %s successfully", nodeCopy.Name)
            }
        }
    } else {
        log.Warningf("Vote: edge node addr %s not found", prosVoteIpList[index])
    }
})
...
  • 添加或者更新”superedgehealth/node-health” annotation值为”true”,表明分布式健康检查判断该节点状态正常
  • 如果node存在NoExecute(node.kubernetes.io/unreachable) taint,则将其去掉,并更新node

而对状态异常的节点会添加或者更新”superedgehealth/node-health” annotation值为”false”,表明分布式健康检查判断该节点状态异常:

// Handle consVoteIpList
util.ParallelizeUntil(context.TODO(), 16, len(consVoteIpList), func(index int) {
    if node := edgeHealthMetadata.GetNodeByAddr(consVoteIpList[index]); node != nil {
        log.V(4).Infof("Vote: vote cons to edge node %s begin ...", node.Name)
        nodeCopy := node.DeepCopy()
        needUpdated := false
        if nodeCopy.Annotations == nil {
            nodeCopy.Annotations = map[string]string{
                common.NodeHealthAnnotation: common.NodeHealthAnnotationCons,
            }
            needUpdated = true
        } else {
            if healthy, existed := nodeCopy.Annotations[common.NodeHealthAnnotation]; existed {
                if healthy != common.NodeHealthAnnotationCons {
                    nodeCopy.Annotations[common.NodeHealthAnnotation] = common.NodeHealthAnnotationCons
                    needUpdated = true
                }
            } else {
                nodeCopy.Annotations[common.NodeHealthAnnotation] = common.NodeHealthAnnotationCons
                needUpdated = true
            }
        }
        if needUpdated {
            if _, err := kubeclient.CoreV1().Nodes().Update(context.TODO(), nodeCopy, metav1.UpdateOptions{}); err != nil {
                log.Errorf("Vote: update cons vote to edge node %s error %+v ", nodeCopy.Name, err)
            } else {
                log.V(2).Infof("Vote: update cons vote to edge node %s successfully", nodeCopy.Name)
            }
        }
    } else {
        log.Warningf("Vote: edge node addr %s not found", consVoteIpList[index])
    }
})

在边端edge-health-daemon向apiserver发送节点健康结果后,云端运行edge-health-admission(Kubernetes mutating admission webhook),会不断根据node edge-health annotation调整kube-controller-manager设置的node taint(去掉NoExecute taint)以及endpoints(将失联节点上的pods从endpoint subsets notReadyAddresses移到addresses中),从而实现即便云边断连,但是分布式健康检查状态正常的情况下:

  • 失联的节点上的pod不会从Service的Endpoint列表中移除
  • 失联的节点上的pod不会被驱逐

总结

  • 分布式健康检查对于云边断连情况的处理区别原生Kubernetes如下:
    • 原生Kubernetes:
      • 失联的节点被置为ConditionUnknown状态,并被添加NoSchedule和NoExecute的taints
      • 失联的节点上的pod被驱逐,并在其他节点上进行重建
      • 失联的节点上的pod从Service的Endpoint列表中移除
    • 分布式健康检查: img
  • 分布式健康检查主要通过如下三个层面增强节点状态判断的准确性:
    • 每个节点定期探测其他节点健康状态
    • 集群内所有节点定期投票决定各节点的状态
    • 云端和边端节点共同决定节点状态
  • 分布式健康检查功能由边端的edge-health-daemon以及云端的edge-health-admission组成,功能分别如下:
    • edge-health-daemon:对同区域边缘节点执行分布式健康检查,并向apiserver发送健康状态投票结果(给node打annotation),主体逻辑包括四部分功能:
      • SyncNodeList:根据边缘节点所在的zone刷新node cache,同时更新CheckMetadata相关数据
      • ExecuteCheck:对每个边缘节点执行若干种类的健康检查插件(ping,kubelet等),并将各插件检查分数汇总,根据用户设置的基准线得出节点是否健康的结果
      • Commun:将本节点对其它各节点健康检查的结果发送给其它节点
      • Vote:对所有节点健康检查的结果分类,如果某个节点被大多数(>1/2)节点判定为正常,则对该节点添加superedgehealth/node-health:true annotation,表明该节点分布式健康检查结果为正常;否则,对该节点添加superedgehealth/node-health:false annotation,表明该节点分布式健康检查结果为异常
    • edge-health-admission(Kubernetes mutating admission webhook):不断根据node edge-health annotation调整kube-controller-manager设置的node taint(去掉NoExecute taint)以及endpoints(将失联节点上的pods从endpoint subsets notReadyAddresses移到addresses中),从而实现云端和边端共同决定节点状态

Refs

]]>
SuperEdge service group StatefulSetGrid深度剖析 2021-02-04T19:10:31+00:00 duyanghao http://duyanghao.github.io/service-group-statefulset-grid 前言

SuperEdge StatefulSetGrid由本人在官方提出方案SEP: ServiceGroup StatefulSetGrid Design Specification,最终与chenkaiyue合作开发完成

初衷是为了补充service group对有状态服务的支持,设计架构图如下:

img

这里先介绍一下StatefulSetGrid的使用示例,有一个直观的感受:

1、部署StatefulSetGrid

apiVersion: superedge.io/v1
kind: StatefulSetGrid
metadata:
  name: statefulsetgrid-demo
  namespace: default
spec:
  gridUniqKey: zone
  template:
    selector:
      matchLabels:
        appGrid: echo
    serviceName: "servicegrid-demo-svc"
    replicas: 3
    template:
      metadata:
        labels:
          appGrid: echo
      spec:
        terminationGracePeriodSeconds: 10
        containers:
        - image: superedge/echoserver:2.2
          name: echo
          ports:
          - containerPort: 8080
            protocol: TCP
          env:
            - name: NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: POD_IP
              valueFrom:
                fieldRef:
                  fieldPath: status.podIP
          resources: {}

注意:template中的serviceName设置成即将创建的service名称

2、部署ServiceGrid

apiVersion: superedge.io/v1
kind: ServiceGrid
metadata:
  name: servicegrid-demo
  namespace: default
spec:
  gridUniqKey: zone
  template:
    selector:
      appGrid: echo
    ports:
    - protocol: TCP
      port: 80
      targetPort: 8080

gridUniqKey字段设置为了zone,所以我们在将节点分组时采用label的key为zone,如果有三组节点,分别为他们添加zone: zone-0, zone: zone-1, zone: zone-2的label即可;这时,每组节点内都有了echo-service的statefulset和对应的pod,在节点内访问统一的service-name也只会将请求发向本组的节点

[~]# kubectl get ssg
NAME                   AGE
statefulsetgrid-demo   21h

[~]# kubectl get statefulset
NAME                          READY   AGE
statefulsetgrid-demo-zone-0   3/3     21h
statefulsetgrid-demo-zone-1   3/3     21h
statefulsetgrid-demo-zone-2   3/3     21h

[~]# kubectl get svc
NAME                   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
kubernetes             ClusterIP   192.168.0.1     <none>        443/TCP   22h
servicegrid-demo-svc   ClusterIP   192.168.21.99   <none>        80/TCP    21h

# execute on zone-0 nodeunit
[~]# curl 192.168.21.99|grep "node name"
        node name:      node0
...
# execute on zone-1 nodeunit
[~]# curl 192.168.21.99|grep "node name"
        node name:      node1
...
# execute on zone-2 nodeunit
[~]# curl 192.168.21.99|grep "node name"
        node name:      node2
...

注意:在各NodeUnit内通过service访问本组服务时,对应clusterIP不能设置成None,暂不支持此种情况下的闭环访问

除了采用service访问statefulset负载,StatefulSetGrid还支持使用headless service的方式进行访问,如下所示:

img

StatefulSetGrid提供屏蔽NodeUnit的统一headless service访问形式,如下:

{StatefulSetGrid}-{0..N-1}.{StatefulSetGrid}-svc.ns.svc.cluster.local

上述访问会对应实际各个NodeUnit的具体pod:

{StatefulSetGrid}-{NodeUnit}-{0..N-1}.{StatefulSetGrid}-svc.ns.svc.cluster.local

每个NodeUnit通过相同的headless service只会访问本组内的pod。也即:对于NodeUnit:zone-1来说,会访问statefulsetgrid-demo-zone-1(statefulset)对应的pod;而对于NodeUnit:zone-2来说,会访问statefulsetgrid-demo-zone-2(statefulset)对应的pod

# execute on zone-0 nodeunit
[~]# curl statefulsetgrid-demo-0.servicegrid-demo-svc.default.svc.cluster.local|grep "pod name"
        pod name:       statefulsetgrid-demo-zone-0-0
[~]# curl statefulsetgrid-demo-1.servicegrid-demo-svc.default.svc.cluster.local|grep "pod name"
        pod name:       statefulsetgrid-demo-zone-0-1
[~]# curl statefulsetgrid-demo-2.servicegrid-demo-svc.default.svc.cluster.local|grep "pod name"
        pod name:       statefulsetgrid-demo-zone-0-2
...
# execute on zone-1 nodeunit
[~]# curl statefulsetgrid-demo-0.servicegrid-demo-svc.default.svc.cluster.local|grep "pod name"
        pod name:       statefulsetgrid-demo-zone-1-0
[~]# curl statefulsetgrid-demo-1.servicegrid-demo-svc.default.svc.cluster.local|grep "pod name"
        pod name:       statefulsetgrid-demo-zone-1-1
[~]# curl statefulsetgrid-demo-2.servicegrid-demo-svc.default.svc.cluster.local|grep "pod name"
        pod name:       statefulsetgrid-demo-zone-1-2
...
# execute on zone-2 nodeunit
[~]# curl statefulsetgrid-demo-0.servicegrid-demo-svc.default.svc.cluster.local|grep "pod name"
        pod name:       statefulsetgrid-demo-zone-2-0
[~]# curl statefulsetgrid-demo-1.servicegrid-demo-svc.default.svc.cluster.local|grep "pod name"
        pod name:       statefulsetgrid-demo-zone-2-1
[~]# curl statefulsetgrid-demo-2.servicegrid-demo-svc.default.svc.cluster.local|grep "pod name"
        pod name:       statefulsetgrid-demo-zone-2-2
...

在熟悉StatefulSetGrid的基本使用后,我们深入源码分析

源码分析

StatefulSetGrid包括两部分组件:

  • StatefulSetGrid Controller(云端):负责根据StatefulSetGrid CR(custom resource) 创建&维护 各nodeunit对应的statefulset
  • statefulset-grid-daemon(边缘):负责生成各nodeunit对应statefulset负载的域名hosts记录(A records),以便屏蔽nodeunit,通过统一的FQDN:{StatefulSetGrid}-{0..N-1}.{StatefulSetGrid}-svc.ns.svc.cluster.local访问有状态服务

这里依次对上述组件进行分析:

StatefulSetGrid Controller

StatefulSetGrid Controller逻辑和DeploymentGrid Controller整体一致,如下:

  • 1、创建并维护service group需要的若干CRDs(包括:StatefulSetGrid)
  • 2、监听StatefulSetGrid event,并填充StatefulSetGrid到工作队列中;循环从队列中取出StatefulSetGrid进行解析,创建并且维护各nodeunit对应的statefulset
  • 3、监听statefulset以及node event,并将相关的StatefulSetGrid塞到工作队列中进行上述处理,协助上述逻辑达到整体reconcile效果

注意各nodeunit创建的statefulset以{StatefulSetGrid}-{nodeunit}命名,同时添加了nodeSelector限制(GridUniqKey: nodeunit)

func (ssgc *StatefulSetGridController) syncStatefulSetGrid(key string) error {
	startTime := time.Now()
	klog.V(4).Infof("Started syncing statefulset grid %s (%v)", key, startTime)
	defer func() {
		klog.V(4).Infof("Finished syncing statefulset grid %s (%v)", key, time.Since(startTime))
	}()

	namespace, name, err := cache.SplitMetaNamespaceKey(key)
	if err != nil {
		return err
	}

	ssg, err := ssgc.setGridLister.StatefulSetGrids(namespace).Get(name)
	if errors.IsNotFound(err) {
		klog.V(2).Infof("statefulset grid %v has been deleted", key)
		return nil
	}
	if err != nil {
		return err
	}

	if ssg.Spec.GridUniqKey == "" {
		ssgc.eventRecorder.Eventf(ssg, corev1.EventTypeWarning, "Empty", "This statefulset-grid has an empty grid key")
		return nil
	}

	// get statefulset workload list of this grid
	setList, err := ssgc.getStatefulSetForGrid(ssg)
	if err != nil {
		return err
	}

	// get all grid labels in all nodes
	gridValues, err := common.GetGridValuesFromNode(ssgc.nodeLister, ssg.Spec.GridUniqKey)
	if err != nil {
		return err
	}

	// sync statefulset grid workload status
	if ssg.DeletionTimestamp != nil {
		return ssgc.syncStatus(ssg, setList, gridValues)
	}

	// sync statefulset grid status and its relevant statefusets workload
	return ssgc.reconcile(ssg, setList, gridValues)
}

func (ssgc *StatefulSetGridController) getStatefulSetForGrid(ssg *crdv1.StatefulSetGrid) ([]*appsv1.StatefulSet, error) {
	setList, err := ssgc.setLister.StatefulSets(ssg.Namespace).List(labels.Everything())
	if err != nil {
		return nil, err
	}

	labelSelector, err := common.GetDefaultSelector(ssg.Name)
	if err != nil {
		return nil, err
	}
	canAdoptFunc := controller.RecheckDeletionTimestamp(func() (metav1.Object, error) {
		fresh, err := ssgc.crdClient.SuperedgeV1().StatefulSetGrids(ssg.Namespace).Get(context.TODO(), ssg.Name, metav1.GetOptions{})
		if err != nil {
			return nil, err
		}
		if fresh.UID != ssg.UID {
			return nil, fmt.Errorf("orignal statefulset grid %v/%v is gone: got uid %v, wanted %v", ssg.Namespace,
				ssg.Name, fresh.UID, ssg.UID)
		}
		return fresh, nil
	})

	cm := controller.NewStatefulSetControllerRefManager(ssgc.setClient, ssg, labelSelector, util.ControllerKind, canAdoptFunc)
	return cm.ClaimStatefulSet(setList)
}

func (ssgc *StatefulSetGridController) reconcile(ssg *crdv1.StatefulSetGrid, setList []*appsv1.StatefulSet, gridValues []string) error {
	existedSetMap := make(map[string]*appsv1.StatefulSet)

	for _, set := range setList {
		existedSetMap[set.Name] = set
	}

	wanted := sets.NewString()
	for _, v := range gridValues {
		wanted.Insert(util.GetStatefulSetName(ssg, v))
	}

	var (
		adds    []*appsv1.StatefulSet
		updates []*appsv1.StatefulSet
		deletes []*appsv1.StatefulSet
	)

	for _, v := range gridValues {
		name := util.GetStatefulSetName(ssg, v)

		set, found := existedSetMap[name]
		if !found {
			adds = append(adds, util.CreateStatefulSet(ssg, v))
			continue
		}

		template := util.KeepConsistence(ssg, set, v)
		if !apiequality.Semantic.DeepEqual(template, set) {
			updates = append(updates, template)
		}
	}

	// If statefulset's name is not matched with grid value but has the same selector, we remove it.
	for _, set := range setList {
		if !wanted.Has(set.Name) {
			deletes = append(deletes, set)
		}
	}

	if err := ssgc.syncStatefulSet(adds, updates, deletes); err != nil {
		return err
	}

	return ssgc.syncStatus(ssg, setList, gridValues)
}

func CreateStatefulSet(ssg *crdv1.StatefulSetGrid, gridValue string) *appsv1.StatefulSet {
	set := &appsv1.StatefulSet{
		ObjectMeta: metav1.ObjectMeta{
			Name:            GetStatefulSetName(ssg, gridValue),
			Namespace:       ssg.Namespace,
			OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(ssg, ControllerKind)},
			// Append existed StatefulSetGrid labels to statefulset to be created
			Labels: func() map[string]string {
				if ssg.Labels != nil {
					newLabels := ssg.Labels
					newLabels[common.GridSelectorName] = ssg.Name
					newLabels[common.GridSelectorUniqKeyName] = ssg.Spec.GridUniqKey
					return newLabels
				} else {
					return map[string]string{
						common.GridSelectorName:        ssg.Name,
						common.GridSelectorUniqKeyName: ssg.Spec.GridUniqKey,
					}
				}
			}(),
		},
		Spec: ssg.Spec.Template,
	}

	// Append existed StatefulSetGrid NodeSelector to statefulset to be created
	if ssg.Spec.Template.Template.Spec.NodeSelector != nil {
		set.Spec.Template.Spec.NodeSelector = ssg.Spec.Template.Template.Spec.NodeSelector
		set.Spec.Template.Spec.NodeSelector[ssg.Spec.GridUniqKey] = gridValue
	} else {
		set.Spec.Template.Spec.NodeSelector = map[string]string{
			ssg.Spec.GridUniqKey: gridValue,
		}
	}

	return set
}

由于逻辑与DeploymentGrid类似,这里不展开细节,重点关注statefulset-grid-daemon部分

statefulset-grid-daemon

在深入分析statefulset-grid-daemon之前,先介绍一下statefulset-grid-daemon的架构,如下:

img

这里使用了coredns的hosts plugins,如下:

  Corefile: |
    .:53 {
        errors
        health {
           lameduck 5s
        }
        ready
        kubernetes cluster.local in-addr.arpa ip6.arpa {
           pods insecure
           fallthrough in-addr.arpa ip6.arpa
           ttl 30
        }
        hosts /data/edge/hosts {
            reload 300ms
            fallthrough
        }
        cache 30
        loop
        reload
        loadbalance
    }

statefulset-grid-daemon会根据statefulset构建对应的{StatefulSetGrid}-{0..N-1}.{StatefulSetGrid}-svc.ns.svc.cluster.local dns A record,并更新到本地挂载文件中

而该挂载文件实际上就是coredns host plugins使用的文件。通过这个文件将两者联系起来,使得statefulset-grid-daemon可以添加原来coredns不存在的domain record,并且生效

接下来关注statefulset-grid-daemon刷新StatefulSetGrid域名的细节。statefulset-grid-daemon主体逻辑是监听statefulset资源,并对每个由StatefulSetGrid产生的statefulset执行域名更新操作:

func (ssgdc *StatefulSetGridDaemonController) Run(workers, syncPeriodAsWhole int, stopCh <-chan struct{}) {
	defer utilruntime.HandleCrash()
	defer ssgdc.queue.ShutDown()

	klog.Infof("Starting statefulset grid daemon")
	defer klog.Infof("Shutting down statefulset grid daemon")

	if !cache.WaitForNamedCacheSync("statefulset-grid-daemon", stopCh,
		ssgdc.nodeListerSynced, ssgdc.podListerSynced, ssgdc.setListerSynced, ssgdc.setGridListerSynced, ssgdc.svcListerSynced) {
		return
	}

	for i := 0; i < workers; i++ {
		go wait.Until(ssgdc.worker, time.Second, stopCh)
	}

	// sync dns hosts as a whole
	go wait.Until(ssgdc.syncDnsHostsAsWhole, time.Duration(syncPeriodAsWhole)*time.Second, stopCh)
	<-stopCh
}

这里会使用两个函数负责StatefulSetGrid域名刷新逻辑:

  • syncDnsHosts(部分更新):从workqueue中取出statefulset,并对该statefulset执行域名增删改操作
  • syncDnsHostsAsWhole(全量更新):作为syncDnsHosts的补充,全量更新StatefulSetGrid的相关域名,保障域名的最终一致性

下面依次分析:

1、syncDnsHosts

func (ssgdc *StatefulSetGridDaemonController) syncDnsHosts(key string) error {
	startTime := time.Now()
	klog.V(4).Infof("Started syncing dns hosts of statefulset %q (%v)", key, startTime)
	defer func() {
		klog.V(4).Infof("Finished syncing dns hosts of statefulset %q (%v)", key, time.Since(startTime))
	}()

	namespace, name, err := cache.SplitMetaNamespaceKey(key)
	if err != nil {
		return err
	}

	set, err := ssgdc.setLister.StatefulSets(namespace).Get(name)
	if errors.IsNotFound(err) {
		klog.V(2).Infof("StatefulSet %v has been deleted", key)
		return nil
	}
	if err != nil {
		return err
	}

	var PodDomainInfoToHosts = make(map[string]string)
	ControllerRef := metav1.GetControllerOf(set)
	// Check existence of statefulset relevant service and execute delete operations if necessary
	if needClear, err := ssgdc.needClearStatefulSetDomains(set); err != nil {
		return err
	} else if needClear {
		if err := ssgdc.hosts.CheckOrUpdateHosts(PodDomainInfoToHosts, set.Namespace, ControllerRef.Name, set.Spec.ServiceName); err != nil {
			klog.Errorf("Clear statefulset %v dns hosts err %v", key, err)
			return err
		}
		klog.V(4).Infof("Clear statefulset %v dns hosts successfully", key)
		return nil
	}

	// Get pod list of this statefulset
	podList, err := ssgdc.podLister.Pods(set.Namespace).List(labels.Everything())
	if err != nil {
		klog.Errorf("Get podList err %v", err)
		return err
	}

	podToHosts := []*corev1.Pod{}
	for _, pod := range podList {
		if util.IsMemberOf(set, pod) {
			podToHosts = append(podToHosts, pod)
		}
	}
	// Sync dns hosts partly
	// Attention: this sync can not guarantee the absolute correctness of statefulset grid dns hosts records,
	// and should be used combined with syncDnsHostsAsWhole to ensure the eventual consistency
	// Actual statefulset pod FQDN: <controllerRef>-<gridValue>-<ordinal>.<svc>.<ns>.svc.cluster.local
	// (eg: statefulsetgrid-demo-nodeunit1-0.servicegrid-demo-svc.default.svc.cluster.local)
	// Converted statefulset pod FQDN: <controllerRef>-<ordinal>.<svc>.<ns>.svc.cluster.local
	// (eg: statefulsetgrid-demo-0.servicegrid-demo-svc.default.svc.cluster.local)
	if ControllerRef != nil {
		gridValue := set.Name[len(ControllerRef.Name)+1:]
		for _, pod := range podToHosts {
			index := strings.Index(pod.Name, gridValue)
			if index == -1 {
				klog.Errorf("Invalid pod name %s(statefulset %s)", pod.Name, set.Name)
				continue
			}
			podDomainsToHosts := pod.Name[0:index] + pod.Name[index+len(gridValue)+1:] + "." + set.Spec.ServiceName
			if pod.Status.PodIP == "" {
				klog.V(2).Infof("There is currently no ip for pod %s(statefulset %s)", pod.Name, set.Name)
				continue
			}
			PodDomainInfoToHosts[hosts.AppendDomainSuffix(podDomainsToHosts, pod.Namespace)] = pod.Status.PodIP
		}
		if err := ssgdc.hosts.CheckOrUpdateHosts(PodDomainInfoToHosts, set.Namespace, ControllerRef.Name, set.Spec.ServiceName); err != nil {
			klog.Errorf("update dns hosts err %v", err)
			return err
		}
	}
	return nil
}

处理逻辑如下:

  • 调用needClearStatefulSetDomains判断该statefulset对应域名是否应该删除,满足如下条件则需要删除:
    • 如果statefulset对应service不存在
    • 如果statefulset不存在 superedge.io/grid-uniq-key gridUniqKey标签(StatefulSetGrid Controller在创建statefulset时会添加该标签表明StatefulSetGrid的gridUniqKey)或者对应gridUniqKey与service对应gridUniqKey不一致
      func (ssgdc *StatefulSetGridDaemonController) needClearStatefulSetDomains(set *appsv1.StatefulSet) (bool, error) {
        // Check existence of statefulset relevant service
        svc, err := ssgdc.svcLister.Services(set.Namespace).Get(set.Spec.ServiceName)
        if errors.IsNotFound(err) {
            klog.V(2).Infof("StatefulSet %v relevant service %s not found", set.Name, set.Spec.ServiceName)
            return true, nil
        }
        if err != nil {
            return false, err
        }
        // Check GridSelectorUniqKeyName label value equation between service and statefulset
        gridUniqKey, _ := set.Labels[controllercommon.GridSelectorUniqKeyName]
        svcGridUniqKey, found := svc.Labels[controllercommon.GridSelectorUniqKeyName]
        if !found {
            return true, nil
        } else if gridUniqKey != svcGridUniqKey {
            return true, nil
        }
        return false, nil
      }
      
  • 如果确认需要删除,则会构建空PodDomainInfoToHosts,调用CheckOrUpdateHosts对hosts文件进行删除操作
  • 获取该statefulset namespace下的所有pod列表,并调用IsMemberOf过滤出属于该statefulset的pods
  • 获取产生该statefulset的父StatefulSetGrid名称,并根据父StatefulSetGrid.Name(statefulsetgrid-demo)以及statefulset.Name(statefulsetgrid-demo-nodeunit1)解析出该statefulset所对应nodeunit(nodeunit1)
  • 将实际的statefulset pod FQDN(statefulsetgrid-demo-nodeunit1-xxx.servicegrid-demo-svc.default.svc.cluster.local)转化为service group对应的statefulset pod FQDN(statefulsetgrid-demo-xxx.servicegrid-demo-svc.default.svc.cluster.local),并构建PodDomainInfoToHosts map(key为转化后的FQDN,value为podIp)
  • 调用CheckOrUpdateHosts检查并更新hosts文件内容

host package(github.com/superedge/superedge/pkg/statefulset-grid-daemon/hosts)封装了coredns host plugin文件的操作:

type Hosts struct {
	hostPath string
	hostsMap map[string]string
	sync.RWMutex
}

func AppendDomainSuffix(domain, ns string) string {
	return domain + "." + ns + suffix
}

func (h *Hosts) isMatchDomain(domain, ns, ssgName, svcName string) bool {
	match, _ := regexp.MatchString(ssgName+"-"+`[0-9]+`+`\.`+svcName+`\.`+ns+suffix, domain)
	return match
}

func (h *Hosts) CheckOrUpdateHosts(PodDomainInfoToHosts map[string]string, ns, ssgName, svcName string) error {
	h.Lock()
	defer h.Unlock()

	isChanged := false
	for domain, ip := range h.hostsMap {
		// Only cares about those domains that matches statefulset grid headless service pod FQDN records
		if h.isMatchDomain(domain, ns, ssgName, svcName) {
			if curIp, exist := PodDomainInfoToHosts[domain]; !exist {
				// Delete pod relevant domains since it has been deleted
				delete(h.hostsMap, domain)
				klog.V(4).Infof("Deleting dns hosts domain %s and ip %s", domain, ip)
				isChanged = true
			} else if exist && curIp != ip {
				// Update pod relevant domains ip since it has been updated
				h.hostsMap[domain] = curIp
				delete(PodDomainInfoToHosts, domain)
				klog.V(4).Infof("Updating dns hosts domain %s: old ip %s -> ip %s", domain, ip, curIp)
				isChanged = true
			} else if exist && curIp == ip {
				// Stay unchanged
				delete(PodDomainInfoToHosts, domain)
				klog.V(5).Infof("Dns hosts domain %s and ip %s stays unchanged", domain, ip)
			}
		}
	}
	if !isChanged && len(PodDomainInfoToHosts) == 0 {
		// Stay unchanged as a whole
		klog.V(4).Infof("Dns hosts domain stays unchanged as a whole")
		return nil
	}
	// Create new domains records
	if len(PodDomainInfoToHosts) > 0 {
		for domain, ip := range PodDomainInfoToHosts {
			klog.V(4).Infof("Adding dns hosts domain %s and ip %s", domain, ip)
			h.hostsMap[domain] = ip
		}
	}
	// Sync dns hosts since it has changed now
	if err := h.saveHosts(); err != nil {
		return err
	}
	return nil
}

其中,Hosts结构体字段含义如下:

  • hostPath:本地domain host文件路径(默认:/data/edge/hosts)
  • hostsMap:本地domain host文件的内存cache

CheckOrUpdateHosts参数含义如下:

  • PodDomainInfoToHosts:转化后的domains map(key为转化后的FQDN,value为podIp)
  • ns:statefulset namespace
  • ssgName:statefulset父StatefulSetGrid.Name
  • svcName:statefulset对应service.Name

根据如上参数会进行增删改逻辑如下:

  • 如果hostsMap中不存在PodDomainInfoToHosts map中某个pod FQDN,则添加该FQDN记录到hostsMap中
  • 如果hostsMap中存在某个PodDomainInfoToHosts map中不存在的pod FQDN,则从hostsMap中删除该FQDN记录(可以解释上述利用空map做记录删除的逻辑)
  • 如果两者同时存在,但是ip不一致,则更新hostsMap为PodDomainInfoToHosts map的对应ip

2、syncDnsHostsAsWhole

syncDnsHostsAsWhole作为syncDnsHosts的补充,弥补syncDnsHosts在某些场景下(例如:删除statefulsetgrid)更新逻辑上的缺失,每隔syncPeriodAsWhole(默认30s)运行一次,会全量更新StatefulSetGrid的相关域名,保障域名的最终一致性:

func (ssgdc *StatefulSetGridDaemonController) Run(workers, syncPeriodAsWhole int, stopCh <-chan struct{}) {
	defer utilruntime.HandleCrash()
	defer ssgdc.queue.ShutDown()

	klog.Infof("Starting statefulset grid daemon")
	defer klog.Infof("Shutting down statefulset grid daemon")

	if !cache.WaitForNamedCacheSync("statefulset-grid-daemon", stopCh,
		ssgdc.nodeListerSynced, ssgdc.podListerSynced, ssgdc.setListerSynced, ssgdc.setGridListerSynced, ssgdc.svcListerSynced) {
		return
	}

	for i := 0; i < workers; i++ {
		go wait.Until(ssgdc.worker, time.Second, stopCh)
	}

	// sync dns hosts as a whole
	go wait.Until(ssgdc.syncDnsHostsAsWhole, time.Duration(syncPeriodAsWhole)*time.Second, stopCh)
	<-stopCh
}

func (ssgdc *StatefulSetGridDaemonController) syncDnsHostsAsWhole() {
	startTime := time.Now()
	klog.V(4).Infof("Started syncing dns hosts as a whole (%v)", startTime)
	defer func() {
		klog.V(4).Infof("Finished syncing dns hosts as a whole (%v)", time.Since(startTime))
	}()
	// Get node relevant GridSelectorUniqKeyName labels
	node, err := ssgdc.nodeLister.Get(ssgdc.hostName)
	if err != nil {
		klog.Errorf("Get host node %s err %v", ssgdc.hostName, err)
		return
	}
	gridUniqKeyLabels, err := controllercommon.GetNodesSelector(node)
	if err != nil {
		klog.Errorf("Get node %s GridSelectorUniqKeyName selector err %v", node.Name, err)
		return
	}
	// List all statefulsets by node labels
	setList, err := ssgdc.setLister.List(gridUniqKeyLabels)
	if err != nil {
		klog.Errorf("List statefulsets by labels %v err %v", gridUniqKeyLabels, err)
		return
	}
	hostsMap := make(map[string]string)
	// Filter concerned statefulsets and construct dns hosts
	for _, set := range setList {
		if rel, err := ssgdc.IsConcernedStatefulSet(set); err != nil || !rel {
			continue
		}
		if needClear, err := ssgdc.needClearStatefulSetDomains(set); err != nil || needClear {
			continue
		}
		// Get pod list of this statefulset
		podList, err := ssgdc.podLister.Pods(set.Namespace).List(labels.Everything())
		if err != nil {
			klog.Errorf("Get podList err %v", err)
			return
		}
		ControllerRef := metav1.GetControllerOf(set)
		gridValue := set.Name[len(ControllerRef.Name)+1:]
		for _, pod := range podList {
			if util.IsMemberOf(set, pod) {
				index := strings.Index(pod.Name, gridValue)
				if index == -1 {
					klog.Errorf("Invalid pod name %s(statefulset %s)", pod.Name, set.Name)
					continue
				}
				podDomainsToHosts := pod.Name[0:index] + pod.Name[index+len(gridValue)+1:] + "." + set.Spec.ServiceName
				if pod.Status.PodIP == "" {
					klog.V(2).Infof("There is currently no ip for pod %s(statefulset %s)", pod.Name, set.Name)
					continue
				}
				hostsMap[hosts.AppendDomainSuffix(podDomainsToHosts, pod.Namespace)] = pod.Status.PodIP
			}
		}
	}
	// Set dns hosts as a whole
	if err := ssgdc.hosts.SetHostsByMap(hostsMap); err != nil {
		klog.Errorf("SetHostsByMap err %v", err)
	}
	return
}

处理逻辑如下:

  • 根据节点名获取本边缘节点node
  • 从node中解析出有效labels key列表,并构建labels.Selector gridUniqKeyLabels(superedge.io/grid-uniq-key, selection.In)
  • 根据gridUniqKeyLabels查询statefulset列表,获取本边缘节点上所有可以访问的service group statefulset
  • 调用IsConcernedStatefulSet过滤出实际可以访问的有效service group statefulset列表
    func (ssgdc *StatefulSetGridDaemonController) IsConcernedStatefulSet(set *appsv1.StatefulSet) (bool, error) {
      // Check statefulset controllerRef
      controllerRef := metav1.GetControllerOf(set)
      if controllerRef == nil || controllerRef.Kind != util.ControllerKind.Kind {
          // Never care about statefulset orphans
          return false, nil
      }
      // Check consistency of statefulset and never care about inconsistent ones
      // Check GridSelectorName labels consistency
      if set.ObjectMeta.Labels == nil {
          return false, nil
      }
      controllerName, found := set.ObjectMeta.Labels[common.GridSelectorName]
      if !found || controllerName != controllerRef.Name {
          return false, nil
      }
      // Check GridSelectorUniqKeyName labels consistency
      gridUniqKeyName, found := set.ObjectMeta.Labels[common.GridSelectorUniqKeyName]
      if !found {
          return false, nil
      }
      if ssg, err := ssgdc.setGridLister.StatefulSetGrids(set.Namespace).Get(controllerRef.Name); err == nil {
          if ssg.Spec.GridUniqKey != gridUniqKeyName {
              return false, nil
          }
          if controllerRef.UID != ssg.UID {
              // The controller we found with this Name is not the same one that the
              // ControllerRef points to.
              return false, nil
          }
      } else if errors.IsNotFound(err) {
          klog.V(4).Infof("StatefulSet %s relevant owner statefulset grid %s not found.", set.Name, controllerRef.Name)
      } else {
          klog.Errorf("Get statefulset grid %s err %v", controllerRef.Name, err)
          return false, err
      }
    
      // Never care about statefulset that does not has service name
      if set.Spec.ServiceName == "" {
          return false, nil
      }
    
      // Check NodeSelector consistency
      node, err := ssgdc.nodeLister.Get(ssgdc.hostName)
      if err != nil {
          klog.Errorf("Get host node %s err %v", ssgdc.hostName, err)
          return false, err
      }
      nodeGridValue, exist := node.Labels[gridUniqKeyName]
      if !exist {
          return false, nil
      }
      if setGridValue, exist := set.Spec.Template.Spec.NodeSelector[gridUniqKeyName]; !exist || !(setGridValue == nodeGridValue) {
          return false, nil
      }
      return true, nil
    }
    
  • 遍历上述列表,对每一个statefulset对应pods FQDN进行转化,构建hostsMap
  • 利用hostsMap调用SetHostsByMap重置host cache
func (h *Hosts) SetHostsByMap(hostsMap map[string]string) error {
	h.Lock()
	defer h.Unlock()
	if !reflect.DeepEqual(h.hostsMap, hostsMap) {
		originalHostsMap := h.hostsMap
		h.hostsMap = hostsMap
		if err := h.saveHosts(); err != nil {
			h.hostsMap = originalHostsMap
			klog.V(4).Infof("Reset dns hosts domain and ip as a whole err %v", err)
			return err
		}
		klog.V(4).Infof("Reset dns hosts domain and ip as a whole successfully")
	}
	return nil
}

func (h *Hosts) saveHosts() error {
	hostData := []byte(h.parseHostsToFile())
	err := ioutil.WriteFile(h.hostPath, hostData, 0644)
	if err != nil {
		return err
	}
	return nil
}

func (h *Hosts) parseHostsToFile() string {
	hf := ""
	for domain, ip := range h.hostsMap {
		hf = hf + fmt.Sprintln(fmt.Sprintf("%s %s", ip, domain))
	}
	return hf
}

总结

  • StatefulSetGrid由本人在官方提出方案SEP: ServiceGroup StatefulSetGrid Design Specification,最终与chenkaiyue合作开发完成。初衷是为了补充service group对有状态服务的支持
  • StatefulSetGrid目前支持两种访问方式:
    • 通过统一的service name进行访问,会路由到本组内的服务(要求service.Spec.clusterIP不能设置成None,也即非headless service)
    • 通过statefulset pod FDQN进行访问。采用屏蔽NodeUnit的统一FQDN访问形式:{StatefulSetGrid}-{0..N-1}.{StatefulSetGrid}-svc.ns.svc.cluster.local,实际转化为各个NodeUnit内的statefulset pod:{StatefulSetGrid}-{NodeUnit}-{0..N-1}.{StatefulSetGrid}-svc.ns.svc.cluster.local
  • StatefulSetGrid包括两部分组件:
    • StatefulSetGrid Controller(云端):负责根据StatefulSetGrid CR(custom resource) 创建&维护 各nodeunit对应的statefulset
    • statefulset-grid-daemon(边缘):负责生成各nodeunit对应statefulset负载的域名hosts记录(A records),以便屏蔽nodeunit,通过统一的FQDN:{StatefulSetGrid}-{0..N-1}.{StatefulSetGrid}-svc.ns.svc.cluster.local访问有状态服务
  • StatefulSetGrid Controller逻辑和DeploymentGrid Controller整体一致,如下:
    • 创建并维护service group需要的若干CRDs(包括:StatefulSetGrid)
    • 监听StatefulSetGrid event,并填充StatefulSetGrid到工作队列中;循环从队列中取出StatefulSetGrid进行解析,创建并且维护各nodeunit对应的statefulset(注意各nodeunit创建的statefulset以{StatefulSetGrid}-{nodeunit}命名,同时添加了nodeSelector限制(GridUniqKey: nodeunit))
    • 监听statefulset以及node event,并将相关的StatefulSetGrid塞到工作队列中进行上述处理,协助上述逻辑达到整体reconcile效果
  • statefulset-grid-daemon会根据statefulset构建对应的{StatefulSetGrid}-{0..N-1}.{StatefulSetGrid}-svc.ns.svc.cluster.local dns A record,并更新到本地挂载文件中。而该挂载文件实际上就是coredns host plugins使用的文件。通过这个文件将两者联系起来,使得statefulset-grid-daemon可以添加原来coredns不存在的domain record,并且生效 img
  • StatefulSetGrid域名刷新逻辑有如下两部分组成:
    • syncDnsHosts(部分更新):从workqueue中取出statefulset,并对该statefulset执行域名增删改操作,处理逻辑如下:
      • 调用needClearStatefulSetDomains判断该statefulset对应域名是否应该删除,满足如下条件则需要删除:
        • 如果statefulset对应service不存在
        • 如果statefulset不存在 superedge.io/grid-uniq-key gridUniqKey标签(StatefulSetGrid Controller在创建statefulset时会添加该标签表明StatefulSetGrid的gridUniqKey)或者对应gridUniqKey与service对应gridUniqKey不一致
      • 如果确认需要删除,则会构建空PodDomainInfoToHosts,调用CheckOrUpdateHosts对hosts文件进行删除操作
      • 获取该statefulset namespace下的所有pod列表,并调用IsMemberOf过滤出属于该statefulset的pods
      • 获取产生该statefulset的父StatefulSetGrid名称,并根据父StatefulSetGrid.Name(statefulsetgrid-demo)以及statefulset.Name(statefulsetgrid-demo-nodeunit1)解析出该statefulset所对应nodeunit(nodeunit1)
      • 将实际的statefulset pod FQDN(statefulsetgrid-demo-nodeunit1-xxx.servicegrid-demo-svc.default.svc.cluster.local)转化为service group对应的statefulset pod FQDN(statefulsetgrid-demo-xxx.servicegrid-demo-svc.default.svc.cluster.local),并构建PodDomainInfoToHosts map(key为转化后的FQDN,value为podIp)
      • 调用CheckOrUpdateHosts检查并更新hosts文件内容
    • syncDnsHostsAsWhole(全量更新):作为syncDnsHosts的补充,弥补syncDnsHosts在某些场景下(例如:删除statefulsetgrid)更新逻辑上的缺失,每隔syncPeriodAsWhole(默认30s)运行一次,会全量更新StatefulSetGrid的相关域名,保障域名的最终一致性。处理逻辑如下:
      • 根据节点名获取本边缘节点node
      • 从node中解析出有效labels key列表,并构建labels.Selector gridUniqKeyLabels(superedge.io/grid-uniq-key, selection.In)
      • 根据gridUniqKeyLabels查询statefulset列表,获取本边缘节点上所有可以访问的service group statefulset
      • 利用IsConcernedStatefulSet过滤出实际可以访问的有效service group statefulset列表
      • 遍历上述列表,对每一个statefulset对应pods FQDN进行转化,构建hostsMap
      • 利用hostsMap调用SetHostsByMap重置host cache

展望

目前SuperEdge service group StatefulSetGrid实现了 通过service以及statefulset pod FQDN 屏蔽nodeunit访问statefulset负载的能力。但是还缺少对headless service场景下的支持,这块需要未来根据项目需求进行补充

Refs

]]>
SuperEdge——不一样的边缘计算 2021-01-12T19:10:31+00:00 duyanghao http://duyanghao.github.io/superedge 前言

superedge是腾讯推出的Kubernetes-native边缘计算管理框架。相比openyurt以及kubeedge,superedge除了具备Kubernetes零侵入以及边缘自治特性,还支持独有的分布式健康检查以及边缘服务访问控制等高级特性,极大地消减了云边网络不稳定对服务的影响,同时也很大程度上方便了边缘集群服务的发布与治理

特性

  • Kubernetes-native:superedge在原生Kubernetes基础上进行了扩展,增加了边缘计算的某干组件,对Kubernetes完全无侵入;另外通过简单部署superedge核心组件就可以使原生Kubernetes集群开启边缘计算功能;另外零侵入使得可以在边缘集群上部署任何Kubernetes原生工作负载(deployment, statefulset, daemonset, and etc)
  • 边缘自治:superedge提供L3级别的边缘自治能力,当边端节点与云端网络不稳定或者断连时,边缘节点依旧可以正常运行,不影响已经部署的边缘服务
  • 分布式健康检查:superedge提供边端分布式健康检查能力,每个边缘节点会部署edge-health,同一个边缘集群中的边缘节点会相互进行健康检查,对节点进行状态投票。这样即便云边网络存在问题,只要边缘端节点之间的连接正常,就不会对该节点进行驱逐;另外,分布式健康检查还支持分组,把集群节点分成多个组(同一个机房的节点分到同一个组中),每个组内的节点之间相互检查,这样做的好处是避免集群规模增大后节点之间的数据交互特别大,难以达成一致;同时也适应边缘节点在网络拓扑上天然就分组的情形。整个设计避免了由于云边网络不稳定造成的大量的pod迁移和重建,保证了服务的稳定
  • 服务访问控制:superedge自研了ServiceGroup实现了基于边缘计算的服务访问控制。基于该特性只需构建DeploymentGrid以及ServiceGrid两种Custom Resource,就可以便捷地在共属同一个集群的不同机房或区域中各自部署一组服务,并且使得各个服务间的请求在本机房或本地域内部即可完成(闭环),避免了服务跨地域访问。利用该特性可以极大地方便边缘集群服务的发布与治理
  • 云边隧道:superedge支持自建隧道(目前支持TCP, HTTP and HTTPS)打通不同网络环境下的云边连接问题。实现对无公网IP边缘节点的统一操作和维护

整体架构

img

组件功能总结如下:

云端组件

云端除了边缘集群部署的原生Kubernetes master组件(cloud-kube-apiserver,cloud-kube-controller以及cloud-kube-scheduler)外,主要管控组件还包括:

  • tunnel-cloud: 负责维持与边缘节点tunnel-edge的网络隧道,目前支持TCP/HTTP/HTTPS协议
  • application-grid controller:服务访问控制ServiceGroup对应的Kubernetes Controller,负责管理DeploymentGrids以及ServiceGrids CRDs,并由这两种CR生成对应的Kubernetes deployment以及service,同时自研实现服务拓扑感知,使得服务闭环访问
  • edge-admission: 通过边端节点分布式健康检查的状态报告决定节点是否健康,并协助cloud-kube-controller执行相关处理动作(打taint)

边缘组件

边端除了原生Kubernetes worker节点需要部署的kubelet,kube-proxy外,还添加了如下边缘计算组件:

  • lite-apiserver:边缘自治的核心组件,是cloud-kube-apiserver的代理服务,缓存了边缘节点组件对apiserver的某些请求,当遇到这些请求而且与cloud-kube-apiserver网络存在问题的时候会直接返回给client端
  • edge-health: 边端分布式健康检查服务,负责执行具体的监控和探测操作,并进行投票选举判断节点是否健康
  • tunnel-edge:负责建立与云端边缘集群tunnel-cloud的网络隧道,并接受API请求,转发给边缘节点组件(kubelet)
  • application-grid wrapper:与application-grid controller结合完成ServiceGrid内的闭环服务访问(服务拓扑感知)

功能概述

应用部署&服务访问控制

superedge可以支持原生Kubernetes的所有工作负载的应用部署,包括:

  • deployment
  • statefulset
  • daemonset
  • job
  • cronjob

而对于边缘计算应用来说,具备如下独特点:

  • 边缘计算场景中,往往会在同一个集群中管理多个边缘站点,每个边缘站点内有一个或多个计算节点
  • 同时希望在每个站点中都运行一组有业务逻辑联系的服务,每个站点内的服务是一套完整的功能,可以为用户提供服务
  • 由于受到网络限制,有业务联系的服务之间不希望或者不能跨站点访问

为了解决上述问题,superedge创新性地构建了ServiceGroup概念,方便用户便捷地在共属同一个集群的不同机房或区域中各自部署一组服务,并且使得各个服务间的请求在本机房或本地域内部即可完成(闭环),避免了服务跨地域访问

ServiceGroup中涉及几个关键概念:

img

NodeUnit

  • NodeUnit通常是位于同一边缘站点内的一个或多个计算资源实例,需要保证同一NodeUnit中的节点内网是通的
  • ServiceGroup组中的服务运行在一个NodeUnit之内
  • ServiceGroup允许用户设置服务在一个NodeUnit中运行的pod(belongs to deployment)数量
  • ServiceGroup能够把服务之间的调用限制在本NodeUnit内

NodeGroup

  • NodeGroup包含一个或者多个 NodeUnit
  • 保证在集合中每个NodeUnit上均部署ServiceGroup中的服务
  • 当集群中增加NodeUnit时会自动将ServiceGroup中的服务部署到新增NodeUnit

ServiceGroup

  • ServiceGroup包含一个或者多个业务服务
  • 适用场景:
    • 业务需要打包部署;
    • 需要在每一个NodeUnit中均运行起来并且保证pod数量
    • 需要将服务之间的调用控制在同一个 NodeUnit 中,不能将流量转发到其他NodeUnit上
  • 注意:ServiceGroup是一种抽象资源概念,一个集群中可以创建多个ServiceGroup

下面以一个具体例子说明ServiceGroup功能:

# step1: labels edge nodes
$ kubectl  get nodes
NAME    STATUS   ROLES    AGE   VERSION
node0   Ready    <none>   16d   v1.16.7
node1   Ready    <none>   16d   v1.16.7
node2   Ready    <none>   16d   v1.16.7
# nodeunit1(nodegroup and servicegroup zone1)
$ kubectl --kubeconfig config label nodes node0 zone1=nodeunit1  
# nodeunit2(nodegroup and servicegroup zone1)
$ kubectl --kubeconfig config label nodes node1 zone1=nodeunit2
$ kubectl --kubeconfig config label nodes node2 zone1=nodeunit2

# step2: deploy echo DeploymentGrid
$ cat <<EOF | kubectl --kubeconfig config apply -f -
apiVersion: superedge.io/v1
kind: DeploymentGrid
metadata:
  name: deploymentgrid-demo
  namespace: default
spec:
  gridUniqKey: zone1
  template:
    replicas: 2
    selector:
      matchLabels:
        appGrid: echo
    strategy: {}
    template:
      metadata:
        creationTimestamp: null
        labels:
          appGrid: echo
      spec:
        containers:
        - image: gcr.io/kubernetes-e2e-test-images/echoserver:2.2
          name: echo
          ports:
          - containerPort: 8080
            protocol: TCP
          env:
            - name: NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: POD_IP
              valueFrom:
                fieldRef:
                  fieldPath: status.podIP
          resources: {}
EOF
deploymentgrid.superedge.io/deploymentgrid-demo created

# note that there are two deployments generated and deployed into both nodeunit1 and nodeunit2
$ kubectl  get deploy
NAME                            READY   UP-TO-DATE   AVAILABLE   AGE
deploymentgrid-demo-nodeunit1   2/2     2            2           5m50s
deploymentgrid-demo-nodeunit2   2/2     2            2           5m50s
$ kubectl  get pods -o wide
NAME                                             READY   STATUS    RESTARTS   AGE     IP            NODE    NOMINATED NODE   READINESS GATES
deploymentgrid-demo-nodeunit1-65bbb7c6bb-6lcmt   1/1     Running   0          5m34s   172.16.0.16   node0   <none>           <none>
deploymentgrid-demo-nodeunit1-65bbb7c6bb-hvmlg   1/1     Running   0          6m10s   172.16.0.15   node0   <none>           <none>
deploymentgrid-demo-nodeunit2-56dd647d7-fh2bm    1/1     Running   0          5m34s   172.16.1.12   node1   <none>           <none>
deploymentgrid-demo-nodeunit2-56dd647d7-gb2j8    1/1     Running   0          6m10s   172.16.2.9    node2   <none>           <none>

# step3: deploy echo ServiceGrid
$ cat <<EOF | kubectl --kubeconfig config apply -f -
apiVersion: superedge.io/v1
kind: ServiceGrid
metadata:
  name: servicegrid-demo
  namespace: default
spec:
  gridUniqKey: zone1
  template:
    selector:
      appGrid: echo
    ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
EOF
servicegrid.superedge.io/servicegrid-demo created
# note that there is only one relevant service generated
$ kubectl  get svc
NAME                   TYPE        CLUSTER-IP        EXTERNAL-IP   PORT(S)   AGE
kubernetes             ClusterIP   192.168.0.1       <none>        443/TCP   16d
servicegrid-demo-svc   ClusterIP   192.168.6.139     <none>        80/TCP    10m

# step4: access servicegrid-demo-svc(service topology and closed-looped)
# execute on onde0
$ curl 192.168.6.139|grep "node name"
        node name:      node0
# execute on node1 and node2
$ curl 192.168.6.139|grep "node name"
        node name:      node2
$ curl 192.168.6.139|grep "node name"
        node name:      node1        

通过上面的例子总结ServiceGroup如下:

  • NodeUnit和NodeGroup以及ServiceGroup都是一种概念,具体来说实际使用中对应关系如下:
    • NodeUnit是具有相同label key以及value的一组边缘节点
    • NodeGroup是具有相同label key的一组NodeUnit(不同value)
    • ServiceGroup具体由两种CRD构成:DepolymentGrid以及ServiceGrid,具备相同的gridUniqKey
    • gridUniqKey值与NodeGroup的label key对应,也即ServiceGroup是与NodeGroup一一对应,而NodeGroup对应多个NodeUnit,同时NodeGroup中的每一个NodeUnit都会部署ServiceGroup对应deployment,这些deployment(deploymentgridName-NodeUnit命名)通过nodeSelector亲和性固定某个NodeUnit上,并通过服务拓扑感知限制在该NodeUnit内访问

分布式健康检查

边缘计算场景下,边缘节点与云端的网络环境十分复杂,连接并不可靠,在原生Kubernetes集群中,会造成apiserver和节点连接的中断,节点状态的异常,最终导致pod的驱逐和endpoint的缺失,造成服务的中断和波动,具体来说原生Kubernetes处理如下:

  • 失联的节点被置为ConditionUnknown状态,并被添加NoSchedule和NoExecute的taints
  • 失联的节点上的pod被驱逐,并在其他节点上进行重建
  • 失联的节点上的pod从Service的Endpoint列表中移除

因此,边缘计算场景仅仅依赖边端和apiserver的连接情况是不足以判断节点是否异常的,会因为网络的不可靠造成误判,影响正常服务。而相较于云端和边缘端的连接,显然边端节点之间的连接更为稳定,具有一定的参考价值,因此superedge提出了边缘分布式健康检查机制。该机制中节点状态判定除了要考虑apiserver的因素外,还引入了节点的评估因素,进而对节点进行更为全面的状态判断。通过这个功能,能够避免由于云边网络不可靠造成的大量的pod迁移和重建,保证服务的稳定

具体来说,主要通过如下三个层面增强节点状态判断的准确性:

  • 每个节点定期探测其他节点健康状态
  • 集群内所有节点定期投票决定各节点的状态
  • 云端和边端节点共同决定节点状态

而分布式健康检查最终的判断处理如下:

节点最终状态 云端判定正常 云端判定异常
节点内部判定正常 正常 不再调度新的pod到该节点(NoSchedule taint)
节点内部判定异常 正常 驱逐存量pod;从Endpoint列表摘除pod;不再调度新的pod到该节点

边缘自治

对于边缘计算的用户来说,他们除了想要享受Kubernetes自身带来的管理运维的便捷之外,同时也想具备弱网环境下的容灾能力,具体来说,如下:

  • 节点即使和 master 失联,节点上的业务能继续运行
  • 保证如果业务容器异常退出或者挂掉,kubelet 能继续拉起
  • 还要保证节点重启后,业务能继续重新被拉起来
  • 用户在厂房内部署的是微服务,需要保证节点重启后,同一个厂房内的微服务可以访问

而对于标准的Kubernentes,如果节点断网失联并且发生异常重启的行为后,现象如下:

  • 失联的节点状态置为ConditionUnknown状态
  • 失联的节点上的业务进程异常退出后,容器可以被拉起
  • 失联的节点上的 Pod IP 从 Endpoint 列表中摘除
  • 失联的节点发生重启后,容器全部消失不会被拉起

superedge自研的边缘自治就是为了解决上述问题的,具体来说边缘自治能达到如下效果:

  • 节点会被置为ConditionUnknown状态,但是服务依旧可用(pod不会被驱逐以及从endpoint列表中剔除)
  • 多节点断网情况下,Pod 业务正常运行,微服务能力正常提供
  • 多节点断网情况下并重启后,Pod 会被重新拉起并正常运行
  • 多节点断网情况下并重启后,所有的微服务可以被正常访问

其中,对于前两点来说可以通过上述介绍的分布式健康检查机制来实现,而后续两点可以通过lite-apiserver,网络快照以及DNS解决方案实现,如下:

lite-apiserver机制

superedge通过在边端加了一层镜像lite-apiserver组件,使得所有边端节点对于云端kube-apiserver的请求,都会指向lite-apiserver组件:

而lite-apiserver其实就是个代理,缓存了一些kube-apiserver请求,当遇到这些请求而且与apiserver不通的时候就直接返回给client:

img

总的来说:对于边缘节点的组件,lite-apiserver提供的功能就是kube-apiserver,但是一方面lite-apiserver只对本节点有效,另一方面资源占用很少。在网络通畅的情况下,lite-apiserver组件对于节点组件来说是透明的;而当网络异常情况,lite-apiserver组件会把本节点需要的数据返回给节点上组件,保证节点组件不会受网络异常情况影响

网络快照

通过lite-apiserver可以实现边缘节点断网情况下重启后pod可以被正常拉起,但是根据原生Kubernetes原理,拉起后的pod ip会发生改变,这在某些情况下是不能允许的,为此superedge设计了网络快照机制保障边缘节点重启,pod拉起后ip保存不变。具体来说就是将节点上组件的网络信息定期快照,并在节点重启后进行恢复

本地DNS解决方案

通过lite-apiserver以及网络快照机制可以保障边缘节点断网情况下重启后,Pod会被重新拉起并正常运行,同时微服务也运行正常。而服务之间相互访问就会涉及一个域名解析的问题:通常来说在集群内部我们使用coredns来做域名解析,且一般部署为Deployment形式,但是在边缘计算情况下,节点之间可能是不在一个局域网,很可能是跨可用区的,这个时候coredns服务就可能访问不通。为了保障dns访问始终正常,superedge设计了专门的本地dns解决方案,如下:

本地dns采用DaemonSet方式部署coredns,保证每个节点都有可用的coredns,同时修改每个节点上kubelet的启动参数--cluster-dns,将其指向本机私有IP(每个节点都相同)。这样就保证了即使在断网的情况下也能进行域名解析

总结

总的来说,superedge是以lite-apiserver机制为基础,并结合分布式健康检查机制、网络快照以及本地coredns,保证了边缘容器集群在弱网环境下的网络可靠性。另外随着边缘自治的级别越高,所需要的组件会越来越多

云边隧道

最后介绍一下superedge的云边隧道,云边隧道主要用于:代理云端访问边缘节点组件的请求,解决云端无法直接访问边缘节点的问题(边缘节点没有暴露在公网中)

架构图如下所示:

img

实现原理为:

  • 边缘节点上tunnel-edge主动连接云端tunnel-cloud service,tunnel-cloud service根据负载均衡策略将请求转到tunnel-cloud的具体pod上
  • tunnel-edge与tunnel-cloud建立grpc连接后,tunnel-cloud会把自身的podIp和tunnel-edge所在节点的nodeName的映射写入DNS(tunnel dns)。grpc连接断开之后,tunnel-cloud会删除相关podIp和节点名的映射

而整个请求的代理转发流程如下:

  • apiserver或者其它云端的应用访问边缘节点上的kubelet或者其它应用时,tunnel-dns通过DNS劫持(将host中的节点名解析为tunnel-cloud的podIp)把请求转发到tunnel-cloud的pod上
  • tunnel-cloud根据节点名把请求信息转发到节点名对应的与tunnel-edge建立的grpc连接上
  • tunnel-edge根据接收的请求信息请求边缘节点上的应用

总结

本文依次介绍了开源边缘计算框架SuperEdge的特性,整体架构以及主要功能和原理。其中分布式健康检查以及边缘集群服务访问控制ServiceGroup是SuperEdge独有的特性功能。分布式健康检查很大程度避免了由于云边网络不可靠造成的大量pod迁移和重建,保证了服务的稳定;而ServiceGroup则极大地方便了用户在共属同一个集群的不同机房或区域中各自部署一组服务,并且使得各个服务间的请求在本机房或本地域内部即可完成(闭环),避免了服务跨地域访问。除此之外还有边缘自治以及云边隧道等功能。整体来说,SuperEdge采用无侵入的方式构建边缘集群,在原有Kubernetes组件保留不变的基础上新增了一些组件完成边缘计算的功能,既保留了Kubernetes强大的编排系统,同时也具备完善的边缘计算能力

]]>