ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入理解Kubernetes控制器运行时Manager:生命周期与启动编排

深入理解Kubernetes控制器运行时Manager:生命周期与启动编排

1. Manager是Kubernetes控制器的大脑,还是大管家?

写controller-runtime系列文章,第一篇通常都在讲脚手架、初始化项目、写第一个Reconciler,让控制器能跑起来。但很多人在跑起来之后会卡住,因为当你想加第二个Controller、第三个Controller,当你想接入Webhook、想开Leader Election、想在Controller里用Client查列表却查到过期数据时,会发现ctrl.NewManager这行被我们"用掉了"但没"想明白"。

这个系列文章,说白了就是先把controller-runtime这个库读透,再回头去写Operator会顺手得多。这一篇专门针对Manager,它是所有Controller、所有Admission Webhook、所有Cache、所有Client的宿主。你写的每个Reconciler,最后都是被Manager用某种方式启动、管理、停止的。理解Manager,等于理解了controller-runtime整个运行时的骨架。

Manager的定位,我用一句话概括:Manager是一个向所有组件提供依赖并负责协调其生命周期的容器对象。它不只是"创建Client和Cache的地方",它还决定了:哪些任务必须在主从选举获胜后才启动、哪些任务随时可以启动、所有组件的停止顺序是什么、干活的Controller拿到的是哪个Client、Cache失效时谁会感知到。

这篇文章我会就着源码讲清楚几件事:Manager接口到底管什么、controllerManager这个核心结构体里有哪些关键字段、Add方法如何做任务分流、Start方法的启动顺序有什么讲究、GetCache和GetClient为什么是懒加载、New函数在返回Manager之前偷偷做了哪些初始化。读完这遍,你再去读controller-runtime里其他结构(比如source、handler、predicate),会有一个更清晰的全局判断:它们是被谁创建、被谁依赖、由谁启动的。

提示:这篇文章基于controller-runtime v0.19.x的源码,不同小版本在字段名和内部实现上会有细微出入,但设计骨架是稳定的。建议你本地把源码拉下来对照着看,体验会好很多。

2. Manager接口:先看它"承诺"了什么,再看它"偷藏"了什么

2.1 接口定义背后的设计意图

打开pkg/manager/manager.go,首先看到的是Manager接口。接口很长,但不要被长度吓到,把它当作一份"服务承诺书"来读:

type Manager interface { cluster.Cluster Add(Runnable) error Elected() <-chan struct{} SetFields(interface{}) error AddMetricsServerExtraHandler(Path string, handler http.Handler) error AddHealthzCheck(name string, check healthz.Checker) error AddReadyzCheck(name string, check healthz.Checker) error Start(ctx context.Context) error GetWebhookServer() webhook.Server }

逐段看:

  • cluster.Cluster:内嵌了一个接口。Cluster接口提供了GetConfig()、GetClient()、GetCache()、GetScheme()、GetRESTMapper()等方法。这说明Manager天然具备"连接Kubernetes API Server并提供读缓存能力"的基本盘。
  • Add(Runnable) error:这是Manager的灵魂方法,任何需要被Manager生命周期管理的任务,都可以通过Add注册进去。后面会重点讲。
  • Elected() <-chan struct{}:这个channel在主从选举获胜时被关闭。它是给那些"必须当选后才干活的组件"准备的信号。
  • SetFields(interface{}) error:通过依赖注入的方式,把Manager持有的依赖写进传入对象的字段。现在它主要被内部兼容性逻辑使用,但理解它有助于看懂老版本controller-runtime的代码。
  • AddHealthzCheck和AddReadyzCheck:注册健康检查探针和就绪探针,Manager启动时如果配置了Port,会把探针挂到HTTP接口上。
  • Start(ctx context.Context) error:Manager的总入口。它会阻塞运行,直到context取消或内部组件彻底退出。
  • GetWebhookServer():返回Webhook服务器实例,Manager在同一进程中承担了Webhook服务器的宿主角色的关键方法。

这个接口设计基本把所有"Manager需要对外提供的服务"都暴露出来了。但接口不会告诉你:这些服务背后哪些是立即初始化的,哪些是懒加载的,哪些是启动时才装的。这些细节全在接口的具体实现controllerManager里。

2.2 接口为什么要把Cluster嵌套进去

有一个容易忽略的问题:为什么不直接让Manager"有一个Client",而是让Manager直接"是一个Cluster"?

这是组合优于继承的一个典型体现。controller-runtime在早期版本里,Manager和Cluster是分开理解的:Cluster负责"Kubernetes连接信息+缓存+客户端"这一组基础设施,Manager则负责"在上面跑东西"。后来库的维护者觉得这种拆法让使用者在初始化和依赖传递上多绕了一层,干脆让Manager直接内嵌Cluster接口,让Manager自己承担Cluster的全部职责。

你在实现自定义类型的Controller时经常看到这样的写法:

type MyReconciler struct { client.Client Scheme *runtime.Scheme }

这里赋值的mgr.GetClient()、mgr.GetScheme(),本质上也是"Manager把自己当成Cluster来用"的体现。内嵌接口的实际好处是:

  1. 不管谁拿到Manager对象,都同时拿到了所有集群访问能力,不需要再单独传一个Cluster。
  2. 后续如果Cluster接口本身扩展新方法,所有依赖Manager的代码都能自动感知。
  3. 代码里做依赖注入时更简洁,不需要mgr.GetCluster().GetClient()这种中间层。

这个设计给我们的启示是:当你在写自己的框架时,如果某个对象本质上就是另一个对象的超集,直接内嵌接口比"持有对方引用"更清晰,也更利于后续扩展。

3. 核心结构体controllerManager:这些字段决定了Manager的行为边界

3.1 分组缓存runnables的设计思路

接口下面是两个重要结构体:controllerManager和runnablesGroup。看controllerManager的字段定义:

type controllerManager struct { // 集群配置相关 cluster.Cluster leaderElectionRunnables *runnablesGroup nonLeaderElectionRunnables *runnablesGroup runnables *runnablesGroup // 启动状态 started bool startedLock sync.Mutex // 选举相关 leaderElectionCancel context.CancelFunc leaderElectionStarted bool leaderElectionAvailable bool leaderElectionID string // 探针相关 healthzHandler *healthz.Handler readyzHandler *healthz.Handler // 错误处理 errChan chan error runnablesClosed chan struct{} // ... 省略部分字段 }

先说最核心的leaderElectionRunnables、nonLeaderElectionRunnables、runnables这三个runnablesGroup,它们是Manager任务调度的关键。

runnablesGroup结构体内部有runnables []Runnable、runLock sync.Mutex、started bool、proceed <-chan struct{}等字段。proceed这个channel非常有意思:当leaderElection配置启用时,proceed由选举结果channel驱动;当没有选举时,proceed直接关闭。

所有组件都被分到这三个组里,但分组逻辑并不完全按照字面意思:

  • nonLeaderElectionRunnables:存放那些不需要等待当选就可以启动的任务。
  • leaderElectionRunnables:存放那些必须在当选后才可以启动的任务。
  • runnables:存放那些"什么时候启动无所谓,但也不强制等待选举"的任务,它的启动顺序插在两者之间。

很多人看到三个名字会以为runnables是不需要选举的,nonLeaderElectionRunnables是永远不需要选举的。实际上更精确的理解是:这三个组代表的是一种启动优先级。你通过Add注册任务时,库内部根据传入的不同类型,把任务归到不同组里。真正决定任务属于哪个组的,是任务自身实现的方式。

3.2 Started、Elected、errChan:并发状态与错误传导

started字段加上startedLock是保护并发启动安全的关键。Manager的Start方法有可能被误调用多次,如果用户代码里出现了两次mgr.Start(ctx),第二次调用应当在started字段上被拦截。controllerManager在Start入口就做了这个检查:

func (cm *controllerManager) Start(ctx context.Context) error { cm.startedLock.Lock() defer cm.startedLock.Unlock() ... if cm.started { return errors.New("manager already started") } cm.started = true ... }

errChan是Manager内部处理"异步启动的组件报错"的方式。由于Start方法中启动runnables时是逐个启动的,但启动后的任务本身是长时间运行的goroutine,某个任务在运行中出错时,需要一种机制通知Manager主流程退出,errChan就是干这个的。某个runnable内部发生了不可恢复错误,它会把错误塞进errChan,Manager会选择整体关闭还是继续运行,取决于错误类型和配置。

runnablesClosed这个channel用来通知所有runnables已经停止,它是确保清理流程不会提前退出、不会重复停止组件的同步机制。

这些字段虽然看起来零散,但它们共同维护了Manager在并发场景下的安全边界。我见过不少人在自己写的控制器框架里,因为没处理好started竞态和错误上报,导致进程出现重复启动、停止时panic的问题。controller-runtime这套字段设计可以直接借鉴。

4. Add方法与runnables分流:任务注册背后的类型判断和三种归宿

4.1 Runnable接口与典型的Manager组件

在分析Add之前,先看Runnable接口定义:

type Runnable interface { Start(ctx context.Context) error }

凡是实现了Start(ctx) error方法的对象,都可以交给Manager管理。Controller是Runnable,Webhook server是Runnable,甚至你自己写的一个后台定时任务,只要实现这个接口,也能变成Manager管理的任务之一。

再看Add方法的实现逻辑:

func (cm *controllerManager) Add(r Runnable) error { cm.startedLock.Lock() defer cm.startedLock.Unlock() if cm.started { return errors.New("manager already started") } // 根据runnable类型分组 switch runnable := r.(type) { case *leaderElector: // 选举器被特殊对待 cm.leaderElectionRunnables.Add(runnable) case *server: return cm.nonLeaderElectionRunnables.Add(runnable) case *webhook.Server: return cm.nonLeaderElectionRunnables.Add(runnable) case *controller.Controller: // 控制器需要等待选举 return cm.leaderElectionRunnables.Add(runnable) case LeaderElectionRunnable: // 实现了LeaderElectionRunnable接口的对象,根据NeedLeaderElection决定 if runnable.NeedLeaderElection() { return cm.leaderElectionRunnables.Add(runnable) } default: // 其余任务放在普通runnables组 } // 如果不属于以上任何情况,放入cm.runnables return cm.runnables.Add(runnable) }

注意这个switch的顺序,以及各种类型的归宿:

  • 选举器leaderElector自己都被放进leaderElectionRunnables,因为选举逻辑本身也需要在"当选前"启动才能有机会当选。
  • Webhook server被强制放入nonLeaderElectionRunnables,因为Webhook通常是独立于控制器主逻辑的,它需要在没有选举资格时也能对外服务。这一点很重要:如果你的Webhook配置了需要访问Kubernetes资源的客户端,却不小心在没当选前就启动,可能会读到一些不完整的缓存数据。默认情况库把你当成年人处理,它强制Webhook server先启动,但Webhook handler里怎么用Client是你自己的事。
  • 实现了LeaderElectionRunnable接口的对象,库会询问NeedLeaderElection()来决定归哪组。
  • 普通的Controller(*controller.Controller)默认放入leaderElectionRunnables,也就是说普通Controller默认是"当选后才干活"的。

4.2 LeaderElectionRunnable这个特殊接口

这里有必要单独展开LeaderElectionRunnable:

type LeaderElectionRunnable interface { NeedLeaderElection() bool }

这个接口让任务自己决定是否必须当选后才启动。我在实际项目中经常用这个能力做两类事情:

  • 第一类是后台定时任务,比如定期清理过期资源、定期同步镜像列表。这类任务如果多个副本同时执行,会造成重复处理甚至数据错乱,我就会让任务实现NeedLeaderElection() bool { return true },确保只有leader副本在执行。
  • 第二类是只读辅助服务,比如暴露一个/metrics扩展接口或者本地调试接口。多副本同时跑没关系,那就返回false。

很多人在使用controller-runtime时从没注意过这个接口,导致所有自定义任务默认去了runnables普通组,无法控制启动时机。如果你的控制器是多副本部署、开了Leader Election,自定义任务必须谨慎选择组别。否则老板会看到两个副本同时跑定时清理任务,然后出现一堆"资源已被删除"的报错,排查半天才发现是任务分组问题。

4.3 全组Add完之后的启动顺序

虽然Add把任务分类放好了,但最终启动顺序还得看各组之间的先后关系。runnablesGroup内部有个proceedchannel,每组在启动前会等待这个channel关闭。三个组的顺序大致是:

  1. nonLeaderElectionRunnables先启动,它不依赖任何选举结果。
  2. runnables再启动,它在选举还没出来时可以先准备。
  3. leaderElectionRunnables最后启动,必须等当选信号。

这里要区分清楚:runnables和nonLeaderElectionRunnables都可以在选举前启动,但前者是"不强制等待",后者是"明确不需要等待"。从实际执行顺序看,非选举组和普通组之间的先后也取决于代码里遍历顺序,通常nonLeaderElection比普通runnables更靠前。这个顺序理解起来有点绕,但通过后面Start的完整流程会一目了然。

5. Start启动全流程:从Metrics到Controller的接棒式启动

5.1 启动前必须先确认,启动后必须先收尾

controllerManager.Start在代码里的实现非常清晰地体现了"先准备、再启动、后清理"三个阶段。先看准备阶段做了什么关键检查:

func (cm *controllerManager) Start(ctx context.Context) error { cm.startedLock.Lock() defer cm.startedLock.Unlock() ... // 初始化healthz/readyz cm.healthzHandler = &healthz.Handler{...} cm.readyzHandler = &healthz.Handler{...} // 启动metrics server if cm.metricsServer != nil { go func() { err := cm.metricsServer.ListenAndServe() ... }() } // 启动健康检查和就绪检查的HTTP服务 if cm.healthzHandler != nil { go func() { ... }() } // leader election ... }

这段逻辑的重点是:Metrics Server和Healthz探针的启动,不依赖选举结果。不管是leader还是follower副本,都需要暴露metrics让Prometheus抓取;都需要响应健康检查让Kubernetes决定是否将其纳入Service Endpoint。这个设计非常合理,实战中如果你发现某次滚动更新时,新Pod一直不被纳入负载均衡,但日志里却没有任何报错,十有八九是Healthz端口没起来或者探针路径没注册上。

启动准备完成后,才进入Leader Election的初始化逻辑。

5.2 Leader Election:从channel关闭到runnables接棒

先看Leader Election的核心代码结构:

if cm.leaderElectionConfig != nil { // 生成选举ID cm.leaderElectionID = cm.leaderElectionConfig.ID // ... elector := &leaderElector{ // ... } // 把elector注册进leaderElectionRunnables组 cm.leaderElectionRunnables.Add(elector) // 启动选举 if err := cm.leaderElectionRunnables.Start(ctx); err != nil { return err } // 等待Elected channel被关闭 <-cm.leaderElectionRunnables.Elected() ... }

leaderElector内部封装了leaderelection.RunOrDie的逻辑。当选成功后,Elected()这个channel会被关闭。谁在等待这个channel?不是Manager自己,而是所有被分到leaderElectionRunnables组里的任务,它们的proceedchannel正是由这个Elected()驱动的。

这里有一个细节值得思考:选举器自身也被放进了leaderElectionRunnables组。这意味着选举器也必须通过proceed才能启动。如果proceed由选举结果决定,选举器自己又怎么启动呢?

原因在于runnablesGroup.Start的实现,它启动每个runnable时有一个独立goroutine,而且启动之前先判断当前是否已经处于"可以启动"的状态。对于选举器,在leaderElectionRunnables组Start之前,它会先把proceed设置为已经关闭状态,或者说,选举器的启动不经过proceed等待,而是直接启动。源码里runnablesGroup在Start流程中先遍历所有runnables,再等待proceed,但启动动作本身是立即开始的。用一个更生活化的比喻:选举器是"发令员",其他选手等枪响才跑,发令员自己先站在起跑线上扣动扳机。

5.3 各组启动顺序与等待关系

Start方法中,各组的启动顺序如下:

  1. 启动nonLeaderElectionRunnables,不需要等待选举。
  2. 启动runnables,这个组在nonLeaderElection之后启动。
  3. 如果配置了Leader Election,启动leaderElectionRunnables,并且阻塞等待选举获胜。
  4. 选举获胜后,leaderElectionRunnables内部所有等待proceed的任务开始正式执行。

这里有一个容易踩的坑:如果你自己实现了LeaderElectionRunnable,并且在NeedLeaderElection里返回false,那么即使它被注册进社区统称的"需要选举的控制器组",实际启动时机也和非选举组一致。分组时接口类型判断先于NeedLeaderElection的询问,只有实现了接口的才会被询问;没实现接口、只是普通结构体实现的Start,全被丢到普通组。这导致很多人在Add自定义任务时,不明不白地发现任务在follower上也在跑,以为隐患很大。其实解决办法很简单:实现LeaderElectionRunnable接口并返回true。

5.4 优雅停止:不要让清理顺序变成雪崩

Start方法不只会启动,也会在context取消时统一停止所有runnables。关键代码大致是:

select { case <-ctx.Done(): // 取消所有子任务 cm.leaderElectionRunnables.Stop() cm.nonLeaderElectionRunnables.Stop() cm.runnables.Stop() ... // 等待runnables goroutine退出 <-cm.runnablesClosed }

这里的停止顺序同样重要。设想一种场景:你在一个Controller的Reconciler里往某个队列里塞了很多事件,Manager退出时,如果先停止Cache再停止Controller,Controller在最后处理事件时可能因为Cache已经关闭而读取数据失败,导致一堆无意义的报错。controller-runtime的做法是依赖context传递取消信号,每个runnable自己监听ctx.Done(),然后在退出前完成必要的清理。

我在实际排障时发现,很多人不敢在Manager的context里挂自己的后台任务,担心退出时被Manager"杀掉"导致资源没清理。其实只要你的任务正确处理了ctx.Done(),在退出前做好资源释放,完全可以把任务放心交给Manager。它的停止流程是等待所有runnable完成退出后才关闭runnablesClosed,不会粗暴kill。

6. GetCache、GetClient、GetAPIReader:Manager的懒加载陷阱

6.1 为什么Client和Cache不能"提前"拿到

Manager的GetClient()并不是每次都直接返回一个早就建好的Client。源码里面藏着一个非常巧妙、也容易让人误会的实现:

func (cm *controllerManager) GetClient() client.Client { // 依赖注入后的客户端,如果是nil则会根据cache lazily初始化 ... }

实际上,ControllerManager的GetCache()和GetClient()在第一次调用时,都会去检查自己是否已经初始化了Cluster。cluster.Cluster本身在New时已经创建,但Client真正被初始化要等到Manager启动时。启动时,Cluster内部构造了delegatingClient,它把读请求代理给带Cache的Reader,把写请求直接发给API Server。这个客户端不是一次性创建的:

func (cm *controllerManager) GetClient() client.Client { cm.clientOnce.Do(func() { if cm.client == nil { cm.client = cm.cluster.GetClient() } }) return cm.client }

我刚开始接触这套代码时,总觉得这种设计多此一举,为什么不直接在New里把所有东西都准备好?后来才明白,Kubernetes API Server的连接是需要实际网络通信的,而Cache建立informer需要先做List/Watch,这些操作如果放在New阶段执行,会导致New函数卡住。Manager构造是纯内存操作,启动才是真正的I/O操作。把重活留到启动阶段,用户就能先构造Manager,再决定是否启动、何时启动、用什么context启动。

6.2 delegatingClient:读走缓存,写走API

Manager返回的Client是client.NewDelegatingClient构造出来的,它内部做了这么一件事:

type delegatingClient struct { cache cache.Cache client client.Client uncached client.Client }

读请求(Get、List)优先走cache,也就是本地informer缓存;写请求(Create、Update、Patch、Delete)走真实API Server。这个设计带来两个直接后果:

  • 如果你刚Create了一个对象,立刻Get它,大概率读到的是旧数据,因为informer缓存还没同步到。这是controller-runtime新手必踩的经典坑。
  • 如果你需要"强制读API Server"而不是缓存,就必须使用apiReader,Manager接口专门提供了GetAPIReader()方法。

我在项目里处理"创建后立即查询"的需求时,最稳妥的做法是:创建成功后,调用GetAPIReader()做一次直达API Server的Get。或者用一个小的重试机制等缓存同步。直接把client切换到apiReader,会失去缓存带来的性能优势,别滥用。

6.3 懒加载触发时机与"未启动就GetClient"的后果

还有一个非常实际的问题:你在Manager还没Start之前调用mgr.GetClient(),会发生什么?

由于GetClient()内部用了sync.Once,而初始化动作里包含cm.cluster.GetClient(),cluster内部如果还没启动Cache,这个调用会等待启动。如果Manager永远不启动,这个调用就会死等。所以一个常见错误是:在Add自定义任务的Start方法里直接调用mgr.GetClient(),但实际上Add发生得早、任务真正执行得晚,Manager已经启动,没问题;可如果你在New之后、构造另一个独立模块时就去调用GetClient(),而这个模块在Manager启动前就用Client,就会卡死。

注意:如果你想在Manager启动前就预构建一些对象,不要主动调用GetClient()。应该把这些对象设计成"懒加载"——第一次使用时再通过Manager获取。manager-runtime的SetFields其实也支持这种注入晚绑定,但你直接依赖它反而容易踩时序坑。

6.4 GetAPIReader与"不经过缓存"的边角情况

GetAPIReader()返回一个直接访问API Server的Reader,每次读都打真实API,性能较差,但数据一定是最新的。很多调试场景和建议都用它。不过要记住:它是Reader,没有Write能力。如果你用apireader调用Create,会编译报错。

这个接口在controller-runtime出现的初衷,是为了满足"少数必须立刻读到最新状态"的场景。例如某个Webhook校验里需要确认某个资源是否存在,此时informer缓存数据可能是几秒前的,用GetAPIReader直查更稳妥。代价就是每一次校验都带来一次API Server往返,如果校验流量大,可能给API Server造成额外压力。

7. New函数里那些"看不见"的初始化,决定了Manager是否健康可用

7.1 New做了什么,以及为什么它不阻塞

New是Manager的构造函数。源码里它比较复杂,因为它要兼容各种选项。关键流程大致是:

  1. 处理Options,填入默认值(默认Scheme、默认Metrics BindAddress、默认Leader Election配置等)。
  2. 构造cluster.New,创建Cluster对象。
  3. 构造controllerManager结构体,把Cluster塞进去。
  4. 根据配置创建Metrics Server、Healthz Handler、Webhook Server等。
  5. 处理Leader Election配置,生成选举ID。
  6. 返回Manager。

这里有个很实际的细节:New方法会调用一个叫做options.newClient的函数来构造Client,但这个函数默认是cluster.DefaultNewClient,它内部需要构造Cache。如果你读过pkg/cluster/cluster.go,会看到cluster.New里其实已经初始化了Cache对象,但Cache真正启动是在Start阶段。这个"初始化但未启动"的状态,正是Manager在New阶段不阻塞的关键。

7.2 Options里的默认值与常见误配置

看一下Options默认值相关的关键字段:

字段默认行为易错点
Scheme如果为空,使用runtime.NewScheme()不包含任何内置GVK,导致无法识别内置资源
MetricsBindAddress:8080端口冲突或安全组遗漏是常见问题
LeaderElection如果为false,不会创建选举器多副本同时跑导致资源竞争
LeaderElectionID如果为空,自动生成<namespace>.<name>形式多个Manager复用同一ID会造成错误选主
HealthProbeBindAddress如果不填,不启动Healthz服务Pod探针挂掉时无日志可查
Cache默认使用informer缓存需要自定义Cache.Options或ByObject时需要显式配置
NewClient默认构造delegatingClient需要禁用缓存时通过Client.Cache.Options.DisableFor单独设置
WebhookServer默认端口9443多Webhook并存时端口冲突

这段表格里最有实际价值的是LeaderElectionID。很多人直接设置LeaderElection: true,却不设置ID,然后多个Manager实例之间互相争抢同一个ID,导致选主行为异常。实际生产环境建议显式设置LeaderElectionID: "my-operator-lock",并保证同一个Operator的不同Manager实例使用同一个ID、不同Operator之间使用不同ID。

7.3 隐藏的RESTMapper、证书与Webhook初始化

New里还有几段容易被忽略、但实际会影响运行安全性的逻辑:

第一处是RESTMapper的初始化。Cluster内部通过apiutil.NewDynamicRESTMapper创建了一个动态RESTMapper。它可以根据实际集群资源动态匹配GVK和GVR,而不是静态地从Scheme里查。这解决了"CustomResourceDefinition被创建后,Controller无法及时识别新GVK"的问题。但这东西不是免费的:首次解析一个陌生GVK时,会触发对API Server的Discovery请求,如果你的网络抖动,Controller可能在启动早期短暂报错"no matches for kind"。不用慌,RESTMapper内部有缓存和重试机制,并不是真挂掉。

第二处是CA证书的自动注入逻辑。当你需要为Webhook Server配置证书时,controller-runtime会在启动时尝试创建Secret并将CA证书写入Service的Annotation。这块逻辑在pkg/webhook里,Manager自己不管证书签发,但会协调Secret和Service的初始化。如果你只用Manager而不用Webhook,这些逻辑不会触发。

第三处是Controller Watch配置的默认值逻辑。在New的末尾,大量默认值被写入到全局配置上下文中。例如cfg := mgr.GetConfig()、scheme := mgr.GetScheme()、mapper := mgr.GetRESTMapper(),这些值会被后续绑定的source、handler使用。这意味着,Manager不仅仅是给Controller提供依赖,也是整个controller-runtime配置上下文的中转站。

7.4 New完成后的Manager并不是"马上能用"

结合前面讲的懒加载,New执行完成后,你拿到的Manager处于一种"半预热"状态:集群的元信息、RESTMapper、Cache对象都建了,但informer没同步、Client没真实可用、Webhook没监听。这个状态其实很健康,它让Manager真正做到"快速构造、按需启动"。

与之相对,如果你需要在Manager启动前就执行一些初始化逻辑,比如注册Controller、注册Webhook,这些工作完全没有问题。Controller和Webhook都是通过Add注册进去的,注册本身不启动任何东西。真正启动它们的是Start。因此,Manager的常用生命周期是:

  1. ctrl.NewManager(cfg, opts)构造Manager。
  2. 通过ctrl.NewControllerManagedBy(mgr).For(&MyKind{}).Complete(reconciler)注册Controller。
  3. 使用webhook.NewServer创建Webhook server并注册到mgr.Add。
  4. 执行mgr.Start(ctx)启动全部。

这套流程里,Manager更像一个"积木底座"而不是"现场施工队":它把所有部件都固定在正确位置,等到Start的一声令下,才真正让机器转起来。

8. 实战中Manager经常被误解的五个场景

这部分我打算把实际项目中反复见到的问题梳理一下,每个问题背后都对应着Manager某个源码机制。提前知道这些坑,至少能省一半排障时间。

8.1 场景一:Leader Election开了,但自定义任务每个副本都在跑

这是最高频的问题。现象是:开了LeaderElection: true,控制器主逻辑只在leader副本上跑,但自定义的定时任务每个副本都在执行。

原因:自定义任务没有实现LeaderElectionRunnable接口,或者实现了但返回false。Add方法默认把不认识的类型丢进cm.runnables组,这个组不等待任何选举信号。

解决:

type myCleanupTask struct { mgr manager.Manager } func (t *myCleanupTask) NeedLeaderElection() bool { return true } func (t *myCleanupTask) Start(ctx context.Context) error { // 定时清理逻辑 } func main() { ... if err := mgr.Add(&myCleanupTask{mgr: mgr}); err != nil { os.Exit(1) } }

8.2 场景二:刚Create完对象,立刻Get却查不到

原因:GetClient()默认返回的是DelegatingClient,读请求走Cache,Cache里informer的同步有延迟。

解决:使用mgr.GetAPIReader()做一次直查,或者等待watch缓存追上。不要用client读刚写的数据,这是很多初学者的预期偏差,不是bug。

created := &appsv1.Deployment{...} if err := cli.Create(ctx, created); err != nil { return err } _ = mgr.GetAPIReader().Get(ctx, client.ObjectKey{ Namespace: created.Namespace, Name: created.Name, }, created)

8.3 场景三:Webhook更新了代码,但旧进程还在接收请求

现象:滚动更新时,旧的Operator Pod已经处于Terminating状态,但依然在接收Admission Review请求。原因:Webhook server被强制放入nonLeaderElectionRunnables,Webhook进程在停止时没有立刻关闭HTTP监听,导致Kubernetes API Server仍然把请求转发过来,直到老的Pod完全退出。

解决:在Pod的preStop钩子里留一点时间让Webhook优雅退出,或者在webhook.Server配置里设置ReadHeaderTimeout等参数。源码中Webhook server实现了Runnable接口,但它的Start内部监听ctx.Done()来关闭Server,只是这个关闭动作不会立刻断掉已经建立的连接。

8.4 场景四:Controller日志里偶尔出现"cache not started"或"client not started"

这种错误通常出现在你提前创建Cache或Client并做了某些操作的时候。比如在New之后手动调用mgr.GetCache().GetInformer(ctx, obj),但Manager还没启动,informer建不出来。controller-runtime的Cache设计是GetInformer必须在Cache启动后才能工作,或者它会先建informer但缓存同步要等启动。因此,推荐的做法是把这类依赖放在Controller的Reconciler逻辑中,通过触发事件来执行,而不是在Manager启动前主动预建。

8.5 场景五:多个Manager混用同一个client字段

有一次我在项目里见到有人写了这样的代码:

var globalClient client.Client func main() { mgr, _ := ctrl.NewManager(...) globalClient = mgr.GetClient() // 其他模块使用globalClient }

看起来没问题,但如果globalClient在其他模块中被用来做"创建后立即可读"的操作,就会碰到场景二的问题。更危险的是,如果某个模块在Manager尚未启动时就用globalClient,可能直接卡在懒加载等待上。我建议所有跨模块共享Client的地方,都要明确区分"这是带缓存Client,读有延迟"还是"这是apiReader,读最新数据"。或者干脆不要共享一个client,而是把Manager本身传给各个模块,让它们按需获取。

9. 从Manager出发,能推导出哪些写Operator的最佳实践

源码读到这里,很多实践就不再是"别人说的经验",而是可以从设计上推导出来的结论。我把这些结论集中写一下,方便以后写Controller时直接用。

9.1 依赖获取,永远从Manager拿,不要自建

controller-runtime的整个依赖体系是以Manager为中心发散出去的。Controller需要Client、Cache、RESTMapper、EventRecorder、Logger,统统从Manager那里拿。你自己在某个包里去client.New或者cache.New,不仅会破坏缓存共享,还会导致多个Cache实例各拉各的informer,资源浪费且数据不一致。

一个例外情况是单元测试。测试里用fake.NewClientBuilder()构造客户端是很正常的;但生产代码路径上,不要再创建二级Client。

9.2 自定义Runnable要和Controller统一管理生命周期

如果有一些后台goroutine需要随Manager启停,实现Runnable接口并通过mgr.Add注册,优先于自己去go func。原因是:Manager的停止逻辑会等待所有runnable退出,如果用裸goroutine,进程退出时无法正确等待,可能造成测试环境资源泄漏。

9.3 事件的触发最好依赖Index和Cache,而不是高频List

在Controller的Reconciler里写大量List调用,等于每次都通知informer遍历所有缓存对象,虽然比打API Server快,但仍然有开销。源码里Cache的GetInformer机制告诉我们,Kubernetes控制器的事件驱动设计本来就是"基于watch推送",不是"周期性全量扫描"。如果你发现自己需要频繁List,大概率是事件拆分不够细,可以考虑增加更细粒度的watch或Index来缩小查询范围。

9.4 健康检查和就绪检查,应当放到Manager本身

AddHealthzCheck和AddReadyzCheck让Manager能同时在/metrics端口之外再提供一个健康检查HTTP服务。如果你自己又起了一个独立的HTTP服务器来暴露健康状态,那么就多了一个需要维护的生命周期。Controller-runtime把健康检查纳入Manager管理的开箱方案,往往比你手动管理更可靠。我在多个项目里把健康检查和就绪检查都绑定到Manager上,滚动更新时一切正常,不再需要额外部署独立health server。

9.5 选主后的变量更新,用Manager的Starter逻辑

如果你需要确切的"当上leader之后才执行全局变量初始化",可以自己写一个Runnable,实现LeaderElectionRunnable,里面做好初始化后再启动真正的业务逻辑。这种方式比在main函数里用mgr.Elected()等待更直观:

type leaderInitTask struct{} func (l *leaderInitTask) NeedLeaderElection() bool { return true } func (l *leaderInitTask) Start(ctx context.Context) error { doLeaderOnlyInit() <-ctx.Done() return nil }

这样即使多副本运行时,初始化逻辑也只会执行一次。

10. 源码阅读的心得和一些推荐路径

这篇Manager分析已经写了很长。最后想聊聊怎么保持对源码阅读的耐心,以及接下来的阅读顺序。

读controller-runtime源码最忌讳的就是从头到尾逐行读。我建议的顺序是:先读文章和文档理解整体流程,再选一个具体场景(比如"我要给我的Operator加上Webhook")反向追踪代码。你在追代码时会发现大量分支和兼容逻辑,但不要陷进去,先抓住主线,再回头研究分支。

Manager这个层面,核心要抓的主线就是三件事:

  1. 任务生命周期管理:谁被Add进来,落在哪个组,什么时候被启动,什么时候被停止。
  2. 依赖注入:Client、Cache、RESTMapper怎样被创建,怎样被各组件共享。
  3. 启动编排:Metrics先启、选举器再启、Controller等选举结果、停止时按标准流程收尾。

把这三点吃透,Manager就通了。

如果你读完Manager想继续深入,我建议下一个读controller.Controller而不是Reconciler。Controller的watch循环、队列和事件处理逻辑,和Manager的启动流程有大量交互;而Reconciler只是被调用的业务方法。理解了Controller层,再去看source和handler这些细节会非常顺。

从Manager到Controller再到Reconciler,这条线走完,你对"写一个Kubernetes Operator"这件事的理解高度会和只看文档完全不同。至少下次遇到"为什么Controller在leader切换后没干活"或者"为什么Webhook和Controller启动顺序不一样"这类问题,你能直接从源码层面给出答案,而不是靠猜。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进