在前面的文章中,我们讲了 kube-apiserver 的基本原理,包括 kube-apiserver 的初始化流程和请求处理步骤,重点放在了分析内置 API 资源的路由初始化和请求处理,如 Pod、Node等。k8s 支持对 API 扩展,用户可以创建一个自定义的 API 类型,这在 k8s 中叫 custom resource definitions(CRD),基于这个 CRD 创建的 API 资源叫做 custom resource(CR),可类比 deployment 是一个内置的 API 类型,你创建了一个具体的 deployment 实例。
不过,仅仅创建 CRD 和 CR 并没有实际的作用,需要有一个对应的控制器对资源变化做出反应,使得最终能够达到 CR 中描述的状态,这在 k8s 中叫调谐(Reconcile)。我们以 deployment 为例说明,当你创建了副本数为 1 的 deployment 时,控制器检测到 deployment 创建后,就开始调谐创建一个 Pod;当你修改副本数为 2 时,控制器监测到 deployment 发生了变化,就开始调谐,达到最终的副本数为 2 的这么一个状态。
每一个内置的 API 类型都有一个控制器,你可以查看 k8s 源码 kubernetes/pkg/controller 目录
虽然我们平时说 kube-controller-manager 是 k8s 的一个核心组件,实际上上图中所有的 API 资源控制器都是按集成在 kube-controller-manager 中,他们各司其职,监听各自负责的资源变化,调谐状态,达到预期的结果,组成了一个完整的 kube-controller-manager 组件。
所以自定义资源也需要一个控制器,对自定义资源进行调谐。
能实现自定义 API 类型扩展主要依托于 k8s 的两个能力:
- apiExtensionsServer 在前面分析 kube-apiserver 初始化的时候我们说过,kube-apiserver 由三个 server 组成:aggregatorServer、kubeAPIServer、apiExtensionsServer。其中的 apiExtensionsServer 就是专门为了用户自定义 API 资源扩展而生的,例如下面是一个 calico bgp 配置的一个 crd,当我们使用 kubectl apply 创建这个 API 类型时,Kubectl 就会访问 http://host:port/apis/apiextensions.k8s.io/v1/customresourcedefinitions,而这个 url 是注册在 apiExtensionsServer,所以最终 apiExtensionsServer 会去调用 etcd 的接口,在 etcd 中写了这个资源的定义。
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: bgpconfigurations.crd.projectcalico.org
spec:
conversion:
strategy: None
group: crd.projectcalico.org
names:
kind: BGPConfiguration
listKind: BGPConfigurationList
plural: bgpconfigurations
singular: bgpconfiguration
preserveUnknownFields: true
scope: Cluster
versions:
- name: v1
served: true
storage: true
- list/watch list/watch 是实现调谐的基石,也是 k8s 的精髓所在,它能够感知资源的变化。顾名思义,lilst/watch 由 list 和 watch 两部分组成,一个控制器启动时首先会对自己负责的资源 list 一遍,list 操作会把存在 etcd 中全量的该资源同步到本地缓存中,后续控制器感知到的资源增量变化(增、删、改)都会在缓存中更新;watch 是用于监测资源变化的, watch 长链接是通过 http 协议 chunked 机制实现的,在响应头里加一个 Transfer-Encoding: chunked 就可以实现分段响应。
那么为什么要以这种方式进行资源变化的感知和调谐呢?
试想一个场景,一个集群有 1000 个节点,节点上的 kubelet 需要感知到哪些 Pod 被调度到本机上,如果采用轮询的机制,kubelet 需要定时向 kube-apiserver 发起请求获取 Pod,那么 kube-apiserver 的压力是巨大的,最坏的情况就是需要处理 QPS 为 1000 的 get pod 请求;并且,并不是时时刻刻都会有 Pod 需要调度的,如果是轮询的方式,很多时候获取到的数据或请求都是无效的,这无疑是浪费网络和计算资源。
而 list/watch模式,在 kubelet 启动的时候先把全量的 pod list下来,后续通过 watch 和 kube-apiserver 建立长链接,只有当 kube-apiserver 有数据需要下发给控制器的时候才会通过 watch 建立的长链接下发,同样是 1000 个节点,这种模式 kube-apiserver 只需要维护 1000 个链接即可,这样即降低了 kube-apiserver 的压力,也减少了网络带宽的浪费。
说了这么多,这个 list/watch需要用户自己实现吗?
k8s 已经在 client-go 中实现了 informer 机制,它的主要功能就是 list/watch。
下面我们来看下 informer 的主要原理,下图是一张经典的 informer 工作机制原理图
这张图分上下两部分,上半部分就是 client-go 的 informer 部分,下半部分则是需要用户实现的控制器。
上半部分主要由:Reflector、DeltaFIFO、LocalStore、Informer 组成,下面我们来看看它的具体工作流程:
-
控制器启动时 Informer 首先会向 kube-apiserver list 注册在 Informer 上的 API 资源,由 Reflector 和 kube-apiserver 建立长链接,将 list 到的全量 API 资源存储在 LocalStore(它是一个线程安全的 map),后续用户的控制器想要获取该 API 类型的实例,就不需要访问 kube-apiserver,而是直接可以从本地缓存 LocalStore 汇总获取到;
-
后续如果该 API 类型的资源有增、删、改等行为,Reflector 都能通过长链接(由 http2 头部增加 Transfer-Encoding: chunked 实现)接收到变化,该事件及它对应的 API 对象这个组合,被称为增量(Delta),它会被放进 DeltaFIFO 中。
-
一旦 DeltaFIFO 中有数据,Informer 会马上取出,然后会做两件事: (1)判断事件的类型(增、删、改),然后根据类型更新 LocalStore,例如是删除数据,则 informer 会删除 LocalStore 中该 API 实例;如果事件类型是增加操作,那么 Informer 会通过 Indexer 把这个增量里的 API 实例保存到LocalStore,并为它创建索引; (2)调用注册在 Informer 上该 API 的事件处理函数,即图中的 Resource Event Handlers,handlers 需要用户自己实现,一般是先对事件进行过滤,然后将事件(实际上就是 API的 name + namespace)写入到队列中。
-
用户实现的控制器从队列中取出对象,启动一个协程 来执行自己的调谐逻辑,调谐逻辑一般就是计算集群当前的状态和用户希望期望状态的状态,然后根据计算结果做出一系列操作,最终让这两个状态达到一致,如 deployment 的 replicas 由 1 变为 2,那么控制器就需要调用 kube-apiserver 的 api 创建一个新的 Pod,达到新的预期状态。
以上就是 informer 和控制器的基本原理。
那么我们该怎么去实现一个控制器呢?一般我们会使用 kubebuilder 工具进行开发?那么他的作用是什么呢?
我们回头看下上图下半部分,用户需要实现的部分包括以下几个:
- 事件处理函数
- 队列
- 调谐逻辑(控制器逻辑)
此外,还需要创建
- CRD yaml
- 为了能够让我们的控制器有权限向 kube-apiserver 访问需要的资源,需要创建rbac yaml
上面的步骤看起来还是比较麻烦的,所幸 kubebuilder 为我们的开发提供了便利,只要我们创建一个 kubebuilder 工程,它就会给我们封装好事件处理函数、队列的操作,创建 crd yaml,创建 rbac yaml,我们唯一需要做的就是专心实现调谐逻辑即可。此外,kubebuilder 还有构建镜像、发布 crd 和控制器到目标集群等能力,为我们省去了很多麻烦。
下一篇文章我们就使用 kubebuilder 来开发一个实例进行实践。