K8s Controller 开发指南

一个 Kubernetes 控制器是一个主动的协调过程,它会监视某个对象的期望 desired 状态,并且还会监视实际 current 状态,然后不断的尝试使当前的实际状态更接近期望的状态。最简单的实现方式是一个循环:

for {
  desired := getDesiredState()
  current := getCurrentState()
  makeChanges(desired, current)
}

 

开发要点

1. 使用 workqueue.Interface 来确保多个 worker 不会同时处理同一个项目(namespace/name),大部分的控制器都需要监控多个资源的状态变化,例如 ReplicaSet Controller 需要监听 ReplicaSet 本身以及所有管理的 pod 的变化,但几乎所有控制器都可以总结为基于 Owner 关系,例如 ReplicaSet 控制器需要对删除的 Pod 做出反应,可以通过监听 pod 的删除事件并通过 pod 的 OwnerRef 关系得到 ReplicaSet 并将其加入队列来实现。

 

2.对 controller 而言,即使是同一种类型的资源的顺序也没有保证。例如在明确先创建 ns1/pod1,再创建 ns2/pod2,对控制器而言(或者说你编写的 reconcile 业务函数而言)可能是先观察到 ns2/pod2 的创建。其实也比较好理解,毕竟有多个并发的 worker 从 queue 中取出资源对象进行处理,每个资源对象处理的时间不一样。在编写控制器时,应该避免依赖于事件的特定顺序。

 

3. 基于水平触发来驱动,而不是边缘触发。获取到的 desired 状态本身是没有状态变化的信息,例如从 apiserver Get 到一个 pod,这个 pod 对象本身不包含这个 pod 上发生的一些 update 信息,我们应该用获取到最新的期望状态,并比较当前实际的状态来 reconcile。另一个方式是在资源的 status 中记录控制器对该资源做出的最新的决策信息,这样,即使控制器在一段时间内关闭,它也能够根据对象的状态来决定是否需要处理该对象。这种方式可以确保控制器的行为是可预测和一致的。

 

4. 尽量使用 SharedInformers。SharedInformers 可以对指定资源配置添加、更新和删除事件的 hook。还提供了方便的函数来访问共享缓存,并确定何时缓存已准备就绪。在 https://git.k8s.io/kubernetes/staging/src/k8s.io/client-go/informers/factory.go 中使用工厂方法,以确保与其他人共享相同的缓存实例,可以节省与 APIServer 的连接、服务器端的重复序列化成本、controller 端的重复反序列化成本和重复缓存成本。

 

5. 不要修改从缓存中获取的对象!informer 缓存的资源对象在 controller 之间是共享的,如果同一个 controller 的多个控制逻辑共用一个 shared informer,由于从 cache 获取到的只是对象的引用,直接修改可能会影响其他控制逻辑,例如从 cache 中获取到一个 pod 之后,直接修改 pod 的 Annotations(一个 map 结构)。建议使用 api.Scheme.Copy 进行深拷贝。

 

6. 使用 framework.WaitForCacheSync 函数在开始 reconcile 之前等待辅助缓存的就绪。许多控制器都有主要资源和辅助资源,主要资源是你将更新 Status 的资源,而辅助资源是你将管理(创建/删除)或用于查找的资源。例如 replicaSet 控制器的主要资源是 replicaSet,辅助资源是 pod。

 

7. 不要忘记当前状态可能随时发生变化——仅仅观察期望状态是不够的。如果你使用期望状态中对象的缺失来指示当前状态中的事物应该被删除,例如在 informer cache 中找不到对应的 pod 就认为这个 pod 已经被删除了,请确保你的观察代码没有 bug(例如,在缓存填充之前就执行操作)。这意味着你需要时刻注意系统中其他参与者的操作,因为他们可能会对共享的资源进行更改。即使你的控制器没有直接操作某个对象,也要时刻关注对象的状态变化,以确保你的控制器能够正确响应系统中的变化,并避免因为观察不及时而导致的问题。要确保你的控制器在处理对象之前,已经获取到了最新的状态信息,以避免基于过时信息做出错误的决策。

 

8. 向上传递 error 到主同步逻辑函数。这样可以通过 error 方便的判断是否需要重新入队进行重试,配合 workqueue.RateLimitingInterface,可以使用合理的退避策略进行简单的重新入队。即应在需要重新入队时返回错误。当不需要重新入队时,应使用 utilruntime.HandleError 并返回 nil。

 

9. informer 的 resync 机制会定期触发一次完整的重新同步过程,可以确保 informer 的本地缓存与实际状态保持一致,同时将所有资源对象分发到 informer 的 ResourceEventHandlerFuncs 的 Update handle 方法上。如果明确认为资源没有发生变化时不需要执行任何动作,可以在 Update handler 中通过比较 old 和 new 对象的 resourceVersion 版本号是否一致来进行忽略。

 

10. 如果控制器在其协调的主资源的 status 中设置了 ObservedGeneration,需要确保 status 的这个值被正确的设置为 metadata.Generation。这样可以明确某个资源已经是否被控制器处理到了。

设置资源的status中的ObservedGeneration字段的作用是跟踪资源的观察代数(observed generation)。这个字段用于确保控制器在处理资源时能够正确地识别和处理最新的更改。当资源的spec字段发生更改时,Kubernetes会自动更新资源的metadata.generation字段。控制器可以通过观察ObservedGeneration字段来了解它所处理的资源的最新代数。如果ObservedGeneration与metadata.generation不匹配,控制器可以确定是否需要采取进一步的操作。通过比较ObservedGeneration和metadata.generation,控制器可以判断资源是否已经被更新,并且可以采取相应的操作,例如更新资源的状态或执行其他逻辑。这种机制有助于确保控制器在处理资源时保持同步,并避免重复处理或处理过时的资源。

 

11. 优先考虑使用 OwnerReferences 来关联父资源和子资源,这样当父资源如 replicaSet 被删除时,k8s-gc 会自动通过 OwnerReferences 删除对应的子资源 pod,参考:https://git.k8s.io/design-proposals-archive/api-machinery/controller-ref.md

 

可以看到对于 controller 关注的多个资源,例如 replicaSet 和 pod,这些资源的状态变化对应的事件,最终都是对应到 enqueue replicaSet 资源,然后从 informer cache 中查询到 replicaSet 的 desired 状态进行 reconcile,也就是说到 reconcile 阶段已经不再关注触发的事件是 add/update/delete 的哪种类型,只是作为一个触发

type Controller struct {
    // pods gives cached access to pods.
    pods informers.PodLister
    podsSynced cache.InformerSynced

    // queue is where incoming work is placed to de-dup and to allow "easy"
    // rate limited requeues on errors
    queue workqueue.RateLimitingInterface
}

func NewController(pods informers.PodInformer) *Controller {
    c := &Controller{
        pods: pods.Lister(),
        podsSynced: pods.Informer().HasSynced,
        queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "controller-name"),
    }
    
    // register event handlers to fill the queue with pod creations, updates and deletions
    pods.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
        AddFunc: func(obj interface{}) {
            key, err := cache.MetaNamespaceKeyFunc(obj)
            if err == nil {
                c.queue.Add(key)
            }
        },
        UpdateFunc: func(old interface{}, new interface{}) {
            key, err := cache.MetaNamespaceKeyFunc(new)
            if err == nil {
                c.queue.Add(key)
            }
        },
        DeleteFunc: func(obj interface{}) {
            // IndexerInformer uses a delta nodeQueue, therefore for deletes we have to use this
            // key function.
            key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj)
            if err == nil {
                c.queue.Add(key)
            }
        },
    },)
    
    return c
}

func (c *Controller) Run(threadiness int, stopCh chan struct{}) {
    // don't let panics crash the process
    defer utilruntime.HandleCrash()
    // make sure the work queue is shutdown which will trigger workers to end
    defer c.queue.ShutDown()

    klog.Infof("Starting <NAME> controller")

    // wait for your secondary caches to fill before starting your work
    if !cache.WaitForCacheSync(stopCh, c.podsSynced) {
        return
    }

    // start up your worker threads based on threadiness.  Some controllers
    // have multiple kinds of workers
    for i := 0; i < threadiness; i++ {
        // runWorker will loop until "something bad" happens.  The .Until will
        // then rekick the worker after one second
        go wait.Until(c.runWorker, time.Second, stopCh)
    }

    // wait until we're told to stop
    <-stopCh
    klog.Infof("Shutting down <NAME> controller")
}

func (c *Controller) runWorker() {
    // hot loop until we're told to stop.  processNextWorkItem will
    // automatically wait until there's work available, so we don't worry
    // about secondary waits
    for c.processNextWorkItem() {
    }
}

// processNextWorkItem deals with one key off the queue.  It returns false
// when it's time to quit.
func (c *Controller) processNextWorkItem() bool {
    // pull the next work item from queue.  It should be a key we use to lookup
    // something in a cache
    key, quit := c.queue.Get()
    if quit {
        return false
    }
    // you always have to indicate to the queue that you've completed a piece of
    // work
    defer c.queue.Done(key)

    // do your work on the key.  This method will contains your "do stuff" logic
    err := c.syncHandler(key.(string))
    if err == nil {
        // if you had no error, tell the queue to stop tracking history for your
        // key. This will reset things like failure counts for per-item rate
        // limiting
        c.queue.Forget(key)
        return true
    }

    // there was a failure so be sure to report it.  This method allows for
    // pluggable error handling which can be used for things like
    // cluster-monitoring
    utilruntime.HandleError(fmt.Errorf("%v failed with : %v", key, err))

    // since we failed, we should requeue the item to work on later.  This
    // method will add a backoff to avoid hotlooping on particular items
    // (they're probably still not going to work right away) and overall
    // controller protection (everything I've done is broken, this controller
    // needs to calm down or it can starve other useful work) cases.
    c.queue.AddRateLimited(key)

    return true
}

 

posted @ 2024-02-28 11:56  JL_Zhou  阅读(389)  评论(0编辑  收藏  举报