除了MySQL,大牛DBA还会啥?
写在前面:想要流畅阅读本文,需要读者——对K8s的架构有简单了解,理解API Server扮演的角色;具有阅读简单golang源码的能力,包括函数/类方法定义、变量声明等。
如何理解Controller
先引用一段官方的解释:
当你设置了温度,告诉了温度自动调节器你的期望状态(Desired State)。房间的实际温度是当前状态(Current State)。通过对设备的开关控制,温度自动调节器让其当前状态接近期望状态。
控制器通过 apiserver 监控集群的公共状态,并致力于将当前状态转变为期望的状态。
上面这段话其实比较形象地说明了Controller的核心工作,就是使得集群的当前状态符合我们所输入的期望状态。比如我们需要这个集群拥有3个节点,那么就有一个Node Controller来帮我们实现这个“期望”,拉起并按照我们所设置的规则部署这3个节点。
这里需要注意的是,我们所谓的Controller,其实更应该说是Controllers。因为并不是说全局只有一个Controller大包大揽,处理完成目标任务的所有逻辑,而是由许许多多个Controller分工合作、各司其职,它们分别只关心自己感兴趣的资源,只有当它们感兴趣的资源发生了变化(添加/更新/删除)时,它们才会执行自己的业务逻辑。就好比如,一个湿度控制器只关心用户输入的期望湿度并将房间调整到这个湿度,而一个温度控制器只关心输入的期望温度并去调整室内温度。
因此,一个最原始的Controller实现可以用下面的伪代码表示:
for {
desired := getDesiredState()
current := getCurrentState()
makeChanges(desired, current)
}
关于Controller工作机制的组成,可以宏观地理解为一个Controller主要由Informer/SharedInformer与Workqueue两部分组成,一个整体的工作流程可以概括为:Informer/SharedInformer监听目标资源(Resource)的变化,并在出现变化时响应事件、发送事件给Workqueue,再由工作线程(Worker)从Workqueue取出事件并执行业务逻辑。下面我将结合源码,对Controller的工作机制进行剖析,除非特殊说明,下面出现的代码均来自K8s的client-go库——一个用于编写与K8s集群交互的客户端(client)的golang库;而我要介绍的Controller,本质上就是一个客户端,通过REST请求与API Server的反复交互最终达成完成的“控制”任务。
Informer
如前面所述,Informer的职责是监听目标资源的变化,并在出现变化时发送事件给Workqueue,将剩下的工作交给执行业务逻辑的工作线程。这也正如其名——Informer(通知者),扮演的是一个“打报告”的角色。
那么一个Informer是如何工作的?这一点我们可以一窥Informer的创建函数来作个初步认识(关键处有注释标记,将在下文展开介绍)。
// K8s.io/client-go/tools/cache/controller.go
// NewInformer returns a Store and a controller for populating the store
// while also providing event notifications. You should only used the returned
// Store for Get/List operations; Add/Modify/Deletes will cause the event
// notifications to be faulty.
//
// Parameters:
// * lw is list and watch functions for the source of the resource you want to
// be informed of.
// * objType is an object of the type that you expect to receive.
// * resyncPeriod: if non-zero, will re-list this often (you will get OnUpdate
// calls, even if nothing changed). Otherwise, re-list will be delayed as
// long as possible (until the upstream source closes the watch or times out,
// or you stop the controller).
// * h is the object you want notifications sent to.
//
func NewInformer(
lw ListerWatcher, // 2.1
objType runtime.Object, // 2.2
resyncPeriod time.Duration, // 2.3
h ResourceEventHandler, // 2.4
) (Store, Controller) {
// This will hold the client state, as we know it.
clientState := NewStore(DeletionHandlingMetaNamespaceKeyFunc) // 2.5
// This will hold incoming changes. Note how we pass clientState in as a
// KeyLister, that way resync operations will result in the correct set
// of update/delete deltas.
fifo := NewDeltaFIFO(MetaNamespaceKeyFunc, clientState)
cfg := &Config{
Queue: fifo,
ListerWatcher: lw,
ObjectType: objType,
FullResyncPeriod: resyncPeriod,
RetryOnError: false,
Process: func(obj interface{}) error {
// from oldest to newest
for _, d := range obj.(Deltas) {
switch d.Type {
case Sync, Added, Updated:
if old, exists, err := clientState.Get(d.Object); err == nil && exists {
if err := clientState.Update(d.Object); err != nil {
return err
}
h.OnUpdate(old, d.Object)
} else {
if err := clientState.Add(d.Object); err != nil {
return err
}
h.OnAdd(d.Object)
}
case Deleted:
if err := clientState.Delete(d.Object); err != nil {
return err
}
h.OnDelete(d.Object)
}
}
return nil
},
}
return clientState, New(cfg)
}
一、ListWatcher
在Informer的工作中,监听目标资源变化的任务由ListWatcher来完成,定义一个ListWatcher,实际上就是定义两个函数——List和Watch:
// ListerWatcher is any object that knows how to perform an initial list and start a watch on a resource.
type ListerWatcher interface {
// List should return a list type object; the Items field will be extracted, and the
// ResourceVersion field will be used to start the watch in the right place.
List(options metav1.ListOptions) (runtime.Object, error)
// Watch should begin a watch at the specified version.
Watch(options metav1.ListOptions) (watch.Interface, error)
}
List所做的,就是向API Server发送一个http短链接请求,罗列所有目标资源的对象。而Watch所做的是实际的“监听”工作,通过http长链接的方式,其与API Server能够建立一个持久的监听关系,当目标资源发生了变化时,API Server会返回一个对应的事件,从而完成一次成功的监听,之后的事情便交给后面的handler来做。
这样一个List & Watch机制,带来了如下几个优势:
-
事件响应的实时性:通过Watch的调用,当API Server中的目标资源产生变化时,能够及时的收到事件的返回,从而保证了事件响应的实时性。而倘若是一个轮询的机制,其实时性将受限于轮询的时间间隔。
-
事件响应的可靠性:倘若仅调用Watch,则如果在某个时间点连接被断开,就可能导致事件被丢失。List的调用带来了查询资源期望状态的能力,客户端通过期望状态与实际状态的对比,可以纠正状态的不一致。二者结合保证了事件响应的可靠性。
-
高性能:倘若仅周期性地调用List,轮询地获取资源的期望状态并在与当前状态不一致时执行更新,自然也可以do the job。但是高频的轮询会大大增加API Server的负担,低频的轮询也会影响事件响应的实时性。Watch这一异步消息机制的结合,在保证了实时性的基础上也减少了API Server的负担,保证了高性能。
-
事件处理的顺序性:我们知道,每个资源对象都有一个递增的ResourceVersion,唯一地标识它当前的状态是“第几个版本”,每当这个资源内容发生变化时,对应产生的事件的ResourceVersion也会相应增加。在并发场景下,K8s可能获得同一资源的多个事件,由于K8s只关心资源的最终状态,因此只需要确保执行事件的ResourceVersion是最新的,即可确保事件处理的顺序性。
二、目标资源
传入的objType形参,代表这个Informer所关心并且监听的目标资源类型。
三、ResyncPeriod
Informer调用List进行轮询的时间间隔。当其不为0时,即便没有更新事件发生,List也会每隔一段时间被调用以获得最新的资源对象状态,从而获得更高一级的可靠性。
四、ResourceEventHandler
当经过List & Watch得到事件时,接下来的实际响应工作就交由ResourceEventHandler来进行,这个Interface定义如下:
type ResourceEventHandler interface {
OnAdd(obj interface{})
OnUpdate(oldObj, newObj interface{})
OnDelete(obj interface{})
}
当事件到来时,Informer根据事件的类型(添加/更新/删除资源对象)进行判断,将事件分发给绑定的EventHandler,即分别调用对应的handle方法(OnAdd/OnUpdate/OnDelete),最后EventHandler将事件发送给Workqueue。
五、Local Store
如果K8s每次想查看资源对象的状态,都要经历一遍List调用,显然对API Server也是一个不小的负担,对此,一个容易想到的方法是使用一个cache作保存,需要获取资源状态时直接调cache,当事件来临时除了响应事件外,也对cache进行刷新。
六、SharedInformer
Informer通过Local Store缓存目标资源对象,且仅为自己所用。但是在K8s中,一个Controller可以关心不止一种资源,使得多个Controller所关心的资源彼此会存在交集。如果几个Controller都用自己的Informer来缓存同一个目标资源,显然会导致不小的空间开销,因此K8s引入了SharedInformer来解决这个问题。
SharedInformer拥有为多个Controller提供一个共享cache的能力,从而避免资源缓存的重复、减小空间开销。除此之外,一个SharedInformer对一种资源只建立一个与API Server的Watch监听,且能够将监听得到的事件分发给下游所有感兴趣的Controller,这也显著地减少了API Server的负载压力。实际上,K8s中广泛使用的都是SharedInformer,Informer则出场甚少。
有了上面各个部分的解释,我们再将其串起来,就可以得到一个Informer/SharedInformer工作的整体步骤:
-
通过List & Watch机制监听目标资源的变化(即,监听一个事件)。
-
事件到来时,将目标资源的新(期望)状态存入cache,并分发给绑定的EventHandler;如果是SharedInformer,可能绑定来自多个Controller的多个EventHandler,此时事件会被分发给所有EventHandler。
-
EventHandler得到事件后,将事件发送到Workqueue。
关于Informer/SharedInformer的工作原理,上面作了一个整体的介绍,但也省略了不少的具体实现细节。看过其他K8s文章的同学们可能还听过Reflector, DeltaFIFO, ProcessListener等概念,这些都是Informer/SharedInformer在实现“监听目标资源变化,响应事件”这一目标所引入的实现细节,本文由于篇幅所限,不对这些细节做详细阐述。想要了解这些实现细节,可以参阅我在学习过程中找到的一篇很不错的博文,给予了我不小的帮助。
Worlqueue
EventHandler向Workqueue加入事件的操作非常简单粗暴,仅仅是放入一个key,这个key的内容是资源对象的命名空间与资源名的组合,即<resource_namespace>/<resource_name>,唯一地标识了一个资源对象。而Controller处理被加入Workqueue中的事件的方式,就是从Workqueue中pop出一个key,根据这个key获取到这个资源的期望状态——从哪里获取呢?自然是从Informer/SharedInformer保存的cache里获取!Controller剩下的工作,就是执行自己的一套业务逻辑,让集群的实际状态达到这个期望状态。
Workqueue中的一个key的生命周期如下图所示:
在Controller执行业务逻辑、处理事件的过程中,也许会由于某些原因遇到失败,这时这个事件并不会被直接丢弃,Controller会调用AddRateLimited()方法,使得这个key过一段时间后重新被推进Workqueue,再由Controller取出、进行retry,直到retry的次数达到上限才被丢弃。当事件执行成功后,只需要将这个key从Workqueue中移除即可:Forget()方法首先被调用,但注意此时key并没有从Workqueue中被移除,因为Forget()只是将这个key在“retry次数表”中的记录删除;而真正将key从Workqueue中移除,是通过Done()方法的调用。
关于Workqueue,还有一个值得一提的设计点。我们先引入一个问题:
一、一个问题
假如用户传入了一个新期望状态,想把一个资源的副本数从1变成2。经过我们上面所介绍的流程,最后轮到Controller从Workqueue中取出这个资源的key,进行事件的处理——就在处理的过程中,用户紧跟着又传入了一个新的期望状态,想把该资源的副本数变成3。这时候,K8s该怎么办?
有同学可能会说,那简单啊,再推入一个同样的key到Workqueue里,Controller再次取出进行处理不就行了。但若是这样做,将会带来两个问题:
(1)Controller在处理事件的过程中是并行的,有许多个Worker线程不断从Workqueue中取事件并处理。在上面的问题背景中,如果这个相同的key被推进Workqueue,可能马上就有一个空闲的Worker线程取出并处理该事件。那么同一时刻,就会有两个Worker线程在工作:一个在尝试把资源的副本数变成2,而另一个在尝试把资源的副本数变成3——这显然是一个非常糟糕的情况。
(2)即便我们假设只有一个Worker线程来处理事件,不会有并行处理带来的竞争。那么简单把同一个key推入Workqueue,Controller在处理完当前的事件(把1变成2)之后再取出该key,再执行一次业务逻辑,把2变成3,听起来没什么问题。但是,假如用户在短短的时间内,陆陆续续地又传入了1000个新期望状态(把3变成4,把4变成5…),这时Controller再反反复复地取出、处理,合理吗?显然不合理,因为K8s中我们只关注“最终状态”,只执行一次业务逻辑把1变成1000,和不断执行1000次业务逻辑把1变成1000,结果上是一样的,但效率上却是一个天上一个地下。
也正是因此,K8s中的Workqueue,并不是说只是一个FIFO队列这么简单。
二、Workqueue的设计
那么K8s是怎么设计Workqueue的呢?
在Workqueue中,有三个主要部分:一个主queue用于给Controller取出key,一个dirty set用于存放“脏key”,以及一个processing set用于存放“正在处理的key”。
要理解Workqueue的工作过程,我们可以分别查看Workqueue的Add(), Get(), Done()方法,理解入key、取key、弹出key这三个操作中,key如何在上面这三个部分间转换。
首先是Add()方法:
// Add marks item as needing processing.
func (q *Type) Add(item interface{}) {
q.cond.L.Lock()
defer q.cond.L.Unlock()
if q.shuttingDown {
return
}
if q.dirty.has(item) { // 如果dirty set中有该key,返回
return
}
q.metrics.add(item)
q.dirty.insert(item) // 将key插入dirty set
if q.processing.has(item) { // 如果processing set中有该key,返回
return
}
q.queue = append(q.queue, item) // 将key放入主queue
q.cond.Signal()
}
代码比较清晰易懂,我们可以从上面总结Add()主要的逻辑:
(1)如果dirty set中有同一个key,则直接返回,因为这个key已经被标记为“脏”。
(2)如果dirty set中没有该key,先将该key插入dirty set中。
(3)如果processing set中有同一个key,说明Controller正在处理一个对应这key的事件,由于上面已经将该key标记为“脏”,此时可以直接返回。
(4)如果dirty set和processing set中都没有该key,则可以将该key放入主queue中,待Controller取出并处理事件。
然后是Get()方法:
// Get blocks until it can return an item to be processed. If shutdown = true,
// the caller should end their goroutine. You must call Done with item when you
// have finished processing it.
func (q *Type) Get() (item interface{}, shutdown bool) {
q.cond.L.Lock()
defer q.cond.L.Unlock()
for len(q.queue) == 0 && !q.shuttingDown {
// 主queue为空,阻塞等待事件到来
q.cond.Wait()
}
if len(q.queue) == 0 {
// We must be shutting down.
return nil, true
}
item, q.queue = q.queue[0], q.queue[1:] // 弹出主queue中最前的key
q.metrics.get(item)
q.processing.insert(item) // 将取得的key插入processing set中
q.dirty.delete(item) // 由于已经开始处理事件,key不再是“脏”的,从dirty set中删除key
return item, false // 返回从主queue中弹出的key
}
可以看到取一个key的操作会将该key从主queue中弹出,并加入processing set中表示正在处理该key的事件,同时抹掉该key的“脏”标记,因为Controller已经开始处理了。
最后是Done()方法:
// Done marks item as done processing, and if it has been marked as dirty again
// while it was being processed, it will be re-added to the queue for
// re-processing.
func (q *Type) Done(item interface{}) {
q.cond.L.Lock()
defer q.cond.L.Unlock()
q.metrics.done(item)
q.processing.delete(item) // 事件已经处理完毕,从processing set中删除key
if q.dirty.has(item) { // 如果dirty set中有该key,说明处理事件的过程中进来了一个或者多个该key的新事件
q.queue = append(q.queue, item) // 将该key推入主queue中,等待再次处理
q.cond.Signal()
}
}
可以看到,Done()方法首先会将已经处理完的key从processing set中移除,之后,如果发现dirty set中还有该key,说明中途有个对应该key的新事件传入,此时已经处理完的事件并不代表资源对象的“最新”期望状态,需要重新将该key放入主queue中、重新处理;如果dirty set中没有该key,说明中途没有同一个key的事件发生,那么就万事大吉,实际状态已经与最新期望状态符合了!
从上面的代码来总结,我们可以得到如下图的Workqueue内部状态转移图:
这时,针对我之前提出的问题,我们就有了K8s给出的答案了:
当Controller处理一个key的事件过程中,传入了该key的一个新事件,Workqueue在内部会将这个key放进dirty set(标记为“脏”),当手头的事件处理完成后,Workqueue会把这个脏key又放入主queue中等待再次处理,确保目标资源对象的实际状态符合最新的期望状态。与此同时,即便在处理过程中陆陆续续传入了几百上千个该key的新事件,dirty set中只会存放该key的单一副本,因此最后也只需要一次性地从中取出、放入主queue、处理,这也就实现了所谓的“只关心最终状态”。而processing set的存在,则保证了同一时间不会有两个Worker线程处理同一个key的事件。
EventHandler事件过滤 – controller-runtime
Informer/SharedInformer将事件分发给EventHandler之后,将由EventHandler把事件发送到Workqueue供工作线程“享用”。但在这之前,EventHandler实际上可以负责一定的过滤工作,因为我们不应该得到一个事件就不管三七二十一地交给工作线程处理:一个传来的事件可能由于发生了某些错误缺少一些关键内容,又或者这个事件不是预定义的规则里关心的事件(比如用户想更新某个资源的一个不允许更新的字段)。
我们以update事件的handle过程为引,对EventHandler做一个剖析。前面Informer的创建函数中,创建的config中有一个Process函数,其中有这一段代码:
switch d.Type {
case Sync, Added, Updated:
if old, exists, err := clientState.Get(d.Object); err == nil && exists {
if err := clientState.Update(d.Object); err != nil { // 更新cache
return err
}
h.OnUpdate(old, d.Object) // 调用handler的OnUpdate函数
}
// ...
}
代码比较直观,当事件到来时,Process函数先判断事件的类型,当识别到是一个update事件时,则调用EventHandler的OnUpdate方法,并将资源对象的旧状态与新状态作为实参传入。而在这个OnUpdate()方法中,我们就可以塞入一些我们自己定制的过滤规则。
在此之前,先介绍一下controller-runtime库:
controller-runtime是由Kubebuilder和Operator-SDK提供的,用于使用户更方便地自定义Controller的go库。与此同时,client-go是由K8s官方提供的、用于与K8s集群打交道的client端,提供了用户自定义Controller的接口。controller-runtime在client-go的基础上,针对常见的应用场景实现了部分接口,并且对低层接口做了一定包装,提供了更高层的API,使得用户自定义Controller的工作更加轻松。
以下的代码,均来自controller-runtime库对client-go库所提供的接口的实现。
EventHandler struct中,对OnUpdate接口的实现如下:
// sigs.K8s.io/controller-runtime/pkg/source/internal
// OnUpdate creates and UpdateEvent and calls Update on EventHandler
func (e EventHandler) OnUpdate(oldObj, newObj interface{}) { // 实现ResourceEventHandler Interface
u := event.UpdateEvent{}
// Pull metav1.Object out of the object
if o, err := meta.Accessor(oldObj); err == nil {
u.MetaOld = o
} else {
log.Error(err, "OnUpdate missing MetaOld",
"object", oldObj, "type", fmt.Sprintf("%T", oldObj))
return
}
// Pull the runtime.Object out of the object
if o, ok := oldObj.(runtime.Object); ok {
u.ObjectOld = o
} else {
log.Error(nil, "OnUpdate missing ObjectOld",
"object", oldObj, "type", fmt.Sprintf("%T", oldObj))
return
}
// Pull metav1.Object out of the object
if o, err := meta.Accessor(newObj); err == nil {
u.MetaNew = o
} else {
log.Error(err, "OnUpdate missing MetaNew",
"object", newObj, "type", fmt.Sprintf("%T", newObj))
return
}
// Pull the runtime.Object out of the object
if o, ok := newObj.(runtime.Object); ok {
u.ObjectNew = o
} else {
log.Error(nil, "OnUpdate missing ObjectNew",
"object", oldObj, "type", fmt.Sprintf("%T", oldObj))
return
}
for _, p := range e.Predicates { // 使用预定义规则过滤事件
if !p.Update(u) {
return
}
}
// Invoke update handler
e.EventHandler.Update(u, e.Queue) // 调用handler的update方法,将事件加入Workqueue
}
代码前一大段的四个if-else分支,处理的都是对传入的资源对象旧状态与新状态的信息检验,当出现关键信息缺失时,由于更新无法进行,因此报错并返回,不将该事件加入Workqueue。
紧跟着最后一个if-else分支的for循环,是我们需要关注的,它的作用是使用所有预定义的规则对传入的事件进行过滤。controller-runtime库中定义了一种Predicate接口,用户可以自己实现Predicate接口来自定义过滤规则,也可以直接使用controller-runtime提供的几个常用场景的实现。一个Predicate对象代表一种规则,由于我们现在处理的是update事件,那么就调用这个Predicate对象p的Update方法,当传入事件不符合p所定义的规则时,p.Update(u)返回false,EventHandler将过滤掉该事件,函数返回,事件不会进入Workqueue。
为了方便理解Predicate,我们可以查看一个controller-runtime库提供的一种Predicate——GenerationChangedPredicate。它内部的规则是过滤掉一切不改变资源对象Generation(这里注意与ResourceVersion区分)的事件,这种情况在更新资源Status的时候会发生。在我们自定义Controller的时候,有时候会在业务逻辑处理事件完毕之后加一个对Status的更新操作,这也会引发一个更新事件进入Workqueue,于是业务逻辑再次取出处理,处理完后又双叒发起一个Status更新,最后造成一个死循环。为了避免死循环,所以我们会需要这个GenerationChangedPredicate对Status更新事件进行过滤,不让它进Workqueue。
GenerationChangedPredicate中对Update方法的定义如下:
// Update implements default UpdateEvent filter for validating generation change
func (GenerationChangedPredicate) Update(e event.UpdateEvent) bool {
if e.MetaOld == nil {
log.Error(nil, "Update event has no old metadata", "event", e)
return false
}
if e.ObjectOld == nil {
log.Error(nil, "Update event has no old runtime object to update", "event", e)
return false
}
if e.ObjectNew == nil {
log.Error(nil, "Update event has no new runtime object for update", "event", e)
return false
}
if e.MetaNew == nil {
log.Error(nil, "Update event has no new metadata", "event", e)
return false
}
if e.MetaNew.GetGeneration() == e.MetaOld.GetGeneration() {
// 对比旧状态与新状态的Generation,相同则过滤该事件
return false
}
return true
}
可以看到关键的逻辑只有一个对新旧状态的Generation对比,如果相同则返回false,该事件就会被EventHandler过滤掉,而不加入Workqueue。
当上面的信息检验、是否符合规则的判断等逻辑走完了之后,EventHandler就会调用Update方法,将事件加入Workqueue中。我们取EnqueueRequestForObject对该接口方法的实现:
// Update implements EventHandler
func (e *EnqueueRequestForObject) Update(evt event.UpdateEvent, q workqueue.RateLimitingInterface) {
if evt.MetaOld != nil {
q.Add(reconcile.Request{NamespacedName: types.NamespacedName{ // 加入Workqueue
Name: evt.MetaOld.GetName(),
Namespace: evt.MetaOld.GetNamespace(),
}})
} else {
enqueueLog.Error(nil, "UpdateEvent received with no old metadata", "event", evt)
}
if evt.MetaNew != nil {
q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
Name: evt.MetaNew.GetName(),
Namespace: evt.MetaNew.GetNamespace(),
}})
} else {
enqueueLog.Error(nil, "UpdateEvent received with no new metadata", "event", evt)
}
}
可以看到将事件加入Workqueue就是通过调用Workqueue对象的Add方法,之后的流程,想必大家通过前面对Workqueue的介绍也知道了。
小结
本篇文章中我结合源码,较深入地讨论了Controller工作机制的整体流程,包括Informer/SharedInformer对资源变化事件进行监听与响应、分发给EventHandler,再由EventHandler将事件发送给Workqueue,以及Workqueue的内部工作逻辑。Informer/SharedInformer与Worker线程的关系,实际上是一个生产者-消费者关系,利用一个Workqueue将二者分开,既实现了两个部件的解耦,也解决了双方处理速度不一致的问题。
K8s的许多设计点都蕴含着的十分精妙的考量,在保证可靠性的基础上也兼顾着高性能,同时由于源代码中大部分方法的调用都是Interface的调用,K8s也具有着十分强大的可扩展性。总而言之,K8s的内部实现是非常值得钻研与学习的。
本文由博客一文多发平台 OpenWrite 发布!