jimyag's Blog

Kubernetes Server-Side Apply:字段归属、冲突与接管

同一个 Deployment,发布系统管理镜像,HPA 管理副本数,运维人员临时改了资源限制。下一次 apply 时,哪些修改会保留,哪些会冲突?如果把所有冲突都加上 --force-conflicts,发布虽然通过了,其他管理者的字段也可能被接管。

Server-Side Apply(SSA)让 API Server 记录字段管理者,并在 Apply 修改其他管理者的字段时检测冲突。它并不锁住字段,也不会阻止普通更新或控制器持续写回期望值。理解它,需要区分对象版本、字段归属和控制器行为三件事。

PUT、Patch 与 Apply 表达不同的修改意图

Update 通常通过 PUT 提交对象的新表示,客户端需要保留仍想存在的字段,并使用正确的 resourceVersion 处理并发更新。普通 Patch 提交局部修改,例如 JSON Merge Patch 或 JSON Patch;它不要求客户端描述整个对象,但具体删除与数组处理语义由 patch 类型决定。

SSA 使用 Apply 类型的 PATCH,并携带 fieldManager。客户端提交自己希望管理的字段集合,API Server 结合当前对象和已有归属计算合并结果。kubectl apply --server-side 才走这条路径;传统客户端 apply 依赖本地三方合并和 last-applied annotation,不能把它们的冲突行为混为一谈。

PatchResource 解析并检查 Content-Type,把结果作为 patch 类型;真正选择不同 patcher 的分支位于 patcher.patchResource。

正在加载 GitHub 文件…
正在加载 GitHub 文件…

普通 JSON Patch、Merge Patch 和 Strategic Merge Patch 完成对象修改后,也会通过 UpdateNoErrors 更新 managedFields。前两种共用 jsonPatcher,Strategic Merge Patch 使用 smpPatcher;两个分支都保留字段管理记录。因此,“managedFields 中有记录”并不等于“对象刚才使用了 SSA”。

正在加载 GitHub 文件…
正在加载 GitHub 文件…

同一对象的 managedFields 可以同时出现 operation: Apply 和 operation: Update。记录包含 manager、API version、字段集合和时间等信息,其中 manager 是客户端提供的管理工作流标识,不是认证用户名,也不是 RBAC 权限边界。换一个 fieldManager 不会获得额外 API 权限,却会改变字段归属计算中“谁在声明这些字段”。

managedFields 记录到字段,不只是记录谁改过对象

以 ConfigMap 为例,manager A 管理 data.color,manager B 管理 data.owner,两者可以共存。managedFields 中的 fieldsV1 用字段路径集合表示归属,f:data 下的 f:color 对应这个 map key。它不是一份足以恢复历史对象的审计日志,也不是每次修改都追加一条不可变记录。

字段粒度依赖资源 schema。map 可以按 key 管理,也可能被 schema 声明为 atomic;列表可能整体原子管理,也可能按关联 key 或集合元素合并。因此,ConfigMap 两个 key 能分别管理,不能直接推广成任意 CRD 中两个列表项都能分别管理。CRD 的 x-kubernetes-list-type、x-kubernetes-list-map-keys 和 map 拓扑会影响字段集合及冲突边界。

Kubernetes v1.35.1 的 structuredMergeManager.Apply 将请求和当前对象转换为 schema 感知的 typed value,然后把它们连同 managed fields、manager 与 force 标志传给 structured-merge-diff。客户端不能自己在 Apply payload 里任意伪造 managedFields 来完成接管:该函数显式拒绝请求中非空的 metadata.managedFields。

正在加载 GitHub 文件…

冲突来自“改了别人管理的字段”

API Server 的 applyPatcher 从 PatchOptions 读取 force,并调用 FieldManager.Apply。冲突不是靠“两个 YAML 是否都有这个字段”简单判断,最终取决于当前值、提交值与归属集合。

正在加载 GitHub 文件…

本版本 Kubernetes go.mod 固定使用 structured-merge-diff v6.3.0。该版本的 Updater.update 把当前对象与新对象比较,再把本次修改或增加的字段,与其他 manager 的字段集合求交。若交集非空且未指定 force,则产生字段冲突。

正在加载 GitHub 文件…

由此可以理解两个常见结果:修改别的 manager 管理的字段值会冲突;提交与现有值一致的字段,则可以形成共享归属。共享归属不是任何一方都能随时改值,一方之后要改变另一个仍共同管理的字段,仍需处理冲突。

  flowchart TB
    A[收到 Apply 请求] --> B[按 schema 合并字段并处理省略字段]
    B --> C[计算与 live 对象的差异]
    C --> D{变化字段是否属于其他 manager}
    D -->|否| E[更新字段归属和对象]
    D -->|是| F{force 是否开启}
    F -->|否| G[返回字段冲突]
    F -->|是| H[移除冲突字段的旧归属]
    H --> E
    E --> I[继续准入校验和存储流程]

图描述字段合并的主要判断,不代表绕过 schema 校验、准入和存储并发控制。强制接管只处理归属冲突,不会让一个原本无权更新对象的请求获得权限,也不会跳过配额或安全约束。

省略字段也可能是一项删除操作

第一次 Apply 时声明 data.obsolete,第二次同一个 manager 不再提交它,表达的是不再管理这项字段。若没有其他管理者仍然持有它,并且字段可删除,服务端会从结果中移除它;有默认值的字段还可能重新得到默认值。

Updater.Apply 与 prune 保存当前 manager 原来的字段集合,构造新集合,再处理旧集合中本次省略的部分。prune 会考虑其他 manager 仍然拥有的字段,不会因为 A 的配置里没有 B 的字段,就把 B 的内容整体清空。

正在加载 GitHub 文件…

这条行为有一个操作后果:同一个管理工作流应该提交自己想持续管理的完整字段集合,而不是把 SSA 一律当成“随便补一点字段的 Merge Patch”。例如同一个 manager 先管理镜像和资源限制,后一次只发镜像,可能放弃并删除原来独占管理的资源字段。

稳定的 manager 名称有助于维持这个工作流。每次流水线用时间戳生成新名字,会让服务端把它们当成不同管理者;所有团队共用同一个名字,又可能让彼此的字段声明被当成同一工作流的前后版本。manager 应对应清楚的职责,而不是某一次执行。

将 replicas 的管理职责交给 HPA

开头的 HPA 场景需要先交接 spec.replicas,再从发布清单中省略它。若 HPA 尚未写入并取得字段归属,原 manager 直接移除自己的独占声明,prune 可能删除 replicas,Deployment 的默认处理再把它设为 1,造成非预期的临时缩容。默认值可以直接在 SetDefaults_Deployment 中核对。

正在加载 GitHub 文件…

交接前应读取实际 managedFields,确认 HPA 已取得 replicas 的归属,而不能只确认 HPA 对象存在。需要显式交接时,可以让一个专用交接 manager 先以相同的当前值共享该字段,再让原发布 manager 省略它;在 HPA 写入并接管后,交接 manager 再放弃归属。其他管理者仍持有字段时,addBackOwnedItems 会在 prune 中保留它。官方 SSA 交接说明描述了这项竞态;本文没有运行 HPA 交接实验,不能用下文 ConfigMap 的结果替代验证。

正在加载 GitHub 文件…

实验:两个 manager 先协作,再发生接管

实验在 zj 的隔离 kind 集群 article-api 执行,kind v0.33.0,kubectl 和服务端均为 Kubernetes v1.35.1,提交 8fea90b45245ef5c8ba54e7ae044d3e777c22500。配置、脚本、证据和复现说明见 k8sdev/article-api-apply。先按该目录 README 的固定提交准备步骤创建独立工作目录,再运行实验。

字段归属实验分别保存冲突、字段省略、强制接管与普通 Patch 的结果,关键断言如下。

正在加载 GitHub 文件…

实验只使用 ConfigMap,避免 Deployment 的控制器默认值和副本调谐影响字段观察。首先由 team-a 通过 SSA 创建:

1
2
data.color = blue
data.obsolete = remove-me

team-b 随后申请把 color 改成 red,实际被拒绝:

1
2
Apply failed with 1 conflict:
conflict with "team-a": .data.color

这里没有提交旧 resourceVersion,也没有先读取一份完整对象再 PUT。冲突指向具体字段和 manager,证明这是归属冲突,不是常见的“读到旧对象后替换更新”冲突。

随后让 team-b 只管理新的 data.owner=team-b,实际成功。读取到的 data 同时包含 color、obsolete 和 owner。接着 team-a 再次 Apply,只保留 color=blue,结果为:

1
2
color: blue
owner: team-b

obsolete 被删除,另一个 manager 的 owner 保留。这组结果同时检验了“省略自己独占的字段”和“不清空其他 manager 的字段”。它没有覆盖 schema 为 atomic 的 map 或所有 CRD 列表类型。

最后,team-b 使用 force 申请 color=red,保留自己原来的 owner。实际对象变成 red,managedFields 显示 color 归入 team-b 的 Apply 字段集合。team-a 再请求把 color 改回 blue 时,冲突消息改为指向 team-b。

强制接管改变了字段归属,没有让 team-b 永久控制这个字段。接下来用 fieldManager=operator 的普通 Merge Patch 把 color 改成 green,请求成功,managedFields 记录了相应 Update 管理者。这是 SSA 不等于字段锁的直接证据。

字段冲突和 resourceVersion 冲突怎样分别处理

字段冲突要求做职责判断:接受当前值并形成共享归属,停止提交该字段,或者在明确需要接管时使用 force。单纯 GET 最新对象再原样重试,不会自动解除其他 manager 的字段归属。

resourceVersion 冲突表示本次更新所依赖的对象版本已经过时。通常需要重新读取、重新计算修改,并在可重试条件下重试;仅仅加 force-conflicts 也不能替代这种并发处理。一个请求也可能同时受字段归属和对象版本前置条件约束,不能只根据 HTTP 409 判断是哪一种。Controller 调谐文章展示了旧版本替换请求的实际冲突。

“手工改完为什么又被改回”还要检查控制器。普通控制器更新可以覆盖 Apply 管理的字段,控制器也可以选择用 SSA 并显式处理冲突。managedFields 能帮助定位写入工作流,但不会暂停调谐循环。解决持续覆盖应先明确这个字段的控制权和期望状态,再协调各方写入;反复手工接管只会把职责冲突变成不断发生的 API 更新。

官方 SSA 文档提供完整字段管理语义。本文运行验证集中在 ConfigMap 的 map key,Deployment、HPA 和 CRD 的例子用于解释职责与 schema 影响,没有借用这组结果宣称那些控制器组合已经测试。

#Kubernetes #源码分析 #Kind