编辑推荐: |
本文来自于infoq,本文主要通过一个具体实例介绍Kubernetes,同时详细分析了API Server和Kubernetes提供的工具链和客户端抽象等知识
。 |
|
本文通过一个具体实例介绍Kubernetes 扩展开发,分析了API
Server的兼容性设计;基于部分源码介绍了Kubernetes API聚合层原理和实现;最后还分析了Kubernetes提供的工具链和客户端抽象,希望为Kubernetes扩展开发提供一些启发
。
状态管理并非新鲜话题,它为中心化系统分发一致的状态,确保分布式系统总是朝预期的状态收敛,是中心化系统的基石之一
。
以Pod(如下图)为例,它抽象了可以独立部署的最小容器单位,Pod描述的变化需要反映到对应的容器上,比如修改了Pod
的image,它对应的容器就会使用指定的image来重建 。
Kubernetes从v1.0开始逐渐形成了完善的API框架和工具链,并以此为基础实现了容器管理平台。时至今日(2018上半年),这套API框架和工具链已经演变为Go生态圈中的通用状态管理解决方案,大大降低了建立分布式系统的难度。
可惜的是,社区尚未出现对Kubernetes状态管理和扩展的详细介绍,官方文档中的只言片语难以支撑复杂的扩展开发需求,本文通过一个具体实例介绍Kubernetes
扩展开发,分析了API Server的兼容性设计;基于部分源码介绍了Kubernetes API聚合层原理和实现;最后还分析了Kubernetes提供的工具链和客户端抽象,希望为Kubernetes扩展开发提供一些启发
。
扩展开发实例
本节介绍一个简单的扩展开发项目来介绍Kubernetes扩展的开发模式(代码生成基于https://github.com/kubernetes-incubator/apiserver-builder),这是典型的C/S架构,分为APIServer和客户端两部分,APIServer提供了高可用的状态存储,而客户端提供了资源的CRUD和可靠的状态分发接口。
先来初始化整个项目框架(这个工具的实现目前还不稳定,不同版本的apiserver-boot生成的代码可能存在差异)。
后续章节将介绍详细介绍扩展开发的过程。
API Server
项目初始化完成之后就可以定义状态了,为了让例子更具一般性,本文将定义多个版本的状态并在后续章节介绍APIServer的兼容性设计。
执行如下命令生成第一个版本的状态定义模板:v1alpha1.Bee。
$ apiserver-boot create resource --group alpha --version v1alpha1 --kind Bee |
然后为生成的v1alpha1.Bee添加自定义的信息(如图1.1.1)。
运行以下命令将修改后的状态定义变化应用到整个项目中去。
$ apiserver-boot build generated |
生成代码时会把所有的子命令打印出来(如图1.1.2),随后可以利用局部代码生成来对生成代码进行微调。
然后将这个自定义的API Server部署到一个Kubernetes集群中,注意这里部署的API
Server是一个实现了Kubernetes API规范的独立的Web服务,与集群Master是完全独立的,仅仅是集群中的一个普通的Pod而已,可以有完全独立于Master
API Server的ETCD存储。
由于此处的APIServer实现了Kubernetes API规范,可以通过kubectl来直接写入一个v1alpha1.Bee,然后再用kubectl读取刚刚写入的v1alpha1.Bee(如图1.1.3),也可以用kubectl来修改图1.1.3中写入的Bee的location信息(如图1.1.4)。
有了基本的存储之后就需要考虑状态修改时的校验,可以为状态描述实现任意形式的扩展逻辑,图1.1.5中为v1alpha1.Bee添加自定义校验,限定location必须以China结尾。
添加了图1.1.5中的校验之后,图1.1.3中给出的Bee描述已经不再合法,这时如果尝试再次写入该数据就会出现校验失败(如图1.1.6)。
图1.1.7将location更正为China结尾后就可以正常写入v1alpha1.Bee了。
以上是定义一个v1alpha1.Bee状态描述的基本过程。
细心的读者可能已经发现,在写入一个Bee之后,如果修改了Bee的校验规则,那么前面已经写入的Bee可能已经不合法了,就会出现脏数据,这个问题怎么解决呢?工程上我们应该规避这种情况,RESTful
API中一个基本设计原则就是“不可变资源”,这里所谓的“不可变”指的是资源涉及的各种上下文,包括Schema、校验、安全、SLA等等,如果出现上下文改变,应该为资源提供新的版本。
为了介绍状态定义的兼容性设计,图1.1.8为Bee这个状态增加一个新版本v1alpha2.Bee,Spec还需要增加一个额外的字段Gender(作者按:虽然蜜蜂分男女有点扯,这里仅仅是为了表达在一个新版本的Bee中添加字段,后文不再解释)。
图1.1.9中使用kubectl同时写入v1alpha1.Bee和v1alpha2.Bee。
客户端
扩展客户端连接到前文实现的API Server来监听Bee的变化,进而在客户端实现自定义扩展逻辑,图1.2.1中为Bee实现的客户端扩展的基本工作流。
前文自动生成的代码中为Bee生成了默认的BeeController,如图1.2.2所示。
当监听到创建或更新Bee状态的时候,可以通过实现Reconcile中的逻辑来处理对Bee的额外扩展,现在来把Controller部署到集群中去。
将前面写入的Bee全部删除后重新写入,可以看到Controller打印了两条日志分别为写入的Bee.Name,如图1.2.3所示。
此处提到的Reconcile接口只能响应存在状态修改的场景,是一种无状态的扩展模式,如果需要响应删除,可以利用后文介绍的Informer接口来实现,此处不赘述,只给一个简单例子(如图1.2.4)。有趣的是,BeeController部署到集群里面,没有进行额外配置就可以连接到对应的APIServer正常运转了,它是如何自动与前文部署的API
Server建立连接呢?这在后文“API聚合层”中会具体介绍,此处亦不赘述。
注意删除了前面写入的v1alpha1.Bee和v1alpha2.Bee两个版本的Bee,但实际上BeeController是利用v1alpha1客户端监听Bee的删除事件,显然v1alpha1的Informer也可以感知到所有版本Bee的变化,这其实就是兼容性设计的美妙之处了,不赘述。
小结
复杂系统中,客户端的维护周期是随机的,设想一个没有兼容性的系统,在运营一段时间后,客户端扩展由于升级维护周期的差异使用了不同版本的客户端实现,这样的系统任何一个点的变化对系统中其他模块可能都存在强依赖,这种耦合可能导致系统不得不部分重启,最终这个系统将陷入举步维艰的泥潭。可以说兼容性设计是Kubernetes高度可扩展性的基础之一,图1.3.1为多版本Bee的兼容性设计模型。
图1.3.1中写入和存储Bee的时候支持全部版本,而API Server为每个版本的Bee的读取接口实现了兼容性适配。当写入原始状态为v1alpha1.Bee,所有客户端都可以感知到这个状态,同理写入状态v1alpha2.Bee时,所有客户端也都可以感知到这个状态变化。
先往API Server写入v1alpha1.Bee和 v1alpha2.Bee两个Bee状态描述,如图1.3.2所示
。
进入etcd中看实际写入的v1alpha1.Bee和v1alpha2.Bee数据(如图1.3.3),可见状态存储和写入时的输入是一致的。
特别提一下,在Kubernetes API设计规范中,{Group, Namespace, metadata.name}
这个三元组是全局唯一的,尽管此处写入的两个Bee分别为不同的版本,还是可以从所有版本的读取接口拿到这些状态(如图1.3.4,1.3.5),这就是为什么图1.2.4中只需使用v1alpha1客户端就可以接受到删除
v1alpha1.Bee和v1alpha2.Bee的事件。
图1.3.4和1.3.5直接使用curl命令分别从v1alpha1和v1alpha2的API来获取Bee列表,这进一步佐证了兼容性设计是在API
Server实现的。
在不同版本的Bee之间互相转换如果少了字段,比如图1.3.5中通过v1alpha2客户端读取v1alpha1.Bee,gender字段为空,这可能是有问题的,v1alpha2客户端拿到这样的数据可能会出现不预期的行为,应该保证客户端取到的数据满足相应版本的约束。
这就需要APIServer提供默认值的支持了,下面来为v1alpha2添加默认值支持,将gender默认设置为Unknown
,如图1.3.6所示。
兼容性设计是困扰很多复杂系统设计者的问题,Kubernetes中的兼容性设计模式不仅是RESTful设计模式的成功应用,也为复杂系统设计提供了一种通用解决方案。
API聚合层
前面的实例中,Controller部署到集群中就能直接访问Bee资源,实际上在部署API Server时还为其配置了API聚合层,将这个独立的API
Server整合到集群的API Server中去了,就好像是Kubernetes API提供了Bee这个Resource一样。
API聚合层是Kubernetes API扩展性的基础,将自定义资源整合到Kubernetes API中,为容器管理平台或相关插件提供状态存储(源码分析基于:f7aafaeb404563cda07b182ad9679f54afd227fe)。
API Service
Kubernetes在早期版本中就提供了API Service支持,最初是为了支持将庞大的API
Server分散在多个独立的API Server中去。它定义了一组灵活的反向代理接口,只要接入的API
Server的设计满足Kubernetes的API设计,就可以整合到Kubernetes的API中去,集群已有的组件可以直接与新加入的API
Server来做集成,图2.1.1为API Service(v1)的定义。
图2.1.2中将自定义的API Server(evangelist-apiserver)整合到Kubernetes
API中去。
这样就可以直接通过kubectl来管理该API扩展的状态了,图2.1.3中使用kubectl来管理Bees。
API Service定义中如果指定了 Service,API Aggregator会为该Service添加一个反向代理配置,图2.1.4是API
Service资源改动后生成反向代理的实现。
每个API Service对都对应了独立的proxyHandler,是针对特定URL的反向代理。
Custom Resource Definition
Custom Resource Definition(CRD)的前身是Third Party Resource(TPR),是API
Service的一种扩展接口,其中TPR已经在v1.7之后被废弃,v1.8之后彻底从代码中移除,因此这里只介绍CRD的基本实现原理。CRD为状态管理扩展定义了以下三种能力:
定义任意类型的资源
定义基于Open API Schema的校验
定义资源(Resource)生命周期中钩子(Hook)
向集群中写入该CRD,就在该APIServer中注册了一个名为Bee的资源,并且对资源加入和校验支持,接下来就可以用kubectl直接管理图2.2.1中定义的Bee资源了(如图2.2.2)。
CRD是基于API Service实现的,Kubernetes为CRD自动保持一个针对Kube API的API
Service配置(如图2.2.3)。
值得一提的是,CRD的两个特点:
由Kube API提供服务
代码里面写死了优先级(如图2.2.4),即versionPriority(100)和groupPriorityMinimum(1000)
小结
Kubernetes APIServer的基本功能之一是反向代理,APIService提供了动态配置接口,可以为相同的转发条件定义多个优先级不同的APIService,这样的设计很重要,保证了Kubernetes
APIServer在切换转发配置对客户端完全透明(Zero Downtime)。
在前文“扩展开发实例”中没有采用CRD来实现APIServer扩展,这是因为CRD存在两方面的局限性:
没有sub-resource支持:因此status和spec一样都是可以被kubectl修改的,这其实打破了Kubernetes
API设计的基本假设
缺少兼容性支持:多版本Schema之间互相转化通常需要自定义代码逻辑来实现,这在未来CRD中也是不可能支持的
CRD提供了一种有限的、轻量级的APIServer扩展,在技术选型中需要考察是否存在下面的需求来决定是否选择CRD:
避免status被kubectl篡改:在多个客户端共享状态,如果status被kubectl人为篡改可能导致系统出现不预期的行为
零宕机(Zero Downtime)升级:客户端和APIServer需要在升级维护的任意时间点保证兼容性和正确可预期的行为
CRD会自动保持相应的APIService设置,即使该APIService被篡改,CRD Controller也能够自动恢复正确的API
Service配置,这也意味着如果要用自定义APIServer来替换已有的CRD服务,需要先将CRD删除再写入新的APIService配置,否则该配置会被CRD自动覆盖。
代码生成
Kubernetes提供了丰富的代码生成工具用来管理RESTful状态的定义和客户端代码,维护状态定义的过程就是为各模块之间状态流转定义Service
Contract。
客户端生成
本节介绍客户端代码生成工具,文中提到了Tag的“作用域”,在Go语言中没有具体的定义,这里给出一种泛泛的描述:
Local Tag:Struct/Field/Function定义之前,用来约束局部代码生成规则
Global Tag:Package下doc.go的package关键字前,作为整个Package下的默认生成规则,可以被Local
Tag覆盖
先看deepcopy-gen,它提供了“深复制”接口生成能力,在执行一些可能会修改对象内容的操作时,“深复制”可以保护原始对象内容,让系统中对象的边界更清晰。
如图3.1.1中计算容器的Resource Limit时,需要同时考虑节点可分配的资源,最终结果会是二者合并的结果,所以这里在计算前先将container深复制一个临时对象,然后合并直接在这个临时对象上进行。
k8s:deepcopy-gen:声明是否生成DeepCopy接口,添加的位置决定了该Tag的作用域,可以添加到Package(任意文件package关键字前)或Struct前,取值相关含义如下:
true/false: 是否生成DeepCopy接口,常用于声明某些Struct不需要生成DeepCopy
package: 只用于Package域,为该Package下所有未显式禁用DeepCopy的类型全部生成DeepCopy接口
k8s:deepcopy-gen:nonpointer-interfaces: 声明是否为某类型生成值类型的DeepCopy接口,只能作用于Struct域
true/false:生成值类型DeepCopy接口,否则为指针类型
k8s:deepcopy-gen:interfaces:指定为Struct生成返回任意接口类型的DeepCopy接口,取值为逗号分隔的类型全名。
接下来介绍conversion-gen和defaulter-gen,这两个工具的作用时相辅相成的,它们为多版本Resource提供了服务端自动转换的能力,比如前面例子中提到的v1alpha1.Bee和v1alpha2.Bee之间的自动转换。
图3.1.6的例子中v1alpha1.Bee向v1alpha2.Bee转换时,缺少了gender字段,在v1alpha2.Bee中赋值为缺省值Unknown;而v1alpha2.Bee向v1alpha1.Bee转换时,只需要将它的location字段赋值给v1alpha1.Bee即可。
再看conversion-gen,它实现了不同版本的Resource在服务端自动转换的能力,这在API兼容性设计中起到了至关重要的作用,是笔者看来这也是最常用的代码生成工具。
k8s:conversion-gen 为“源Package”和“目标Package”之间的同名Struct生成自动转换代码:
true/false: 声明是否注册Conversion逻辑,作用域可以为Struct和Field。
Package名:声明为当前Package和传入的Package中同名Struct生成Conversion逻辑
k8s:conversion-gen-external-types: 指定生成Conversion的“当前Package”位置,这是因为有时候当前Package中Resource定义是独立维护的。
需要注意的是,图3.1.8中“源类型”Package需要保证和“目标”Package分属不同的版本,否则生成的Method会出现重名的情况,无法编译。
类型转换是Go开发中一个比较麻烦的地方,加之语言层面反射的性能和标准库支持都限制了开发的灵活性,自动转换接口为API在进行兼容性的转换时做到游刃有余。
最后看defaulter-gen,在定义了SetDefaults函数(图3.1.9)之后,为之自动生成Object/List等使用场景的Defaulter函数和注册逻辑。
生成Defaulter注册函数后,还需要注册RegisterDefaults(图3.1.10)函数将该默认值规则应用于任意Scheme的初始化阶段,在该Scheme的作用范围内就会使用输入的默认值规则。
defaulter-gen在维护状态完整中起到至关重要的作用,如果不同版本的Resource互相转换时,缺失的字段默认为零值,而有了初始化默认值的能力后,就能保证转换的结果始终满足对应Resource的语义约束,如前面例子中v1alpha2中增加了Gender(性别)后,读取v1alpha1数据时,Gender字段默认给出的是空字符串,这显然在语义上是说不通的。
k8s:defaulter-gen:
true/false: 如果作用域为Type,则声明是否为Type生成Defaulter;如果作用于
Function常用来生成明明满足SetDefault_$Name格式的Function
FIELDNAME: 声明为所有含有传入的Field的Type生成Defaulter,作用域为Package
k8s:defaulter-gen-input:用来指定生成Defaulter的输入Package。
图3.1.11 指定 “../../../../vendor/k8s.io/api/apps/v1”
作为实际Struct的输入,在当前目录生成Defaulter注册逻辑。
此外Kubernetes中为构建灵活的扩展提供了丰富的高层代码生成工具,超出本文要论述的范畴,不赘述。
客户端抽象
代码生成工具还可以为扩展程序生成三种常用的客户端抽象:Clientset, Lister, Informer。
相比状态定义的代码生成工具的复杂配置,客户端代码生成通常使用默认配置即可。
其中Clientset封装了对Resource以及对应集合类型的基础CRUD以及常用复杂读写接口;Lister封装了对Resource按照Label过滤的接口;Informer提供了状态主动分发能力,让客户端能监听服务端状态的变化并执行相应的回调逻辑。Lister和Informer和API
Server通信都基于Clientset实现。
client.AlphaV1alpha2().Bees("default")返回的BeeInterface封装了具体版本的RESTful接口,此处就是v1alpha2的RESTful接口,正如前文兼容性设计介绍的,v1alpha2客户端是可以读取v1alpha1.Bee的
。
此处AddEventHandler可以传入ResourceEventHandler接口,允许实现三个回调函数,如图3.2.3所示。
OnAdd:创建Resource时
OnUpdate:修改Resource时,或定时获取最新的Resource状态时
OnDelete:删除Resource时
其中值得一提的是OnUpdate被调用时,Resource不一定真的被修改,也可能只是定时获取Resource最新的状态,这个回调函数常用在启动后来自动恢复客户端的状态。
小结
代码生成除了需要结合各种需求灵活使用Tag之外,大部分的工具生成的代码都是可以局部自定义的,比如现在有alpha/v1alpha2.Bee和beta/v1beta1.Bee两个版本的Bee,并为它们生成了conversion逻辑,有时会希望自定义生成的逻辑,只需要在conversion代码所在Package下任意代码文件添加具有相同签名的函数,代码生成工具就会忽略该函数,充分利用代码生成工具的前提下又不失其灵活性(如图3.3.1)。
总结
自v1.0开始,Kubernetes API形成一套完整的状态管理解决方案, 其高度灵活的API
Server和客户端实现可以为任意复杂系统提供状态管理支持 。
让人眼前一亮的是它的兼容性设计,它为第三方扩展提供了稳定的接口;第三方扩展集成到平台中后,维护周期可以和平台保持相对独立。兼容性设计对微服务模块的设计和实现也具有极强的指导意义,微服务中一个重要的指导原则之一就是“自治”,模块之间的Service
Contract不但需要做到稳定可靠,还需要做到向下兼容,否则就会出现模块之间互相影响,就与“自治”这个基本假设矛盾了。
API聚合层不仅为Kubernetes APIServer提供了无限横向扩展能力,独立的APIServer可以拥有独立的存储(不一定是ETCD),这意味着数据量不再是APIServer的瓶颈,也让客户端插件无缝集成成为可能。
还有Kubernetes提供的代码生成工具链也很值得借鉴, 这些工具不但极大的降低了扩展开发的成本,还为自动生成的代码提供了定制能力。
他山之石可以攻玉,Kubernetes作为Google的重要开源项目之一,集中体现了Google优秀的分布式系统实践,也为Go社区提供了诸多良好的典范。
|