K8S API-Server 源码剖析(一)| 监听机制 List-Watch 剖析

K8S API-Server 源码剖析(一)| 监听机制 List-Watch 剖析_pod (sohu.com)

1.List-Watch 介绍

List-Watch 是 kubernetes 中非常常见的一种监听机制,为了展现 List-Watch 的作用,我们先从一个非常普通的操作:创建一个 Deployment 说起。

如果我们把 kubernetes 创建一个 Deployment (3副本) 的过程给简化,省去中间认证,授权准入等繁琐的步骤,我们大概可以得到这样一种流程:

1.1. 第一步:kubectl 发送请求

这里比如我们创建了一个 Deployment 的 yaml 文件,然后我们执行`kubectl apply -f deployment.yaml`的时候,这个请求并没有直接给到节点上的 kubelet 让他来创建 pod,而是只在 etcd 中把这个 Deployment 给写了进去,这时候 kubectl 的任务就完成了。

1.2. 第二步:Deployment controller 监听

注意在上一步将 Deployment 数据写入 etcd 后,并非 etcd 主动向 Deployment 发送事件的,而是 Deployment 通过持续的监听去得知了 etcd 里有这么一个 Deployment 需要被创建。所以当监听到这个事件后,便创建了 Deployment 的下一层:ReplicaSet。同样也是将 ReplicaSet 数据写入到了 etcd 中。

1.3. 第三步:ReplicaSet controller 监听

Deployment controller 只会创建一个 ReplicaSet 的数据,真正创建 Pod 的数据需要通过 ReplicaSet 去做,其步骤与上面一步基本相同,是依靠 ReplicaSet controller 的监听获取事件的,它获取到 etcd 里面的这个 ReplicaSet 竟然没有一个 Pod,与自己的期望 3 个 Pod (副本)不符合,所以就创建了 3 个 Pod 的数据,随后将 Pod 的数据写进了 etcd 中。

1.4. 第四步:Sechduler监听

Schudler 虽不是 controller,但也是靠监听的机制去触发的。同样的,所谓的「调度到某个节点」其实也只是往 etcd 里写入数据罢了。Schduler 监听到这 3 个 Pod 还是属于 Pending 的状态,所以就通过一些列的调度算法(预选,优选)去算出最合适的 Node 节点,然后将这个将更新 etcd 中这 3 个 Pod 的信息。

1.5. 第五步:Kubelet 监听

最后在节点上的 kubelet,也是通过监听的方式,去查询 master 节点上的 Pod 列表,然后看看这些 Pod 列表中属于该节点的 Pod 是否和自己现在已经有的 Pod 相同,很显然,目前有 3 个 Pod 已经属于这个节点了,但是 kubelet 还没有创建。所以,当 Kubelet 监听到这个事件之后,便在这个节点上创建 Pod 了。至于这个创建 Pod 的过程,其实也非常繁琐,这里就不赘述了,但无论如何,到这一步过后,真正的 Pod 和 Pod 里面的容器就运行起来了。

通过上面创建 Deployment 的五个步骤来看,kubernetes 的组件大量地用到了监听机制去获取事件,每个组件有种互不干涉(不直接向另一个组件发送请求)但是却息息相关(时刻监听变化)的感觉,而这种机制的主要实现方式就是 List-Watch 机制,不过很多时候,我们看到很多资料讲 Informer 机制或者 Reflactor 机制来实现监听,但其实他们的本质都是 List-Watch。

这里 List,一般就是指获取全量数据,一个一次性的请求。Watch,一般指获取增量数据,一个持久链接 (Http streaming) 的请求,其原理是采用的是 Chunked transfer encoding。

HTTP 分块传输编码允许服务器为动态生成的内容维持 HTTP 持久链接。通常,持久链接需要服务器在开始发送消息体前发送Content-Length消息头字段,但是对于动态生成的内容来说,在内容创建完之前是不可知的。使用分块传输编码,数据分解成一系列数据块,并以一个或多个块发送,这样服务器可以发送数据而不需要预先知道发送内容的总大小。

服务端回复的ehttp header中带有"Transfer-Encoding": "chunked"。客户端收到这种header的response之后会等待后续数据。

另一方面,通过上面描述,我们看到其实监听可以说有两个层面:

1. kubernetes 组件 ( controller, sheduler, kubelet ) 到 apiserver 的层面。

2. apiserver 到 etcd 的层面。

我们在使用 kubectl 发送 List-Watch 请求或者我们在自定义开发 CustomResourceDefinition( CRD ) 的时候,所关心的基本上都是第一个层面的,这段代码的实现主要是在 kubernetes 的 client-go 模块中,这也是本文章想着重阐述的。至于第二个层面,本质上它是使用了 etcd-client 来发送监听请求,这一点我们会在之后关于 etcd 的介绍中详细阐述。

2. 使用List-Watch

在一个 k8s 集群中,List-Watch 时时刻刻都在发生但是我们并不感知,所以为了让我们更明白 List-Watch 的作用和意义,在这一节中,我们会开始使用 kubectl 的命令或者简单的 go 代码来真正使用 List-Watch。

2.1. kubectl 发送 List-Watch 请求

我们最常用的 Watch 请求可能发生在发送 kubectl 的 get 命令时带上了 "watch " 参数:

1[root@master ~]# kubectl get pods/my-busybox --watch -v 7

2I1110 10: 18: 42.60493316898loader.go: 375] Config loaded fromfile: /root/.kube/config

3I1110 10: 18: 42.61355516898round_trippers.go: 420] GET https: //10.6.192.3:6443/api/v1/namespaces/default/pods/my-busybox

4I1110 10: 18: 42.61359716898round_trippers.go: 427] Request Headers:

5I1110 10: 18: 42.61363116898round_trippers.go: 431] Accept: application/json; as=Table;v=v1beta1;g=meta.k8s.io, application/json

6I1110 10: 18: 42.61364816898round_trippers.go: 431] User-Agent: kubectl/v1 .16.2(linux/amd64) kubernetes/c97fe50

7I1110 10: 18: 42.62650716898round_trippers.go: 446] Response Status: 200OK in12milliseconds

8NAME READY STATUS RESTARTS AGE

9my-busybox 1/ 1Running 2359d

10I1110 10: 18: 42.62755716898round_trippers.go: 420] GET https: //10.6.192.3:6443/api/v1/namespaces/default/pods?fieldSelector=metadata.name%3Dmy-busybox&resourceVersion=0&watch=true

11I1110 10: 18: 42.62757316898round_trippers.go: 427] Request Headers:

12I1110 10: 18: 42.62758416898round_trippers.go: 431] Accept: application/json; as=Table;v=v1beta1;g=meta.k8s.io, application/json

13I1110 10: 18: 42.62759416898round_trippers.go: 431] User-Agent: kubectl/v1 .16.2(linux/amd64) kubernetes/c97fe50

14I1110 10: 18: 42.62911716898round_trippers.go: 446] Response Status: 200OK in1milliseconds

可以明显得看到,这里的请求发送了两次,第一次是一个简单的 Get 请求,获得到了这个 Pod 的信息;第二个 Get 请求就加上了" fieldSelector ", " resourceVersion ", "watch "参数,在发送完这个请求之后,我们可以看到控制台已经开始在监听这个 Pod 了,为了验证这一点,我们稍微改动一下这个 Pod,比如将这个 Pod 的 label 进行添加或者修改,然后再使用 kubectl 的 apply 命令应用这个修改,可以看到控制台立刻就回显了这个 Pod 一次。 所以可以看出,我们使用 kubectl 发送的 List-Watch 命令,就已经可以实现某种程度上的对资源的监听了。顺带一提,有一个命令叫做 `kubectl wait`,就是根据某种条件来等待某种资源的命令,它本质上也是发送 List-Watch 请求。

2.2. 使用 Controller 进行 List-Watch

我们在创建 CRD 的时候,单单只是写了几个 yaml 文件肯定是不够的,因为本质上这里只是一个 OpenApi 的 Schema,真正实现功能还需要 CRD Controller,也就是对 CRD 进行管理。当然这也是一个比较复杂的过程,为了简述,我们这里就不创建 CRD 和 CRD controller,而是直接参考 Pod 的 Controller 的代码,来实现关于 Pod 资源的 List-Watch,我们要实现的功能是:当这段代码运行时,我可以监听 Pod 资源的变更,如果有新的 Pod 被创建,我就需要打印出这个 Pod 的名字。核心代码如下:

1// 第一步: 创建Informer

2factory := informers.NewSharedInformerFactory(clientset, 0)

3podInformer := factory.Core.V1.Pods

4informer := podInformer.Informer

5

6// 第二步: 启动Informer,也就是开始了List-Watch

7go factory.Start(stopper)

8

9// 第三步:添加事件回调函数,也就是在资源发生变动时会触发的事件,这里只是在Pod被创建时,会打印该Pod。

10informer.AddEventHandler(cache.ResourceEventHandlerFuncs{

11AddFunc: onAdd,

12UpdateFunc: func(interface{}, interface{}) { fmt.Println( "update not implemented") },

13DeleteFunc: func(interface{}) { fmt.Println( "delete not implemented") },

14})

15func onAdd(obj interface{}) {

16pod:= obj.(*corev1.pod)

17fmt.Println( "add a pod:", pod.Name)

18}

当然这部分代码也是省去了很多细节,不过大体的流程基本就是这样,我们从这个代码中可以看到,由于 client-go 对代码的封装,我们在实现一个 List-Watch 的时候,需要考虑的东西非常少:

1. 我们要 List-Watch 什么资源。

2. 我们获得这种资源的新的 Event 之后,回调函数是什么。

当然也是因为代码的封装程度非常高,我们并不能看到其执行的细节,所以在下一章节中,我们会专门针对于这段代码,描述 kubernetes 的 Controller 在进行对资源的 List-Watch 时的代码流程。

3.List-Watch 代码实现

List-Watch 的代码实现主要在:k8s.io/client-go/tools/cache 中,这也是我们在这一节中着重要讲的。

从上一节的示例代码中,我们看到基本上都是靠 Informer 去实现功能的,在代码的流程中, Informer 是最高的一层,之后流程基本就是:

Informer → Controller → Reflector → ListWatch (最后靠 Controller 里面的 processLoop 去回调事件处理函数),我们也会按照这个顺序去梳理代码。

3.1. 初始化层面

这段代码创建了一个 cache.NewSharedIndexInformer。注意里面的 cache.ListWatch 是需要的两个函数:一个 ListFunc 实现 List,一个 WatchFunc 实现 Watch,这两个函数本质也是向 apiserver 发送请求。这两个函数会在后面被调用。

1// k8s.io/client-go/informers/core/v1/pod.go

2

3func NewFilteredPodInformer(client kubernetes.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer {

4returncache.NewSharedIndexInformer(

5&cache.ListWatch{

6ListFunc: func(options metav1.ListOptions) (runtime.Object, error) {

7iftweakListOptions != nil {

8tweakListOptions(&options)

9}

10returnclient.CoreV1.Pods(namespace).List(options)

11},

12WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) {

13iftweakListOptions != nil {

14tweakListOptions(&options)

15}

16returnclient.CoreV1.Pods(namespace).Watch(options)

17},

18},

19&corev1.Pod{},

20resyncPeriod,

21indexers,

22)

23}

注意这个 cache.NewSharedIndexInformer,它做了一件很重要的事就是 初始化了本地缓存。

1// k8s.io/client-go/tools/cache/shared_informer.go

2indexer: NewIndexer(DeletionHandlingMetaNamespaceKeyFunc, indexers),

而本地缓存就是一个 ThreadSafe Store。关于这个 ThreadSafe Store,我们会在下一章节进行更详细的介绍。

1// k8s.io/client-go/tools/cache/store.go

2func NewIndexer(keyFunc KeyFunc, indexers Indexers) Indexer {

3return&cache{

4cacheStorage: NewThreadSafeStore(indexers, Indices{}),

5keyFunc: keyFunc,

6}

7}

其中 indexers 是索引方法,使得这个缓存可以依照 indexer 取出数据。这个本地缓存很重要,它至少有这两个作用:

1. 将事件先放在 DeltaFIFO 中,然后把这个 NewThreadSafeStore 作为二级缓存。

2. 提供 Lister 方法,可以直接查这个缓存即可。

3.2. Informer 层面

先不管那个 WatchFunc,我们有了这个 Informer 之后就可以开始 Start 了。调用的是 k8s.io/client-go/tools/cache/shared_informer.go 里面的 Run 方法。代码比较多就不贴出来了,它会创建一个 DeltaFIFO ,然后初始化一个 Controller ,指明它使用的 store 是刚才创建的 DeltaFIFO,它使用的 Process 是 sharedInformer 的 HandleDeltas (下面会马上提到)。它主要干了四件事情,如下图所示:

3.3. Controller 层面

上面 Informer.Run 调用了 controller.Run,然后这里主要分析一下 k8s.io/client-go/tools/cache/controller.go 里面的 controller.run,它会初始化一个 Reflector,而 Reflector 才是真正执行 List-Watch 的地方。它主要干了两件事情,如下图所示:

3.4. Reflector 层面

上面 controller.Run 调用了 reflector.Run,然后这里主要分析一下 k8s.io/client-go/tools/cache/reflector.go 里面的 reflector.run。这个 Run 方法其实就直接调用了 Reflector.ListAndWatch 方法,在这个方法中,我们终于调用了最开始的 ListFunc 和 WatchFunc,也就是真正得发送请求给 apiserver 了。

其中 ListAndWatch 做的事情可以用下图表示:

3.5. 事件回调层面 ( processorListener )

这个被调用到的其实前面 Informer 的 process.run。这个就是事件回调函数,根据自己定义的 EventHandler 来对这些 Event 做出处理。我们看一下这个 run 方法:

1// k8s.io/client-go/tools/cache/shared_informer.go

2

3func (p *sharedProcessor) run(stopCh <-chan struct{}) {

4func {

5p.listenersLock.RLock

6defer p.listenersLock.RUnlock

7for_, listener:= range p.listeners {

8p.wg.Start(listener.run)

9p.wg.Start(listener.pop)

10}

11p.listenersStarted = true

12}

13<-stopCh

14p.listenersLock.RLock

15defer p.listenersLock.RUnlock

16for_, listener:= range p.listeners {

17close(listener.addCh) // Tell .pop to stop. .pop will tell .run to stop

18}

19p.wg.Wait // Wait for all .pop and .run to stop

20}

可以看到它同时开了两个 goroutine:

1. listener.run : 从 processorListener 的 p.nextCh 拿事件,然后触发 p.handler 相应的函数。

2.listener.pop : 从 p.addCh 拿事件,塞进 p.nextCh,如果 p.nextCh 阻塞,那么就放进 p.pendingNotifications 的环形存储区中。

这里 pendingNotifications 就是所谓的第三层缓存,作为最后一层缓存,这里的事件拿出来之后就会真正触发回调函数。

回顾整个过程我们省略了很多细节,其实还是比较复杂的。

图片来源:https://www.bbsmax.com/A/pRdBPbbPJn/

这个图更加清晰得展现了,我们一直在说的三个缓存的位置和作用:

1. DeltaFIFO:ListWatch 事件最开始存储的地方。

2. ThreadSafeStore:一个线程安全缓存,存储了数据以供其他 controller的Lister 方法调用。

3. pendingNotifications(RingGrowing):事件在调用前的最后缓存。

4.List-Watch 三级缓存

我们已知 List-watch 是一个典型的生产者-消费者模型,这种模型常见的问题就是,消费者处理事件的速度跟不上生产者生成事件的速度,所以我们需要缓存来存储生产者的事件,然后让消费者慢慢处理。

4.1. DeltaFIFO

它的使用在 k8s.io/client-go/tools/cache/shared_informer.go 中的 sharedIndexInformer.Run 中。

所谓 Delta,就是指一个 ActionType 加上一个 Object。那么 Deltas 就是指这个 item 下面所有的 Delta。

为了更详细说明,我们假如对一个 Pod (这里是 testFifoObject ) 先是新建,然后再对它进行修改 ( Update),那么我们得到的 Deltas 如下:

1k8s.io/client-go/tools/cache.Deltas len: 2, cap: 2, [

2{

3Type: "Added",

4Object: interface {}(k8s.io/client-go/tools/cache.testFifoObject) *(*interface {})( 0xc00060c010),},

5{

6Type: "Updated",

7Object: interface {}(k8s.io/client-go/tools/cache.testFifoObject) *(*interface {})( 0xc00060c030),},

8]

DeltaFIFO 是作为 Controller 的 Store,让之后 watch 到的数据同步到这里。

它的定义在 k8s.io/client-go/tools/cache/delta_fifo.go 中。

注意,这个 DeltaFIFO 是存事件的,也就是不管是删除,增加,修改等等,都是以事件的形式存入的。

我们这里单独谈一下这个 DeltaFIFO 的特性:

4.1.1. 提供队列先进先出 ( FIFO ) 的 Pop 方法

FIFO 是实现了 Queue 的接口,这个接口最重要的就是 Pop 方法:

1Pop(PopProcessFunc) (interface{}, error)

具体实现就不贴代码了,总之这个特殊的 Pop 方法可以达成:

1. 根据先进先出的策略来处理事件。

2. 如果事件处理失败,可以把事件重新加进来。

这样的机制保障了事件能够按顺序地,安全地被处理完。

4.1.2. 读写锁

DeltaFIFO 也是借助于 go 的 sync 包中的 RWMutex 实现了在写入数据时加锁的功能。这样也保障了读数据的一致性。

4.1.3. 提供 Replace 方法

在 List-Watch 中,会定期调用 DeltaFIFO 的 Replce 方法,目的是为了更新 ThreadSafeStore ( 二级缓存 ) 中的数据。

DeltaFIFO 在初始化的时候,会给一个 Indexer 作为自己的 knowObjects ,这个上文中有介绍,其实就是 ThreadSafeStore ( 二级缓存 ),DeltaFIFO 根据其保存的对象状态变更消息(增/删/改/同步) KnownObject。这个 Replace 方法就是用来通过 ListFunc 拿到新的数据然后同步 KnowObject 的。

Replce 方法的实现有点绕,因为它做的事情只有两件:

1. 往 DeltaFIFO 新增这些对象(拿到的新数据) Sync 的 Delta。也就是他们的 Type 是 " sync "。

2. 删除 knowObjects 中不存在于这些对象 ( 拿到的新数据 ) 的对象。

第二点还好理解,问题就在第一点,增加了一个 Sync 的 Delta,它在之后的事件回调函数中和 Type 为 " Added " 和 " Updated " 有什么不同吗?

事实上这里就要弄清楚事件回调函数的本质:其实不管是 " Added ", " Updated " 还是 " Sync " 的 Delta,在最后的 PopProcessFunc 的时候,都是一视同仁的:

1. 如果这个对象在 ThreadSafeStore 没有,首先给 ThreadSafeStore 增加这个对象,然后向 listeners 分发 Add 通知,如果是 Sync 的 Delta,则附加上 sync 为 True。

2. 如果这个对象在 ThreadSafeStore 有,首先给 ThreadSafeStore 更新这个对象,然后向 listeners 分发 Update 通知,如果是 Sync 的 Delta,则附加上 sync 为 True。

代码实现在:

1// k8s.io/client-go/tools/cache/shared_informer.go

2

3caseSync, Added, Updated:

4isSync := d.Type == Sync

5s.cacheMutationDetector.AddObject(d.Object)

6ifold, exists, err:= s.indexer.Get(d.Object); err == nil && exists {

7iferr := s.indexer.Update(d.Object); err != nil {

8returnerr

9}

10s.processor.distribute(updateNotification{ oldObj: old, newObj: d.Object}, isSync)

11} else{

12iferr := s.indexer.Add(d.Object); err != nil {

13returnerr

14}

15s.processor.distribute(addNotification{ newObj: d.Object}, isSync)

16}

那么问题又来了,一个 sync 为 True 的通知,又和别的通知有什么不一样呢?

其实只是把这个通知给塞进了 syncingListeners ( 一个 listener 列表 ) 而已 ( 然后就开始进行事件处理了 ) ,而这个 syncingListeners 是怎么来的呢?其实listeners每隔一段时间,就会把这个listener加进syncingListeners中。这样就实现了listener的重复调用。

不得不说,这个sync的功能还是写的比较晦涩难懂的,希望社区之后能优化这部分的代码。

4.2. ThreadSafeMap

从上一节的描述中,相信大家已经看到了很多次这个 ThradSafeMap ,这个存储的目的是为了实现并发的数据请求。

它的作用是用于缓存之前 DeltaFIFO 里面的数据,并提供 indexer 索引方法,已某种索引的存储结构来存放数据。在我的理解中,DeltaFIFO 是面向事件的,而 ThreadSafeMap 主要是面向数据的。

它的特性有:

4.2.1. 读写锁

同样的,它也是依靠 sync.RWMutex 来实现读写锁。

4.2.2. 自定义索引

在 Lister 调用 List/Get 方法的时候,其实是返回的 indices 里面的数据。而 indices 是根据 indexer 索引方法来得到的。

图片来源:https://www.bbsmax.com/A/pRdBPbbPJn/

4.3. RingGrowing

RingGrowing 是用于最后的事件回调中的,其实就是从 DeltaFIFO 中拿事件,然后触发事件。但是它并没有直接触发事件,然而是多做了一层 sharedProcessor。

RingGrowing 是一个环形数据结构。chan 的缓存也是类似的环形数据结构。甚至可以说 sharedProcessor 里面的 processorListener 基本借鉴了 chan 的实现。

这个 processorListener 就是一个 listener,它一开始就初始化一个大小为 1024 的 RingGrowing 。然后自己带有一个 addCh 和 nextCh ,注意这两个 chan 都是无缓存的 chan,缓存是交给 RingGrowing 的。

所以它的特性就是类似于 chan 的事件处理机制:

1. run 就是从 nextCh 中拿取事件,调用事件处理函数。

2. pop 是就是 addChRingGrowing 中里拿事件,然后判断 nextCh 是否阻塞,如果是则把事件放进 RingGrowing 中。

通过上面关于 DeltaFIFO,ThreadMapStore,RingGrowing的介绍,我们可以看出,List-Watch 在生产者-消费者的模型中,下了很多功夫来解决事件阻塞问题。事实上,在 Controller 端,也就是事件回调函数端,还有自己的事件队列来处理事件,使用的代码是 k8s.io/client-go/util/workqueue ,但是本人认为这部分属于 Controller 自身的处理队列,并不属于 List-Watch,所以在这里并没有细讲。

5. 总结

不得不说 kubernetes 能获得成功,是源于它的很多高明的设计,本人相信 List-Watch 机制就是其中之一。本人也是刚接触 kubernetes 不久,花了一段时间仔细研究了 List-Watch 的实现之后,不得不佩服这种机制。当然这篇文章只是针对于 Controller 到 ApiServer 端进行了一定程度的讲解,即便如此,其实还是有很多没有讲清楚的地方,甚至也可能有讲错的地方,还希望读者能及时指正和交流。

 

 

api探索   K8S API-Server 源码剖析(二)| 探索 API_Kind (sohu.com)

posted @ 2024-07-22 16:37  技术颜良  阅读(1)  评论(0编辑  收藏  举报