Kubernetes 编程 / Operator 专题【左扬精讲】—— 深入学习 Server-Side Apply:managedFields 替代 last-applied-configuration 的演进方向

深入学习 Server-Side Apply:managedFields 替代 last-applied-configuration 的演进方向【左扬精讲】

Kubernetes 编程 / Operator 专题【左扬精讲】—— 深入学习 Server-Side Apply:managedFields 替代 last-applied-configuration 的演进方向

只要你使用过 kubectl apply -f 命令部署资源,大概率都见过 kubectl.kubernetes.io/last-applied-configuration 这个注解。在 Kubernetes 服务端应用(Server-Side Apply)机制诞生之前,这是官方为模拟声明式更新设计的客户端兜底方案。

kubectl 会在本地缓存上一次 apply 操作的完整资源对象配置,后续执行新的 apply 操作时,通过三方差异比对(三向 diff),自动识别字段的保留、更新与删除逻辑,以此完成资源迭代。该方案有效解决了早期运维的核心痛点:手动执行 kubectl edit 修改资源后,再次执行 apply 会清空手动调整的字段。

但该机制存在天然缺陷:所有比对、更新逻辑均运行在客户端,Kubernetes APIServer 完全无法感知变更细节,资源管控存在明显盲区,极易引发配置错乱、字段丢失、多人协同冲突等问题。

为彻底优化这一问题,Kubernetes 在 v1.16 版本正式引入服务端应用(Server-Side Apply,简称 SSA),将字段所有权的判定逻辑从客户端迁移至 APIServer,依托 metadata.managedFields 结构化所有权账本,替代了原先冗长、易被人为误编辑、占用内存的 JSON 注解。

SSA 机制在 v1.22 版本正式 GA 稳定落地,后续在 v1.26、v1.34 版本持续迭代增强。直至当前 v1.36.1 版本,managedFields 已成为 Kubernetes 资源对象不可或缺的核心属性,是多控制器协同调度、多人团队共同维护同一份资源时,唯一精准可靠的配置溯源依据与协同基准。

本文将从一条实操命令切入,结合 v1.36.1 源码完整链路,一次性讲透 SSA 四大核心维度:机制定义、设计背景、底层工作流程、版本演进全过程。

Kubernetes 1.36.1 Server-Side Apply managedFields 字段所有权 源码精讲

🔑 学习重点提示  — 建议先通读全文,再重点回顾标注内容

★ 重点掌握(必须)
   • last-applied-configuration 的本质缺陷:客户端补救方案、占内存、容易被改坏、apiserver 不感知
   • managedFields 的 6 个核心字段:Manager / Operation / APIVersion / Subresource / FieldsType / FieldsV1
   • 三向合并原理:live object ⊕ applied object ⊕ previous managedFields
   • DefaultFieldManager 包装链:7 层 Manager 装饰器,每层职责明确
   • 冲突检测与 force 选项:两个 Manager 争抢同一字段时的 409 行为

☆ 次重点(了解即可)
   • SSA 在 1.16 alpha → 1.22 GA → 1.26/1.34/1.36 的演进节点
   • client-go 中 fake client 的 managedFieldObjectTracker 实现
   • kubectl apply --server-side 与 --force-conflicts 的使用场景


📑 文章目录

  1. 一、问题的起点:为什么需要 Server-Side Apply?
  2. 二、What:managedFields 到底是什么?
  3. 三、How:源码层的三向合并实现
  4. 四、DefaultFieldManager 包装链深度拆解
  5. 五、Why:为什么 managedFields 能替代 last-applied-configuration?
  6. 六、演进方向:从 1.16 alpha 到 1.36.1
  7. 七、实战:5 个常用 kubectl 命令
  8. 八、迁移与排错指南
  9. 九、常见问题 FAQ(20+)

一、问题的起点:为什么需要 Server-Side Apply?

在我们钻进 managedFields 的源码之前,先把"它为什么会出现"这件事讲清楚。我们用一段最常见的运维场景来还原这个痛点。

假设你负责一个 Deployment,YAML 文件由 Git 管理:

# deployment.yaml (k8s v1.36.1)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: nginx
        image: nginx:1.25
        resources:
          limits:
            cpu: "500m"
            memory: 512Mi

你跑了一次 kubectl apply -f deployment.yaml。这时候 kubectl 做了一件事:把这份 YAML 完整序列化后,作为注解塞入 apiserver 的资源对象中,这个注解就是 kubectl.kubernetes.io/last-applied-configuration

接下来问题来了:线上有人手动修改副本数 replicas,从 3 改成了 5;或者 HPA 自动扩容至 6。随后你更新本地 YAML,将镜像版本 image 从 1.25 升级至 1.27,再次执行 kubectl apply -f deployment.yaml。kubectl 内部会执行三向合并对比:将 last-applied(上一次提交配置)与 live(线上实时状态)比对,再将新提交配置与历史配置比对,最终仅覆盖用户明确修改的字段,未改动字段保留线上状态,因此 replicas=6 不会被重置。这就是 Client-Side Apply(客户端应用) 的核心逻辑。

看似完美适配运维场景,但这套客户端更新方案,存在几个无法规避的底层硬伤

  1. apiserver 对字段所有权一无所知。它仅能识别 annotation 字符串与完整的资源 spec 配置,无法识别字段归属关系,不能区分 replicas 是人为修改还是 HPA 自动修改,也就无法智能保留字段数据。所有配置合并、差异比对逻辑,全部只能在客户端实现,服务端无感知。
  2. annotation 存储完整对象快照,资源冗余严重。无论是 Deployment、ConfigMap 还是 Secret 资源,都会在 etcd 中冗余存储两份完整配置。例如 1MB 大小的 ConfigMap,资源存储成本直接翻倍,持续占用 apiserver 缓存,严重影响资源 watch 与整体集群访问性能。
  3. 多工具协作无法兼容共存kustomize、helm、ArgoCD、Flux 等主流声明式运维工具,均无法识别彼此的配置记录。由于 last-applied-configuration 是 kubectl 独占的私有格式,多工具协同管理资源时,会出现配置基准错乱、相互覆盖的问题。
  4. 缺失字段级精准所有权能力。集群中典型的字段归属关系完全无法记录:HPA 管控 replicas 字段、Istio 管控业务标签字段、Prometheus Operator 管控容器注解字段等。精细化的字段所有权信息全部丢失,无法支撑多控制器协同运维。
  5. 注解易被人为篡改,隐蔽性故障频发。手动执行 kubectl edit 编辑并保存资源后,原生 JSON 注解会被强制覆盖,导致后续 apply 操作的差异比对基准失效,引发隐性配置异常,且这类问题排查难度极大。

Server-Side Apply 就是为了根治这五个问题而诞生的。它把"声明式管理"这件事从客户端搬到了服务端,用一个叫 managedFields 的结构化字段所有权账本,替代了那串又长又脆弱的 JSON annotation。

💡 一句话总结
last-applied-configuration 是"客户端自己记账",apiserver 不知情;managedFields 是"apiserver 统一记账",所有客户端都看同一本账。这就是 SSA 的根本性演进。

二、What:managedFields 到底是什么?

2.1 数据结构定义

managedFields 不是一个孤立的字段,它是 ObjectMeta 的标准成员,定义在 metav1 包里。我们直接看 v1.36.1 的源码:

// staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/types.go (L265-296, k8s v1.36.1)

type ObjectMeta struct {
    Name                       string            `json:"name,omitempty" protobuf:"bytes,1,opt,name=name"`
    GenerateName               string            `json:"generateName,omitempty" protobuf:"bytes,2,opt,name=generateName"`
    Namespace                  string            `json:"namespace,omitempty" protobuf:"bytes,3,opt,name=namespace"`
    // ... 省略若干标准字段 ...
    // 关键字段:managedFields,记录"哪些 manager 拥有哪些字段"
    ManagedFields []ManagedFieldsEntry `json:"managedFields,omitempty" protobuf:"bytes,17,rep,name=managedFields"`
}

每一条 ManagedFieldsEntry,就是"一个客户端对资源某些字段的所有权声明"。我们继续看它的结构:

// staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/types.go (L1389-1430, k8s v1.36.1)

type ManagedFieldsEntry struct {
    // Manager 是"工作流"的标识符,可以是 "kubectl"、"helm"、"kustomize"、
    // "kube-controller-manager"、或者一个 Controller 起的名字比如 "horizontal-pod-autoscaler"
    Manager string `json:"manager,omitempty" protobuf:"bytes,1,opt,name=manager"`

    // Operation 是创建这条 entry 的操作类型,目前只能是 Apply 或 Update
    Operation ManagedFieldsOperationType `json:"operation,omitempty" protobuf:"bytes,2,opt,name=operation"`

    // APIVersion 是这个字段集所属的 group/version。注意:APIVersion 字段并不
    // 跟 Subresource 绑定,它永远对应主资源的版本。
    APIVersion string `json:"apiVersion,omitempty" protobuf:"bytes,3,opt,name=apiVersion"`

    // Time 是这条 entry 加入/最近修改的时间戳。注意:如果是别的 Manager
    // 把字段抢走了,本条 entry 不会被更新时间戳。
    Time *Time `json:"time,omitempty" protobuf:"bytes,4,opt,name=time"`

    // FieldsType 是字段格式的鉴别器,目前只有 "FieldsV1" 一种取值
    FieldsType string `json:"fieldsType,omitempty" protobuf:"bytes,6,opt,name=fieldsType"`

    // FieldsV1 才是真正的"字段集合"——以 SMD(structured-merge-diff)库定义的
    // 路径集合表示,比如 {"f:metadata", "f:spec", "f:spec:f:replicas"}
    FieldsV1 *FieldsV1 `json:"fieldsV1,omitempty" protobuf:"bytes,7,opt,name=fieldsV1"`

    // Subresource 区分同一个 Manager 在不同子资源上的所有权。
    // 比如 "kubectl" 写主资源、".status" 写 status 子资源,
    // 它们的 Manager 名相同但 Subresource 不同,会被识别成两个独立的 entry。
    Subresource string `json:"subresource,omitempty" protobuf:"bytes,8,opt,name=subresource"`
}

这段定义里有 6 个关键字段,我们必须把它们跟 last-applied-configuration 对照着理解:

字段含义对比 last-applied-configuration
Manager 哪个客户端在管理这部分字段 last-applied 只有"kubectl"一种 Manager,没有多客户端概念
Operation Apply(声明式管理)或 Update(普通更新) last-applied 没有这个概念
APIVersion 记录的是 group/version,决定能否自动转换 last-applied 只能跟着对象当前的 apiversion 走
Subresource 区分主资源、status、scale、exec 等子资源 last-applied 无法区分主/status,所有字段混在一起
FieldsV1 SMD 字段路径集合(结构化的) last-applied 是完整 JSON,无法做精确字段级合并
Time 最后修改时间,便于排错 last-applied 没法记录"什么时候改的"等中间信息

2.2 一个真实例子:三个 Manager 共存

光看结构不够直观,我们看一个真实场景。假设有一个 Deployment:

  • kubectl 创建了它,定了 replicas=3、image=nginx:1.25
  • HPA(horizontal-pod-autoscaler controller)把 replicas 改成 5
  • istio-sidecar-injector 给 Pod 模板加了 sidecar.istio.io/proxyImage 注解

打开这个 Deployment 的 metadata,会看到 managedFields 是这样:

# kubectl get deployment nginx -o yaml 截取 (k8s v1.36.1)

metadata:
  name: nginx
  managedFields:
  - manager: kubectl
    operation: Apply
    apiVersion: apps/v1
    fieldsType: FieldsV1
    fieldsV1:
      f:metadata:
        f:labels:
          f:app: {}
        f:name: {}
      f:spec:
        f:replicas: {}              # 注意:replicas 已不在这里
        f:template: {}
  - manager: kube-controller-manager
    operation: Update
    apiVersion: apps/v1
    fieldsType: FieldsV1
    subresource: status
    fieldsV1:
      f:status:
        f:availableReplicas: {}
        f:observedGeneration: {}
  - manager: horizontal-pod-autoscaler
    operation: Update
    apiVersion: apps/v1
    fieldsType: FieldsV1
    fieldsV1:
      f:spec:
        f:replicas: {}              # HPA 拥有了 spec.replicas 这个字段
  - manager: istio-sidecar-injector
    operation: Update
    apiVersion: apps/v1
    fieldsType: FieldsV1
    fieldsV1:
      f:spec:
        f:template:
          f:metadata:
            f:annotations:
              f:sidecar.istio.io/proxyImage: {}

看到了吗?同一个对象上 4 个 manager 的"领地"清清楚楚。kubectl 再 apply 时,它只能动自己 fieldsV1 里列出的字段——碰到 horizontal-pod-autoscaler 拥有的 replicas,要么报错(冲突),要么加 --force-conflicts 抢过来。这就是字段级所有权。

🌟 实用技巧
想快速看某个对象上的所有权分布?执行:kubectl get deployment nginx -o jsonpath='{.metadata.managedFields}' | jq .。或者用更友好的 kubectl get deployment nginx --show-managed-fields,会自动把"谁拥有哪个字段"格式化输出。

三、How:源码层的三向合并实现

3.1 入口:PATCH /api/.../.../ 走 Apply

当我们跑 kubectl apply --server-side -f deployment.yaml 时,HTTP 请求长这样:

# curl 模拟(k8s v1.36.1)

PATCH /apis/apps/v1/namespaces/default/deployments/nginx HTTP/1.1
Content-Type: application/apply-patch+yaml
Accept: application/json
User-Agent: kubectl/v1.36.1
body: |
  apiVersion: apps/v1
  kind: Deployment
  metadata:
    name: nginx
  spec:
    replicas: 3
    ...
  # 注意:metadata.managedFields 一定不能带,由 apiserver 自己写入

apiserver 收到后,通过 content-type 识别这是 SSA 请求(不是普通的 strategic merge patch),路由到 handlers 里的 apply.go。这个文件位于 staging/src/k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/。我们看 SSA 真正的核心——Manager 接口和它的两个实现方法 Update / Apply:

// staging/src/k8s.io/apimachinery/pkg/util/managedfields/internal/manager.go (k8s v1.36.1)

// Manager 是 SSA 的核心接口——所有"字段合并器"必须实现这两个方法
type Manager interface {
    // Update 用于普通 PUT/PATCH 请求:不会引入新的所有权,
    // 只是用 newObj 的字段去更新 liveObj 现有的 FieldsV1
    Update(liveObj, newObj runtime.Object, managed Managed, manager string) (runtime.Object, Managed, error)

    // Apply 用于 SSA 请求:把 patchObj 当作"声明的目标状态",
    // 跟 liveObj 做三向合并,更新 managedFields
    Apply(liveObj, patchObj runtime.Object, managed Managed, manager string, force bool) (runtime.Object, Managed, error)
}

3.2 核心实现:structuredMergeManager.Apply

这是 v1.36.1 里 Apply 方法的完整实现(行 120-182),也是理解 SSA 算法的关键。每一行都加了注释:

// staging/src/k8s.io/apimachinery/pkg/util/managedfields/internal/structuredmerge.go (L120-182, k8s v1.36.1)

// Apply 实现了"声明式合并":把 patchObj 当作目标状态,
// 跟 liveObj 当前的 managedFields 做三向合并
func (f *structuredMergeManager) Apply(liveObj, patchObj runtime.Object, managed Managed, manager string, force bool) (runtime.Object, Managed, error) {
    // 第 1 步:版本校验。SSA 要求 patch 对象的 apiVersion 必须跟
    // 当前 Manager 负责的 groupVersion 完全一致。否则返回 400。
    if patchVersion := patchObj.GetObjectKind().GroupVersionKind().GroupVersion(); patchVersion != f.groupVersion {
        return nil, nil,
            errors.NewBadRequest(
                fmt.Sprintf("Incorrect version specified in apply patch. "+
                    "Specified patch version: %s, expected: %s",
                    patchVersion, f.groupVersion))
    }

    // 第 2 步:拿到 patchObj 的元数据 accessor
    patchObjMeta, err := meta.Accessor(patchObj)
    if err != nil {
        return nil, nil, fmt.Errorf("couldn't get accessor: %v", err)
    }
    // 关键安全校验:客户端绝不能携带 managedFields,
    // 否则可能被注入伪造的所有权声明
    if patchObjMeta.GetManagedFields() != nil {
        return nil, nil, errors.NewBadRequest("metadata.managedFields must be nil")
    }

    // 第 3 步:把 liveObj 也转换到目标 groupVersion(多版本场景)
    liveObjVersioned, err := f.toVersioned(liveObj)
    if err != nil {
        return nil, nil, fmt.Errorf("failed to convert live object (%v) to proper version: %v", objectGVKNN(liveObj), err)
    }

    // 第 4 步:把 Go 对象转成 SMD(structured-merge-diff)库的 typed 对象。
    // patchObj 用 AllowDuplicates=false(不允许重复键),
    // liveObj 用 AllowDuplicates=true(允许重复,因为 etcd 里可能已有)
    patchObjTyped, err := f.typeConverter.ObjectToTyped(patchObj)
    if err != nil {
        return nil, nil, fmt.Errorf("failed to create typed patch object (%v): %v", objectGVKNN(patchObj), err)
    }
    liveObjTyped, err := f.typeConverter.ObjectToTyped(liveObjVersioned, typed.AllowDuplicates)
    if err != nil {
        return nil, nil, fmt.Errorf("failed to create typed live object (%v): %v", objectGVKNN(liveObjVersioned), err)
    }

    // 第 5 步:核心算法——三向合并
    // 三个输入:liveObjTyped(线上真实状态)、patchObjTyped(声明目标状态)、
    //         managed.Fields()(旧的字段所有权账本)
    // 三个输出:newObjTyped(合并后的新对象)、managedFields(新账本)、err
    apiVersion := fieldpath.APIVersion(f.groupVersion.String())
    newObjTyped, managedFields, err := f.updater.Apply(liveObjTyped, patchObjTyped, apiVersion, managed.Fields(), manager, force)
    if err != nil {
        return nil, nil, err
    }
    managed = NewManaged(managedFields, managed.Times())

    if newObjTyped == nil {
        return nil, managed, nil
    }

    // 第 6 步:把合并结果转回 runtime.Object,跑一遍 defaulter(填充默认值),
    // 然后转回 hubVersion(内部版本)返回
    newObj, err := f.typeConverter.TypedToObject(newObjTyped)
    if err != nil {
        return nil, nil, fmt.Errorf("failed to convert new typed object (%v) to object: %v", objectGVKNN(patchObj), err)
    }
    newObjVersioned, err := f.toVersioned(newObj)
    if err != nil {
        return nil, nil, fmt.Errorf("failed to convert new object (%v) to proper version (%v): %v", objectGVKNN(patchObj), f.groupVersion, err)
    }
    f.objectDefaulter.Default(newObjVersioned)

    newObjUnversioned, err := f.toUnversioned(newObjVersioned)
    if err != nil {
        return nil, nil, fmt.Errorf("failed to convert to unversioned (%v): %v", objectGVKNN(patchObj), err)
    }
    return newObjUnversioned, managed, nil
}

代码读完,三向合并的流程图也就画出来了:

   patchObj (kubectl apply 的 YAML)
   {"spec":{"replicas":3,"image":"nginx:1.27"}}
                │
                ▼
   ┌────────────────────────────────────┐
   │  SMD 库:updater.Apply(...)        │ ← 核心算法
   │                                    │
   │  liveObjTyped    ┐                  │
   │   {replicas: 5,  │ 三向合并        │
   │    image: 1.25,  ├─────►  newObjTyped    │
   │    ...}          │       (合并后)         │
   │                  │       {replicas: 3,   │
   │  managed.Fields()│        image: 1.27,  │
   │   (旧的所有权账本)│        ...}           │
   └────────────────────────────────────┘
                │
                ▼
   返回:runtime.Object + 新 ManagedFields
   HTTP 200 OK(无冲突)或 409 Conflict

SMD 库(即 sigs.k8s.io/structured-merge-diff)才是真正的"合并引擎",它内部维护着 OpenAPI schema,知道 Deployment 的 spec.replicas 是 int 字段、spec.template 是 map 字段,合并规则按 Kubernetes 类型系统走。比如合并 map 时是 union、合并 list 时按 patch strategy 走(merge/replace/create 等)。这部分逻辑是独立项目,但我们只要知道——Kubernetes 借用了它,规避了"手写所有类型的合并规则"的天坑。

四、DefaultFieldManager 包装链深度拆解

你以为 structuredMergeManager 就是最终形态了?不,apiserver 真正使用的是 NewDefaultFieldManager 包装的版本,里面有一整条 7 层装饰器链。我们直接看 v1.36.1 的实现:

// staging/src/k8s.io/apimachinery/pkg/util/managedfields/internal/fieldmanager.go (L57-75, k8s v1.36.1)

// NewDefaultFieldManager 是 apiserver 真正使用的工厂方法。
// 它把最底层的 structuredMergeManager 包装成 7 层装饰器链
func NewDefaultFieldManager(f Manager, typeConverter TypeConverter, objectConverter runtime.ObjectConvertor, objectCreater runtime.ObjectCreater, kind schema.GroupVersionKind, subresource string) *FieldManager {
    return NewFieldManager(
        NewVersionCheckManager(                     // 7. 版本校验
            NewLastAppliedUpdater(                  // 6. 反向同步 last-applied annotation(兼容老客户端)
                NewLastAppliedManager(               // 5. 把 last-applied 升级为 managedFields
                    NewProbabilisticSkipNonAppliedManager(  // 4. 概率跳过非 Apply 请求的字段跟踪
                        NewCapManagersManager(      // 3. 限制 Update Manager 数量(默认 10)
                            NewBuildManagerInfoManager(    // 2. 补充 Manager 元信息(operation、subresource)
                                NewManagedFieldsUpdater(   // 1. 真正把 ManagedFields 写回对象
                                    NewStripMetaManager(f), // 0. 剥离 managedFields 后再交给 structuredMergeManager
                                ), kind.GroupVersion(), subresource,
                            ), DefaultMaxUpdateManagers,  // = 10
                        ), objectCreater, DefaultTrackOnCreateProbability,  // = 1.0
                    ), typeConverter, objectConverter, kind.GroupVersion(),
                ),
            ), kind,
        ), subresource,
    )
}

用 ASCII 图把这条链画出来(外层是调用方,内层是被装饰者):

调用方 FieldManager.Apply(live, applied, manager, force)
    │
    ▼
┌────────────────────────────────────────────────────────┐
│ NewVersionCheckManager        (7) 校验操作合法、版本一致  │
│   └─ NewLastAppliedUpdater    (6) 把声明回写到 annotation│
│       └─ NewLastAppliedManager(5) 升级老格式到新格式      │
│           └─ NewProbabilisticSkipNonAppliedManager (4) │
│               └─ NewCapManagersManager   (3) 限 10 个   │
│                   └─ NewBuildManagerInfoManager (2)      │
│                       └─ NewManagedFieldsUpdater (1)     │
│                           └─ NewStripMetaManager  (0)   │
│                               └─ structuredMergeManager │
└────────────────────────────────────────────────────────┘
    │
    ▼
返回:合并后的 runtime.Object + 新 ManagedFields

每层的职责我总结到表格里:

装饰器职责
0 NewStripMetaManager 把 managedFields、resourceVersion、uid 等元数据从 live object 中剥离,再交给最底层做合并
1 NewManagedFieldsUpdater 合并完成后,把新的 ManagedFields 写回对象的 metadata 字段
2 NewBuildManagerInfoManager 为每条 entry 补充 operation(Apply/Update)、subresource、apiVersion
3 NewCapManagersManager 限制 Update 类型的 Manager 数量不超过 10(DefaultMaxUpdateManagers=10),超了会合并最老的
4 NewProbabilisticSkipNonAppliedManager 用概率方式决定是否对非 Apply 请求(普通 Update)记录字段所有权,默认概率 1.0
5 NewLastAppliedManager 把 liveObj 上的 kubectl.kubernetes.io/last-applied-configuration 注解升级到 managedFields,保留老客户端的使用习惯
6 NewLastAppliedUpdater 反向把新声明也回写到 annotation,让老 kubectl 不再使用 SSA 也能继续工作

这条链的设计哲学是:每层只关心一件事,最内层只做"三向合并",中间层分别负责"剥元数据/补 Manager 信息/限数量/概率跟踪/双向同步 annotation",最外层做"版本校验"。这样未来要加新行为(比如审计、跨集群同步),只需要再加一层装饰器,不用动核心合并算法。这就是经典的装饰器模式在 k8s 源码里的典范应用。

📖 官方引用  — 引用自 k8s 官方源码注释
源码注释原文:"NewDefaultFieldManager creates a new FieldManager that merges apply requests and update managed fields for other types of requests." 翻译:NewDefaultFieldManager 创建一个 FieldManager,用于合并 apply 请求,并为其他类型的请求更新 managedFields。

再看 NewDefaultFieldManager 的入口:

// staging/src/k8s.io/apimachinery/pkg/util/managedfields/fieldmanager.go (L36-42, k8s v1.36.1)

// NewDefaultFieldManager 暴露给 apiserver 调用的工厂方法
func NewDefaultFieldManager(typeConverter TypeConverter, objectConverter runtime.ObjectConvertor, objectDefaulter runtime.ObjectDefaulter, objectCreater runtime.ObjectCreater, kind schema.GroupVersionKind, hub schema.GroupVersion, subresource string, resetFields map[fieldpath.APIVersion]fieldpath.Filter) (*FieldManager, error) {
    // 第 1 步:创建最底层的 structuredMergeManager(SMD 算法核心)
    f, err := internal.NewStructuredMergeManager(typeConverter, objectConverter, objectDefaulter, kind.GroupVersion(), hub, resetFields)
    if err != nil {
        return nil, fmt.Errorf("failed to create field manager: %v", err)
    }
    // 第 2 步:套上 7 层装饰器
    return internal.NewDefaultFieldManager(f, typeConverter, objectConverter, objectCreater, kind, subresource), nil
}

这条链还有一个同伴函数 NewDefaultCRDFieldManager——专门给 CRD 用,区别在于底层用 NewCRDStructuredMergeManager(允许 OpenAPI schema 里没定义的字段存在),其他装饰器层完全一样。这就是为什么 CRD 的字段合并也能走 SSA。

五、Why:为什么 managedFields 能替代 last-applied-configuration?

到这里我们可以正面回答"为什么"了。我们用 5 个对比维度,把两种方案掰开揉碎:

对比维度last-applied-configuration(客户端)managedFields(服务端)
记录位置 apiserver 里的对象 annotation(字符串) apiserver 里的对象 metadata.managedFields(结构化字段)
合并逻辑跑在哪 每个客户端自己实现(kubectl、helm、kustomize 各做各的) apiserver 统一实现,所有客户端都看到一致行为
存储开销 完整对象快照 × 1(annotation 里),对象越大越浪费 字段路径集合,多个 Manager 共享,规模可控
多客户端协作 不支持:annotation 被任意工具覆盖就出错 原生支持:每个 Manager 拥有自己的字段子集
冲突检测 只能做整对象级 diff,无法精确到字段 字段级冲突:两个 Manager 改同一字段时返回 409 + 冲突字段列表
可被改坏吗 kubectl edit 后保存,annotation 直接被改 PATCH 请求里携带 managedFields 会被 apiserver 拒绝("metadata.managedFields must be nil")

一句话:last-applied 是"私有账本",managedFields 是"公共账本"。前者只有写它的人能读懂,后者任何客户端、任何 controller、任何审计工具都能读懂。这是声明式管理从"客户端协议"升级为"集群原生能力"的关键一步。

🚀 版本更新  — k8s v1.36.1 引入 / 变更
在 v1.36.1 中,DefaultMaxUpdateManagers 仍为 10、DefaultTrackOnCreateProbability 仍为 1.0(位于 staging/src/k8s.io/apimachinery/pkg/util/managedfields/internal/fieldmanager.go L32-39),保持与 v1.34 行为一致。这意味着默认情况下所有 Update 操作都会被记录,生产环境如果觉得 managedFields 太大,可以通过下调 probability 或在 ad-hoc 客户端中手动设置 resetFields 来减少存储。

六、演进方向:从 1.16 alpha 到 1.36.1

Server-Side Apply 不是一蹴而就的,它经历了 7 个版本、8 个 milestone 才到今天这个形态。我们用一张时间线把关键节点串起来:

v1.14
(2019)
v1.15
KEP 介绍
v1.16
alpha
v1.18
beta
v1.22
GA
v1.26
扩展
v1.34
优化
v1.36.1
成熟
  • v1.16 (2019-10):首次引入 SSA 概念,alpha 阶段,ServerSideApply feature gate 控制。
  • v1.18 (2020-04):beta 阶段,kubectl 默认启用 SSA,Controller 也开始用 SSA 写入字段。
  • v1.22 (2021-08):正式 GA,所有内置资源(Deployment、Service、ConfigMap 等)都支持 SSA。
  • v1.26 (2022-12):引入 ApplyStatus 子资源专用 API 路径,方便 Controller 写 status 时不影响主资源的所有权。
  • v1.28+:引入 fieldValidation 严格模式(Warn / Strict),SSA 写入时遇到 schema 不认识的字段可以选择拒绝或警告。
  • v1.32+:fake client 全面支持 managedFieldObjectTracker,单元测试不再需要 mock 一堆字段合并细节。
  • v1.34 (2025):与 CRD 协作优化,CRD schema 字段移除后 managedFields 也能正确清理。
  • v1.36.1 (2026-当前):完善 last-applied 升级路径、装饰器链结构稳定,已成为所有云原生工具的事实标准

演进的核心方向是:从"替代 last-applied"到"取代 kubectl edit 的部分场景"再到"成为多 Controller 协作的基石"。到 v1.36.1 时代,helm、kustomize、ArgoCD、Flux 全部以 SSA 作为底层协议,CRD 默认推荐开启 x-kubernetes-validations 配合 SSA 使用,Controller 模式也鼓励使用 controllerutil.CreateOrPatch + SSA 风格而非裸 client.Update。

七、实战:5 个常用 kubectl 命令

7.1 启用服务端 apply

# 推荐用 --server-side 而不是 client-side

kubectl apply -f deployment.yaml --server-side --field-manager=kubectl-zt
# --field-manager 是命名你这条 entry 的"主人",不指定默认是 kubectl

7.2 查看字段所有权分布

# 格式化输出 managedFields

kubectl get deployment nginx --show-managed-fields -o yaml
# 输出会按 Manager 分组标出每个字段归谁管

7.3 强制覆盖冲突字段

# 遇到 409 Conflict 时抢回所有权

kubectl apply -f deployment.yaml --server-side --force-conflicts
# 注意:这会"抢走"其他 Manager 的字段,要谨慎使用

7.4 显式放弃所有权(仅清字段,不删资源)

# kubectl edit 风格的 SSA 放弃

# 提交一个把目标字段设为 null 的 apply,apiserver 会清掉该字段并把所有权交还
cat <<EOF | kubectl apply -f - --server-side
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  replicas: null
EOF

7.5 Controller 中使用 SSA 风格(Go)

# staging/src/k8s.io/client-go/examples/apply/example.go 简化版 (k8s v1.36.1)

// 构造一个 ApplyConfiguration
deployment := appsv1ac.Deployment("nginx", "default").
    WithSpec(appsv1ac.DeploymentSpec().
        WithReplicas(3).
        WithSelector(metav1ac.LabelSelector().WithMatchLabels(map[string]string{"app": "nginx"})))

// 走 SSA 路径,不会清掉其他 Manager 拥有的字段
_, err := client.AppsV1().Deployments("default").Apply(
    ctx, deployment,
    metav1.ApplyOptions{
        FieldManager: "my-operator",   // 这个 Controller 的"主人"标识
        Force:        false,           // 遇到冲突不抢,让他自己处理
    },
)

八、迁移与排错指南

8.1 从 client-side 迁移到 server-side

这个迁移是无痛的——源码里 v1.18+ 的 NewLastAppliedManager 会自动把 last-applied annotation 升级到 managedFields,下次 apply 时不会产生冲突。但要确认两件事:

  1. kubectl 版本 ≥ v1.18。老版本 kubectl 不识别 --server-side 标志,会当成 client-side apply 处理。
  2. 字段值变化要让 apiserver 知道。如果以前用 kubectl edit 直接改 live 对象,迁移到 SSA 后第一次 apply 可能会被识别为"冲突"——因为 apiserver 不知道你手动改的字段属于谁。解决办法是加 --force-conflicts 一次,让 apiserver 重新建立所有权账本。

8.2 常见报错速查

错误信息原因 + 排查
metadata.managedFields must be nil SSA 请求里携带了 managedFields(源码 L135-137),去掉就好
409 Conflict + 字段路径列表 两个 Manager 改了同一字段,加 --force-conflicts 抢回,或 kubectl edit 后保存
Incorrect version specified in apply patch YAML 里的 apiVersion 跟 apiserver 期望的 groupVersion 不一致(源码 L122-129)
apply 写入的字段莫名消失 可能是 owner controller 抢回了所有权(ProbabilisticSkipNonApplied 触发),用 --show-managed-fields 查账

⚠️ 警告
生产环境慎用 --force-conflicts。它会无条件抢占所有冲突字段,可能导致 HPA、Operator、Istio sidecar 注入等机制失效。正确做法是先 kubectl get --show-managed-fields 看清谁拥有冲突字段,再决定是否强制覆盖。

九、常见问题 FAQ

本节按"基础 5 + 进阶原理 8 + 生产实践 8"分类组织,21 个 Q&A 全是开发/运维真正会卡住的问题。


▼ Q1:Server-Side Apply 到底"服务端"在哪一段?

A: 在 apiserver 进程的 handlers/fieldmanager 包里,更精确说在 staging/src/k8s.io/apimachinery/pkg/util/managedfields 目录下的 FieldManager 上。当请求以 Content-Type: application/apply-patch+yaml 进入时,apiserver 会取出其中的 FieldManager 字段作为"主人"标识,然后调用 NewDefaultFieldManager 构造的 7 层装饰器链,最内层的 structuredMergeManager 负责三向合并。整个合并过程完全在 apiserver 进程内完成,etcd 只看到最终的对象快照。


▼ Q2:managedFields 的 FieldsV1 字段路径具体长什么样?

A: FieldsV1 的本质是 SMD 库的 Set 类型,本质是 JSON 序列化的有序集合,元素形如 {"f:spec":{}} 或 {"f:spec":{"f:replicas":{}}}。其中 f: 是 field 路径的前缀,每一层用 f:xxx 表示字段名。读起来就是把 YAML 的缩进结构翻译成 f: 字符串数组。例如 {"f:metadata":{"f:labels":{"f:app":{}}}} 表示"拥有 metadata.labels.app 这个字段"。v1.36.1 的定义在 staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/types.go L1419。


▼ Q3:什么是 FieldManager?它的命名规范是什么?

A: FieldManager 是"声明式工作流的标识符",对应 ManagedFieldsEntry.Manager 字段。规范建议:使用能反映真实来源的可读字符串,比如 kubectl、helm、argocd-application-controller、horizontal-pod-autoscaler、my-custom-operator。同一个 Manager 名 + 同一个 Subresource = 同一组所有权。修改 Subresource 即使 Manager 名相同也会被识别成不同组。


▼ Q4:SSA 和 client-side apply 的根本区别是什么?

A: 根本区别在"谁做合并"。client-side apply 是 kubectl 本地做 diff,把 last-applied-configuration 跟当前 live 算差异再发 PATCH;SSA 是把整个目标对象发给 apiserver,apiserver 用 live + 旧 managedFields + 客户端提交的对象做三向合并,再写回。两者最终的合并结果可能一致,但 SSA 多了字段级所有权信息——这才是协作的基石。


▼ Q5:kubectl apply 时没加 --server-side,为什么某些资源还是走了 SSA?

A: kubectl 从 v1.18 开始对内置资源(Deployment/Service 等)默认会尝试 SSA 路径,如果失败再回退到 client-side 路径。从 v1.22 GA 之后,kubectl 对所有原生类型都倾向于 SSA。如果想强制走 client-side,可以加 --traditional 标志(仅 client-go ≥ v1.30 之前支持),或者用 kubectl apply -f --server-side=false。


▼ Q6:DefaultFieldManager 装饰器链里 NewCapManagersManager 的作用是什么?

A: 它防止"无限多 manager 占用太多 etcd 空间"。managedFields 只对 Apply 类型的 manager 严格保留——每个 Apply 都会有一条独立 entry。但对 Update 类型的 manager(普通 PUT/PATCH 产生的),如果超过 DefaultMaxUpdateManagers=10,会把最老的 entry 合并掉(保留最新的 N 条)。源码在 fieldmanager.go L35 注释里写得很清楚:"If the number of update managers exceeds this, the oldest entries will be merged until the number is below the maximum"


▼ Q7:NewLastAppliedManager 和 NewLastAppliedUpdater 名字相似,职责有何不同?

A: 两者方向相反。NewLastAppliedManager(内层)做:当一个对象上还残留着 kubectl.kubernetes.io/last-applied-configuration annotation(说明是 client-side 创建的),在第一次 SSA 时把它升级成 managedFields,避免产生冲突。NewLastAppliedUpdater(外层)做:SSA 完成后把声明再回写到 annotation,兼容老的 client-side kubectl 工具。这两个配合就是"双向同步"——保证迁移期新老工具都能工作。


▼ Q8:apply 请求里 metadata.managedFields 一定不能带吗?

A: 是的,绝不能带。源码 L135-137 有显式校验:if patchObjMeta.GetManagedFields() != nil { return nil, nil, errors.NewBadRequest("metadata.managedFields must be nil") }。这是一个安全护栏:防止客户端伪造所有权声明直接修改 apiserver 的字段账本。要修改 managedFields 只能通过 SSA 的"字段集"机制,apiserver 自己计算后再写入。


▼ Q9:SMD 库(structured-merge-diff)是什么?为什么要单独抽出来?

A: SMD(sigs.k8s.io/structured-merge-diff)是 Kubernetes 抽出来的一个独立 Go 库,专门解决"按 OpenAPI schema 做精确字段合并"的问题。它知道哪些字段是 map、哪些是 list、哪些有 patch strategy,能正确处理 list 的 merge/replace/create 语义。把这个能力抽成独立项目的好处是:kustomize、controller-tools、client-gen 等所有需要"字段级合并"的工具都能复用,Kubernetes 自身只负责把 SMD 的结果应用到 etcd 对象上。


▼ Q10:什么是 subresource 维度的所有权?为什么要单独拆分?

A: ManagedFieldsEntry.Subresource 字段标识"这条所有权对应哪个子资源",常见值是空字符串(主资源)、status、scale、exec。意义是:同一个 Manager 名(比如 kube-controller-manager)在主资源和 status 子资源上的操作是两个独立的 entry,互不影响。源码 L1421-1428 注释明确:"a status update will be distinct from a regular update using the same manager name"。这就是 v1.26 引入 ApplyStatus 子资源专用 API 的原因。


▼ Q11:apply 时遇到 409 Conflict 是怎么产生的?冲突字段怎么定位?

A: 当你的 apply 试图修改一个不属于你的 manager 的字段时,SMD 库会返回 merge.Conflicts 错误。FieldManager.Apply 在 fieldmanager.go L198-200 把它转成 NewConflictError,apiserver 把它序列化成 409 响应。冲突响应体里会列出所有冲突的字段路径,例如 {"spec.replicas": "two managers disagree"}。解决方法只有两个:要么用 --force-conflicts 抢回,要么先 kubectl edit 把那个字段改成自己的。


▼ Q12:v1.36.1 中 managedFields 还能继续精简吗?存储开销会很大吗?

A: 会有一定开销但完全可控。Apply 类型的 manager 每条都保留,但数量通常很少(kubectl、helm、argocd 等都是 1-3 条)。Update 类型的 manager 被 NewCapManagersManager 限到 10 条。如果一个对象的字段集是 100 个路径,managedFields 整体大小约几 KB——比起 1MB+ 的 last-applied 已经是 1-2 个数量级的优化。如果你的对象特别大(比如 ConfigMap 几十 MB),v1.32+ 可以通过 apiserver 配置 --feature-gates=ServerSideApply=false 临时关闭(不推荐,影响协作)。


▼ Q13:CRD 资源也能用 SSA 吗?需要 CRD 做特殊配置吗?

A: 可以。CRD 在 v1.16+ 默认就走 SSA,底层用 NewDefaultCRDFieldManager(区别于内置资源的 NewDefaultFieldManager)——差别是底层用 NewCRDStructuredMergeManager,允许 OpenAPI schema 里没定义的字段存在。CRD 本身不需要特殊配置,只要在 spec.versions[].subresources.status 启用了 status 子资源,controller 就能通过 ApplyStatus 写状态。Helm、ArgoCD 部署的所有 CRD 应用默认走 SSA 路径。


▼ Q14:production 中 managedFields 莫名被改小或者丢失,怎么排查?

A: 三种可能:第一,kubectl replace --force 会丢弃整个 managedFields(虽然保留对象)——生产严禁使用;第二,kubectl apply -f 一个把整个对象 replace 化的 YAML 会触发 NewProbabilisticSkipNonAppliedManager 的逻辑;第三,etcd 故障恢复后可能丢失 managedFields(极少见)。排查命令:kubectl get <resource> -o yaml --show-managed-fields 看完整状态,再 kubectl logs -n kube-system kube-apiserver-xxx | grep -i managed 看 apiserver 日志。


▼ Q15:client-go 单元测试怎么模拟 SSA?fake client 支持吗?

A: 支持。client-go 从 v1.30 开始提供 managedFieldObjectTracker 替代老的 tracker.Apply(老版本回退到 strategic merge patch)。源码在 staging/src/k8s.io/client-go/testing/fixture.go L863-915 完整实现了 Apply 流程:先 Get 现有对象,构造 FieldManager(通过 fieldManagerFor 方法),调用 mgr.Apply() 计算新对象和 managedFields,最后 Create/Update 写回 fake store。controller 单元测试直接用 fake.NewClientset() 就能体验完整 SSA 行为,包括 conflict 检测。


▼ Q16:Operation 字段只有 Apply 和 Update 两种值吗?

A: 当前实现是这两种。ManagedFieldsOperationType 是字符串枚举类型,源码注释里明确写"the only valid values for this field are 'Apply' and 'Update'"。Apply 表示该 entry 是声明式 apply 产生的,Update 表示由普通 PUT/PATCH 产生——区别在于 Apply 类型 entry 不会被 CapManagersManager 合并限流(被永久保留),Update 类型才受 10 条上限影响。


▼ Q17:apiserver 收到 SSA 请求的 Content-Type 一定得是 application/apply-patch+yaml 吗?

A: 是的。apiserver 内部的 PATCH 路由会根据 Content-Type 区分:application/strategic-merge-patch+json 走 strategic merge;application/merge-patch+json 走 JSON merge patch;application/apply-patch+yaml(或 +json)才走 SSA。如果 Content-Type 写错,apply 行为不会触发,managedFields 不会被更新。kubectl 内部会自动设这个 header,但手写 curl 时容易忽略。


▼ Q18:--force-conflicts 真的安全吗?什么场景下绝对不能加?

A: 不能加的场景:(1) HPA 控制的 spec.replicas——抢回会导致 HPA 持续回写造成循环冲突;(2) Istio/Linkerd sidecar 注入的 pod template annotation——抢回会导致 sidecar 注入器每次重启 Pod;(3) cert-manager 维护的 spec.tls、cluster-autoscaler 维护的 metadata.annotations——任何 controller 持续管理的字段都别抢。安全场景:你确认要"重新接管"这个资源的所有权(比如某个 controller 故障了,你想手动修一下然后接管)。


▼ Q19:为什么 ArgoCD / Flux 默认就用 SSA,而 Helm 默认不是?

A: 设计哲学不同。ArgoCD/Flux 把自己定位为"集群外部的协调器"——它们管理的对象所有权应该完全归自己(argocd-application-controller / kustomize-controller),其他工具改了就视为漂移(drift),所以天然走 SSA 拒绝冲突。Helm v3 默认是 client-side apply 模拟(保留了 v2 的渲染-应用-升级模型),需要显式 --server-side 才走 SSA。Helm 走 SSA 时通常会出现 "no errors" 之外的"drift detected" 问题——这就是 SSA 严格性的体现。


▼ Q20:v1.36.1 的 SSA 还有哪些已知限制?未来演进方向是什么?

A: 当前主要限制:(1) client-go fake 在 v1.30 之前对 SSA 支持不完整,老测试代码需要迁到 managedFieldObjectTracker;(2) 部分 CRD 字段在 v1.34+ 才有完整的 x-kubernetes-validations 集成;(3) 大量历史集群的 last-applied annotation 还在迁移期,混合模式下 managedFields 会有 "ghost entries" 现象。演进方向:根据 SIG API Machinery 的 KEP,v1.37+ 可能引入"按 namespace 配置 default field manager"、"managedFields 压缩存储"、"Apply 操作的资源配额(防止恶意大规模 apply)"等能力,最终目标是把 SSA 变成"所有声明式工具的唯一接口"。


▼ Q21:怎么判断一个对象当前是 client-side 还是 server-side apply 创建的?

A: 三种方法:(1) 看 annotations:kubectl get deploy nginx -o jsonpath='{.metadata.annotations}' 里有 kubectl.kubernetes.io/last-applied-configuration 说明是 client-side 创建的(即便后续 apply 改成了 SSA 也可能残留);(2) 看 managedFields 的 Operation 列:v1.18+ 创建的对象主要是 Apply 类型的 entry;(3) 看 ManagedFields 数量:纯 client-side apply 创建的对象 managedFields 可能是空的(v1.16 之前的对象没有这个字段),第一次 SSA 时才会由 NewLastAppliedManager 升级出来。


▼ Q22:apply 写 status 子资源时,主资源的 managedFields 会变吗?

A: 不会。status 是单独的 subresource,FieldManager 会用 subresource: status 区分主资源 entry。一个 Deployment 可能有两条 entry:manager: my-operator, subresource: "" 拥有 spec 字段;manager: my-operator, subresource: status 拥有 status 字段。两者的 fieldsV1 完全独立。这是为什么 v1.26 之后要推荐 Controller 用 ApplyStatus 而不是直接 Update status——避免无意中改到主资源的所有权账本。

Kubernetes 编程 / Operator 专题【左扬精讲】—— 深入学习 Server-Side Apply · 来源:Kubernetes v1.36.1 源码(apimachinery/pkg/util/managedfields、apiserver/pkg/endpoints/handlers/fieldmanager、client-go/testing、apiextensions-apiserver)
相关阅读:  Server-Side Apply 官方文档 ·  Kubernetes API v1.36 参考 ·  managedfields 源码 (release-1.36)

posted @ 2026-06-16 11:24  左扬  阅读(1)  评论(0)    收藏  举报