Kubernetes 基础体系 · 第 81/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。

Kubernetes Server-Side Apply:Field Manager、冲突、所有权和 Controller

Server-Side Apply(SSA,服务端应用)是 Kubernetes API Server 提供的一种声明式写入机制。客户端提交“我希望哪些字段是什么值”,API Server 根据对象的现状、字段所有权和 OpenAPI Schema 合并这份意图,并在无法安全合并时报告冲突。

理解 SSA 不能只记住 kubectl apply --server-side 这个命令。它背后至少包含四个相互关联的概念:

  • Field Manager:一次写操作的身份。
  • 字段所有权:某个 Field Manager 当前负责维护哪些字段。
  • 冲突:一个写入者试图改变另一个写入者拥有的字段。
  • Controller:持续观察集群状态,并通过幂等的声明式写入推动状态收敛的程序。

这套机制解决的不是“如何发送 YAML”,而是多个客户端如何共同维护同一个 Kubernetes 对象,同时尽量避免互相覆盖。


一、先区分三种写入语义

同一个对象可以通过多种 API 操作修改,但这些操作的语义不同。

1. Create:创建完整对象

POST /api/.../namespaces/{namespace}/{resource} 通常用于创建对象。

客户端提交对象的初始内容,API Server 进行认证、授权、准入、默认值处理和持久化。对象不存在时,Create 没有“与旧对象合并”的问题。

2. Update:替换整个对象

PUT 对应 Update。客户端通常先读取完整对象,修改本地副本,再提交整个对象。

Update 的核心语义更接近:

Onew=OrequestO_{new} = O_{request}

其中:

  • OrequestO_{request} 是请求中的完整对象;
  • OnewO_{new} 是服务器最终保存的对象。

Update 通常依靠 metadata.resourceVersion 做乐观并发控制。如果读取对象后,别人已经修改了它,Update 可能返回 409 Conflict

但 Update 的冲突粒度通常是整个对象的版本,不是某个字段。即使两个客户端修改的是互不相关的字段,只要基于同一个旧版本提交,也可能发生资源版本冲突。

3. Patch:部分修改

Patch 只提交部分变化,但不同 Patch 类型的语义不同:

  • JSON Patch:按照操作数组修改路径;
  • JSON Merge Patch:按 JSON 对象合并,数组通常整体替换;
  • Strategic Merge Patch:Kubernetes 内部部分内置类型支持的语义;
  • Apply Patch:Server-Side Apply 使用的声明式 Patch 类型。

SSA 的 Apply Patch 不只是“另一种 Patch 格式”。它还携带 Field Manager,并让 API Server 维护字段级的管理关系。


二、SSA 的核心模型:对象、配置和字段集

设 Kubernetes 中当前对象为 OO,客户端提交的声明式配置为 CC

普通的“发送 YAML”容易被理解成:

Onew=merge(O,C)O_{new} = merge(O, C)

但 SSA 实际上还需要维护一组字段所有权:

M:managerFieldSetM : manager \rightarrow FieldSet

其中:

  • manager 是 Field Manager 的名称;
  • FieldSet 是字段路径集合;
  • M(m)M(m) 表示 Manager mm 当前声明或管理的字段。

例如,下面这份配置:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo
  namespace: default
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: app
          image: nginx:1.27

经过 Schema 解析后,可以抽象为字段集合:

spec.replicas
spec.template.spec.containers[name=app].image

注意这里不是简单的 JSON 文本比较。Kubernetes 会根据资源的 OpenAPI Schema 理解字段类型、嵌套关系和列表合并规则。

一次 SSA 可以抽象为:

  1. 解析配置 CC,得到字段集合 FCF_C
  2. 对每个配置字段,比较当前值与其他 Manager 的所有权;
  3. 若存在其他 Manager 拥有该字段,且新值会改变该字段,则产生冲突;
  4. 若没有冲突,则将配置值合并到对象;
  5. 更新当前 Manager 的字段集合;
  6. 对当前 Manager 之前拥有、但本次配置省略的字段,按照字段结构决定是否放弃所有权以及是否删除字段。

最后一点非常重要:Apply 配置不是“只修改配置中出现的字段,然后永远保留自己以前的字段”。它表达的是当前 Manager 对某部分字段的声明。一个 Manager 后续省略自己原来管理的字段,可能意味着放弃该字段;如果没有其他 Manager 接管,它还可能导致该字段被删除或恢复为默认行为。


三、Field Manager:写入身份,而不是用户账号

3.1 Field Manager 的定义

Field Manager 是一次写操作使用的逻辑名称,例如:

platform-team
web-deployer
nginx-controller
kubectl

它不是 Kubernetes 用户名,也不是 ServiceAccount 名称。认证身份由 Kubernetes 的认证和授权系统决定;Field Manager 主要用于字段管理和冲突判断。

一个 HTTP 请求可以同时具有:

  • 认证身份:例如 system:serviceaccount:operators:web-controller
  • Field Manager:例如 web-controller
  • 操作类型:Apply、Update、Patch 等。

如果一个 Controller 的多个实例使用同一个 Field Manager,那么从 SSA 角度看,它们通常属于同一个逻辑写入者。这正是 HA Controller 常见的做法:主备实例切换后仍然继续管理同一批字段。

3.2 如何指定 Field Manager

使用 kubectl 时:

kubectl apply \
  --server-side \
  --field-manager=platform-team \
  -f deployment.yaml

如果省略 --field-manager,客户端会使用默认名称。不同 kubectl 版本和不同操作可能使用不同的默认 Manager 名称,因此生产 Controller 不应依赖默认值,而应显式设置稳定的名称。

使用 Go client-go 时,在 ApplyOptions 中指定:

metav1.ApplyOptions{
    FieldManager: "web-controller",
}

Field Manager 应满足两个要求:

  1. 稳定:同一个逻辑 Controller 的重启、升级和 Leader 切换不应产生大量新 Manager;
  2. 职责清晰:不同组件修改不同职责的字段时,使用不同 Manager,便于冲突定位。

例如,可以区分:

web-controller-spec
web-controller-status
platform-defaults
human-operator

但不要为每次 Reconcile、每个 Pod 或每个随机请求生成一个新名称,否则 managedFields 会膨胀,所有权也会难以理解。


四、managedFields:所有权的持久化表示

启用 SSA 后,可以通过以下命令查看对象的字段管理信息:

kubectl get deployment demo -o yaml

输出中可能包含:

metadata:
  managedFields:
    - manager: platform-team
      operation: Apply
      apiVersion: apps/v1
      time: "2025-..."
      fieldsType: FieldsV1
      fieldsV1:
        f:spec:
          f:replicas: {}
          f:template:
            f:spec:
              f:containers:
                k:{"name":"app"}:
                  f:image: {}

它表达的是:

  • manager: platform-team:字段管理者;
  • operation: Apply:最近一次记录的操作类型;
  • apiVersion:写入时使用的 API 版本;
  • fieldsV1:FieldSet 的序列化表示。

其中:

  • f:replicas 表示普通字段;
  • k:{"name":"app"} 表示列表元素使用键 name=app 识别;
  • 空对象 {} 表示该路径被纳入管理集合。

managedFields 不是给业务程序随意编辑的配置区域。它由 API Server 维护,手工修改通常不能解决真实的所有权问题,而且可能在后续写入时被重新计算或覆盖。

为了只查看 Manager 和操作类型,可以使用:

kubectl get deployment demo \
  -o jsonpath='{range .metadata.managedFields[*]}{.manager}{"\t"}{.operation}{"\t"}{.subresource}{"\n"}{end}'

需要注意:

  • managedFields 会增加对象体积;
  • 它可能包含多个历史 Manager;
  • 不同 API 操作可能产生不同的 managedFields 条目;
  • managedFields 的内部序列化格式不应被当作稳定业务 API;
  • 诊断时应结合对象当前值、审计日志和 Controller 日志,而不能只看 Manager 名称。

五、字段所有权到底意味着什么

字段所有权不是“这个 Manager 可以永远阻止别人修改字段”的 ACL。它更准确的含义是:

这个 Manager 声明自己负责维护该字段;另一个 Apply Manager 如果想改变该字段,需要明确处理冲突。

因此,所有权主要影响 Apply-to-Apply 的协作,而不是所有 Kubernetes 写入都被同样限制。

5.1 共享所有权

多个 Manager 可以共同拥有一个字段,尤其是在它们提交相同值或通过兼容的操作逐步形成共享管理关系时。

例如:

manager-a owns spec.replicas = 3
manager-b applies spec.replicas = 3

这不应简单理解成 manager-b 一定获得独占所有权。实际结果受操作类型、字段值和服务器版本影响,诊断时应以 managedFields 和后续冲突行为为准。

核心安全条件仍然是:

冲突    字段由其他 Manager 管理本次值与当前值不一致\text{冲突} \iff \text{字段由其他 Manager 管理} \land \text{本次值与当前值不一致}

这是理解 SSA 的重要近似模型。它解释了为什么“提交相同值”通常不会触发同样的修改冲突,也解释了为什么“提交不同值”需要明确的接管动作。

5.2 放弃字段

假设 platform-team 第一次 Apply:

spec:
  replicas: 3
  strategy:
    type: RollingUpdate

它管理:

spec.replicas
spec.strategy.type

之后它提交:

spec:
  replicas: 5

那么它不再声明 spec.strategy.type。如果该字段没有其他 Manager 管理,API Server 可能移除该字段;如果该字段有默认值,默认值处理可能使最终对象重新出现一个值。

这不是“YAML 中没写,所以服务器忽略它”,而是“当前声明中不再包含这个 Manager 以前管理的字段”。

因此,Controller 的 Apply 配置应当稳定、完整地表达它真正负责的字段,不能根据某次临时计算结果随意省略字段。


六、冲突:什么时候发生,为什么发生

6.1 一个完整的冲突算例

先准备一个 Deployment:

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo
  namespace: default
spec:
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      containers:
        - name: app
          image: nginx:1.27
  replicas: 3

由 Manager alice 创建:

kubectl apply \
  --server-side \
  --field-manager=alice \
  -f deployment.yaml

此时可以认为:

alice owns spec.replicas = 3
alice owns spec.template.spec.containers[name=app].image = nginx:1.27

现在 bob 提交:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo
  namespace: default
spec:
  replicas: 5

执行:

kubectl apply \
  --server-side \
  --field-manager=bob \
  -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo
  namespace: default
spec:
  replicas: 5
EOF

预期会看到类似错误:

conflict: Apply failed with 1 conflict:
conflicts with "alice" using apps/v1:
- .spec.replicas

冲突的推导过程是:

  1. 当前值为 spec.replicas = 3
  2. alice 管理 spec.replicas
  3. bob 的配置希望该值为 5
  4. 5 != 3
  5. bob 试图改变 alice 管理的字段;
  6. API Server 拒绝这次 Apply。

这不是网络错误,也不是资源版本冲突。它是 SSA 主动阻止的字段级冲突。

6.2 --force-conflicts 的含义

如果 bob 确实要接管该字段,可以执行:

kubectl apply \
  --server-side \
  --field-manager=bob \
  --force-conflicts \
  -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo
  namespace: default
spec:
  replicas: 5
EOF

--force-conflicts 不是“忽略错误继续写入”的普通开关。它表示:

即使其他 Manager 拥有将被修改的字段,也强制执行本次 Apply,并调整相关字段所有权。

执行后,bob 可能成为 spec.replicas 的管理者,而 alice 不再拥有该字段,具体结果仍应通过 managedFields 验证。

强制接管适合明确的迁移、初始化或所有权转移,不适合 Controller 每次 Reconcile 都使用。Controller 若无条件 Force,两个 Controller 可能不断夺取同一个字段,表现为“最后一次 Reconcile 获胜”的竞态,SSA 失去保护作用。


七、Apply、Update 与“最后写入者获胜”的差别

场景一:两个 Apply Manager 修改不同字段

当前对象:

spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: app
          image: nginx:1.27

alice 管理 spec.replicasbob 管理容器镜像。

  • alice 将 replicas 改为 5;
  • bob 将 image 改为 nginx:1.28

如果两者没有越过对方的所有权边界,两个修改可以合并。

形式化地说,若:

FaliceFbob=F_{alice} \cap F_{bob} = \varnothing

并且每次 Apply 只修改自己的字段,则字段级协作是安全的。

场景二:两个 Apply Manager 修改同一字段

如果 alicebob 都管理 spec.replicas,且当前值为 3:

  • alice Apply 5;
  • bob Apply 7。

第二个操作会面临冲突,除非它使用 Force 或字段所有权已经发生变化。

场景三:Update 覆盖了 Apply 管理的字段

某个程序执行:

GET object
修改本地完整对象
PUT object

它可能把多个字段一起提交。Update 不是按 Apply 配置中的 FieldSet 逐字段声明意图,因此可能影响 SSA 管理信息或改变多个字段的值。

常见误解是:

既然 alice 拥有字段,任何其他写操作都不能修改它。

不准确。SSA 冲突保护主要针对 Apply 的字段管理语义;Update、某些 Patch、准入插件和控制器内部修改可能以不同方式改变对象。生产环境中应尽量让同一字段只有一个明确的写入职责,而不是依赖“别人一定会被 SSA 拦截”。


八、默认值、隐式字段和省略字段

Kubernetes 对象常常包含客户端没有显式写出的字段:

spec:
  strategy:
    type: RollingUpdate

即使用户没有在 YAML 中写 strategy.type,API Server 或资源默认值逻辑也可能把它填充出来。

SSA 处理默认值时不能简单套用“没写就没人拥有”的规则。默认值处理发生在请求进入持久化和字段管理计算的过程中;一个 Apply 请求最终可能对默认字段产生所有权影响。具体结果还与资源类型、Schema、默认值实现以及写入路径有关。

因此,对下面这种配置不要做未经验证的假设:

spec:
  replicas: 3

不能仅凭 YAML 判断:

  • strategy.type 是否存在;
  • 谁拥有 strategy.type
  • 后续省略它是否会删除它;
  • 它是否会被默认值重新填充。

正确的诊断方式是查看对象:

kubectl get deployment demo -o yaml
kubectl get deployment demo -o json | jq '.metadata.managedFields'

对生产 Controller 而言,应特别注意以下问题:

  1. 不要把服务端返回的完整对象直接当作 Apply 配置;
  2. 不要为了“避免默认值”而使用非结构化、随意拼接的 JSON;
  3. 对 Controller 真正负责的字段显式建立稳定的 Apply 配置;
  4. 对默认值敏感的字段,在目标 Kubernetes 版本上测试所有权和冲突行为。

九、列表字段为什么必须理解 Schema

SSA 是否能对列表进行细粒度合并,取决于资源 Schema。

9.1 原子列表

原子列表被视为一个整体。修改其中一个元素,可能等价于修改整个列表。

例如,若 items 是原子列表:

items:
  - a
  - b

另一个 Manager 试图提交:

items:
  - a
  - c

服务器可能把它看成整个 items 字段发生变化,而不是只修改第二个元素。

9.2 集合列表

集合列表按元素值识别,不强调元素顺序。适合表达一组无序成员。

9.3 映射列表

映射列表通过一个或多个键识别元素。Deployment 的容器列表通常可以抽象为:

spec.template.spec.containers[name=app]

因此,两个 Manager 分别修改不同名称容器的字段时,可能实现更细粒度的协作。

例如:

containers:
  - name: app
    image: nginx:1.27
  - name: sidecar
    image: busybox:1.36

name 不仅是容器运行时名称,也可能参与 API Schema 定义的列表合并键。

9.4 CRD 的影响

对自定义资源(CRD),列表行为取决于 CRD 的结构化 OpenAPI Schema 以及列表标记,例如 listTypelistMapKeys 等。若 CRD Schema 不完整,SSA 可能只能把结构视为原子字段,导致:

  • 无法按子元素合并;
  • 更容易发生冲突;
  • 字段所有权粒度比预期粗;
  • 升级 Schema 后行为发生变化。

因此,Operator 作者不能只测试 Go 结构体序列化结果,还要检查 CRD 的最终 Schema 和 API Server 对该 Schema 的解释。


十、Controller 为什么适合使用 SSA

Controller 的职责可以抽象为一个收敛过程。

设:

  • DD 是期望状态;
  • SS 是当前观察到的实际状态;
  • R(S,D)R(S, D) 是 Reconcile 计算出的修改;
  • TT 是 API Server 应用修改后的状态。

Controller 持续执行:

Sn+1=T(Sn,R(Sn,D))S_{n+1} = T(S_n, R(S_n, D))

当系统达到:

Sn+1SnS_{n+1} \approx S_n

就进入稳定状态。这里的“约等于”不是所有 JSON 字节完全相同,而是 Controller 负责的字段已经达到期望值,状态变化不再触发无意义更新。

SSA 与 Controller 的结合点是:Controller 可以只声明自己负责的字段,让其他组件继续管理其他字段。

例如一个 WebApp Controller 可能负责:

Deployment.spec.replicas
Deployment.spec.template.spec.containers[name=app].image
Service.spec.ports
WebApp.status.conditions

而平台团队负责:

Deployment.metadata.labels
Deployment.spec.template.spec.affinity

只要职责边界不重叠,两个写入者可以共同维护资源。


十一、Controller 的事件、缓存、队列和 Reconcile

SSA 只解决写入语义,不能替代 Controller 的事件处理架构。一个典型 Controller 的数据流如下:

flowchart LR
    A[Informer Watch] --> B[Shared Cache]
    B --> C[Workqueue]
    C --> D[Reconcile]
    D --> E[读取缓存中的对象]
    D --> F[构造 Apply 配置]
    F --> G[API Server Apply]
    G --> H[对象变化]
    H --> A
    G --> I[Conflict / Timeout / 5xx]
    I --> J[错误分类与重试]
    J --> C

关键路径是:

  1. Informer 从 API Server Watch 事件;
  2. 将对象写入本地 Cache;
  3. 将对象键 namespace/name 放入 Workqueue;
  4. Worker 取出键并执行 Reconcile;
  5. Reconcile 从 Cache 读取对象,而不是依赖事件中的旧副本;
  6. 计算期望状态;
  7. 使用稳定的 Field Manager 执行 Apply;
  8. 成功后等待新的 Watch 事件;
  9. 临时失败则重新入队,永久性配置错误则记录并等待后续事件。

幂等性

一次 Reconcile 可能执行多次,因此它应满足:

R(R(S,D),D)=R(S,D)R(R(S, D), D) = R(S, D)

直觉是:第一次已经把对象改到目标状态后,第二次不应继续制造新变化。

SSA 很适合表达幂等意图,但 Controller 仍然可能因为以下原因产生循环:

  • 每次都写入变化的时间戳;
  • 每次都重新排列无序列表;
  • 把 API Server 返回的默认字段重新拼接进配置;
  • status 中写入导致自身不断触发的非稳定值;
  • 读取旧对象后使用 Update 覆盖别人刚写入的字段。

十二、使用 client-go 执行 Apply

下面是一个基于 typed client-go 和生成的 ApplyConfiguration 的示例。它假设:

  • Kubernetes 集群可访问;
  • 使用 kubeconfig
  • 已安装与集群兼容的 k8s.io/client-gok8s.io/api
  • 资源为 apps/v1 Deployment
  • generated apply 类型可用。
package main

import (
	"context"
	"flag"
	"fmt"
	"path/filepath"
	"time"

	appsv1 "k8s.io/api/apps/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/util/wait"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/tools/clientcmd"

	appsv1apply "k8s.io/client-go/applyconfigurations/apps/v1"
)

func main() {
	var kubeconfig string
	flag.StringVar(&kubeconfig, "kubeconfig",
		filepath.Join(homeDir(), ".kube", "config"),
		"path to kubeconfig")
	flag.Parse()

	config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
	if err != nil {
		panic(err)
	}

	client, err := kubernetes.NewForConfig(config)
	if err != nil {
		panic(err)
	}

	ctx := context.Background()

	applyConfig := appsv1apply.Deployment("demo", "default").
		WithLabels(map[string]string{
			"app.kubernetes.io/name": "demo",
		}).
		WithSpec(
			appsv1apply.DeploymentSpec().
				WithReplicas(3).
				WithSelector(&metav1.LabelSelector{
					MatchLabels: map[string]string{
						"app": "demo",
					},
				}).
				WithTemplate(
					appsv1apply.PodTemplateSpec().
						WithLabels(map[string]string{
							"app": "demo",
						}).
						WithSpec(
							appsv1apply.PodSpec().
								WithContainers(
									appsv1apply.Container().
										WithName("app").
										WithImage("nginx:1.27"),
								),
						),
				),
		)

	deployment, err := client.AppsV1().
		Deployments("default").
		Apply(ctx, applyConfig, metav1.ApplyOptions{
			FieldManager: "demo-controller",
			Force:        false,
		})
	if err != nil {
		panic(err)
	}

	fmt.Printf("applied %s, generation=%d\n",
		deployment.Name, deployment.Generation)

	_ = appsv1.Deployment{}
	_ = runtime.Unknown{}
	_ = wait.PollUntilContextTimeout
	_ = time.Second
}

func homeDir() string {
	// 示例中省略了跨平台 home 目录处理。
	// 实际程序应使用 os.UserHomeDir()。
	return "/home/user"
}

上面的代码展示了几个关键点。

1. ApplyConfiguration 不是普通对象

appsv1apply.Deployment 返回的是声明式 Apply 配置,而不是用于完整替换的 appsv1.Deployment 对象。它的用途是表达 Controller 负责的字段。

2. Force: false 是默认的安全选择

当其他 Manager 拥有同一字段时,Apply 返回冲突。Controller 应先判断冲突是否代表真实职责重叠,而不是直接把 Force 改成 true

3. 需要处理 API 错误

生产代码不能使用 panic。至少应区分:

  • 409 Conflict:字段所有权冲突,通常需要人工或控制逻辑处理;
  • 404 NotFound:被管理对象已删除,通常是正常收敛路径;
  • 429 TooManyRequests:限流,需要退避;
  • 5xx、网络超时:临时错误,适合重新入队;
  • 403 Forbidden:RBAC 不允许,应报警而不是无限重试;
  • 422 Invalid:配置或 Schema 错误,应记录具体字段。

典型的 Reconcile 错误策略是:

obj, err := client.AppsV1().
	Deployments(namespace).
	Apply(ctx, cfg, metav1.ApplyOptions{
		FieldManager: "demo-controller",
		Force:        false,
	})
if err != nil {
	if apierrors.IsConflict(err) {
		// 记录冲突字段,通常不应盲目 Force。
		return fmt.Errorf("apply deployment field conflict: %w", err)
	}
	if apierrors.IsNotFound(err) {
		// 目标或依赖已删除,按 Controller 语义处理。
		return nil
	}
	// 临时错误交给上层队列重试。
	return err
}

在真正的 controller-runtime 或 client-go Controller 中,还需要由队列控制重试速率,避免 API Server 故障时形成紧密重试循环。


十三、Controller 的 spec Apply 和 status 更新应分开

Kubernetes API 通常把用户意图放在 spec,把 Controller 观察结果放在 status

例如:

spec:
  replicas: 3
status:
  availableReplicas: 3
  conditions:
    - type: Available
      status: "True"

Controller 不应通过一次完整对象 Update 同时重写 specstatus,因为这会扩大写入范围并增加互相覆盖的机会。

更合理的路径是:

  • 对主资源或子资源执行 Apply,管理 spec 中属于自己的字段;
  • /status 子资源执行 Status Update 或 Status Apply,管理 status 字段。

使用 typed client 时,通常可以使用类似:

statusConfig := appsv1apply.Deployment("demo", "default").
	WithStatus(
		appsv1apply.DeploymentStatus().
			WithAvailableReplicas(3),
	)

updated, err := client.AppsV1().
	Deployments("default").
	ApplyStatus(ctx, statusConfig, metav1.ApplyOptions{
		FieldManager: "demo-controller-status",
	})

实际是否支持 ApplyStatus、目标资源是否启用 status 子资源,应以对应 Kubernetes 版本、资源类型和 client-go 生成代码为准。

specstatus 分离的原因不是代码风格,而是状态模型不同:

  • spec 是用户或上层系统声明的期望;
  • status 是 Controller 根据观察结果报告的事实;
  • Controller 写 status 时不应改变用户的 spec
  • 用户改变 spec 时不应被 Controller 的状态写入覆盖。

十四、Cache 不是最新真相,Reconcile 必须重新读取

Informer 的 Cache 是 Controller 的本地只读视图,通常存在传播延迟。一个常见故障路径是:

  1. Reconcile 读取 Cache 中的对象版本 v1v_1
  2. 另一个客户端将对象修改为版本 v2v_2
  3. Controller 根据 v1v_1 计算并执行写入;
  4. Controller 误以为自己仍然基于最新状态工作。

SSA 的字段冲突可以拦截一部分这种错误,但不能替代正确的读取和职责设计。Controller 应:

  • 使用对象事件触发 Reconcile;
  • Reconcile 开始时从 Cache 获取当前缓存版本;
  • 不保存跨 Reconcile 的可变对象指针作为事实来源;
  • 不把一次事件携带的对象直接当作最终状态;
  • 对外部依赖的变化使用额外 Watch、索引或重新计算机制。

如果 Controller 需要强一致读取,可以直接从 API Server 获取对象,但这会增加 API Server 负载,也会绕过 Cache 的设计。绝大多数 Controller 应以 Cache 为主,并接受最终一致性。


十五、冲突与资源版本错误不是一回事

这两个错误都可能表现为 HTTP 409,但原因不同。

SSA 字段冲突

含义:

另一个 Manager 拥有你要改变的字段

典型错误会包含冲突字段路径和 Manager 名称,例如:

conflicts with "alice" using apps/v1:
- .spec.replicas

处理方式:

  1. 确认双方职责;
  2. 若字段确实应由当前组件管理,考虑一次明确的 Force 接管;
  3. 若不应由当前组件管理,删除该字段或调整 Apply 配置;
  4. 不要把所有 Conflict 都当成可以自动 Force 的临时错误。

Update 的资源版本冲突

含义:

你提交的 resourceVersion 已经过期

常见错误:

the object has been modified; please apply your changes to the latest version

处理方式通常是重新读取、重新计算并重试,或者改用更合适的 Patch/Apply 语义。

两者的区别可以概括为:

类型 判断粒度 典型原因 常见处理
SSA 字段冲突 字段 管理者之间修改同一字段 重新划分职责或显式接管
Update 版本冲突 对象版本 基于旧对象提交完整替换 重新读取并重试

十六、Admission Webhook、默认化和 API 转换会改变观察结果

对象写入 API Server 时,通常会经过:

  1. 认证;
  2. 授权;
  3. 资源解析;
  4. 默认值处理;
  5. Mutating Admission;
  6. 字段管理处理;
  7. Validating Admission;
  8. 持久化。

Mutating Webhook 可能向对象添加或修改字段。因此,Controller 提交的 Apply 配置和 API Server 最终保存的对象不一定相同。

例如,Controller 只提交:

metadata:
  labels:
    app: demo

Webhook 可能加入:

metadata:
  labels:
    injected-by: mesh

如果某个 Manager 后续 Apply 了同一标签,可能出现所有权关系和 Controller 预期不同的情况。

API 版本转换也需要注意。一个对象可能以 v1beta1 写入、以 v1 读取,API Server 需要在版本之间转换字段。Field Manager 记录中的 apiVersion 因此可能反映写入操作所使用的版本,而不是你当前读取对象时使用的版本。

生产诊断不能只对比原始 YAML,应同时检查:

kubectl get deployment demo -o yaml
kubectl explain deployment.spec.template.spec.containers
kubectl get deployment demo -o json | jq '.metadata.managedFields'

其中 kubectl explain 可以帮助确认当前客户端发现到的字段结构,但最终行为仍以目标 API Server 的 Schema 和准入链为准。


十七、常见误解和反例

误解一:SSA 会自动解决所有并发问题

反例:两个 Controller 都使用:

FieldManager: "controller"
Force: true

它们虽然名称相同,但并不代表它们业务职责相同。只要都持续 Apply 同一个字段,最后执行的写入仍可能覆盖前一个结果。

SSA 提供的是冲突检测和字段管理,不会自动推导业务上的正确值。

误解二:Field Manager 就是用户身份

反例:

ServiceAccount: system:serviceaccount:ops:web-controller
FieldManager: kubectl

RBAC 判断使用认证身份;SSA 字段管理判断使用 Field Manager。二者可以不同,但应该在设计上保持可解释。

误解三:Apply YAML 中没写的字段一定不会动

反例:某 Manager 以前管理:

spec.replicas
spec.strategy.type

现在 Apply 配置只保留 spec.replicas。被省略的 spec.strategy.type 可能被放弃所有权,进而被删除或重新默认化。

误解四:看到 managedFields 中有 Manager,就表示它拥有整个对象

反例:

fieldsV1:
  f:spec:
    f:replicas: {}

这只表示它管理 spec.replicas,不表示它管理整个 spec、整个 Deployment 或所有嵌套字段。

误解五:Controller 应该 Force 以保证最终状态

Force 会牺牲冲突保护。若用户明确拥有某字段,Controller 强制覆盖用户值,Controller 可能看似“稳定”,但系统实际失去了清晰的声明边界。

更安全的做法是先回答:

这个字段的业务所有者到底是谁?

然后让唯一职责方管理它。


十八、如何诊断一次 Apply 失败

可以按以下顺序缩小问题范围。

第一步:确认操作类型

检查客户端是否真的使用了 SSA:

kubectl apply --server-side ...

如果使用的是普通 kubectl apply、Update、JSON Patch 或某个 SDK 的 Update 方法,不能直接用 SSA 的冲突模型解释结果。

第二步:查看对象当前值和所有权

kubectl get deployment demo -o yaml

重点观察:

metadata:
  managedFields:

定位:

  • 冲突字段当前值;
  • 该字段的 Manager;
  • 该 Manager 最近的操作;
  • 是否存在 subresource: status
  • 列表是否按键拆分管理。

第三步:检查实际发送的 Apply 配置

Controller 常见错误是把“读取到的完整对象”作为 Apply 配置发送。这会导致 Controller 意外声明大量字段。

Apply 配置应只包含 Controller 负责的字段。例如,Controller 只负责镜像时,不要把用户的亲和性、标签、滚动升级策略等字段全部复制进去。

第四步:使用服务端 Dry Run

kubectl apply \
  --server-side \
  --dry-run=server \
  --field-manager=debug-manager \
  -f deployment.yaml

服务端 Dry Run 会执行较接近真实请求的解析、默认化、准入和 Apply 检查,但不持久化对象。它适合验证:

  • YAML 是否符合 Schema;
  • 是否会发生字段冲突;
  • 服务端最终会如何处理请求。

Dry Run 不是完整的生产证明,因为真实写入仍可能受到时间窗口内其他并发写入影响。

第五步:检查 RBAC 和子资源权限

如果对 /status 执行 Apply,权限通常需要针对 resource/status 单独授权。例如:

rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "patch"]
  - apiGroups: ["apps"]
    resources: ["deployments/status"]
    verbs: ["get", "patch", "update"]

实际权限应根据资源和客户端操作确认。拥有主资源的 patch 权限,不一定自动拥有 status 子资源的权限。


十九、生产设计中的所有权边界

一个可维护的 Controller 设计,应先画出字段职责,而不是先决定使用哪个 Patch API。

例如,定义如下边界:

字段 所有者
WebApp.spec.replicas 用户
Deployment.spec.replicas WebApp Controller
Deployment.spec.template.spec.containers[name=app].image WebApp Controller
Deployment.spec.template.spec.affinity 平台策略 Controller
Deployment.status Deployment Controller
WebApp.status.conditions WebApp Controller

然后保证:

  1. Controller 的 Apply 配置只包含它负责的字段;
  2. 用户配置与生成对象之间的映射是确定的;
  3. 不使用完整对象 Update 覆盖不属于自己的字段;
  4. 需要接管时有明确的迁移步骤;
  5. 升级 Controller 时不随意改变 Field Manager 名称;
  6. 不把随机值、当前时间或运行时临时信息写进稳定的 spec 字段。

如果两个组件必须共同维护同一个字段,应明确谁负责最终值。共享所有权适合表达兼容协作,不适合掩盖两个独立控制循环对同一字段的竞争。


二十、版本、兼容性和生产风险

Server-Side Apply 已在 Kubernetes 1.22 达到 GA,但具体字段行为仍受以下因素影响:

  • API Server 的 Kubernetes 版本;
  • kubectl 与集群的版本偏差;
  • 资源是否具备结构化 OpenAPI Schema;
  • CRD 的 Schema 定义;
  • API 版本转换;
  • Mutating/Validating Webhook;
  • client-go 版本与目标集群版本;
  • 云厂商对控制面、准入链或发行版补丁的差异。

因此,不应只在本地集群中验证一次命令就推断所有集群行为。尤其需要在目标版本上测试:

  • 默认字段的所有权;
  • 列表字段的合并粒度;
  • CRD 升级前后的冲突行为;
  • status 子资源 Apply;
  • Webhook 注入字段;
  • Controller 升级时 Field Manager 是否保持一致。

managedFields 也会增加对象存储和 API 返回体积。对于字段很多、Manager 很多或频繁变更的对象,应关注 etcd 空间、Watch 流量和 Controller 反序列化成本,但不要在没有测量的情况下假设某个固定性能数字。


二十一、把 SSA 放回 Kubernetes 声明式体系

Kubernetes 的声明式配置可以分成几个层次:

  1. YAML 或 JSON:人和工具表达期望;
  2. Schema:API Server 判断字段类型、默认值和列表语义;
  3. Apply:提交某个 Manager 对一组字段的声明;
  4. managedFields:持久化字段管理关系;
  5. Controller:持续将实际状态推进到期望状态;
  6. Status:报告当前观察结果,而不是重新声明用户意图。

其中,Apply 不是 Diff 的同义词。

Diff 回答的是:

当前对象和候选配置有什么不同?

SSA 还要继续回答:

这些不同中,哪些字段由当前 Manager 负责?
哪些字段由其他 Manager 负责?
本次修改是否会越过所有权边界?
省略字段是否意味着放弃维护?

因此,Controller 使用 SSA 时,真正提交的不是“我看见的完整对象”,而是:

“对于这组字段,我声明以下期望值,并愿意对这些字段承担持续管理责任。”

当 Field Manager 稳定、字段边界清晰、Apply 配置幂等、冲突处理有明确策略时,多个团队、工具和 Controller 才能在同一 Kubernetes 对象上形成可解释的协作关系。反之,如果所有组件都使用完整对象 Update,或所有 Controller 都无条件 Force,那么 Kubernetes 只能表现为一个不断被最后写入者覆盖的共享文档,而不是一个具备字段级协作能力的声明式系统。


系列导航与关联阅读

官方资料

本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。