在程序中经常需要用到内存缓存,说简单一点就是一个map。这里以k8s中的canche为例看看好的缓存机制是如何设计的以及有哪些需要注意的地方,以后在程序中遇到类似的缓存相关的问题就可以直接拿过来使用。这部分的介绍不需要对k8s的背景知识有任何的了解,但是了解了缓存机制之后再看一些相关的组件比如kube-controller,多少会更有一些更加全局的认识对于k8s自身业务代码的深入理解也会有所帮助,甚至可以按照自己的业务逻辑和场景实现一个定制的controller。
采用的分析k8s的代码版本是v1.6.2,所分析的文件位于.vendor/k8s.io/client-go/tools/cache
,其实具体功能上都是比较好理解的,关键是对于一些细节部分的把握。
threadSafetyStore
顾名思义,这个store就是实现了一个线程安全的strore,直接使用这个store中提供的Add,Update,Delete方法操纵对象,就不需要再自己考虑各种加锁的问题了。
再分析具体实现之前,先简单看下测试文件store_test.go,看下这个cache是如何使用的,threadSafetyStore没有直接暴露出来,它和一个keyFunc组合起来被封装在cache结构体中。
type cache struct { |
具体的cache结构包含两部分,一个是ThreadSafeStore,其中的主要内容是一个map,具体稍后介绍,另一个是KeyFunc,用于将Name转化成为可以进行index的值,出于扩展性的考虑需要将这个函数单独抽象出来。
比如最基本的KeyFunc可以是以下的形式:
type testStoreObject struct { |
初始化的时候可以将threadSafeMap中的index设置为空,直接通过KeyFunc从原始的object得到一个index,就是提取出用于存储的对象的id值将其作为index value。
func NewStore(keyFunc KeyFunc) Store { |
之后具体的Add,Update,Delete,List,Replace等等方法都比较直接,一看就明白了,具体使用的时候如果有不清楚的地方可以根据测试文件中的操作来熟悉相关使用。最简单的情况下就是在生成Store实例的时候把Indexers{}, Indices{}都设置为空实现即可。
threadsafetycache 具体实现
这个cache为了保证执行时候的线程安全,在具体进行添加删除操作的时候都加上了lock。struct中主要包含的内容如下:
type threadSafeMap struct { |
lock与存放items的map在这里都比较好理解,关键是后面两个元素,这两个元素体现了工程性与扩展性。indexers是一个name到IndexFunc的映射,IndexFunc的功能是将一个name变换成一个可以作为index的value,这个函数可以自己定义。另外一个map存储的是name到Index的映射,具体的Index对象又是一个map,其值是一个set,因为一个可以用于index的value可能对应的是多个key,不过实际使用的时候常产跨过这个中间层,一个object通过IndexFunc对应到一个Key元素。
比如在每次Add一个元素的时候都需要添加一个updateIndices的操作,这个操作就是用来更新最后一个indices元素,所有的用于index的函数都保存在这个结构体中,之后每次做更新操作的时候都会用到这些函数。
FIFO
首先明确下FIFO cache的基本功能
- 具备对象的CRUD操作
- 按照FIFO的方式存储数据
- 在cache中的内容发生变化的时候可以调用回调函数
使用队列进行操作的场景都可以通过FIFO cache来进行处理,FIFO cache工作起来就类似于一个队列。
这里采用的基本结构是map+queue,相比于上一种simplecache,这里增加了queue用于存储index,每次进出cache的元素都是从index中进行检索而不是直接从map中进行检索。具体的回调函数的实现通过sync.condition变量进行,这里有一个实际的例子。
补充sync.condition的使用,在只用了broadcaster的方法之后,会依次向所有执行了condition.wait的goroutine发广播,(这些goroutine本质上是阻塞在condition.Wati所在的位置)将它们启动起来。sync.condition.Wait会将当前的goroutine挂起,知道执行了sync.condition.Broadcast方法之后,所有的相关的被挂起的goroutine就会重新被调度。
具体的FIFO的代码仍然参考的k8s中的实现,基本的CRUD就不再赘述,直接参考这里(https://github.com/wangzhezhe/BLG/blob/develop/Components/cache/fifo.go)的源码,这里主要分析下回调操作的实现。
注意pop方法的使用,这个也是唯一多增加的地方,其实就是把queue的头元素拿出来,唯一不容易理解的地方就是sync.condition的使用
func (f *FIFO) Pop(process PopProcessFunc) (interface{}, error) { |
可以看到,在队列中没有元素的时候,就执行f.cond.Wait()方法,将这个goroutine一直挂起,直到新的元素加入了队列中使得条件符合,比如ADD了一个元素进入队列,这个时候就通过cond.Broadcast方法通知所有被cond.Wait挂起的goroutine,最后从队列中取出第一个元素然后执行传入进来的process函数。整个过程被放在一个大的for循环中,以上的逻辑一直中复执行,还有一点要注意的是,每次Pop的逻辑执行完成之后都要将被处理过的元素从当前的队列中删除,相当于说是当前的元素已经被处理过了,可以从队列中移出了,由于存储的key值仅仅是string类型,这里的queue只需要通过[]string来模拟即可。
LRU
在实际程序中最有效的缓存应该算是 LRU (least recently used)类型的缓存了,因为申请的map的空间的大小毕竟是有限的,应该存储哪些在最近一段使用比较频繁的缓存,这样才能提高缓存的效率。这部分代码主要参考的是这个库。LRU cache实现的时候需要用到链表的结构,如果是已经存在的元素就将旧的元素调整到链表的第一个位置,具体的实现思路可以参考FIFO的结构,存储数据还是用map来进行,检索的操作通过List来进行,List中只存储元素的id,而不是完整的信息。
UndeltaStore
原先store的变种,每次状态变之后,比如ADD,DELETE,UPDATE方法被执行之后,就调用PushFunc,对缓存中剩下的所有的元素都执行一次pushfunc,比如对于Add操作:
func (u *UndeltaStore) Add(obj interface{}) error { |
在执行完成之后得到object list然后将其输入到PushFunc中,其他的操作类似。
在k8s的cahce中还提供了delta_FIFO
类型的cache,同时提供了以上介绍的delta功能和类似于Queue一样的FIFO的操作。
Listwatch
list watch到底实现了什么功能?
// ListerWatcher is any object that knows how to perform an initial list and start a watch on a resource. |
其中metav1.ListOptions 包含了对象的一些元信息比如selector,resourceversion,timeout等标记,这些是k8s中用于进行select操作的通用的信息。
lw 的能力包括list以及watch特定的资源。list资源比较容易理解,对于一个catch来说,把其中所有的元素都列出来就是实现了list的功能,那watch具体是什么?首先看一个测试函数中使用的list watch(cache/testing/fake_controller_source.go
):
list方法是模仿 http get 的操作,将fakeobject 缓存中的元素取出来组合成一个list返回,返回的list中,元素的类型是runtime.Object
,这个interface所暴露出来的方法是返回元素注册进来的Kind:
type Object interface { |
其返回值schema.ObjectKind也是一个interface类型,具体有以下能力:
type ObjectKind interface { |
ObjectKind的能力包括:
- 第一个能力就是将传入进来的Kind以及GroupVerionKind参数set到具体的实现对象中。
- 第二个能力是得到当前的object的标识符,具体指的是某个api所在Group,Version以及Kind,从这里也能看出k8s中api对象的层级结构。
list object 本身来说也实现了这个接口。在以前的k8s代码版本中,ObjectKind:
每个api对象在相对应的register文件中都是按照以下的方式实现这个接口
(func *obj) Cluster() GetObjectKind.schema { return &obj.TypeMeta } |
其中的obj.TypeMeta就是每个api对象的元信息,具体包括 Kind 以及 APIVersion信息,他实现了上面所说的ObjectKind接口也就是说实现了SetGroupVersionKind以及GroupVersionKind这两个方法。
watch 功能主要是通过暴露出来的watch interface来体现:
type Interface interface { |
对于watch来说,具体的实现并不像是list那么简单,起真正的底层实现通过interface接口暴露出来。具体的实现细节是在 apimachinery/pkg/watch 中描述,具体来看下watch机制,具体源文件在/apimachinery/pkg/watch
中:
默认的watch的事件类型包括"ADDED" "MODIFIED" "DELETED" "ERROR"
几种。默认的用于消息传递的event结构体:
type Event struct { |
newEmptyWatch函数的功能就是创建一个channel,channel中具体的元素为Event:
type emptyWatch chan Event |
emptywatch对于stop函数的操作直接返回nil,对于ResultChan的操作直接返回chan Event。
fakewatch在empty的基础上实现了一些辅助功能,可以向channel中写入不同的Event数据,具体包括Add,Modified,Delete等等。
RaceFreeFakeWatcher在fakewatch的基础上对每种行为都添加了lock。
本质上来讲最基本的watch就是对channel进行了一个封装,因为其接口暴露出来的两个方法就只有Stop()以及ResultChan() <-chan Event
。
Event mux
再深入一些看下mux.go的实现,这个部分的主要功能是将watch收到的Event分发到注册进来的多个watcher中,主要是实现了notification的一对多的功能。先大致看下broadcaster结构体中所包含的信息:
type Broadcaster struct { |
在实际的系统中应该结合场景,比较细致地考虑watchQueueLength以及fullChannelBehavior,在NewBroadcaster操作的时候,这两个也是关键的输入参数。NewBroadcaster操作的时候会将waitgroup中的数字+1,这个loop标记的是有几个Broadcaster正在处于loop的状态,然开始loop循环:
func NewBroadcaster(queueLength int, fullChannelBehavior FullChannelBehavior) *Broadcaster { |
loop的主要内容就是for循环,从incoming的channel中检测传过来的Event再将Event distribute到注册进来的watcher中,这部分可以说是Broadcaster的核心逻辑了:
func (m *Broadcaster) loop() { |
有一点要注意,放在Event中的内容有可能是正常的起到通知作用的消息,也可能是functionFakeRuntimeObject封装成的Event:
func (obj functionFakeRuntimeObject) GetObjectKind() schema.ObjectKind { |
这里要注意functionFakeRuntimeObject所执行的function具体来说是一个创建并注册Event的操作(具体在后面的Wacher部分介绍)这样操作的原因是确保注册watcher的时间和Event的发布是有序进行的,也就是说watcher在注册了之后确保可以接收到后面的Event,需要确保一个watcher创建并且注册好之后才能distribute新的Event,所以执行distribute操作之前,必须要确保创建并注册watcher的操作全部完成,这里实现的比较巧妙,需要好好体会以下。
再来看下distribute操作的实现:
func (m *Broadcaster) distribute(event Event) { |
利用channel的通信+内存共享机制可以很容易地分析清楚核心逻辑,将event依次写入每个watchers的channel,如果是非阻塞行为的话在select语句中会多一个default:的判断,也就是说当某个watchers的result channel被缓存占满的时候,select操作会直接跳过阻塞的部分去继续执行default相关操作,直接跳过。
另外一个重要的部分就是注册新的Watch的操作,这部分的操作就是注册一个新的broadcasterwatcher到Broadcaster中,当然新注册进来的watcher是无法接收到历史的event信息的。
func (m *Broadcaster) Watch() Interface { |
主要的创建新的broadcasterWatcher以及添加到map的过程是放在一个匿名函数中然后传入m.blockQueue中被执行的。等下再看blockQueue的意义,匿名函数的主要功能是初始化broadcasterWatcher的参数,注意m.nextWatcher在最初的时候才用默认初始值为0,之后每次创建一个broadcasterWatcher就会增加1,这个相当于是broadcasterWatcher的一个index信息,具体的blockQueue的使用原因可以参考前面的loop循环操作的相关介绍。
watch部分还有一个操作是WatchWithPrefix,相比于之前的watcher,唯一的区别就是在创建了watcher之后,首先将参数中的queuedEvents []Event发送给这个watcher然后再将original的events依次发送给watcher。
以上就是watch部分的主要函数和功能,其他的简单的功能相关的函数就不赘述了,总之主要的流程就是从上游的incoming channel中取数据,然后按照固定的格式不断地分发给下游的watcher。
回到最初的FakeControllerSource实现List Watch的操作,List操作已经介绍过,就是将缓存中的元素copy出来返回。根据resourceversion与Event队列中元素的长度差别,决定才用WatchWithPrefix的操作(distribute Event操作事前,先将queuedEvents中所有的Event输入到这个watcher的channel中)或者是普通的Watch操作,直接注册Event。
reflector cache用到了watch package的内容
Reflector
reflector类型的cache的作用是watch特定类型的资源,并且将被watch对象的变化情况对应到自身所保持的store中,相当于是做了一层映射。在发现被watch对象发生变化的时候还可以执行一个函数。
具体的结构体如下:
type Reflector struct { |
如果expectedType不为空,reflector只存放expectedType所指定的类型的变化,如果为空,则所有watch到的元素的变化都会被放入store中。主要的函数包括:
func (r *Reflector) Run() { |
wait.Until是k8s实现的一些中间操作,用于再r.period时间周期内执行传如的func,这里的func主要的操作就是ListAndWatch。主要的逻辑就是这个ListAndWatch操作,这个函数比较长,这里仅仅列下函数中的主要逻辑:
采用options := metav1.ListOptions{ResourceVersion: “0”}通过r.listerWatcher.List列出当前的所有对象,具体参考ResourceVersion的注释,之后从list中提取出resourceversion并更新reflector实例中的相关参数。之后启动一个goroutine负责select各种停止的channel。
在一个循环中执行watch操作,对可能得到的error进行各种处理,然后通过watchHandler函数对得到的watch channel进行处理。
具体来看下watchHandler的逻辑,它主要做了两个事情:判断watch的返回channel中接受到的object的类型是不是期望的类型,以及相关的错误处理,之后得到最新的resourceversion。然后分别判断Event的类型,是ADD,Modified,或是Delete,之后分别对不同的操作进行响应,在相关联的底层store中ADD,Modified或者Delete相对应的Object。 (watch 操作时候用于消息通知的Event结构体中就只有两个部分,EventType以及runtime.Object)
更新resourceVersion并且做一些错误处理,比如watch channel异常关闭。
才用reflector+deltaStore可以实现一旦store中的信息变化就执行指定函数的操作。
Controller
controller类型的cache首先抽象出了一个config用于初始化必要的信息,注释中对于每个参数的解释已经非常清晰了:
// Config contains all the settings for a Controller. |
最基本的生成controller的方法:
func New(c *Config) Controller { |
按照之前的套路重点看下Run方法:
func (c *controller) Run(stopCh <-chan struct{}) { |
创建reflector之后每隔一秒钟调用processLoop方法,注意在创建reflector的时候第三个参数,也就是reflector的底层store就是这个Queue。
func (c *controller) processLoop() { |
processLoop就是不断循环从Queue中pop出object然后传入PopProcessFunc中,具体的Pop的实现细节可以看之前的DeltaFIFO的cache。
Informer
informer在本质上来讲是将一个reflector以及一个DeltaFIFO组装起来,对上listwatch到source information的变化然后同步到reflector中,其中reflector的cache类型使用的是DeltaFIFO的类型,对下通过PushFunc将变化类型再解析出来调用提前注册好的不同类型(Add,Update,Delete,Modify)的handler函数。
理解了informer 之后再看k8s的 controller 以及 event 机制就会有豁然开朗的感觉。sharedinformer的主要功能是从list watch上游接口中获取信息,一旦发现上游数据变化就调用对应的函数进行处理。这些函数主要包括:
type ResourceEventHandler interface { |
这个接口中的方法可以通过以下结构体来实现:
type ResourceEventHandlerFuncs struct { |
这个结构体实现了OnAdd,OnUpdate以及OnDelete方法,其实就是在这些方法中调用对应注册进来的AddFunc,UpdateFunc以及DeleteFunc方法。
来看下informer struct中具体包含的内容(基本的informer与controller在一个文件中):
func NewInformer( |
主要就是把对应的参数填入controller的config,之后生成新的controller,之后controller Run的时候,每次从队列中Pop一个元素出来,所执行的Process函数就是这里注册进来的。这个函数的功能主要是根据对象中的操作类型(这个Delta的结构于之前的Event比较类似)进行具体的细分,分别调用OnAdd,OnUpdate以及OnDelete方法。
相关参考
golang sync condition
http://www.liguosong.com/2014/05/07/golang-sync-cond/
类似的介绍controller原理的文章
http://borismattijssen.github.io/articles/kubernetes-informers-controllers-reflectors-stores