






















深入学习 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 的使用场景
📑 文章目录
在我们钻进 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(客户端应用) 的核心逻辑。
看似完美适配运维场景,但这套客户端更新方案,存在几个无法规避的底层硬伤:
Server-Side Apply 就是为了根治这五个问题而诞生的。它把"声明式管理"这件事从客户端搬到了服务端,用一个叫 managedFields 的结构化字段所有权账本,替代了那串又长又脆弱的 JSON annotation。
💡 一句话总结
last-applied-configuration 是"客户端自己记账",apiserver 不知情;managedFields 是"apiserver 统一记账",所有客户端都看同一本账。这就是 SSA 的根本性演进。
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 没法记录"什么时候改的"等中间信息 |
光看结构不够直观,我们看一个真实场景。假设有一个 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,会自动把"谁拥有哪个字段"格式化输出。
当我们跑 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)
}
这是 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 借用了它,规避了"手写所有类型的合并规则"的天坑。
你以为 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。
到这里我们可以正面回答"为什么"了。我们用 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 来减少存储。
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
成熟
演进的核心方向是:从"替代 last-applied"到"取代 kubectl edit 的部分场景"再到"成为多 Controller 协作的基石"。到 v1.36.1 时代,helm、kustomize、ArgoCD、Flux 全部以 SSA 作为底层协议,CRD 默认推荐开启 x-kubernetes-validations 配合 SSA 使用,Controller 模式也鼓励使用 controllerutil.CreateOrPatch + SSA 风格而非裸 client.Update。
# 推荐用 --server-side 而不是 client-side
kubectl apply -f deployment.yaml --server-side --field-manager=kubectl-zt # --field-manager 是命名你这条 entry 的"主人",不指定默认是 kubectl
# 格式化输出 managedFields
kubectl get deployment nginx --show-managed-fields -o yaml # 输出会按 Manager 分组标出每个字段归谁管
# 遇到 409 Conflict 时抢回所有权
kubectl apply -f deployment.yaml --server-side --force-conflicts # 注意:这会"抢走"其他 Manager 的字段,要谨慎使用
# 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
# 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, // 遇到冲突不抢,让他自己处理
},
)
这个迁移是无痛的——源码里 v1.18+ 的 NewLastAppliedManager 会自动把 last-applied annotation 升级到 managedFields,下次 apply 时不会产生冲突。但要确认两件事:
| 错误信息 | 原因 + 排查 |
|---|---|
| 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 看清谁拥有冲突字段,再决定是否强制覆盖。
本节按"基础 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)
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。