☰
K8s声明式API深度解析:控制器调谐、三路合并与CRD实战
2026/11/4 3:55:23 网站建设 项目流程

凌晨两点被电话叫起来,团队里一位同事在群里贴了一串命令历史,说他把线上服务的副本数从 12 改成了 3,想问怎么改回去。问题不在于改回去有多难,而在于没人知道改之前到底是多少——命令式操作留下的只有一堆 shell 历史,没有"当前应该是什么样"的可信记录。这件事之后我们把大部分运维动作都收敛到了 YAML 文件加版本控制,也就是K8s声明式API这一套东西。这篇内容想聊的就是这套机制到底怎么运转:它不只是"写个 YAML 然后kubectl apply"这么表面,背后有对象模型、控制器调谐循环、三路合并、字段所有权、CRD 扩展这一整套设计。适合已经能跑起集群、写过 Deployment 但经常被"字段莫名被改回去""删除卡在 Terminating""apply 报冲突"这类问题困扰的同学,也适合想自己写 Operator 把业务逻辑接进 K8s 的开发者。我会按"为什么这么设计—核心机制怎么运转—实操怎么落地—踩坑怎么排查"的顺序讲,代码和命令都可以直接抄。

1. 从一次误操作说起:声明式API到底解决了什么问题

1.1 命令式操作的三笔隐性账单

很多人对声明式的第一印象是"写文件比敲命令麻烦"。确实,改一个副本数,命令式只要kubectl scale一行,声明式要去改 YAML 再 apply。但如果只算敲键盘的成本,就把账算窄了。

第一笔账是状态不可追溯。命令式操作的语义是"执行一个动作",动作执行完就消失了,系统里只剩结果。你没法回答"这个副本数是谁在什么时候因为什么改成 3 的",除非有完整的审计日志。而声明式操作的语义是"系统应该处于这个状态",这个意图被固化在文件里,进版本控制,谁改的、改了什么、为什么改,全在 commit 记录里。

第二笔账是重复执行不收敛。kubectl scale --replicas=3跑两次结果一样,但kubectl rollout restart跑两次就会触发两次滚动;kubectl delete pod跑两次第二次直接报 NotFound。命令式操作对"当前处于什么状态"是敏感的,脚本里必须写一堆 if 判断,写着写着就变成了自己手搓的状态机。

第三笔账是异常恢复靠人。节点挂了、Pod 被驱逐、镜像仓库短暂不可用,命令式系统里这些都要靠监控告警加人工介入。声明式系统的核心假设是"实际状态会不断偏离期望状态",所以它内置了一个持续纠偏的循环,偏离了自动拉回来。

注意:声明式不等于"只能 apply 不能 delete"。kubectl delete依然是命令式动作,删除一个对象是"执行一次删除",而不是"声明它不存在"。真正声明式的删除方式是靠 ownerReference 级联或者 finalizer 清理,这个话题后面第 5 章会展开。

1.2 声明式API的三条硬约定

K8s 的 API 之所以能支撑声明式,靠的是三条约定,缺一不可。

第一条:API 对象描述的是终态,不是过程。你写replicas: 3,意思是"我希望最终有 3 个副本在跑",而不是"请帮我启动 3 个容器"。中间是滚动更新、是扩容缩容、是先创建后删除,这些过程细节由控制器决定,你不需要也不应该关心。这条约定带来的直接好处是:同一个 YAML 在单机开发环境和 500 节点集群里语义完全一致,控制器自己会根据规模选择策略。

第二条:提交操作必须幂等。同一份 YAML 连续 apply 十次,集群状态不应该发生第十一次变化。这一条看起来简单,实现起来坑很多——比如 YAML 里如果写了时间戳、随机串、自动生成的 ID,每次 apply 都会被识别成"变更",触发无意义的滚动更新。第 6 章会专门列这类坑。

第三条:系统内部存在持续的调谐循环。这条是最容易被忽略的。声明式 API 本身只是一堆存在 etcd 里的 JSON,它不会自己动。真正让它"活"起来的是控制器:watch 到对象变化,对比期望状态和实际状态,执行差异动作,然后回到等待。没有控制器的 CRD 就是一个死对象,你写replicas: 3也不会有人理你。

这三条约定合起来,构成一个闭环:用户声明终态 → 持久化到 etcd → 控制器感知 → 收敛实际状态 → 更新 status。理解了这条链路,后面所有的细节都是它的展开。

1.3 什么场景不该用声明式

说句实在话,声明式不是银弹,有些场景硬套会很难受。

一次性运维任务,比如"把某张表的历史数据清理一下",写个 Job 当然可以,但如果这个任务本身逻辑复杂、需要交互、执行时间不确定,用声明式反而增加负担——你得考虑 Job 的backoffLimit、activeDeadlineSeconds、Pod 失败后重试的幂等性。这类场景老老实实写个脚本跑一次更省事。

再比如需要严格顺序的变更,声明式天生不保证顺序。你 apply 一个包含 20 个对象的清单,API Server 是按顺序处理的,但控制器收敛是并行的。如果 B 依赖 A 创建完成,正确做法是用 initContainer 等待、用 readinessProbe 表达就绪,而不是指望 apply 顺序。

还有一种情况是外部系统状态无法观测。声明式的前提是控制器能读到实际状态。如果某个外部资源的状态查不到,控制器就没法判断是否收敛,只能盲目重试。这时候要么补一个状态查询接口,要么退化成一次性调用加人工确认。

2. 声明式API的底座:对象模型、spec/status与资源版本

2.1 spec写期望,status写现实

每一个 K8s 对象都遵循同一套骨架:apiVersion和kind决定它是什么类型,metadata放身份信息,spec放你想要的,status放系统实际观测到的。

apiVersion: apps/v1 kind: Deployment metadata: name: demo-web namespace: demo-prod spec: # 用户负责写 replicas: 3 selector: matchLabels: app: demo-web template: metadata: labels: app: demo-web spec: containers: - name: web image: registry.local/demo/web:1.4.2 ports: - containerPort: 8080 status: # 控制器负责写,用户不要碰 observedGeneration: 7 replicas: 3 readyReplicas: 3 availableReplicas: 3 conditions: - type: Available status: "True" reason: MinimumReplicasAvailable

这个分工不是约定俗成,而是有强制机制保障的。当 CRD 或内置资源开启了subresources.status,用户通过主资源端点提交 status 字段会被直接丢弃。你可以自己验一下:写个带上status的 YAML 去 apply,然后kubectl get -o yaml看回来,status 里不会有你写的内容。

提示:手工往 YAML 里写 status 是新手最常见的错误之一。它不会报错,只是静默失效,然后你会困惑"为什么我改了 status 没生效"。如果确实需要改 status 做调试(比如模拟某个条件为 True 来跳过等待),要用kubectl patch <kind> <name> --subresource=status --type=merge -p '{...}'。

observedGeneration这个字段值得单独说一句。metadata.generation在 spec 每次变更时递增,控制器处理完某一版 spec 后,把那个版本号写进status.observedGeneration。所以判断"控制器是否已经处理了我最新的修改",看这两个值是否相等就够了,不用去猜控制器内部状态。写 Operator 的时候这个字段一定要维护,否则调用方没法判断收敛进度。

2.2 metadata里那几个容易忽略但很关键的字段

metadata看着只是名字和标签,实际上有几个字段直接决定了声明式行为。

resourceVersion是 etcd 的 MVCC 版本号,每次对象写入都会变。它是乐观并发控制的核心,第 2.3 节细讲。

uid是对象的全局唯一 ID,跨删除重建保持不变的是 name,变的是 uid。判断"这个对象是不是我上次看到的那个"必须用 uid,用 name 会出错——同名对象删了重建,name 一样但 uid 完全不同。

finalizers是一个字符串数组,只要非空,对象就删不掉。这是实现"删除前先清理外部资源"的标准手段,后面第 5 章会详细写怎么用。

ownerReferences描述归属关系,用于级联删除和垃圾回收。这里有个关键区分:blockOwnerDeletion: true加上controller: true表示这是主控制器,一个对象只能有一个 controller owner,但可以有多个普通 owner。

managedFields是服务端字段管理的记账本,记录每个字段是被哪个管理者(manager)写入的。这个字段在 SSA(Server-Side Apply)里是核心,第 4 章展开。

annotations和labels的区别也值得强调:labels 会被 selector 索引,参与对象筛选,值有长度和字符集限制(63 字符,字母数字加-_.);annotations 是纯元数据,不参与索引,可以放很长的 JSON、URL、配置片段。放错地方会导致 selector 匹配不上或者超长被截断。

2.3 resourceVersion与乐观并发:为什么你会遇到409

多人协作改同一个对象,冲突是必然的。K8s 的选择是乐观并发:不锁对象,写入时带上你读到的resourceVersion,如果期间别人改过,版本号对不上,API Server 直接返回 409 Conflict,让你重新读、重新改、重新提交。

# 先读出当前对象和它的 resourceVersion kubectl get deployment demo-web -n demo-prod -o jsonpath='{.metadata.resourceVersion}' # 输出比如 184223 # 带着这个版本号去更新,如果期间没人动过就成功 kubectl replace -f deployment.yaml # yaml 里 metadata.resourceVersion 必须是 184223

这套机制的好处是不会因为一个慢客户端把整个对象锁死,坏处是客户端必须处理冲突重试。kubectl apply内部就做了重试,所以你很少感知到;但自己写控制器用Update时,冲突是家常便饭,必须写 retry 逻辑。

// 控制器里更新对象的标准姿势:读-改-写 + 冲突重试 func updateWithRetry(ctx context.Context, c client.Client, obj *appsv1.Deployment, mutate func(*appsv1.Deployment)) error { return retry.RetryOnConflict(retry.DefaultRetry, func() error { // 每次重试都重新读最新版本 if err := c.Get(ctx, client.ObjectKeyFromObject(obj), obj); err != nil { return err } mutate(obj) return c.Update(ctx, obj) }) }

这里有个实操心得:不要缓存对象然后直接 Update。很多人从 informer 缓存里拿对象改一改就提交,缓存可能已经过期几百毫秒,冲突率会非常高。正确姿势是重新Get(走 API Server,不走缓存),改,提交。client-go的Patch接口更省事,merge patch 和 strategic merge patch 都不需要带 resourceVersion,天然规避冲突,代价是表达能力弱一些——删数组元素、精确控制某个字段为 null 会比较绕。

Patch的三种类型在实际工作中各有用途,我整理成表方便对照:

Patch 类型Content-Type特点典型场景
mergeapplication/merge-patch+json简单合并,数组整体替换改标签、改镜像、加注解
strategic mergeapplication/strategic-merge-patch+json按 patchMergeKey 合并数组,可用$patch: delete精确操作 containers 数组
json patchapplication/json-patch+jsonRFC6902,按路径操作,可删任意字段删除某个 label、移除数组元素
applyapplication/apply-patch+yaml服务端字段所有权管理与 kubectl apply 等价

实际排查中遇到"我用 merge patch 想把容器删掉,结果整个数组被替换了",就是没搞清 merge 和 strategic merge 的区别。strategic merge 依赖 Go struct 上的patchMergeKeytag,内置资源都有,CRD 默认没有,除非你在 OpenAPI schema 里显式声明x-kubernetes-list-map-keys。

3. 控制器模式:声明式API能"自愈"的真正原因

3.1 reconcile循环:只拿到key,不拿到对象

控制器最核心的函数是 Reconcile,它的签名很有意思:输入不是对象本身,而是namespace/name这个 key。

func (r *DemoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { // req 里只有 NamespacedName,没有对象内容 var obj appsv1.Deployment if err := r.Get(ctx, req.NamespacedName, &obj); err != nil { if apierrors.IsNotFound(err) { return ctrl.Result{}, nil // 对象被删了,什么都不用做 } return ctrl.Result{}, err } // ... 对比期望与实际,执行差异动作 return ctrl.Result{}, nil }

为什么只给 key 不给对象?因为如果直接把事件里的对象内容传进来,处理逻辑会天然依赖"事件发生那一刻的快照",而这个快照很可能已经过期。给你 key,逼你每次都从 API Server(或者 local cache)重新读最新状态,这样无论队列里堆积了多少次同一对象的变更事件,处理时看到的都是当前最新状态,天然幂等。这个设计决策在整个控制器模式里是最关键的一条。

配套的是工作队列,通常是 rate-limiting queue。同一个 key 在队列里只会存在一份,重复事件会被合并。所以你不用担心"一次 apply 触发 50 个事件导致 reconcile 跑 50 次",最坏情况是跑几次,而且每次处理的都是最新状态。

整个链路是这样的:

  1. Reflector 对一个资源做 List-Watch,建立本地缓存(Informer Store)。
  2. EventHandler 收到 Add/Update/Delete 事件,把namespace/name塞进工作队列。
  3. Worker 从队列取 key,调用 Reconcile。
  4. Reconcile 处理成功,key 出队;处理失败返回 error,key 按退避策略重新入队。
  5. 如果 Reconcile 返回RequeueAfter,key 在指定时间后重新入队,用于轮询式检查。

这里有个常见误解:很多人以为控制器是"收到事件才工作"。实际上事件只是触发手段,真正的工作模式是"拿最新状态,对齐,结束"。即使没有事件,定期 resync 也能让状态收敛。所以丢掉几个事件不会导致状态永久不一致,最坏是收敛慢一点。这个容错特性是声明式系统能扛住 API Server 短暂不可用的原因。

3.2 幂等性的三条落地法则

幂等听起来简单,写起来容易翻车。我总结了三条在真实项目里反复验证过的法则。

法则一:先读,再判断,再动手。不要基于参数推断当前状态,一定去读。比如你想把副本数调到 3,不是直接调scaleAPI,而是先读 Deployment 看spec.replicas是不是已经是 3,是就跳过。看似多了一次读,实际上避免了一大堆无意义的更新和随之而来的滚动重启。

法则二:只做差异动作,不做全量重建。想让 Service 的 selector 匹配新的标签,应该是 patch selector,而不是删除重建 Service。重建会导致 ClusterIP 变化、连接中断。同理,更新 ConfigMap 内容不要删了重建,否则挂载它的 Pod 会被触发重启。判断"什么是差异"依赖对比逻辑,equality.Semantic.DeepEqual或者apiequality.Semantic.DeepDerivative都可以用,后者用于"期望值是实际值的子集"这种场景。

法则三:失败就返回 error,让队列去重试,不要在 Reconcile 里写 while 循环等待。很多人喜欢在 Reconcile 里for { if ready { break }; time.Sleep(...) },这会占住一个 worker,worker 数量有限(默认并发度取决于配置),几个慢任务就能把整个控制器堵死。正确做法是返回RequeueAfter,让 key 过一会儿再来。

// 等待外部资源就绪:不要阻塞,交回队列 if !externalResourceReady(obj.Status.ExternalID) { return ctrl.Result{RequeueAfter: 10 * time.Second}, nil }

这三条法则背后是同一个思路:Reconcile 应该是一个快速的、无副作用的纯函数式判断加少量必要写入。任何慢操作、阻塞等待、外部长事务,都要挪出去。

3.3 一个最小控制器的骨架

光讲原理太空,给一个能跑起来的最小结构。用 controller-runtime,项目结构大致如下:

demo-operator/ ├── api/v1/demo_types.go ├── controllers/demo_controller.go ├── config/crd/bases/ └── main.go

Reconcile的完整骨架:

func (r *DemoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := log.FromContext(ctx) var demo demov1.DemoApp if err := r.Get(ctx, req.NamespacedName, &demo); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } // 删除流程:先处理 finalizer if !demo.DeletionTimestamp.IsZero() { if controllerutil.ContainsFinalizer(&demo, "demo.example.com/cleanup") { if err := r.cleanupExternal(ctx, &demo); err != nil { return ctrl.Result{}, err } controllerutil.RemoveFinalizer(&demo, "demo.example.com/cleanup") if err := r.Update(ctx, &demo); err != nil { return ctrl.Result{}, err } } return ctrl.Result{}, nil } // 确保 finalizer 存在 if !controllerutil.ContainsFinalizer(&demo, "demo.example.com/cleanup") { controllerutil.AddFinalizer(&demo, "demo.example.com/cleanup") if err := r.Update(ctx, &demo); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, nil // 更新后重入,下一轮继续 } // 收敛实际状态 if err := r.ensureDeployment(ctx, &demo); err != nil { return ctrl.Result{}, err } // 更新 status demo.Status.Ready = true demo.Status.ObservedGeneration = demo.Generation if err := r.Status().Update(ctx, &demo); err != nil { return ctrl.Result{}, err } log.Info("reconciled", "name", demo.Name) return ctrl.Result{}, nil }

注意Update之后立刻return的那一处。因为 Update 会改变 resourceVersion,当前手里的对象已经过期,继续往下用会冲突。这种"写完就返回,下一轮重入"的模式在控制器里非常常见,看着像多跑了一轮,实际上是最稳的写法。

4. kubectl apply的台前幕后:三路合并与字段管理

4.1 apply其实是一次三路合并

很多人以为kubectl apply -f x.yaml就是把文件内容 POST 上去。实际不是,客户端 apply 做的是三路合并:

  • 路一:last-applied-configuration,存在对象的一个 annotation 里,记录上一次 apply 的完整内容。
  • 路二:本地文件,这次要 apply 的内容。
  • 路三:线上对象,API Server 里当前的实际内容。

合并规则大致是:本地文件里有的字段,以本地为准;本地文件里删掉但 last-applied 里有的字段,从线上对象里删掉;last-applied 里没有、线上对象里有的字段(别人加的,或者控制器写的),保留不动。

第三条规则是 apply 能和其他工具共存的关键。比如 HPA 往 Deployment 的spec.replicas写值,你的 YAML 里没写 replicas,apply 就不会把它改回去。反过来,如果你 YAML 里写了replicas: 3,HPA 扩到 10 之后你再 apply,会被改回 3,然后 HPA 又改成 10,来回拉锯。这是很经典的坑,正确做法是在用 HPA 的场景下把 replicas 从 YAML 里彻底删掉,或者用 SSA 明确放弃该字段的所有权。

last-applied-configuration这个 annotation 有两个副作用值得知道。一是它会显著增大对象体积,大对象(比如几千行的 ConfigMap)会让 annotation 超过 256KB 上限直接报错。二是它容易被误删,有人手工kubectl edit把 annotation 清了,之后 apply 的行为就变得诡异——所有字段都变成"线上有、last-applied 没有",于是全部保留,你删字段的操作完全失效。这种情况用 SSA 就能规避。

4.2 字段冲突:到底是谁在改我的字段

SSA 的核心是managedFields,它记录每个字段由哪个 manager 拥有。当两个 manager 同时声明同一个字段且值不同,就会报冲突。

kubectl apply --server-side -f deployment.yaml # 输出:Apply failed with 1 conflict: conflict with "kubectl-client-side-apply" # using apps/v1: .spec.replicas

这条报错的意思是:.spec.replicas这个字段被另一个叫kubectl-client-side-apply的 manager 占着,值和你现在要写的不一样。处理方式有三种,选哪种取决于你想让谁赢。

第一,如果确认该由你接管,加--force-conflicts:

kubectl apply --server-side --force-conflicts -f deployment.yaml

这会强制把字段所有权转移到当前 manager,原来的 manager 失去所有权。

第二,如果只是想看看到底谁在管:

kubectl get deployment demo-web -n demo-prod -o yaml --show-managed-fields | \ yq '.metadata.managedFields[] | {manager, operation, fieldsType}'

第三,如果是 HPA 和你的 YAML 打架,正确解法是从 YAML 里删掉该字段,而不是天天 force。

一个实际项目里的经验:过渡期混用客户端 apply 和服务端 apply 会制造大量冲突。因为两者用的 manager 名字不同(kubectl-client-side-apply和kubectl),字段所有权会来回转移。要么全量切 SSA,要么先别切。切换时可以用kubectl apply --server-side --force-conflicts跑一遍全量清单,把所有权一次性收归,之后就清净了。

4.3 diff和dry-run:上线前的两道闸

kubectl diff是我最推荐养成习惯的命令,它模拟一次服务端 apply,把结果和线上对象做 diff 打印出来,但不真正写入。

kubectl diff -f deployment.yaml

输出会标出哪些字段会被新增、删除、修改。放到 CI 里当一个门禁,凡是 diff 结果里出现意料之外的字段变更(比如意外的镜像 tag 变化、意外的 label 删除),直接卡住不让合并。这一招拦下过好几次误提交的环境变量覆盖。

--dry-run=server也很实用,它会真的走一遍 API Server 的准入链(admission webhook、schema 校验、配额检查),只是最后不落盘。

kubectl apply --dry-run=server -f deployment.yaml

相比--dry-run=client(纯本地校验,不联网),服务端 dry-run 能发现 webhook 拒绝、配额不足、字段类型不匹配这些客户端查不出来的问题。写 Operator 的 CRD 时尤其有用,schema 里required字段漏了、enum值写错了,客户端 dry-run 完全看不出来。

提示:--dry-run=server对某些有副作用的 webhook 依然可能触发外部调用(比如某些验证型 webhook 会去查数据库)。如果你们的 webhook 实现里带外部依赖,跑 dry-run 前最好和平台团队确认一下。

5. CRD与Operator:把声明式扩展到自己的领域

5.1 CRD设计里的几个关键取舍

写 CRD 的第一件事是设计 schema。这里最大的坑是不写 validation。K8s 允许 CRD 的 schema 为x-kubernetes-preserve-unknown-fields: true,也就是什么字段都收。图省事的后果是所有字段类型错误都要到控制器里才发现,而且错误信息非常难定位。

正确的写法是把能约束的都约束住:

apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: demoapps.example.com spec: group: example.com scope: Namespaced names: kind: DemoApp plural: demoapps singular: demoapp shortNames: [dapp] versions: - name: v1 served: true storage: true subresources: status: {} # 开启 status 子资源,用户写 status 会被拒绝 additionalPrinterColumns: # 让 kubectl get 有意义的列 - name: Ready type: string jsonPath: .status.ready - name: Age type: date jsonPath: .metadata.creationTimestamp schema: openAPIV3Schema: type: object required: [spec] properties: spec: type: object required: [image, replicas] properties: image: type: string minLength: 1 replicas: type: integer minimum: 1 maximum: 50 # 上限很重要,防止误写导致集群被打爆 env: type: array items: type: object required: [name, value] properties: name: { type: string } value: { type: string } x-kubernetes-list-map-keys: [name] x-kubernetes-list-type: map

x-kubernetes-list-type: map这行值得单独说。它让这个数组在 SSA 里按name作为 key 做合并,而不是整体替换。没有它,两个 manager 各管一个环境变量会互相覆盖;有了它,各管各的,互不干扰。这个细节在官方文档里藏得比较深,但实际做多团队协作的 CRD 时必须加上。

另一个取舍是要不要拆多个 version。经验是:第一个版本稳定之前别急着发 v1alpha2,版本多了 conversion webhook 的维护成本非常高。确有需要时,用served: false, storage: true让老版本还能读但不接受写入,逐步迁移。

5.2 finalizer和ownerReference:删除流程的两个关键

声明式的删除不是简单的 DELETE 请求,而是两步:先标记deletionTimestamp,等所有 finalizer 清理完,才真正从 etcd 移除。

这套机制解决的是"外部资源谁来清理"的问题。假设你的 CRD 在云上创建了一个负载均衡器,删除 CR 时如果对象直接消失,那个 LB 就永远漏在外面了。正确做法是:

  1. 创建 CR 时加上 finalizer。
  2. 收到删除事件时(deletionTimestamp非空),先调用云 API 删 LB。
  3. 删成功后移除 finalizer,对象才会真正被删除。
if !obj.DeletionTimestamp.IsZero() { if controllerutil.ContainsFinalizer(obj, finalizerName) { // 幂等清理外部资源 if err := r.deleteExternalLB(ctx, obj); err != nil { return ctrl.Result{}, err // 失败就返回,下一轮继续重试 } controllerutil.RemoveFinalizer(obj, finalizerName) return ctrl.Result{}, r.Update(ctx, obj) } return ctrl.Result{}, nil }

ownerReference解决的是另一个问题:级联删除。控制器创建出来的 Deployment、Service、ConfigMap,都设上指向 CR 的 ownerReference,删 CR 时这些子资源自动被 GC 掉。注意 owner 和 child 必须在同一个命名空间(除非是 cluster-scoped 的 owner),跨命名空间的引用会直接报错。

这里有个非常经典的坑:finalizer 卡住导致对象删不掉。表现为kubectl delete之后对象一直在,kubectl get能看到deletionTimestamp但就是不走。原因通常是控制器挂了、或者清理逻辑一直返回错误。排查顺序是:先看控制器 Pod 是否存活,再看控制器日志里这个对象的 reconcile 是否在报错,最后看 finalizer 名字是否对应一个已经不存在的控制器(比如换了一版 Operator,finalizer 名字变了,老对象上还挂着老名字)。

紧急情况下可以用 patch 强摘 finalizer,但一定要确认外部资源确实已经清理干净:

kubectl patch demoapp demo-1 -n demo-prod --type=json \ -p='[{"op":"remove","path":"/metadata/finalizers/0"}]'

5.3 Operator落地的四个步骤

从零到能用的 Operator,我习惯按四步推进。

第一步,先手写一遍 YAML 跑通。不要一上来就写代码,先在纸上把想要的资源组合手动创建出来,确认它们能正常工作、依赖顺序清楚、各自的字段都对。这一步如果跳过,后面控制器里的 bug 会多到怀疑人生。

第二步,用 kubebuilder 生成骨架。kubebuilder init加上kubebuilder create api能把 CRD、控制器脚手架、Makefile 全生成好。别自己手搓项目结构,那些 boilerplate 里的细节(比如 leader election、metrics server、健康检查端口)漏一个就可能在生产环境出问题。

第三步,Reconcile 只做一件事。一个 Reconcile 里塞七八个资源的创建逻辑,是后期维护的噩梦。拆成多个小函数,每个函数只负责一个资源的"读—比对—写入",用controllerutil.CreateOrUpdate或者controllerutil.CreateOrPatch包起来。

deploy := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{ Name: obj.Name + "-web", Namespace: obj.Namespace, }} op, err := controllerutil.CreateOrUpdate(ctx, r.Client, deploy, func() error { // 这个函数会在 Create 和 Update 两种情况下都被调用 deploy.Spec.Replicas = &obj.Spec.Replicas deploy.Spec.Template.Spec.Containers = []corev1.Container{{ Name: "web", Image: obj.Spec.Image, }} return controllerutil.SetControllerReference(obj, deploy, r.Scheme) })

CreateOrUpdate内部会先 Get,然后调用 mutate 函数,再对比前后是否变化,变了才 Update。这就是第 3.2 节说的"只做差异动作"的直接应用,比手写"Get—判断—Update"省事且不容易错。

第四步,给 Reconcile 加指标和日志。至少要有 reconcile 总次数、失败次数、耗时分布这几项,标签里带上 CR name。出问题时能直接看出是哪个对象在反复失败,而不是从一堆日志里捞。

6. 常见问题与排查实录

6.1 高频问题速查表

下面这些是我在实际项目里反复遇到的,按现象、根因、处理三列整理,遇到可以对着查。

现象常见根因处理方式
apply 后字段被改回去控制器或 HPA 在管理该字段从 YAML 删掉该字段,或改用 SSA 明确放弃所有权
apply 报conflict with "kubectl-client-side-apply"客户端 apply 与服务端 apply 混用全量--server-side --force-conflicts迁移一次
对象一直 Terminatingfinalizer 未被移除查控制器存活与日志,确认外部资源已清理后 patch 摘除
status 不更新控制器未维护 observedGeneration,或 status 子资源未开启检查 CRD 的subresources.status,检查控制器返回
CRD 加了新字段不生效CRD 本身未重新 apply改完 schema 要 apply CRD,再 apply CR
删除字段没反应last-applied-configuration被手工清掉用 SSA 重跑一次接管,或补回 annotation
selector 不匹配导致 Pod 不归 Deployment 管labels 与spec.selector.matchLabels不一致注意 Deployment 的 selector 创建后不可变,错了只能重建
多环境 YAML 差异难维护直接复制多份文件用 kustomize 的 overlay 管理差异

6.2 几个真实踩过的坑

坑一:把随机值写进了 spec。有个项目在 Deployment 的 annotation 里写了个构建时间戳,每次 CI 构建重新生成,结果每次 apply 都触发滚动更新,本来只是改了个日志级别,整个服务重启了。判断标准很简单:spec 里的内容应该是"期望状态",任何每次生成都不同的东西都不该放进去。时间戳、git commit hash、随机后缀,这些放 labels 或者独立的 ConfigMap 里,别放 template 里。

坑二:ConfigMap 更新后 Pod 没重启。挂载为 volume 的 ConfigMap 热更新后,容器内文件最终会变,但应用未必重读;用envFrom注入的都根本不会更新。这个问题没有优雅的通用解,我的做法是给 ConfigMap 的名字加内容 hash 后缀(demo-config-a3f9c1),Deployment 引用这个名字,内容一变名字就变,Pod 模板跟着变,自然触发滚动更新。kustomize 的configMapGenerator自带这个能力,namePrefix或者hashSuffix都能用。

坑三:CRD 的 enum 加了新值,老集群不认。改完 CRD 的 schema 直接 apply,发现新值还是被拒。原因是有时候 API Server 的 OpenAPI 缓存需要几秒刷新,或者你 apply 的是 CR 而不是 CRD。排查方法:kubectl get crd demoapps.example.com -o yaml看 schema 里到底有没有新枚举值。另外,K8s 对 CRD schema 的变更有兼容性要求,删字段、改类型都会被拒,必须先升版本或者用x-kubernetes-preserve-unknown-fields过渡。

坑四:多个控制器抢同一个对象。曾经有两个团队各自写了个控制器,都在 watch 同一类 Deployment 并往里加 sidecar。结果是 A 加了 B 删,B 加了 A 删,Pod 永远起不来。这种问题在日志里表现为两个 controller 的 reconcile 频繁交替。解法是用 SSA 让 A 只声明自己关心的字段(比如只 apply 一个 patch,只包含 sidecar 容器),或者干脆合并成一个控制器。在共享集群里,多个控制器改同一个对象前一定要约定字段所有权,否则后期排查成本极高。

坑五:reconcile 里调外部 API 没设超时。有个控制器在 Reconcile 里调云厂商接口查实例状态,接口偶尔会卡 30 秒。默认并发度下,几个卡住的请求就把 worker 占满,整个控制器对所有对象的处理都停了。修法很简单,给 HTTP client 设Timeout,并且把外部调用放到RequeueAfter模式里轮询,不在 Reconcile 里同步等。

6.3 上线前的自检清单

每次交付一个新的声明式清单或 Operator,我会过一遍这几条:

YAML 里有没有硬编码的环境差异(IP、域名、账号)?有没有该用 Secret 却写成明文的字段?resources.requests和limits是否都设置了,尤其是内存 limit 不设会导致节点 OOM 时被连带影响?readinessProbe和livenessProbe的阈值是否合理,会不会因为启动慢被反复重启?Pod 的terminationGracePeriodSeconds是否大于应用实际处理完存量请求的时间?

CRD 这边要检查:schema 是否有required,关键字段是否有minimum/maximum/pattern约束,数组是否声明了x-kubernetes-list-type: map,是否开启了status子资源,additionalPrinterColumns是否让kubectl get有可读输出。

控制器这边要检查:有没有维护observedGeneration,删对象的路径上 finalizer 是否成对出现,Reconcile 里有没有阻塞调用,冲突重试是否用了RetryOnConflict,有没有给关键的失败路径打点。

最后再分享一个我一直在用的小技巧:把kubectl diff做成一个 git hook,提交前自动跑一遍,把 diff 结果贴进 commit message 里。这样一来,每次变更留下了"我预期改什么"和"实际会改什么"的双份记录,出问题时对照着看,比翻 YAML 历史快得多。这个习惯坚持了两年多,帮我省下的排查时间大概比配置它花的时间多一个数量级。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询