凌晨两点被电话叫起来,团队里一位同事在群里贴了一串命令历史,说他把线上服务的副本数从 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 | 特点 | 典型场景 |
|---|---|---|---|
| merge | application/merge-patch+json | 简单合并,数组整体替换 | 改标签、改镜像、加注解 |
| strategic merge | application/strategic-merge-patch+json | 按 patchMergeKey 合并数组,可用$patch: delete | 精确操作 containers 数组 |
| json patch | application/json-patch+json | RFC6902,按路径操作,可删任意字段 | 删除某个 label、移除数组元素 |
| apply | application/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 次",最坏情况是跑几次,而且每次处理的都是最新状态。
整个链路是这样的:
- Reflector 对一个资源做 List-Watch,建立本地缓存(Informer Store)。
- EventHandler 收到 Add/Update/Delete 事件,把
namespace/name塞进工作队列。 - Worker 从队列取 key,调用 Reconcile。
- Reconcile 处理成功,key 出队;处理失败返回 error,key 按退避策略重新入队。
- 如果 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.goReconcile的完整骨架:
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: mapx-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 就永远漏在外面了。正确做法是:
- 创建 CR 时加上 finalizer。
- 收到删除事件时(
deletionTimestamp非空),先调用云 API 删 LB。 - 删成功后移除 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迁移一次 |
| 对象一直 Terminating | finalizer 未被移除 | 查控制器存活与日志,确认外部资源已清理后 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 历史快得多。这个习惯坚持了两年多,帮我省下的排查时间大概比配置它花的时间多一个数量级。