learn-tech/专栏/深入剖析Kubernetes/25深入解析声明式API(二):编写自定义控制器.md
2024-10-16 06:37:41 +08:00

474 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
25 深入解析声明式API编写自定义控制器
你好我是张磊。今天我和你分享的主题是深入解析声明式API之编写自定义控制器。
在上一篇文章中我和你详细分享了Kubernetes中声明式API的实现原理并且通过一个添加Network对象的实例为你讲述了在Kubernetes里添加API资源的过程。
在今天的这篇文章中我就继续和你一起完成剩下一半的工作为Network这个自定义API对象编写一个自定义控制器Custom Controller
正如我在上一篇文章结尾处提到的“声明式API”并不像“命令式API”那样有着明显的执行逻辑。这就使得基于声明式API的业务功能实现往往需要通过控制器模式来“监视”API对象的变化比如创建或者删除Network然后以此来决定实际要执行的具体工作。
接下来我就和你一起通过编写代码来实现这个过程。这个项目和上一篇文章里的代码是同一个项目你可以从这个GitHub库里找到它们。我在代码里还加上了丰富的注释你可以随时参考。
总得来说编写自定义控制器代码的过程包括编写main函数、编写自定义控制器的定义以及编写控制器里的业务逻辑三个部分。
首先我们来编写这个自定义控制器的main函数。
main函数的主要工作就是定义并初始化一个自定义控制器Custom Controller然后启动它。这部分代码的主要内容如下所示
func main() {
...
cfg, err := clientcmd.BuildConfigFromFlags(masterURL, kubeconfig)
...
kubeClient, err := kubernetes.NewForConfig(cfg)
...
networkClient, err := clientset.NewForConfig(cfg)
...
networkInformerFactory := informers.NewSharedInformerFactory(networkClient, ...)
controller := NewController(kubeClient, networkClient,
networkInformerFactory.Samplecrd().V1().Networks())
go networkInformerFactory.Start(stopCh)
if err = controller.Run(2, stopCh); err != nil {
glog.Fatalf("Error running controller: %s", err.Error())
}
}
可以看到这个main函数主要通过三步完成了初始化并启动一个自定义控制器的工作。
第一步main函数根据我提供的Master配置APIServer的地址端口和kubeconfig的路径创建一个Kubernetes的clientkubeClient和Network对象的clientnetworkClient
但是如果我没有提供Master配置呢
这时main函数会直接使用一种名叫InClusterConfig的方式来创建这个client。这个方式会假设你的自定义控制器是以Pod的方式运行在Kubernetes集群里的。
而我在第15篇文章《深入解析Pod对象使用进阶》中曾经提到过Kubernetes 里所有的Pod都会以Volume的方式自动挂载Kubernetes的默认ServiceAccount。所以这个控制器就会直接使用默认ServiceAccount数据卷里的授权信息来访问APIServer。
第二步main函数为Network对象创建一个叫作InformerFactorynetworkInformerFactory的工厂并使用它生成一个Network对象的Informer传递给控制器。
第三步main函数启动上述的Informer然后执行controller.Run启动自定义控制器。
至此main函数就结束了。
看到这你可能会感到非常困惑编写自定义控制器的过程难道就这么简单吗这个Informer又是个什么东西呢
别着急。
接下来,我就为你详细解释一下这个自定义控制器的工作原理。
在Kubernetes项目中一个自定义控制器的工作原理可以用下面这样一幅流程图来表示在后面的叙述中我会用“示意图”来指代它
图1 自定义控制器的工作流程示意图
我们先从这幅示意图的最左边看起。
这个控制器要做的第一件事是从Kubernetes的APIServer里获取它所关心的对象也就是我定义的Network对象。
这个操作依靠的是一个叫作Informer可以翻译为通知器的代码库完成的。Informer与API对象是一一对应的所以我传递给自定义控制器的正是一个Network对象的InformerNetwork Informer
不知你是否已经注意到我在创建这个Informer工厂的时候需要给它传递一个networkClient。
事实上Network Informer正是使用这个networkClient跟APIServer建立了连接。不过真正负责维护这个连接的则是Informer所使用的Reflector包。
更具体地说Reflector使用的是一种叫作ListAndWatch的方法来“获取”并“监听”这些Network对象实例的变化。
在ListAndWatch机制下一旦APIServer端有新的Network实例被创建、删除或者更新Reflector都会收到“事件通知”。这时该事件及它对应的API对象这个组合就被称为增量Delta它会被放进一个Delta FIFO Queue增量先进先出队列中。
而另一方面Informe会不断地从这个Delta FIFO Queue里读取Pop增量。每拿到一个增量Informer就会判断这个增量里的事件类型然后创建或者更新本地对象的缓存。这个缓存在Kubernetes里一般被叫作Store。
比如如果事件类型是Added添加对象那么Informer就会通过一个叫作Indexer的库把这个增量里的API对象保存在本地缓存中并为它创建索引。相反如果增量的事件类型是Deleted删除对象那么Informer就会从本地缓存中删除这个对象。
这个同步本地缓存的工作是Informer的第一个职责也是它最重要的职责。
而Informer的第二个职责则是根据这些事件的类型触发事先注册好的ResourceEventHandler。这些Handler需要在创建控制器的时候注册给它对应的Informer。
接下来,我们就来编写这个控制器的定义,它的主要内容如下所示:
func NewController(
kubeclientset kubernetes.Interface,
networkclientset clientset.Interface,
networkInformer informers.NetworkInformer) *Controller {
...
controller := &Controller{
kubeclientset: kubeclientset,
networkclientset: networkclientset,
networksLister: networkInformer.Lister(),
networksSynced: networkInformer.Informer().HasSynced,
workqueue: workqueue.NewNamedRateLimitingQueue(..., "Networks"),
...
}
networkInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: controller.enqueueNetwork,
UpdateFunc: func(old, new interface{}) {
oldNetwork := old.(*samplecrdv1.Network)
newNetwork := new.(*samplecrdv1.Network)
if oldNetwork.ResourceVersion == newNetwork.ResourceVersion {
return
}
controller.enqueueNetwork(new)
},
DeleteFunc: controller.enqueueNetworkForDelete,
return controller
}
我前面在main函数里创建了两个clientkubeclientset和networkclientset然后在这段代码里使用这两个client和前面创建的Informer初始化了自定义控制器。
值得注意的是在这个自定义控制器里我还设置了一个工作队列work queue它正是处于示意图中间位置的WorkQueue。这个工作队列的作用是负责同步Informer和控制循环之间的数据。
实际上Kubernetes项目为我们提供了很多个工作队列的实现你可以根据需要选择合适的库直接使用。
然后我为networkInformer注册了三个HandlerAddFunc、UpdateFunc和DeleteFunc分别对应API对象的“添加”“更新”和“删除”事件。而具体的处理操作都是将该事件对应的API对象加入到工作队列中。
需要注意的是实际入队的并不是API对象本身而是它们的Key该API对象的<namespace>/<name>
而我们后面即将编写的控制循环则会不断地从这个工作队列里拿到这些Key然后开始执行真正的控制逻辑。
综合上面的讲述你现在应该就能明白所谓Informer其实就是一个带有本地缓存和索引机制的、可以注册EventHandler的client。它是自定义控制器跟APIServer进行数据同步的重要组件。
更具体地说Informer通过一种叫作ListAndWatch的方法把APIServer中的API对象缓存在了本地并负责更新和维护这个缓存。
其中ListAndWatch方法的含义是首先通过APIServer的LIST API“获取”所有最新版本的API对象然后再通过WATCH API来“监听”所有这些API对象的变化。
而通过监听到的事件变化Informer就可以实时地更新本地缓存并且调用这些事件对应的EventHandler了。
此外在这个过程中每经过resyncPeriod指定的时间Informer维护的本地缓存都会使用最近一次LIST返回的结果强制更新一次从而保证缓存的有效性。在Kubernetes中这个缓存强制更新的操作就叫作resync。
需要注意的是这个定时resync操作也会触发Informer注册的“更新”事件。但此时这个“更新”事件对应的Network对象实际上并没有发生变化新、旧两个Network对象的ResourceVersion是一样的。在这种情况下Informer就不需要对这个更新事件再做进一步的处理了。
这也是为什么我在上面的UpdateFunc方法里先判断了一下新、旧两个Network对象的版本ResourceVersion是否发生了变化然后才开始进行的入队操作。
以上就是Kubernetes中的Informer库的工作原理了。
接下来我们就来到了示意图中最后面的控制循环Control Loop部分也正是我在main函数最后调用controller.Run()启动的“控制循环”。它的主要内容如下所示:
func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
...
if ok := cache.WaitForCacheSync(stopCh, c.networksSynced); !ok {
return fmt.Errorf("failed to wait for caches to sync")
}
...
for i := 0; i < threadiness; i++ {
go wait.Until(c.runWorker, time.Second, stopCh)
}
...
return nil
}
可以看到启动控制循环的逻辑非常简单
首先等待Informer完成一次本地缓存的数据同步操作
然后直接通过goroutine启动一个或者并发启动多个)“无限循环的任务
而这个无限循环任务的每一个循环周期执行的正是我们真正关心的业务逻辑
所以接下来我们就来编写这个自定义控制器的业务逻辑它的主要内容如下所示
func (c *Controller) runWorker() {
for c.processNextWorkItem() {
}
}
func (c *Controller) processNextWorkItem() bool {
obj, shutdown := c.workqueue.Get()
...
err := func(obj interface{}) error {
...
if err := c.syncHandler(key); err != nil {
return fmt.Errorf("error syncing '%s': %s", key, err.Error())
}
c.workqueue.Forget(obj)
...
return nil
}(obj)
...
return true
}
func (c *Controller) syncHandler(key string) error {
namespace, name, err := cache.SplitMetaNamespaceKey(key)
...
network, err := c.networksLister.Networks(namespace).Get(name)
if err != nil {
if errors.IsNotFound(err) {
glog.Warningf("Network does not exist in local cache: %s/%s, will delete it from Neutron ...",
namespace, name)
glog.Warningf("Network: %s/%s does not exist in local cache, will delete it from Neutron ...",
namespace, name)
// FIX ME: call Neutron API to delete this network by name.
//
// neutron.Delete(namespace, name)
return nil
}
...
return err
}
glog.Infof("[Neutron] Try to process network: %#v ...", network)
// FIX ME: Do diff().
//
// actualNetwork, exists := neutron.Get(namespace, name)
//
// if !exists {
// neutron.Create(namespace, name)
// } else if !reflect.DeepEqual(actualNetwork, network) {
// neutron.Update(namespace, name)
// }
return nil
}
可以看到在这个执行周期里processNextWorkItem我们首先从工作队列里出队workqueue.Get了一个成员也就是一个KeyNetwork对象的namespace/name)。
然后在syncHandler方法中我使用这个Key尝试从Informer维护的缓存中拿到了它所对应的Network对象
可以看到在这里我使用了networksLister来尝试获取这个Key对应的Network对象这个操作其实就是在访问本地缓存的索引实际上在Kubernetes的源码中你会经常看到控制器从各种Lister里获取对象比如podListernodeLister等等它们使用的都是Informer和缓存机制
而如果控制循环从缓存中拿不到这个对象networkLister返回了IsNotFound错误那就意味着这个Network对象的Key是通过前面的删除事件添加进工作队列的所以尽管队列里有这个Key但是对应的Network对象已经被删除了
这时候我就需要调用Neutron的API把这个Key对应的Neutron网络从真实的集群里删除掉
而如果能够获取到对应的Network对象我就可以执行控制器模式里的对比期望状态实际状态的逻辑了
其中自定义控制器千辛万苦拿到的这个Network对象正是APIServer里保存的期望状态”,用户通过YAML文件提交到APIServer里的信息当然在我们的例子里它已经被Informer缓存在了本地
那么,“实际状态又从哪里来呢
当然是来自于实际的集群了
所以我们的控制循环需要通过Neutron API来查询实际的网络情况
比如我可以先通过Neutron来查询这个Network对象对应的真实网络是否存在
如果不存在这就是一个典型的期望状态实际状态不一致的情形这时我就需要使用这个Network对象里的信息比如CIDR和Gateway调用Neutron API来创建真实的网络
如果存在那么我就要读取这个真实网络的信息判断它是否跟Network对象里的信息一致从而决定我是否要通过Neutron来更新这个已经存在的真实网络
这样我就通过对比期望状态实际状态的差异完成了一次调协Reconcile的过程
至此一个完整的自定义API对象和它所对应的自定义控制器就编写完毕了
备注与Neutron相关的业务代码并不是本篇文章的重点所以我仅仅通过注释里的伪代码为你表述了这部分内容如果你对这些代码感兴趣的话可以自行完成最简单的情况你可以自己编写一个Neutron Mock然后输出对应的操作日志
接下来我们就一起来把这个项目运行起来查看一下它的工作情况
你可以自己编译这个项目也可以直接使用我编译好的二进制文件samplecrd-controller)。编译并启动这个项目的具体流程如下所示
# Clone repo
$ git clone https://github.com/resouer/k8s-controller-custom-resource$ cd k8s-controller-custom-resource
### Skip this part if you don't want to build
# Install dependency
$ go get github.com/tools/godep
$ godep restore
# Build
$ go build -o samplecrd-controller .
$ ./samplecrd-controller -kubeconfig=$HOME/.kube/config -alsologtostderr=true
I0915 12:50:29.051349 27159 controller.go:84] Setting up event handlers
I0915 12:50:29.051615 27159 controller.go:113] Starting Network control loop
I0915 12:50:29.051630 27159 controller.go:116] Waiting for informer caches to sync
E0915 12:50:29.066745 27159 reflector.go:134] github.com/resouer/k8s-controller-custom-resource/pkg/client/informers/externalversions/factory.go:117: Failed to list *v1.Network: the server could not find the requested resource (get networks.samplecrd.k8s.io)
...
你可以看到自定义控制器被启动后一开始会报错
这是因为此时Network对象的CRD还没有被创建出来所以Informer去APIServer里获取”(ListNetwork对象时并不能找到Network这个API资源类型的定义
Failed to list *v1.Network: the server could not find the requested resource (get networks.samplecrd.k8s.io)
所以接下来我就需要创建Network对象的CRD这个操作在上一篇文章里已经介绍过了
在另一个shell窗口里执行
$ kubectl apply -f crd/network.yaml
这时候你就会看到控制器的日志恢复了正常控制循环启动成功
...
I0915 12:50:29.051630 27159 controller.go:116] Waiting for informer caches to sync
...
I0915 12:52:54.346854 25245 controller.go:121] Starting workers
I0915 12:52:54.346914 25245 controller.go:127] Started workers
接下来我就可以进行Network对象的增删改查操作了
首先创建一个Network对象
$ cat example/example-network.yaml
apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
name: example-network
spec:
cidr: "192.168.0.0/16"
gateway: "192.168.0.1"
$ kubectl apply -f example/example-network.yaml
network.samplecrd.k8s.io/example-network created
这时候查看一下控制器的输出
...
I0915 12:50:29.051349 27159 controller.go:84] Setting up event handlers
I0915 12:50:29.051615 27159 controller.go:113] Starting Network control loop
I0915 12:50:29.051630 27159 controller.go:116] Waiting for informer caches to sync
...
I0915 12:52:54.346854 25245 controller.go:121] Starting workers
I0915 12:52:54.346914 25245 controller.go:127] Started workers
I0915 12:53:18.064409 25245 controller.go:229] [Neutron] Try to process network: &v1.Network{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"example-network", GenerateName:"", Namespace:"default", ... ResourceVersion:"479015", ... Spec:v1.NetworkSpec{Cidr:"192.168.0.0/16", Gateway:"192.168.0.1"}} ...
I0915 12:53:18.064650 25245 controller.go:183] Successfully synced 'default/example-network'
...
可以看到我们上面创建example-network的操作触发了EventHandler的添加事件从而被放进了工作队列
紧接着控制循环就从队列里拿到了这个对象并且打印出了正在处理这个Network对象的日志
可以看到这个Network的ResourceVersion也就是API对象的版本号是479015而它的Spec字段的内容跟我提交的YAML文件一摸一样比如它的CIDR网段是192.168.0.0/16
这时候我来修改一下这个YAML文件的内容如下所示
$ cat example/example-network.yaml
apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
name: example-network
spec:
cidr: "192.168.1.0/16"
gateway: "192.168.1.1"
可以看到我把这个YAML文件里的CIDR和Gateway字段修改成了192.168.1.0/16网段
然后我们执行了kubectl apply命令来提交这次更新如下所示
$ kubectl apply -f example/example-network.yaml
network.samplecrd.k8s.io/example-network configured
这时候我们就可以观察一下控制器的输出
...
I0915 12:53:51.126029 25245 controller.go:229] [Neutron] Try to process network: &v1.Network{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"example-network", GenerateName:"", Namespace:"default", ... ResourceVersion:"479062", ... Spec:v1.NetworkSpec{Cidr:"192.168.1.0/16", Gateway:"192.168.1.1"}} ...
I0915 12:53:51.126348 25245 controller.go:183] Successfully synced 'default/example-network'
可以看到这一次Informer注册的更新事件被触发更新后的Network对象的Key被添加到了工作队列之中
所以接下来控制循环从工作队列里拿到的Network对象与前一个对象是不同的它的ResourceVersion的值变成了479062而Spec里的字段则变成了192.168.1.0/16网段
最后我再把这个对象删除掉
$ kubectl delete -f example/example-network.yaml
这一次在控制器的输出里我们就可以看到Informer注册的删除事件被触发并且控制循环调用Neutron API删除了真实环境里的网络这个输出如下所示
W0915 12:54:09.738464 25245 controller.go:212] Network: default/example-network does not exist in local cache, will delete it from Neutron ...
I0915 12:54:09.738832 25245 controller.go:215] [Neutron] Deleting network: default/example-network ...
I0915 12:54:09.738854 25245 controller.go:183] Successfully synced 'default/example-network'
以上就是编写和使用自定义控制器的全部流程了
实际上这套流程不仅可以用在自定义API资源上也完全可以用在Kubernetes原生的默认API对象上
比如我们在main函数里除了创建一个Network Informer外还可以初始化一个Kubernetes默认API对象的Informer工厂比如Deployment对象的Informer这个具体做法如下所示
func main() {
...
kubeInformerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, time.Second*30)
controller := NewController(kubeClient, exampleClient,
kubeInformerFactory.Apps().V1().Deployments(),
networkInformerFactory.Samplecrd().V1().Networks())
go kubeInformerFactory.Start(stopCh)
...
}
在这段代码中我们首先使用Kubernetes的clientkubeClient创建了一个工厂
然后我用跟Network类似的处理方法生成了一个Deployment Informer
接着我把Deployment Informer传递给了自定义控制器当然我也要调用Start方法来启动这个Deployment Informer
而有了这个Deployment Informer后这个控制器也就持有了所有Deployment对象的信息接下来它既可以通过deploymentInformer.Lister()来获取Etcd里的所有Deployment对象也可以为这个Deployment Informer注册具体的Handler来
更重要的是这就使得在这个自定义控制器里面我可以通过对自定义API对象和默认API对象进行协同从而实现更加复杂的编排功能
比如用户每创建一个新的Deployment这个自定义控制器就可以为它创建一个对应的Network供它使用
这些对Kubernetes API编程范式的更高级应用我就留给你在实际的场景中去探索和实践了
总结
在今天这篇文章中我为你剖析了Kubernetes API编程范式的具体原理并编写了一个自定义控制器
这其中有如下几个概念和机制是你一定要理解清楚的
所谓的Informer就是一个自带缓存和索引机制可以触发Handler的客户端库这个本地缓存在Kubernetes中一般被称为Store索引一般被称为Index
Informer使用了Reflector包它是一个可以通过ListAndWatch机制获取并监视API对象变化的客户端封装
Reflector和Informer之间用到了一个增量先进先出队列进行协同而Informer与你要编写的控制循环之间则使用了一个工作队列来进行协同
在实际应用中除了控制循环之外的所有代码实际上都是Kubernetes为你自动生成的pkg/client/{informers, listers, clientset}里的内容
而这些自动生成的代码就为我们提供了一个可靠而高效地获取API对象期望状态的编程库
所以接下来作为开发者你就只需要关注如何拿到实际状态”,然后如何拿它去跟期望状态做对比从而决定接下来要做的业务逻辑即可
以上内容就是Kubernetes API编程范式的核心思想
思考题
请思考一下为什么Informer和你编写的控制循环之间一定要使用一个工作队列来进行协作呢
感谢你的收听欢迎你给我留言也欢迎分享给更多的朋友一起阅读