Kubernetes 声明式 API
Kubernetes 项目中,一个API 对象在 Etcd 里的完整资源路径由:Group(API 组)、Version(API 版本)和 Resource (API 资源类型)三部分组成。
通过该图可以清楚看见 kubernetes 里 API 组织方式是层层递进的。
1 | apiVersion: batch/v2alpha1 |
当Group和完整的版本保证后,kind标识版本下的对象,此时APIServer 就可以创建这个对象了。
首先发起 创建 CronJob 的 Post 请求后,我们编写的 YAML 的信息就被提交给了 APIServer。而 APIServer 的第一个功能,就是过滤这个请求,并完成一些前置性工作,比如授权、超时处理、审计等。
然后,请求会进入 MUX 和 Routes 流程。MUX 和 Routes是 APIServer 完成 URL 和 Handler 绑定的场所,而 APIServer 的 Handler 用于匹配API组织,找到对应的CronJob的类型定义。
接着,APIServer 根据CronJob类型定义,使用用户提交的YAML文件里的字段,创建一个CronJob对象。
最后,APIServer 会把验证过的API对象转换为用户最初提交的版本,进行序列化操作,并调用 Etcd 的API 把它保存起来。
Kubernetes CRD 设计
假设创建一个 network 对象文件名为example-network.yaml
,它的 YAML 文件类似为:
1 | apiVersion: samplecrd.k8s.io/v1 |
可以看出,描述网络的 API 资源类型是Network,API组是 samplecrd.k8s.io,API 版本是 v1。这就是一个CRD实例,也叫CR。
接下来我们编写一个CRD 的 YAML,命名为 network.yaml
,内容如下所示:
1 | apiVersion: apiextensions.k8s.io/v1beta1 |
这里指定了group:samplecrd.k8s.io
、version:v1
这样的API信息,也指定了CR资源类型叫 Network,复数(plural)为:networks。
接着声明了它的 scope 是 Namespaced,也就是我们定义的这个Network属于Namespace对象,类似于Pod。
以上是一个Network API资源类型的API部分的宏观定义。
首先,构建项目:
1 | tree $GOPATH/src/github.com/<your-name>/k8s-controller-custom-resource |
- pkg/apis/samplecrd 就是 API 组的名字,v1是版本,而 v1 下面的 types.go 文件里,则定义了Networ对象的完整表述。
接着,在 pkg/apis/samplecrd 下创建一个名为 register.go
文件,用来放置后面用到的全局变量。
1 | package samplecrd |
然后,在 pkg/apis/samplecrd 目录创建一个 doc.go
文件。内容如下:
1 | // +k8s:deepcopy-gen=package |
<tag_name>+[=value]
格式的注释, 这就是 Kubernetes 进行代码生成要用的Annotation 风格的注释。+k8s:deepcopy-gen=package 意思是,请为整个 v1 包里的所有类型定义自动生成 DeepCopy 方法;而+groupName=samplecrd.k8s.io,则定义了这个包对应的 API 组的名字。
可以看到,这些定义在 doc.go 文件的注释,起到的是全局的代码生成控制的作用,所以也被称为 Global Tags。
接下来,添加 types.go 文件,它的作用就是定义一个 Network 类型需要哪些字段(比如,spec里面的内容)。
1 | package v1 |
而其中的 Spec 字段,就是需要我们自己定义的部分。所以,在 networkspec 里,我定义了 Cidr 和Gateway 两个字段。其中,每个字段最后面的部分比如json:”cidr”,指的就是这个字段被转换成 JSON 格式之后的名字,也就是 YAML 文件里的字段名字。
此外,还需要定义一个 NetworkList用于描述一组 Network对象需要包括哪些字段,因为 k8s 中,获取所有X对象的List()方法,返回都是List类型,而不是X类型的数组。
同样的,在Network和NetworkList类型上,也有代码生成注释。
其中+genclient的意思是为下面这个API资源类型生成对应的Client代码。而+genclient:noStatus的意思是这个API资源类型定义没有Status字段,否则生成的Client就会自动带上UpdateStatus方法。
如果你的定义类型包括了Status字段的话,就不需要这句+genclient:noStatus注释了,比如:
1 | // +genclient |
+genclient 只需要写在 Network 类型上,而不用写在 NetworkList 上。因为NetworkList 只是一个返回值类型,Network 才是“主类型”。
由于在Global Tags里已经定义了为所有类型生成DeepCopy方法,这里就不需要再显式加上 +k8s:deepcopy-gen=true
,当然你可以使用 +k8s:deepcopy-gen=false来阻止某些类型生成DeepCopy。
+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
的注释。他的意思是生成DeepCopy的时候,实现k8s提供的runtime.Object接口。否则,在某些版本的k8s里这个类型定义会不嫌编译错误,只是一个固定操作,记住即可。
最后 ,再编写的一个 pkg/apis/samplecrd/v1/register.go 文件。
APIServer 工作原理的讲解中已经提到 registry 作用就是注册一个类型(Type)给 APIServer。其中,Network 资源类型在服务器端的注册工作,APIServer 会自动帮助我们完成,但是,客户端需要知道Network 资源类型的定义。这就需要我们在项目里添加一个 register.go 文件。它最主要的功能,就是定义了如下所示的addKnownTypes() 方法:
1 | package v1 |
有个这个方法,k8s就能够在生成客户端的时候,知道Network、NetworkList类型的定义了。
像上面这种 register.go
文件里面的内容其实是非常固定的,以后可以直接使用上面提供的这部分代码做模版,然后把其中的资源类型、GroupName和Version替换成你自己的定义即可。
这样,Network对象定义的工作就全部完成了,它定义了两部分内容:
- 自定义资源类型 API的描述包括组(Group)、版本(Version)、资源类型(Resource)等。
- 自定义资源类型对象的描述,包括Spec、Status等。
接下来使用 k8s 提供的代码生成工具k8s.io/code-generator
为上面的Network资源类型自动生成 clientset、informer和lister。其中,clientset 就是 操作Network 对象所需要使用的客户端。
1 | 代码生成的工作目录,也就是我们的项目路径 |
执行完毕会生成以下的目录结构:
1 | tree |
有了这些内容,现在你就可以在K8S集群里创建一个Network 对象的 CRD:
1 | kubectl apply -f crd/network.yaml |
这个操作,就告诉了k8s,我现在要添加一个自定义的API对象,而这个类型的 API 信息,正是network.yaml 里定义的内容,可以通过命令查看crd:
1 | kubectl get crd |
然后我们就可以创建一个Network对象了,这里用到的是 example-network.yaml:
1 | kubectl apply -f example/example-network.yaml |
通过命令,k8s集群里面创建了一个Network对象,路径是samplecrd.k8s.io/v1/networks。
此时通过命令获取创建的 Network对象:
1 | kubectl get network |
为 Network 这个自定义 API 对象编写一个自定义控制器
首先为这个控制器编写main函数
Main 函数的主要工作就是定义并初始化一个自定义控制器(Custom Controller),然后启动它。
1 | func main() { |
main 函数主要通过三步:
- main 函数根据我们提供的master 配置(APIServer 的地址端口和kubeconfig 的路径),创建一个 k8s的client(kubeclient)和Network对象的 Client (networkClient)
main函数会直接使用 InClusterConfig 来创建 client。这个方式会假设你自定义的控制器是以Pod的方式运行在k8s集群里的,这个控制器会使用默认的serviceaccount数据卷里的授权信息来访问api-server。
- main 函数 Network 对象创建一个 InformerFactory(即networkInformerFactory)的工厂,并使用它生成一个Network对象的Informer,传递给控制器。
- main函数启动上述的Informer,然后执行controler.Run 启动自定义控制器。
工作原理:
这个控制器要做的事是从k8s的apiserver里获取它关心的对象,也就是我们定义的Network对象。这个操作依靠的是一个叫做Informer(通知器)的代码库完成的。Informer与API对象是一一对应的,所以我们传递给自定义控制器的,正是一个Network对象的Informer,当我们在创建这个Informer的时候,需要给他传递一个networkClient。
事实上,Network Informer 正是使用这个 networkClient,跟APIServer 建立了连接,但是真正负责这个连接的则是Informer 所使用的Reflector包。Reflector 使用的是一种叫做ListAndWatch的方法来获取并监听这些Network对象实例的变化。
在ListAndWatch机制下,一旦APIServer端有新的Network实例被创建、删除或者更新,Reflector都会收到事件通知,此时,该事件与它对应的API对象这个组合就被成为增量(Delta),它会被放进一个Delta FIFO Queue(即:增量先进先出队列)中。
而另一方面Informer会不断从这个 Delta FIFO Queue 读取(Pop)增量。每拿到一个增量,Informer就好判断这个增量里的事件类型,然后创建或者更新本地对象的缓存。这个缓存在k8s里叫做 Store。
比如,事件类型是Added(添加对象),那么Informer 就会通过一个叫做 Indexer的库把这个增量里的API对象保存在本地缓存中,并把它创建索引。相反地,如果增量的事件类型是Deleted(删除对象),那么Informer就会从本地缓存中删除这个对象。
同步本地缓存的工作,是Informer的第一个职责,也是它最重要的职责。
而Informer的第二个职责是根据这些事件的类型,触发事先注册好的ResourceEventHander。这些Hander需要在创建控制器的时候注册给它对应的Informer。
下面编写控制器定义:
1 | func NewController( |
main 函数里创建了两个 client(kubeclientset 和 networkclient),这里使用者两个client和前面创建的Informer,初始化自定义控制器。这里还设置了一个工作队列(work queue),它处于示意图中间位置的WorkQueue。这个工作队列的作用是,负责同步Informer和控制循环直接的数据。
实际上,kubernetes项目为我们提供了很多个工作队列的实现,可以根据需要选择合适的库直接使用。
接着,为networkInformer注册了三个Handler(AddFunc、UpdateFunc和DeleteFunc),分别对应API对象的增加、更新、删除事件。而具体的处理操作都是将该事件对应的API对象加入到工作队列中。
实际入队的并不是API对象本身,而是他们的key,即:API对象的<namespace>/<name>
。而我们后面即将编写的控制循环,则会不断的从这些工作队列里拿到这些key,然后开始执行真正的控制逻辑。
因此,所谓的Informer,其实就是一个带有本地缓存机制和索引机制的、可以注册EventHandler的client。它是自定义控制器跟APIServer进行数据同步的重要组件,Informer通过ListAndWatch的方法把APIServer中的API对象缓存在了本地,并负责更新和维护这个缓存。
其中,ListAndWatch方法的含义是首先通过APIServer的List API获取所有最新版本的API对象,然后通过WatchAPI 来监听所有API对象的变化。通过监听到的事件变化,Informer就可以实时更新本地缓存,并且调用这些事件对应的EventHandler。
此外在整个工程中,每经过resyncPeriod指定的时间,Informer维护的本地缓存,都会使用最近因此List返回的结果强制更新一次,从而保证缓存的有效性。在k8s中,这个缓存强制更新的操作叫做resync。该操作会触发Informer注册的更新事件。但此时这个更新事件对应的Network对象实际上并没有发生变化,也就是新旧两个Network对象的ResourceVersion是一样的。在这种情况下Informer就不需要对这个更新事件再做进一步的处理了。
也就是为什么在上面的UpdateFunc方法里,先判断了新旧两个Network对象版本(ResourceVersion)是否发生变化,然后才开始进行入队操作。
接下来到了示意图的控制循环(Control Loop)部分,也正是main函数最后调用control.Run()启动的控制循环
以下是控制循环的主要内容:
1 | func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error { |
- 首先,等待Informer完成一次本地缓存的数据同步操作
- 接着,直接通过goroutine启动一个或并发多个无限循环任务
而这个无限循环任务每一个循环周期,执行的正是我们真正关心的业务逻辑。
下面编写自定义控制器的业务逻辑:
1 | func (c *Controller) runWorker() { |
在这个执行周期里(processNextWorkItem),我们首先从工作队列里出队(workqueue.Get)了一个成员,也就是一个Key(Network对象的:namespace/name)。
然后在syncHandler方法中,使用这个key,尝试从 Informer 维护的缓存中拿到了它所对应的 Network 对象。
可以看到,在这里使用了networksLister来尝试获取这个 Key 对应的 Network 对象。这个操作其实就是在访问本地缓存的索引。实际上,在kubernetes的源码中,你会经常看到控制器从各种Lister里获取对象比如:podLister、nodeLister等,它们使用的都是Informer和缓存机制。
而如果控制循环从缓存中拿不到这个对象(即:networkList返回了IsNotFound错误),那么意味着这个Network对象的Key是通过签名的删除实践添加进工作队列的。所有尽管队列里有这个key,但是对应的Network对象已经被删除。
这个时候,需要调用Neutron的API,把这个对应的Neutron网络从真实的集群里删除掉。
如果能够获取到对应的Network对象,就可以知晓控制器模式里对比期望状态和实际状态的逻辑了。其中自定义控制器千辛万苦拿到的这个Network对象,正是APIServer里保存的期望状态,即用户通过YAML文件提交到APIServer里的信息。当然这个例子里它已经被缓存到了本地。
控制循环通过调用Neutron API 来查询实际的网络情况。
比如,通过Neurton来查询这个Network对象对应的真实网络是否存在。
- 如果不存在,需要使用Network对象里的信息(如 CIDR、Gateway),调用Neutron API来创建真实的网络
- 如果存在,那么读取这个真实的网络信息,判断它是否跟Network对象里的信息一致,从而决定是否要通过Neutron来更新这个存在的真实网络。
这样通过对比期望和实际状态的差异来完成一次调协(Reconcile)过程。
至此,一个完整的自定义API对象及其控制器编写完毕。
运行:
1 | Clone repo |
一开始报错是由于Network的CRD并没有被创建:
1 | kubectl apply -f crd/network.yaml |
此时日志恢复正常,接下来实现Network的CRUD。
首先创建一个对象:
1 | cat example/example-network.yaml |
查看控制器的输出:
1 | ... |
可以看到,通过创建Network的操作,触发了EventHandler的添加操作,从而被放进了工作队列。
接着,控制循环从队列里拿到这个对象,打印了正在处理这个Network对象的日志。
可以看到这个Network的ResourceVersion,也是API对象的版本号:479015,而Spec字段的内容和YAML内容一致。
此时修改这个YAML文件的内容:
1 | $ cat example/example-network.yaml |
执行更新:
1 | kubectl apply -f example/example-network.yaml |
观察输出:
1 | ... |
执行删除:
1 | kubectl delete -f example/example-network.yaml |
这次,控制器的输出力,可以看见Informer注册的删除事件被触发,并且控制循环调用 NeutronAPI 删除了真实环境的网络:
1 | W0915 12:54:09.738464 25245 controller.go:212] Network: default/example-network does not exist in local cache, will delete it from Neutron ... |
以上就是自定义控制器全部流程,这套流程也完全可以用的k8s原生的默认API对象。
比如在main函数中,再初始化一个k8s默认API对象的Informer工厂如Deployment:
1 | func main() { |
这种方式就使得这个自定义控制器里面可以通过自定义API对象对默认API对象进行协同,实现更复杂的编排功能。比如创建一个新的Deployment,这个自定义控制器为它创建一个对应的Network供其使用。