Informer原理详解
原文博客:https://nosae.top
基于client-go@v0.31.13
informer介绍
informer是k8s客户端库提供的一个组件,用于 资源监听+缓存,用于高效感知k8s集群中的资源变化。
实际上它就是构建controller的基础。比如我们用kubebuilder搭好一个CRD的脚手架后,我们只需要去实现Reconcile方法进行资源的调谐,不需要Reconcile什么时候会被调用,底层其实是informer监听到资源变化后会自动回调Reconcile,即:
- Informer:负责监听和缓存资源变化
- Controller:负责消费这些变化,比如执行 Reconcile(调谐逻辑)
当然,informer可以单独拿出来用,它并不与controller强绑定,只是informer+controller是最常用的方法,下面我也会按照informer+controller的方式进行详解。
informer架构
上半部分是client-go库内部实现informer的相关组件,下半部分是用户自定义controller
介绍一下图中的核心组件:
- Reflector:负责从apiserver获取资源的全量数据(list)并持续监听增量变化(watch)
- DeltaFIFO:先进先出的队列,队列元素就是资源对象的历史变更事件
- Indexer:存放全量的资源对象,并提供索引用于快速访问对象
以及用户代码部分的组件:
- ResourceEventHandler:用于当事件发生时的处理回调函数,由informer进行调用。该回调函数的实现一般是拿到对象的key然后放进workqueue中等待下一步的处理
- WorkQueue:用于解耦对象(从informer)的接收以及处理
- ProcessItem:用于处理对象的函数,一般会持有Indexer的引用,通过key快速查询对象
示例代码
talk is cheap,我们来看下如何使用informer实现一个最简单的,只是简单打印一下资源对象增删改事件的程序,图中大部分组件都涉及。省略了WorkerQueue、ProcessItem等,因为这些不是本文的重点。
package main
import (
"context"
"flag"
"fmt"
"path/filepath"
"time"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/cache"
"k8s.io/client-go/tools/clientcmd"
)
func main() {
// 1. 加载 kubeconfig
kubeconfig := filepath.Join(
homeDir(), ".kube", "config",
)
config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
if err != nil {
panic(err)
}
// 2. 创建 clientset,reflector会用它来进行listAndWatch
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
panic(err)
}
// 3. 创建工厂对象 SharedInformerFactory,用于创建informer以及统一启动所有informer
factory := informers.NewSharedInformerFactory(clientset, 30*time.Second)
// 4. 获取 Pod 的 Informer
podInformer := factory.Core().V1().Pods().Informer()
// 5. 注册 ResourceEventHandler
podInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
pod := obj.(*v1.Pod)
fmt.Printf("[ADD] Pod %s/%s\n", pod.Namespace, pod.Name)
},
UpdateFunc: func(oldObj, newObj interface{}) {
oldPod := oldObj.(*v1.Pod)
newPod := newObj.(*v1.Pod)
fmt.Printf("[UPDATE] Pod %s/%s -> Phase: %s -> %s\n",
newPod.Namespace, newPod.Name,
oldPod.Status.Phase, newPod.Status.Phase)
},
DeleteFunc: func(obj interface{}) {
pod := obj.(*v1.Pod)
fmt.Printf("[DELETE] Pod %s/%s\n", pod.Namespace, pod.Name)
},
})
// 6. 启动所有通过这个工厂创建的 informer
stopCh := make(chan struct{})
factory.Start(stopCh)
// 7. 等待同步完成
factory.WaitForCacheSync(stopCh)
fmt.Println("Pod informer started, listening...")
<-stopCh
}
// 获取 home 目录
func homeDir() string {
if h := filepath.Clean("~"); h != "" {
return h
}
return "/root"
}
Reflector
Reflector负责保持本地store(DeltaFIFO)与API Server中的资源内容同步。
Reflector构造函数如下,创建一个Reflector必须指定一个ListerWatcher以及要监听的资源类型,由此可以看出,一个reflector只监听一种资源类型(比如只监听pod)
func NewReflectorWithOptions(lw ListerWatcher, expectedType interface{}, store Store, options ReflectorOptions) *Reflector
ListerWatcher是将Lister和Watcher两个接口合并成同一个接口(类似ReaderWriter那样),提供List和Watch的能力,具体实现是cache.ListWatch:
type ListWatch struct {
ListFunc ListFunc
WatchFunc WatchFunc
}
func (lw *ListWatch) List(options metav1.ListOptions) (runtime.Object, error) {
return lw.ListFunc(options)
}
func (lw *ListWatch) Watch(options metav1.ListOptions) (watch.Interface, error) {
return lw.WatchFunc(options)
}
具体的实现交给了ListFunc和WatchFunc,下面看看pod的list和watch:
// client就是ClientSet
func NewFilteredPodInformer(client kubernetes.Interface, /* ... */) cache.SharedIndexInformer {
return cache.NewSharedIndexInformer(
&cache.ListWatch{
// List
ListFunc: func(options metav1.ListOptions) (runtime.Object, error) {
return client.CoreV1().Pods(namespace).List(context.TODO(), options)
},
// Watch
WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) {
return client.CoreV1().Pods(namespace).Watch(context.TODO(), options)
},
},
// ...
)
}
由此可以看到,informer其实是对client提供的List和Watch能力的进一步抽象与封装:
Reflector通过Run方法运行,它会调用ListAndWatch处理具体逻辑。传统的ListWatch是通过chunking方式list完成后关闭连接,再开新的watch长连接:
List(获取快照) → Watch(监听变化)
而现在informer使用同一个watch连接,先发全量数据,继续使用同一条连接获取增量数据:
Watch(带 SendInitialEvents) → 收到所有 Added → Bookmark → 继续 Watch
相比传统方式,使用同一条连接进行list+watch的好处在于这样可以降低apiserver的压力,详情可见proposal。
// ListWatch方法会获取全量资源对象以及持续监听后续变更
func (r *Reflector) ListAndWatch(stopCh <-chan struct{}) error {
klog.V(3).Infof("Listing and watching %v from %s", r.typeDescription, r.name)
var err error
var w watch.Interface
useWatchList := ptr.Deref(r.UseWatchList, false)
fallbackToList := !useWatchList
// stream方式list
if useWatchList {
w, err = r.watchList(stopCh)
if err != nil {
klog.Warningf("The watchlist request ended with an error, falling back to the standard LIST/WATCH semantics because making progress is better than deadlocking, err = %v", err)
fallbackToList = true
w = nil
}
}
// chunking方式list(fallback行为)
if fallbackToList {
err = r.list(stopCh)
if err != nil {
return err
}
}
// watch
return r.watchWithResync(w, stopCh)
}
下面把两种方式都介绍下,做个对比
流式list
func (r *Reflector) watchList(stopCh <-chan struct{}) (watch.Interface, error) {
// ...
for {
// ...
// 最后一次观察到的最大RV,该RV作为List和Watch的分界点,即小于该RV的对象属于全量数据,后续的对象都属于增量数据。初始时还没有观察到任何数据,因此RV="",表示使用当前集群最大RV
lastKnownRV := r.rewatchResourceVersion()
// 临时存储接收到的全量
temporaryStore = NewStore(DeletionHandlingMetaNamespaceKeyFunc)
options := metav1.ListOptions{
ResourceVersion: lastKnownRV,
AllowWatchBookmarks: true,
// 为了以watch的方式进行list,使用该参数指定watch在发送增量数据之前还要发送当前的全量数据,并且这些全量数据以Added事件表示
SendInitialEvents: pointer.Bool(true),
ResourceVersionMatch: metav1.ResourceVersionMatchNotOlderThan,
TimeoutSeconds: &timeoutSeconds,
}
// 开始流式list
w, err = r.listerWatcher.Watch(options)
// ...
// 处理接收的到的数据,并存入temporaryStore中
watchListBookmarkReceived, err := handleListWatch(start, w, temporaryStore, r.expectedType, r.expectedGVK, r.name, r.typeDescription,
func(rv string) { resourceVersion = rv },
r.clock, make(chan error), stopCh)
// ...
// 收到了Bookmark事件,意味着list已完成,否则进行下一次循环重试
if watchListBookmarkReceived {
break
}
}
// 将store整体替换成接收到的全量数据
if err := r.store.Replace(temporaryStore.List(), resourceVersion); err != nil {
return nil, fmt.Errorf("unable to sync watch-list result: %w", err)
}
// 更新观察到的最大的RV
r.setLastSyncResourceVersion(resourceVersion)
// 返回w,后续将使用同一个w进行watch
return w, nil
}
流式list的处理核心点在于对bookmark的识别。
代码省略了一些err处理,当出现err或者本次循环没有收到Bookmark(watchListBookmarkReceived=false),将会在下一次循环进行重试。
分块list
不同于watch的持续监听事件流,list的行为是发一次请求,就返回一次数据
func (r *Reflector) list(stopCh <-chan struct{}) error {
// ...
go func() {
// 使用pager进行分页读取数据
pager := pager.New(pager.SimplePageFunc(func(opts metav1.ListOptions) (runtime.Object, error) {
return r.listerWatcher.List(opts)
}))
// 决定是否分页、分页的大小
switch {
case r.WatchListPageSize != 0:
pager.PageSize = r.WatchListPageSize
case r.paginatedResult:
case options.ResourceVersion != "" && options.ResourceVersion != "0":
pager.PageSize = 0
}
// 开始分块list
list, paginatedResult, err = pager.ListWithAlloc(context.Background(), options)
// ...
close(listCh)
}()
select {
// ...
case <-listCh:
}
// list已结束
// 此后的list调用一直启用分页(详解见下面第3点)
if options.ResourceVersion == "0" && paginatedResult {
r.paginatedResult = true
}
//...
// 将store整体替换成接收到的全量数据
if err := r.syncWith(items, resourceVersion); err != nil {
return fmt.Errorf("unable to sync list result: %v", err)
}
// ...
}
关于分页这块可以注意一下:
- 目前没有开放的API让用户设置分页大小,因此分页大小使用默认的大小500(每次最多返回500个对象)
- 不一定会进行分页,即可能会一次性返回所有对象。原因与设置的参数/API协议有关,不过多深究,想了解的话可以看3
- (TL;DR)首次list时将会使用RV="0",在数据一致性方面,这意味这返回的数据是非强一致性的,可能是旧一点的数据,但读取效率比较高(CAP权衡中选择了A);在数据源方面,将会优先从apiserver的watch cache中读取,watch cache会忽略分页,一次性返回所有数据,但如果watch cache没有启用,将会从etcd的follower或者leader读取。首次list结束之后,如果发现请求的RV="0"并且数据是以分页的方式返回的,说明watch cache没有启用,并且返回的对象比较多,已经超过了一页,那么将r.paginatedResult设置为true,以后的每次list都会使用分页的方式去拉数据。当RV等于某个精确值时,将始终从watch cache中拉数据,此时禁止分页。当RV=""时,将从etcd拉数据,此时需要使用分页。
分块list的处理核心点在于是否分页以及分页大小
watch
watchList或list完了之后,就开始持续不断地watch了。在watch的同时,还会周期性地执行resync。resync用于将本地缓存的对象,以Sync事件重新放到DeltaFIFO中,随后会回调OnUpdate通知上层handler进行处理。至于为什么要resync,可以参考这个问答,以及这篇博客。
// watchWithResync runs watch with startResync in the background.
func (r *Reflector) watchWithResync(w watch.Interface, stopCh <-chan struct{}) error {
resyncerrc := make(chan error, 1)
cancelCh := make(chan struct{})
defer close(cancelCh)
// 定时resync
go r.startResync(stopCh, cancelCh, resyncerrc)
// watch
return r.watch(w, stopCh, resyncerrc)
}
resync不是我们的重点,来看下watch:
// watch simply starts a watch request with the server.
func (r *Reflector) watch(w watch.Interface, stopCh <-chan struct{}, resyncerrc chan error) error {
// ...
for {
// ...
// w==nil说明之前可能用的是分块list,主动发起Watch即可
if w == nil {
timeoutSeconds := int64(r.minWatchTimeout.Seconds() * (rand.Float64() + 1.0))
options := metav1.ListOptions{
ResourceVersion: r.LastSyncResourceVersion(),
TimeoutSeconds: &timeoutSeconds,
AllowWatchBookmarks: true,
}
w, err = r.listerWatcher.Watch(options)
// ...
}
// 持续watch,处理接收到的事件并直接存入store中
err = handleWatch(start, w, r.store, r.expectedType, r.expectedGVK, r.name, r.typeDescription, r.setLastSyncResourceVersion,
r.clock, resyncerrc, stopCh)
// ...
// 错误处理
// 可能会进行下一轮循环重新建立Watch,或者退出
}
}
controller
这里再放一张架构图来补充之前那张架构图没有体现的 controller 的概念。这个controller并不是指用户自定义controller,这里的controller作用是驱动reflector、deltafifo、indexer三者协同运行。对照上方的架构图来说,这里的controller仍然属于上方client-go的那层。
controller.go文件第一行注释就说明了controller的作用:
// This file implements a low-level controller that is used in
// sharedIndexInformer, which is an implementation of
// SharedIndexInformer. Such informers, in turn, are key components
// in the high level controllers that form the backbone of the
// Kubernetes control plane.
controller逻辑很简单,就是启动reflector以及运行processLoop方法。之前介绍过,reflector会将监听到的数据送入DeltaFIFO。那么processLoop就是不断从DeltaFIFO取出事件并处理:
func (c *controller) Run(stopCh <-chan struct{}) {
// 创建reflector
r := NewReflectorWithOptions(/*...*/)
r.WatchListPageSize = c.config.WatchListPageSize
// ...
// 启动reflector
wg.StartWithChannel(stopCh, r.Run)
// 启动processLoop
wait.Until(c.processLoop, time.Second, stopCh)
wg.Wait()
}
func (c *controller) processLoop() {
for {
// 不断从DeltaFIFO中pop事件并处理
obj, err := c.config.Queue.Pop(PopProcessFunc(c.config.Process))
// ...
}
}
处理事件的Process函数是sharedIndexInformer的HandleDeltas方法,后面会说。
DeltaFIFO
DeltaFIFO顾名思义是一个先进先出队列,队列元素是Deltas,一个Delta是一个对象事件:
type Deltas []Delta
type Delta struct {
Type DeltaType // 在对象上发生的事件,比如新增、删除等
Object interface{} // 对象
}
因此每次从队列pop元素时,是pop对象的在这段时间内发生的所有事件,用张图举个例子:

按照时间序,在两个对象上发生了四个事件,最终pop的时候是以对象为粒度进行pop,即先将对象1的两个事件作为整体pop,再到对象2。
DeltaFIFO的代码部分就没必要细看了,知道它在Pop元素的时候会处理被pop的元素即可,Pop简化一下大概如下:
func (f *DeltaFIFO) Pop(process PopProcessFunc) (interface{}, error) {
f.lock.Lock()
defer f.lock.Unlock()
for {
// 弹出队首元素(Deltas)
id := f.queue[0]
f.queue = f.queue[1:]
item, ok := f.items[id]
delete(f.items, id)
// 进行处理
err := process(item, isInInitialList)
// 将元素的所有权移交给外界
return item, err
}
}
Indexer
Indexer顾名思义是用来做索引的,索引什么呢?索引对象的。比如要获取namespace="default"的所有的pod,用Indexer就能快速得到结果。这个过程有点像倒排索引,即搜索属性为给定值的所有资源,比如namespace就是pod的属性。因此,索引的结构大概是这样的:
{
// 属性名称
"namespace": {
// 属性值 对应的资源
"default": ["pod1", "pod2"],
"kube-system": ["pod3"],
},
"nodeName": {
"node-1": ["pod1", "pod3"],
"node-2": ["pod2"],
},
}
实际上Indexer的本质还是一个存储了所有对象的本地缓存,只不过在这之上提供了索引功能。
(TL;DR: 并且Indexer只是一个接口,它在Store接口基础上提供了索引的相关方法。Indexer的实现是cache,cache又依赖于threadSafeMap提供索引功能。另外,DeltaFIFO所实现的Queue接口其实也是Store...)
说到这里我觉得可以捋一下这些本地存储相关的各个接口的关系,因为看起来还挺乱的。不过具体实现的代码不会去精读,自己扫一眼即可。
总结
本文只介绍了informer,利用informer我们可以实时监听资源对象上的增删改事件。而WorkQueue以及后续的处理,就是用户控制器的事情了。
用operator那一套来举例:平时基于opeartor那一套去进行开发用户控制器进行集群资源调谐的时候,一般用kubebuilder去生成一些资源对象的深拷贝代码、Reconciler的脚手架代码等,虽然这些代码看起来并没涉及informer、indexer那些东西,但运行起来,当集群资源对象发生变更时,确实会及时回调我们的Reconcile调谐方法,在感知上应该是有informer在运行的。没错,实际上,kubebuilder生成的代码里是用了controller-runtime这个库,这个库替开发者封装了informer、workqueue、processitem这些东西,因此开发者只需要定义一个Reconciler,将其注册到controller-runtime这个库中,就OK了(这些kubebuilder生成的代码中也已经做好了,用户只需要编写Reconcile方法,实现自己的业务逻辑)。controller-runtime这个库是k8s的sigs小组开发的,是对client-go进一步封装,让开发者更方便地去开发用户控制器。
参考
https://isekiro.com/categories/client-go/
https://github.com/kubernetes/sample-controller/blob/master/docs/controller-client-go.md
https://herbguo.gitbook.io/client-go/informer
https://github.com/cloudnativeto/sig-kubernetes/issues/11
https://gobomb.github.io/post/whats-resync-in-informer/
https://kubernetes.io/zh-cn/docs/reference/using-api/api-concepts/#resource-versions