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

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

<!--more-->

## 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](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/patch.go#L76-L89) 解析并检查 Content-Type，把结果作为 patch 类型；真正选择不同 patcher 的分支位于 `patcher.patchResource`。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/patch.go#L76-L89" >}}

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/patch.go#L653-L674" >}}

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

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/patch.go#L367-L374" >}}

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/patch.go#L459-L467" >}}

同一对象的 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](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apimachinery/pkg/util/managedfields/internal/structuredmerge.go#L121-L153) 将请求和当前对象转换为 schema 感知的 typed value，然后把它们连同 managed fields、manager 与 force 标志传给 structured-merge-diff。客户端不能自己在 Apply payload 里任意伪造 managedFields 来完成接管：该函数显式拒绝请求中非空的 `metadata.managedFields`。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apimachinery/pkg/util/managedfields/internal/structuredmerge.go#L121-L153" >}}

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

API Server 的 [applyPatcher](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/patch.go#L500-L530) 从 PatchOptions 读取 force，并调用 FieldManager.Apply。冲突不是靠“两个 YAML 是否都有这个字段”简单判断，最终取决于当前值、提交值与归属集合。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/patch.go#L500-L530" >}}

本版本 Kubernetes [go.mod](https://github.com/kubernetes/kubernetes/blob/v1.35.1/go.mod#L123) 固定使用 structured-merge-diff v6.3.0。该版本的 [Updater.update](https://github.com/kubernetes-sigs/structured-merge-diff/blob/v6.3.0/merge/update.go#L130-L163) 把当前对象与新对象比较，再把本次修改或增加的字段，与其他 manager 的字段集合求交。若交集非空且未指定 force，则产生字段冲突。

{{< github-file url="https://github.com/kubernetes-sigs/structured-merge-diff/blob/v6.3.0/merge/update.go#L130-L163" >}}

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

```mermaid
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](https://github.com/kubernetes-sigs/structured-merge-diff/blob/v6.3.0/merge/update.go#L209-L254) 保存当前 manager 原来的字段集合，构造新集合，再处理旧集合中本次省略的部分。prune 会考虑其他 manager 仍然拥有的字段，不会因为 A 的配置里没有 B 的字段，就把 B 的内容整体清空。

{{< github-file url="https://github.com/kubernetes-sigs/structured-merge-diff/blob/v6.3.0/merge/update.go#L209-L254" >}}

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

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

## 将 replicas 的管理职责交给 HPA

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

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/apis/apps/v1/defaults.go#L38-L44" >}}

交接前应读取实际 managedFields，确认 HPA 已取得 replicas 的归属，而不能只确认 HPA 对象存在。需要显式交接时，可以让一个专用交接 manager 先以相同的当前值共享该字段，再让原发布 manager 省略它；在 HPA 写入并接管后，交接 manager 再放弃归属。其他管理者仍持有字段时，`addBackOwnedItems` 会在 prune 中保留它。[官方 SSA 交接说明](https://v1-35.docs.kubernetes.io/docs/reference/using-api/server-side-apply/#transferring-ownership)描述了这项竞态；本文没有运行 HPA 交接实验，不能用下文 ConfigMap 的结果替代验证。

{{< github-file url="https://github.com/kubernetes-sigs/structured-merge-diff/blob/v6.3.0/merge/update.go#L256-L282" >}}

## 实验：两个 manager 先协作，再发生接管

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

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

{{< github-file url="https://github.com/jimyag/k8sdev/blob/3cfa654905a750b1716de8c32aa42d7573220954/article-api-apply/run.py#L35-L50" >}}

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

```text
data.color = blue
data.obsolete = remove-me
```

`team-b` 随后申请把 color 改成 red，实际被拒绝：

```text
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`，结果为：

```text
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 调谐文章](/posts/kubernetes-controller-reconcile/)展示了旧版本替换请求的实际冲突。

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

[官方 SSA 文档](https://kubernetes.io/docs/reference/using-api/server-side-apply/)提供完整字段管理语义。本文运行验证集中在 ConfigMap 的 map key，Deployment、HPA 和 CRD 的例子用于解释职责与 schema 影响，没有借用这组结果宣称那些控制器组合已经测试。

