干货分享 | 浅析K8s API设计与实现
Posted 苏研大云人
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了干货分享 | 浅析K8s API设计与实现相关的知识,希望对你有一定的参考价值。
友情提示:全文7000多文字,预计阅读时间12分钟
导语
Kubernetes(以下简称K8s)是容器集群管理系统,是一个开源的平台,可以实现容器集群的自动化部署、自动扩缩容、维护等功能。K8s本身是一个依照云原生设计理念实现的优秀软件,我们可以深入分析并理解清楚其设计理念,并将此理念融入到自己的产品设计。关于K8s的设计理念,大致可以分为2类,API设计和控制器设计。本文主要从K8s的API声明式设计原则及实现进行深入的剖析,并提炼出一套API设计方法。本文的重点内容包括:什么是声明式API、K8s API实现原理剖析、基于云原生API设计方法。
01
什么是声明式API
任何的云平台都需要设计API,而API设计的合理性、兼容性等一般是需要在设计之初就要考虑的。每支持一项新的特性或者功能点,会引入对应API对象,支持对该功能的管理。当涉及到资源的更新、优化,通常对于基础服务API,比如K8s Apiserver服务,是项重大的考验,除了需要考虑新功能特性,更需要保证向前或者其它服务兼容的特性。
K8s的设计原则之首就是“所有的API应该都是声明式的”,这也是K8s能够“生存”的核心所在。那么什么是声明式API呢?
首先,相对于声明式,实现一套API最简单的方式就是命令式,step-by-step,准确的输入各参数,并根据命令执行过程,直到实现期望的结果,它注重的是过程(how)。
举个例子。我们知道Docker作为容器的典型开源实现,它的操作都是基于命令行的,比如:
#docker load -i ubuntu.tar
#docker images
# docker run -i -t -d ubuntu:latest
像这样3条命令,是在环境中启动了基于ubuntu镜像的容器,然后通过这个容器,可以访问自己的应用。而在K8s中,也可以实现同样的效果,流程是先编写好资源YAML文件,再通过定义好的资源文件(YAML格式),比如名称为ubuntu.yaml,内容如下:
kind: Deployment
apiVersion: extensions/v1beta1
metadata:
name: ubuntu
namespace: test
…
我们通过在K8s中执行命令,如kubectl create -f ubuntu.yaml,启动一个容器应用。使用过K8s的同学,相信你们很熟悉YAML文件,它作为K8s声明式API的必备因素,那么kubectl create命令的操作过程,我们能否称之为声明式API的实现?
答案是:并不能。所谓声明式,简单理解就是提交一个定义好的API对象来进行“声明”,系统会去达到它期望的状态,它注重的是想要什么(what),而让系统想出如何做。我们再来理解一下上图中YAML文件中的内容,其中kind声明了类型;apiVersion声明了资源的版本等其它的参数,当然如果仅仅在K8s API中创建这个数据结构,这并不能称为完全意义上的声明式API,K8s还需要做到在系统中,尽可能去达到它期望的状态,对于示例中的Deployment资源,系统期望达到的状态是创建对应的pod(控制器实现),并提供服务。而这一切,如果仅仅通过API,用户是无法感知的,并且系统可以自主完成从“期望状态”到“实际状态”的调谐(Reconcile)过程。这里Reconcile的过程,在K8s中是通过控制器技术实现的。
如果需要引入新的特性,比如增加字段,可以采用API新版本的方式来实现。这样的API设计可以保证重复的操作效果是稳定的,更易被用户使用,系统向用户隐藏了实现的细节,这样就保证了系统未来优化、扩展的可能性。那么在K8s中,它究竟是怎么保证的呢?
答案是:在“apply”的过程中。那apply这个操作是什么呢?
下面我们来看一下K8s另一个最常用的命令:kubectl apply -f ubuntu.yaml,这个命令是将文件配置应用于资源,根据配置文件里面列出来的内容,可以只更新现有的资源配置,即YAML文件的内容,可以只写需要升级的属性。
这种声明式的API(apply),与命令式的API过程最本质的区别就是,它一次可以处理多个写操作,并不会产生冲突,具备merge能力。所以K8s中的多数实现都采用apply的操作过程。
在apply过程中,K8s最重要的流程就是PATCH的实现,那么K8s是如何实现的呢?
下面我们用伪代码的形式,来梳理一下它最核心的过程。
{
//返回资源的结构对象
objectInfo := r.store.Newfunc()
//根据资源key获取存储后端的resourceVersion
resourceVersion := getCurrentResourceVersion()
//获取当前的资源对象
currentObj := getCurrentObj()
//将当前的资源对象,与待更新的资源结构内容进行合并
objToUpdate := r.applyPatchToCurrentObj()
//资源的admission插件Admit过程
objToUpdate := applyAdmission()
//获取更新后的资源版本
resourceVersionUpdate := getUpdateObj()
//对比版本信息,一致才可以创建
Err := restBeforeUpdate(version …)
//根据合并后的结构更新结构
err := r.store.update(objToUpdate)
}
(1)首先,获取资源的结构,然后根据请求中的key获取当前对象状态中的resource Version资源版本号,及资源数据内容。
(2)再将当前请求中的结构变化信息,和已存在的数据进行合并,生成新的资源结构。
(3)在后端更新这个新资源结构之前,还需要进行一系列插件admission的过滤与检测,比如认证、鉴权、pod的必要信息判定等等。只要通过检测,才能到后端更新新生成的对象。
(4)此外,还需要保证更新后的资源版本号,与之前系统的版本号要一致,否则不予更新,这很好的保证了资源的一致性。
经过这个流程,我们可以看到Patch请求中的数据,只是资源全量一部分,换句话说,它并不需要知道资源之前存在的完整内容,它只需要填入自己想要的一部分更新字段。这是声明式API能够处理多个写操作最重要的“根源”所在。而资源不同版本的API,它们各自定义了所需的资源格式、字段信息,因此在资源版本升级时候,原有的资源格式,只需要处理与原先定义格式匹配的字段,而后续合并的内容,并不影响它的处理逻辑。
02
K8s API实现原理剖析
在 Kubernetes 项目中,一个 API 对象在 Etcd 里的完整资源路径,是由:Group(API 组)、Version(API 版本)和 Resource(API 资源类型)三个部分组成的。
通过这样的结构,整个 Kubernetes 里的所有 API 对象,实际上就可以用如下的树形结构表示出来:
在这张图中,我们可以清晰看出K8s资源的组织方式,是层层递进的,所有资源的设计基本是一致的。那么K8s是如何来实现不同版本API的呢?资源的路由是如何实现的?
下面从实现层面来剖析一下。随着K8s的版本的迭代,API的设计也逐步趋向成熟化,实现的核心过程,如下结构简图所示。(本文只截取了API Server服务启动API安装、启动最核心的实现,更详细的实现细节,可以参考github上K8s的代码实现。)我们围绕这张图的实现细节贯穿说明,大体分为:资源不同版本安装与注册、资源GVR的实现、资源路由实现原理。
资源不同版本安装与注册
K8s的API服务在启动之前,首先需要安装API(InstallAPIs)服务,而这个过程,会针对各种storage provider(各种资源的REST storage factory),逐个获取对应资源的apiGroupInfo,包括资源所在的组、添加到scheme对象、VersionResourcesStorageMap对象等,其中VersionResourcesStorageMap是一个map[string]map[string]rest.Storage的对象,从它可以知道资源所拥有的版本,以及在各版本下,支持的资源类型,及各类型后端对应的REST存储工厂。在这个存储工厂中,可以映射资源的各种结构属性,以及通用的存储实现方法。
图1 API实现逻辑图
然后将上述得到的apiGroupInfo添加到数组对象apiGroupsInfo中,进入下一步安装API组(InstallAPIGroups),在这个过程中,最核心的调用是依次为每个apiGroupInfo,安装其对应的API资源(InstallAPIResources)。
资源GVR(Group、Version、Resource)的实现
在InstallAPIResources的过程中,需要关注apiGroupInfo的PrioritizedVersions属性,这个属性来自各资源,在初始化的时候,注册进来的。这里得明确知道,在K8s中任何资源都需要有如下一些属性,Group、Version、Kind,资源所属的组,所属的版本,所属的类型。而每一类资源初始化的时候,都会调用scheme.PrioritizedVersionsForGroup(group)的方法,而这个方法返回的是[]scheme.GroupVersion对象,比如[]{Group: “app”, Version:”v1”}。当然各资源的版本增加的时候,这个数组就会相应增加。apiGroupInfo的PrioritizedVersions属性就是上面的[]groupVersion数组,依次针对各groupVersion获取对应的APIGroupVersion对象,这个对象的属性包含了Root资源结构前缀、Storage指明了资源、Creater、Serializer、Typer(Typer指向apiGroupInfo Scheme)等。下面重要的部分是针对各APIGroupVersion进行REST安装(InstallREST)的调用,我们需要明确传递进去的参数GenericAPIServer.Handler.GoRestfulContainer的来源,回溯分析一下配置初始化的过程,这里的GenericAPIServer是从New()方法中建立的,有个NewAPIServerHandler()的方法,深入其中,看到了GoRestfulContainer属性,来自restful.NewContainer()方法。
下面简要分析一下NewContainer的过程,实质是初始化Container结构体的过程,包括了webServices、router等等,而webservice结构体中的字段包含typeNameHandleFunc,这个字段,说明处理请求对应的handler。到此处,我们终于看到了与路由、服务相关的内容,这离我们看到的API实现深处更近了一步。屏住呼吸,继续往下走。
继续回到InstallREST方法,在这个方法中,首先我们需要建立一个新的APIInstaller对象,它包括了prefix、minRequestTimeout、group等属性,其中prefix,我们可以理解为API的路径前缀,是由{Root}/{Group}/{Version}这类似的方式组成。minRequestTimeout表示请求的过期时间。group对应的是APIGroupVersion。然后通过对APIInstaller对象执行安装(Install),这个过程完成了各资源API的处理器的注册。
在Install方法中,它首先生成了webService的对象ws,返回的是一个restful.WebService对象,可以设置path、设置apiversion等。由于Install方法是来自APIInstaller对象,这个对象有group属性,我们可以根据不同的资源,找到它所在组及可以操作的Storage资源。将他们加入到paths路径中,用于下一步的注册资源处理器的过程。这里我们举例来说,比如“apps”下的组,对应的后端资源包括“deployments”、“daemonsets”等等,将这些资源加入到paths数组中。
根据上述设置的各group中的paths数组,依次为这些path,注册资源处理器,即registerResourceHandlers,当然这里需要将不同组下的所有的storage的各种资源,都注册一遍,并且同时将它注册到ws的服务中去。
资源路由实现原理
所以,到这里K8s的API最关键的方法,就是registerResourceHandlers,掌握了这个原理,就等于知道了资源、存储、后端处理器的关系。下面针对这个实现过程,进行进一步分析。这个函数,是及其之长。我们挑其中最关键的步骤,进行流程说明。
首先是Append Actions,可以理解为添加动作过程,当前区分namespaceScoped,比如node这类资源,是与namesapce无关,而deploy、pod这类资源与namespace强相关,因此针对这2种模式,需要有针对性的生成对应的actions组。其中的每一个action包括对应的Verb,即操作方法,比如POST、PUT等,还包括资源路径、资源参数等。
有了actions组之后,下面针对每一个action,进行资源路由的创建及添加(Append to route)。所以如何添加处理器到路由?是理解API最核心的环节,从图1这张简图的右半部分,我们可以看到Store是比较关键的对象,通过它可以实现各种方法对应的处理器,同时也可以访问底层的存储。那么Store是怎么来的?
通过上述的分析,我们回溯一下API的构建过程,我们发现在获得各资源的APIGroupInfo的时候,会针对各不同版本,初始化后端的storageMap,而这个storageMap实质对应的就是各资源的后端处理的各种方法,包括Create()、Update()、Delete()等,就是通用的genericregistry.Store{}的结构实现的这些方法。而得到这个对象外,还发现了一个比较重要的过程,store.CompleteWithOptions()方法。通过这个方法,将会得到Storage对象,这是资源操作最直接的结构对象,但是这个storage的对象是从RESTOptions.Decorator方法生成,如下部分截图所示:
经过同样方法分析,我们得知opts是来自RESTOptions结构的对象,因此,我们需要再次回溯API启动过程,初始化RESTOptions的过程,奔着这样的目的,终于在store.CompleteWithOptions()的方法中,看到了它的身影,有关键的一步:opts, err := options.RESTOptions.GetRESTOptions()。在GetRESTOptions方法中,由于配置的存储后端是etcd,所以会调用etcd相关的实现方法,(f *StorageFactoryRestOptionsFactory) GetRESTOptions(resource schema.GroupResource),这是找到Decorator最关键的步骤,找到了Decorator就等于找到了存储的后端的直接实现。
我们简要分析一下GETRESTOptions的过程,发现了Decorator的由来,并且还区分了是否开启监听缓存,这似乎也找到了优化APIServer的一项机制,可以通过开启缓存的方式。以其中一种“不开启缓存”的模式来说明,它的流程是通过factory.Create()方法,生成对应的存储操作接口对象及销毁的接口对象。当然我们还看到了存储的后端,在K8s 1.18以上的版本中,默认使用V3版本的客户端进行连接。
通过factory.Create方法,可以到达基于V3版本的etcd集群的客户端连接,而这个连接相关联的结构对象,实现了诸多的方法,包括Create、Put、Get、Watch等方法。
继续回到上图1中的右半部分,我们此时不难理解,Storage对象,实际对应的是资源后端的存储逻辑,而它调用的Decorator过程,实际是操作etcd的各自实现方法。
通过上述复杂的过程,我们完成了API实现过程的剖析。可以看出K8s的声明式的API实现,没有过分的突出每一类资源的接口,而是将资源以可注册的方式,即等同于可插拔的方式,安装不同的版本,整个实现,版本之间不会相互依赖,也不会因为版本的更新,引发原始功能问题。新增加的版本,可以增加特定的新的属性,而这个属性的实现,可以在控制器逻辑中实现管控。
03
基于云原生API设计方法
通过对K8s的API实现分析,我们从中受到启发,声明式的API,比较适合基于云原生基础服务,尤其是API版本迭代比较频繁的场景。下面我们有针对性地总结一种基于云原生的API设计方法,支持API的无缝扩容、优化,并提供对不同版本的兼容性。它的整体结构设计如上如所示,API设计遵循REST风格,所有的资源处于特定的group模块,并通过group中的gvk结构注册到scheme center中,其中gvk表明group、version、kind,举例来说,“app”、“v1”、“Deployment”,这表明资源的模块属于app应用模块,v1是它的版本号,Deployment是它的资源类型。这样设计的好处是,可以动态实现资源的安装与注册、实现资源版本的动态优化、更新。并且注册中心采用scheme的模式,它有比较丰富的结构与方法,可以实现资源的统一纳管。
然后,通过对注册到scheme center中的所有资源,分别安装对应的handler,此处的handler用于处理数据,连接到特定的存储设备storage,比如etcd、mysql等。
基于这种结构,客户端的请求REST client在访问API服务之前,需要经过通用的插件流程,比如authentication认证模块,authorization鉴权模块等。通过这些模块后,再访问API服务。而API服务将对应的请求发到指定资源的handler上去,再由handler完成数据的CRUD。
到这里,这种API设计完成了声明式API设计的一大半工作。从用户侧来说,通过声明式的API规范,可以完成资源的创建。但是,服务端如何完成真正资源的操作,还需要对应的资源Controller完成。即在这种设计中,controller服务监听对应的资源,并按照资源的格式,完成它在后端的状态,即在后端实现这种数据结构的过程。
通过K8s API实现的深入剖析,我们从一定层面上了解了K8s ApiServer的工作原理,而这距离玩转K8s的框架,还有很长的路要走。下一步我们将针对控制器的设计及存储性能进行深入的研究。敬请期待!
往期精选
1、
2、
3、
以上是关于干货分享 | 浅析K8s API设计与实现的主要内容,如果未能解决你的问题,请参考以下文章