1. 从一次Pod“失联”说起:为什么需要Controller
去年帮一个朋友排查他自建集群里的问题,现象很典型:一个跑批任务的Pod莫名其妙没了,日志里没有任何OOM或崩溃的痕迹,节点也健康。他反复kubectl get pods,发现Pod确实消失了,而且没有新的Pod补上来。他当时的第一反应是“集群是不是出bug了”,但真正的原因其实很简单——他手动创建了一个裸Pod(Bare Pod),没有挂任何控制器。裸Pod的宿命就是:一旦它所在的节点出问题、或者Pod本身因为某种原因被驱逐,它就彻底消失了,没有任何机制会把它拉回来。
这件事让我意识到,很多人学k8s的时候,把大量精力花在了Pod、Service、Ingress这些“看得见”的资源上,却忽略了Controller(控制器)才是整个集群真正的“自动驾驶系统”。你声明一个期望状态,控制器负责让实际状态无限逼近这个期望状态——这才是k8s最核心的设计哲学。
这篇文章面向的是已经能跑起一个集群、会用kubectl创建Pod和Deployment,但对Controller体系还停留在“知道有这东西”阶段的同学。我会把Controller的核心概念、常见类型、工作原理、实操要点、排查技巧全部拆开讲清楚,尽量用我在实际运维和排障中遇到的真实场景来解释,而不是照本宣科念文档。读完你至少能做到三件事:第一,理解Controller的调谐循环到底在干什么;第二,能根据业务场景选对控制器类型;第三,遇到控制器相关的异常时有一套可复用的排查路径。
2. Controller到底是什么:拆解调谐循环的底层逻辑
2.1 一句话说清Controller的本质
Controller的本质是一个持续运行的调谐循环(Reconciliation Loop)。它的工作模式可以用一句话概括:观察当前状态,对比期望状态,如果不一致就采取行动让它一致,然后重复这个过程。
这个逻辑听起来简单,但它是k8s声明式API的基石。你写一个YAML说“我要3个副本”,你并没有告诉k8s“请创建3个Pod”——你只是声明了一个期望状态。真正去创建Pod、监控Pod数量、在Pod挂掉时补上新Pod的,是Controller。它就像一个不知疲倦的运维工程师,7x24小时盯着你的资源,发现偏差就立刻修正。
我用一个生活化的类比来解释。想象你家里有一个恒温器,你设定温度为26度。恒温器不会“创建”冷气或暖气,它只是持续测量当前温度,和26度对比,如果高了就开冷气,低了就开暖气。Controller就是这个恒温器,期望状态就是你设定的26度,实际状态就是当前室温,调谐循环就是那个不断测量和调整的过程。
2.2 调谐循环的三个核心阶段
每个Controller的调谐循环都可以拆成三个阶段,理解这三个阶段对排查问题非常关键。
第一阶段:Observe(观察)。Controller通过API Server的List-Watch机制获取资源对象的最新状态。这里有一个很重要的细节:Controller通常不会直接轮询API Server,而是使用Informer机制。Informer在本地维护一份缓存,通过Watch接收增量更新,这样既减轻了API Server的压力,又保证了Controller能快速感知变化。如果你在排查Controller不工作的原因,第一步就应该确认Informer的缓存是否正常同步。
第二阶段:Diff(对比)。Controller拿到实际状态后,和期望状态做对比。期望状态通常来自资源的Spec字段,实际状态来自Status字段。比如Deployment的Spec里写着replicas: 3,Status里显示availableReplicas: 2,那Controller就知道需要再创建一个Pod。
第三阶段:Act(行动)。发现差异后,Controller会调用API Server执行相应操作,比如创建Pod、删除Pod、更新Service的Endpoints等。行动完成后,循环回到第一阶段,继续观察。
注意:调谐循环是异步的,Controller不保证在某个时间点实际状态一定等于期望状态。它只保证最终一致(Eventual Consistency)。这意味着你在创建Deployment后立刻查看Pod列表,可能只看到1个Pod而不是3个,这是正常的,等几秒就会补齐。
2.3 为什么Controller要设计成“水平触发”而非“边缘触发”
这是一个容易被忽略但非常重要的设计决策。边缘触发(Edge-triggered)是指只在状态发生变化的那一刻做出响应;水平触发(Level-triggered)是指不管状态有没有变化,都持续检查当前状态并做出响应。
k8s的Controller全部采用水平触发。为什么?因为边缘触发在分布式系统中非常脆弱。如果Controller在状态变化的那一刻恰好宕机了,或者消息丢失了,那这个变化就永远不会被处理。而水平触发的好处是:即使Controller重启、即使某次事件丢失,下一次调谐循环仍然会发现状态不一致并修正。这就是为什么k8s能做到“自愈”——不是因为它有什么神奇的机制,而是因为它一直在检查、一直在修正。
我在实际运维中遇到过一种情况:某个Controller因为API Server短暂不可用而报错,但恢复后它自动把之前遗漏的所有变更都补齐了。这就是水平触发的威力。
3. 常见Controller类型全解析:选对控制器比会写YAML更重要
3.1 Deployment:无状态服务的默认选择
Deployment是日常使用频率最高的Controller,没有之一。它管理ReplicaSet,ReplicaSet管理Pod,形成三层结构。为什么要多一层ReplicaSet?因为Deployment需要版本管理能力——每次你更新镜像版本,Deployment会创建一个新的ReplicaSet,然后逐步把流量从旧ReplicaSet迁移到新的。旧的ReplicaSet不会被删除,而是保留下来用于回滚。
Deployment的核心字段包括:
replicas:期望的Pod副本数selector:用来匹配Pod的标签选择器strategy:更新策略,支持RollingUpdate和RecreateminReadySeconds:Pod被认为可用之前需要等待的时间revisionHistoryLimit:保留多少个旧ReplicaSet用于回滚
RollingUpdate策略下还有两个关键参数:maxSurge和maxUnavailable。maxSurge控制更新过程中最多可以超出期望副本数的Pod数量,maxUnavailable控制更新过程中最多有多少Pod不可用。这两个参数直接决定了更新的速度和服务的可用性。
举个例子,如果你有10个副本,maxSurge: 25%,maxUnavailable: 25%,那更新过程中最多可以有13个Pod(10+3),最少保证有8个Pod可用(10-2)。如果你把maxUnavailable设为0,那更新会先创建新Pod再删除旧Pod,保证服务容量不下降,但更新速度会慢一些。
实操心得:对于延迟敏感的服务,建议
maxUnavailable设为0或很小的值,maxSurge适当调大,用资源换稳定性。对于可以容忍短暂中断的批处理服务,可以把maxUnavailable调大,加快更新速度。
3.2 StatefulSet:有状态服务的专属管家
StatefulSet解决的是Deployment解决不了的问题:稳定的网络标识、稳定的存储、有序的部署和扩缩容。
每个StatefulSet管理的Pod都有一个稳定的序号,从0到N-1。这个序号会体现在Pod名称里(比如web-0、web-1、web-2),也会体现在DNS记录里。配合Headless Service,每个Pod都能获得一个稳定的DNS名称,格式是<pod-name>.<service-name>.<namespace>.svc.cluster.local。这对于需要互相发现和通信的有状态服务(比如数据库集群、消息队列集群)至关重要。
存储方面,StatefulSet通过volumeClaimTemplates为每个Pod创建独立的PVC。即使Pod被重新调度到其他节点,它仍然会挂载原来的PVC,数据不会丢失。
部署和扩缩容的顺序也是严格控制的:创建时从0到N-1依次创建,每个Pod Ready之后才创建下一个;删除时从N-1到0依次删除。这个顺序保证对于需要主从同步的服务来说非常关键。
注意事项:StatefulSet的Pod管理策略(podManagementPolicy)默认是OrderedReady,也就是严格按顺序。如果你希望并行创建和删除Pod,可以设为Parallel。但要注意,Parallel模式下不保证启动顺序,只适合那些Pod之间没有启动依赖的场景。
3.3 DaemonSet:每个节点都要有一份
DaemonSet的语义非常直接:确保每个(符合条件的)节点上都运行一个Pod。它通常用于运行集群级别的守护进程,比如日志收集、监控代理、网络插件、存储插件等。
DaemonSet的调度行为比较特殊。默认情况下,它会绕过常规的调度器,直接为每个节点创建Pod。但如果你使用了nodeSelector、affinity、taints/tolerations等字段,它就会尊重这些约束。比如你只想在带有disktype=ssd标签的节点上运行DaemonSet,就可以通过nodeSelector来实现。
DaemonSet的更新策略也支持RollingUpdate,但和Deployment不同的是,它没有副本数的概念,更新时是逐个节点替换Pod。maxUnavailable参数控制同时有多少个节点上的Pod可以被替换。
实操心得:DaemonSet经常用来部署日志采集组件。这里有一个坑:如果你的日志采集组件需要读取宿主机上的日志文件,记得挂载hostPath。另外,如果节点上有污点(Taint),DaemonSet的Pod需要对应的容忍(Toleration)才能调度上去。很多新手部署完DaemonSet后发现某些节点上没有Pod,八成就是污点的问题。
3.4 Job与CronJob:批处理任务的正确打开方式
Job用于运行一次性任务,它保证指定数量的Pod成功完成。Job的关键字段包括:
completions:需要成功完成的Pod数量parallelism:同时运行的Pod数量backoffLimit:失败重试次数上限activeDeadlineSeconds:任务超时时间ttlSecondsAfterFinished:任务完成后多久自动清理
CronJob则是在Job的基础上增加了定时调度能力,使用标准的Cron表达式。它的关键字段包括:
schedule:Cron表达式concurrencyPolicy:并发策略,支持Allow、Forbid、ReplacestartingDeadlineSeconds:错过调度时间后多久内仍然可以启动successfulJobsHistoryLimit和failedJobsHistoryLimit:保留多少历史Job记录
注意事项:CronJob的时区问题经常被忽略。默认情况下,CronJob使用UTC时区。如果你写
0 2 * * *以为是在凌晨2点执行,实际上是在UTC 2点,也就是北京时间上午10点。从k8s 1.27开始支持timeZone字段,可以显式指定时区。
3.5 其他值得关注的Controller
除了上面四种,还有一些Controller在特定场景下非常有用:
HorizontalPodAutoscaler(HPA):根据CPU、内存或自定义指标自动调整Deployment或StatefulSet的副本数。HPA的调谐循环默认每15秒运行一次,它会查询Metrics Server获取当前指标,然后根据目标值计算需要的副本数。
VerticalPodAutoscaler(VPA):和HPA不同,VPA调整的是Pod的资源请求和限制,而不是副本数。VPA目前在生产环境中使用需要谨慎,因为它可能会导致Pod重启。
PodDisruptionBudget(PDB):这不是一个严格意义上的Controller,但它和Controller配合使用,用来限制自愿中断(比如节点维护、集群升级)时同时不可用的Pod数量。
NetworkPolicy:虽然它本身不是Controller,但NetworkPolicy的实现依赖于网络插件的Controller来生效。
4. 控制器工作原理深度拆解:Informer、WorkQueue与调谐
4.1 Informer机制:Controller的眼睛和耳朵
Controller要工作,首先得知道资源对象的状态。它获取状态的方式就是Informer。Informer的核心组件包括:
- Reflector:通过List-Watch与API Server通信,List获取全量对象,Watch获取增量事件。
- DeltaFIFO:一个先进先出的队列,存储对象的变化事件(Added、Updated、Deleted)。
- Indexer:本地缓存,支持按命名空间、标签等索引快速查找对象。
- Processor:从DeltaFIFO中取出事件,分发给注册的EventHandler。
Informer的一个重要特性是共享。同一个资源类型的Informer在同一个进程中只会创建一次,多个Controller可以共享同一个Informer的缓存。这大大减少了API Server的压力和内存占用。
实操心得:如果你在开发自定义Controller,一定要用SharedInformerFactory来创建Informer,而不是自己手动创建。否则每个Controller都维护一份独立的缓存,内存会爆炸。
4.2 WorkQueue:Controller的待办事项列表
Informer的EventHandler通常不会直接处理业务逻辑,而是把对象的Key(namespace/name)放入一个WorkQueue。Controller的主循环从WorkQueue中取出Key,然后执行调谐逻辑。
WorkQueue有几个重要特性:
- 去重:如果同一个Key被多次加入队列,队列中只会保留一个。
- 限速:支持RateLimitingQueue,可以对失败的任务进行指数退避重试。
- 延迟:支持AddAfter,可以延迟一段时间后再处理某个Key。
为什么要用WorkQueue而不是直接在EventHandler里处理?因为EventHandler是串行执行的,如果处理逻辑很慢,会阻塞后续事件的接收。而WorkQueue把事件接收和事件处理解耦,Controller可以按自己的节奏消费队列。
4.3 调谐逻辑的编写要点
如果你要开发一个自定义Controller,调谐函数(Reconcile)的编写有几个关键要点:
第一,调谐函数必须是幂等的。同一个Key可能被处理多次,每次处理的结果应该是一样的。比如你要创建一个Deployment,应该先检查它是否已经存在,存在就更新,不存在才创建。
第二,调谐函数不应该假设它能看到所有变化。由于缓存同步的延迟,调谐时看到的状态可能不是最新的。所以调谐逻辑应该是“检查当前状态,对比期望状态,采取行动”,而不是“根据事件类型决定行动”。
第三,调谐函数应该尽快返回。如果调谐逻辑需要很长时间,应该把它拆成多个小步骤,或者使用异步方式处理。长时间阻塞调谐函数会导致WorkQueue积压。
第四,错误处理要区分可重试和不可重试。如果是临时错误(比如API Server暂时不可用),应该返回错误让WorkQueue重试;如果是永久错误(比如YAML格式不对),应该记录事件并返回成功,避免无限重试。
5. 实操:从零手写一个自定义Controller
5.1 场景定义:一个自动打标签的Controller
假设我们有一个需求:集群里所有带有team=backend标签的Namespace,都需要自动打上一个billing=backend-team的标签。这个需求虽然可以用准入控制器(Admission Webhook)实现,但用自定义Controller来实现更灵活,也更容易理解Controller的工作原理。
我们把这个Controller叫做NamespaceLabeler。它的调谐逻辑是:监听Namespace的创建和更新事件,如果Namespace带有team=backend标签但没有billing标签,就给它加上billing=backend-team。
5.2 项目结构与依赖
我们使用Go语言和client-go库来开发。项目结构如下:
namespace-labeler/ ├── main.go ├── controller.go └── go.modgo.mod文件内容:
module namespace-labeler go 1.21 require ( k8s.io/api v0.29.0 k8s.io/apimachinery v0.29.0 k8s.io/client-go v0.29.0 k8s.io/klog/v2 v2.110.1 )5.3 核心代码实现
先看controller.go的核心逻辑:
package main import ( "context" "fmt" "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" "k8s.io/client-go/util/workqueue" "k8s.io/klog/v2" ) type NamespaceLabeler struct { clientset kubernetes.Interface queue workqueue.RateLimitingInterface nsInformer cache.SharedIndexInformer } func NewNamespaceLabeler(clientset kubernetes.Interface) *NamespaceLabeler { factory := informers.NewSharedInformerFactory(clientset, 30*time.Second) nsInformer := factory.Core().V1().Namespaces().Informer() c := &NamespaceLabeler{ clientset: clientset, queue: workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter()), nsInformer: nsInformer, } nsInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { key, err := cache.MetaNamespaceKeyFunc(obj) if err == nil { c.queue.Add(key) } }, UpdateFunc: func(oldObj, newObj interface{}) { key, err := cache.MetaNamespaceKeyFunc(newObj) if err == nil { c.queue.Add(key) } }, }) return c }这段代码做了几件事:创建SharedInformerFactory、获取Namespace的Informer、创建RateLimitingQueue、注册EventHandler。EventHandler的逻辑很简单:把Namespace的Key放入队列。
接下来是调谐逻辑:
func (c *NamespaceLabeler) reconcile(key string) error { _, name, err := cache.SplitMetaNamespaceKey(key) if err != nil { return err } ns, err := c.clientset.CoreV1().Namespaces().Get( context.TODO(), name, metav1.GetOptions{}) if err != nil { return err } if ns.Labels["team"] != "backend" { return nil } if ns.Labels["billing"] == "backend-team" { return nil } if ns.Labels == nil { ns.Labels = make(map[string]string) } ns.Labels["billing"] = "backend-team" _, err = c.clientset.CoreV1().Namespaces().Update( context.TODO(), ns, metav1.UpdateOptions{}) if err != nil { return fmt.Errorf("failed to update namespace %s: %v", name, err) } klog.Infof("Labeled namespace %s with billing=backend-team", name) return nil }调谐逻辑非常清晰:获取Namespace,检查标签,如果满足条件就更新。注意这里用了Get而不是从Informer缓存中读取,这是为了保证拿到最新版本的对象,避免更新冲突。
最后是主循环:
func (c *NamespaceLabeler) Run(stopCh <-chan struct{}) { defer c.queue.ShutDown() go c.nsInformer.Run(stopCh) if !cache.WaitForCacheSync(stopCh, c.nsInformer.HasSynced) { klog.Error("Failed to sync caches") return } klog.Info("NamespaceLabeler started") wait.Until(c.runWorker, time.Second, stopCh) } func (c *NamespaceLabeler) runWorker() { for c.processNextItem() { } } func (c *NamespaceLabeler) processNextItem() bool { key, shutdown := c.queue.Get() if shutdown { return false } defer c.queue.Done(key) err := c.reconcile(key.(string)) if err != nil { klog.Errorf("Error reconciling %s: %v", key, err) c.queue.AddRateLimited(key) return true } c.queue.Forget(key) return true }主循环的逻辑是:启动Informer,等待缓存同步,然后进入无限循环从队列中取Key并调谐。如果调谐失败,调用AddRateLimited进行限速重试;如果成功,调用Forget清除重试计数。
5.4 编译与部署
编译:
go build -o namespace-labeler .部署到集群中,可以用Deployment来运行这个Controller:
apiVersion: apps/v1 kind: Deployment metadata: name: namespace-labeler namespace: kube-system spec: replicas: 1 selector: matchLabels: app: namespace-labeler template: metadata: labels: app: namespace-labeler spec: serviceAccountName: namespace-labeler containers: - name: labeler image: namespace-labeler:v1 imagePullPolicy: IfNotPresent还需要创建ServiceAccount、ClusterRole和ClusterRoleBinding,赋予它读取和更新Namespace的权限:
apiVersion: v1 kind: ServiceAccount metadata: name: namespace-labeler namespace: kube-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: namespace-labeler rules: - apiGroups: [""] resources: ["namespaces"] verbs: ["get", "list", "watch", "update", "patch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: namespace-labeler roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: namespace-labeler subjects: - kind: ServiceAccount name: namespace-labeler namespace: kube-system注意事项:自定义Controller的RBAC权限一定要最小化。只授予它需要的资源和操作,不要图省事直接给cluster-admin。我在实际环境中见过因为Controller权限过大导致误操作的事故。
6. 控制器排查实战:常见异常与速查手册
6.1 Deployment不创建Pod的排查路径
这是最常见的Controller问题之一。你创建了一个Deployment,但kubectl get pods看不到任何Pod。排查路径如下:
第一步:检查Deployment的状态。
kubectl get deployment <name> -o yaml重点看Status部分。如果conditions里有ReplicaFailure: True,说明ReplicaSet创建Pod失败了。message字段通常会给出具体原因。
第二步:检查ReplicaSet。
kubectl get rs -l app=<name> kubectl describe rs <rs-name>如果ReplicaSet不存在,说明Deployment Controller没有成功创建它。常见原因是selector和template的labels不匹配,或者RBAC权限不足。
第三步:检查资源配额和限制。
kubectl describe namespace <ns>如果Namespace有ResourceQuota,而Deployment请求的资源超过了配额,Pod就不会被创建。Events里会有exceeded quota的提示。
第四步:检查节点资源。
kubectl describe nodes | grep -A 5 "Allocated resources"如果所有节点的CPU或内存都已经分配完了,Pod会一直处于Pending状态。这时候需要扩容节点或者调整资源请求。
6.2 StatefulSet Pod卡在Pending的常见原因
StatefulSet的Pod卡在Pending,通常和存储有关。排查步骤:
第一,检查PVC的状态。
kubectl get pvc -l app=<name>如果PVC处于Pending状态,说明没有可用的PV或者StorageClass配置有问题。kubectl describe pvc会给出详细原因。
第二,检查StorageClass。
kubectl get storageclass确认StorageClass存在且是默认的。如果没有默认StorageClass,PVC不会自动绑定。
第三,检查节点亲和性。
StatefulSet的Pod可能有nodeAffinity或podAntiAffinity,导致没有节点满足条件。kubectl describe pod的Events里会有相关提示。
6.3 DaemonSet在部分节点上没有Pod
这个问题通常有三个原因:
原因一:节点有污点。检查节点的Taints:
kubectl describe node <node-name> | grep Taints如果节点有污点,DaemonSet的Pod需要对应的Toleration才能调度上去。
原因二:nodeSelector或affinity不匹配。检查DaemonSet的nodeSelector和节点的Labels是否匹配。
原因三:节点不可调度。如果节点处于NotReady状态或者被标记为Unschedulable,DaemonSet不会在上面创建Pod。
6.4 Job一直不完成
Job一直不完成,可能是以下原因:
- Pod反复失败,达到了
backoffLimit。检查Pod的日志和Events。 activeDeadlineSeconds设置得太短,任务还没跑完就被终止了。completions设置得太大,需要成功完成的Pod数量太多。- 任务本身是死循环或者阻塞了,需要检查应用逻辑。
6.5 常见问题速查表
| 现象 | 可能原因 | 排查命令 |
|---|---|---|
| Deployment不创建Pod | selector不匹配、RBAC不足、配额限制 | kubectl describe deploy |
| Pod一直Pending | 资源不足、PVC未绑定、亲和性不满足 | kubectl describe pod |
| StatefulSet启动顺序卡住 | 前一个Pod未Ready、PVC问题 | kubectl get pods -l app=X |
| DaemonSet部分节点无Pod | 污点、nodeSelector、节点不可调度 | kubectl describe node |
| Job不完成 | backoffLimit达到、超时、任务阻塞 | kubectl logs <pod> |
| HPA不扩缩容 | Metrics Server不可用、指标不满足 | kubectl top pods |
| Controller不响应 | Informer缓存未同步、WorkQueue阻塞 | 查看Controller日志 |
实操心得:排查Controller问题时,养成先看Events的习惯。
kubectl get events --sort-by=.lastTimestamp能按时间顺序列出所有事件,很多问题的答案就在里面。另外,Controller的日志级别通常可以通过启动参数调整,排查时把日志级别调到-v=4或更高,能看到调谐的详细过程。
7. 控制器选型与生产实践中的经验总结
7.1 如何根据业务场景选择控制器
选型其实有一个很简单的决策树:
- 服务是无状态的,需要滚动更新和回滚能力?用Deployment。
- 服务是有状态的,需要稳定的网络标识和存储?用StatefulSet。
- 需要在每个节点上运行一个守护进程?用DaemonSet。
- 需要运行一次性任务或定时任务?用Job或CronJob。
- 需要根据负载自动扩缩容?用HPA配合Deployment或StatefulSet。
- 需要限制自愿中断时的可用性?用PDB。
但实际场景往往比这复杂。比如一个服务虽然是有状态的,但状态可以外部化到Redis或数据库中,那用Deployment也没问题。再比如一个服务需要稳定的网络标识但不需要持久化存储,那用StatefulSet配合Headless Service也可以,只是不需要volumeClaimTemplates。
7.2 生产环境中的几个关键注意事项
第一,永远不要创建裸Pod。裸Pod没有任何自愈能力,节点故障时不会自动迁移。即使你只是临时测试,也建议用Deployment来创建Pod。
第二,合理设置资源请求和限制。资源请求影响调度,资源限制影响运行时。请求设得太小会导致节点超卖,设得太大又浪费资源。我的经验是:请求设置为正常负载下的P50值,限制设置为P99值的1.5倍左右。
第三,配置PodDisruptionBudget。对于多副本服务,PDB能保证在节点维护或集群升级时,不会同时终止太多Pod。比如minAvailable: 50%表示至少保持一半的Pod可用。
第四,使用PodAntiAffinity打散副本。默认情况下,同一个Deployment的多个Pod可能被调度到同一个节点上。如果这个节点故障,所有副本都会挂掉。通过PodAntiAffinity可以让副本尽量分散到不同节点。
第五,定期清理旧的ReplicaSet和Job。Deployment的revisionHistoryLimit默认是10,意味着最多保留10个旧ReplicaSet。CronJob的successfulJobsHistoryLimit默认是3,failedJobsHistoryLimit默认是1。这些历史记录会占用etcd空间,建议根据实际情况调整。
7.3 控制器调谐性能优化的几个方向
当集群规模变大时,Controller的调谐性能可能成为瓶颈。几个优化方向:
减少不必要的调谐。在EventHandler里做过滤,只把真正需要处理的对象放入队列。比如只处理标签匹配的对象,或者只处理Spec发生变化的更新事件。
使用工作池。默认情况下,一个Controller只有一个Worker在消费队列。如果调谐逻辑比较耗时,可以启动多个Worker并行处理。client-go的wait.Until配合多个goroutine就能实现。
合理设置Informer的ResyncPeriod。ResyncPeriod控制Informer多久触发一次全量同步。设得太短会增加API Server压力,设得太长会导致状态变化感知延迟。默认值通常是10小时,对于大多数场景够用了。
使用Indexer加速查询。如果调谐逻辑需要频繁查询关联对象,可以在Informer的Indexer上创建自定义索引,避免每次遍历整个缓存。
7.4 我踩过的一个真实坑:OwnerReference与级联删除
有一次我手动创建了一个ReplicaSet,但没有设置OwnerReference。后来我想删除它管理的Pod,发现删除ReplicaSet后Pod并没有被删除。原因是ReplicaSet的OwnerReference为空,k8s的垃圾回收器不知道这些Pod属于谁,所以不会级联删除。
这个坑的教训是:如果你手动创建资源,一定要正确设置OwnerReference。对于通过Controller创建的资源,Controller会自动设置OwnerReference,不需要你操心。但如果你在开发自定义Controller,记得在创建子资源时设置OwnerReference,否则垃圾回收会出问题。
OwnerReference的blockOwnerDeletion字段也值得注意。如果设为true,删除Owner对象时会被阻塞,直到所有子对象都被删除。这可以防止误删Owner导致子对象变成孤儿。
7.5 关于控制器的一个常见误解
很多人以为Controller是“事件驱动”的——收到事件就处理,没事件就不动。但实际上Controller是“状态驱动”的——它不关心事件本身,只关心当前状态和期望状态是否一致。事件只是触发调谐的一个信号,即使没有事件,Controller也会定期Resync,确保状态一致。
这个区别很重要,因为它决定了你写调谐逻辑的方式。如果你按事件驱动的思路写,可能会在EventHandler里直接处理业务逻辑,然后发现各种边界情况处理不了。正确的做法是把所有逻辑都放在调谐函数里,EventHandler只负责把Key放入队列。
我在实际开发自定义Controller时,调谐函数的第一行通常是重新从API Server获取对象的最新状态,而不是依赖事件里携带的对象。这样可以避免因为缓存延迟导致的错误决策。虽然多了一次API调用,但换来了正确性,这笔账是划算的。
最后分享一个排查Controller问题的小技巧:如果Controller的行为不符合预期,先看它的日志,再看Events,最后看资源对象的Status和Conditions。这三样东西能覆盖90%以上的问题。如果还找不到原因,那就检查RBAC权限和网络策略,这两个是最容易被忽略的。