快轉到主要內容

KEP-555 閱讀筆記:Server Side Apply 的設計原理和脈絡

·1536 字·8 分鐘·
ChengHao Yang
作者
ChengHao Yang
SRE / CNCF Ambassador
目錄

Server Side Apply (SSA) 從 Kubernetes v1.22(2021 年 8 月)宣告 GA,也發了 blog 說大家都該使用它。

2022 年 10 月,Argo CD v2.5.0 宣布支援 SSA。2025 年 11 月,Helm 4.0.0 發布,新安裝的 release 預設啟用 SSA。究竟「SSA」是何方神聖?

今天帶各位來閱讀「KEP-555: Server Side Apply」的設計原理和脈絡吧!

註記

本篇內容當下撰寫是 2026/6/27,會以此 commit 當前內容來介紹。

Client Side Apply
#

開始介紹 SSA 之前,先來了解 kubectl apply 原始的做法 —— Client Side Apply (CSA)。CSA 會在 client 端透過 Three-Way Merge Patch(三方合併修補),再產生 Strategic Merge Patch (策略性合併修補) 送往 apiserver。

所謂的 Three-Way 分別為:

  1. 期望的配置(Desired Configuration)—— 這次 apply 的 YAML
  2. 上次套用的配置(Last Applied Configuration)
  3. Live Object —— 該物件在 apiserver 上的現行狀態

不過,K8s 怎麼知道上次 apply 的內容是什麼?還記得每次 kubectl edit 的時候,出現在 .metadata.annotationskubectl.kubernetes.io/last-applied-configuration,那就是上次 apply 的內容。

flowchart TD
    subgraph inputs["Three-Way Merge Patch 的三個輸入"]
        A["① Desired Configuration
這次 apply 的 YAML"] B["② Last Applied Configuration
kubectl.kubernetes.io/last-applied-configuration
annotation 存的上次 apply 內容"] C["③ Live Object
apiserver 上的現行狀態
(含 server defaults)"] end subgraph diff["kubectl 端計算"] D["Three-Way Merge Patch
CreateThreeWayMergePatch()"] E["Strategic Merge Patch
JSON patch payload"] end A -- "① vs ②
你改了什麼" --> D B --> D C -- "② vs ③
別人改了什麼" --> D D --> E E -- "PATCH request
Content-Type:
strategic-merge-patch+json" --> F["apiserver"] F -- "回寫 ② annotation
到 Live Object" --> B

三個輸入、算出 patch 然後送出去,流程看起來很直覺,但實際問題可大了!

CSA 的問題在哪?
#

1. 合併計算邏輯依賴於 client 端版本
#

合併 (merge) 計算邏輯放在 kubectl,算完 Patch 後才會送到 server 處理。

但合併本身依靠欄位的型別與 schema,而 server 最清楚這些資訊,讓 client 重現一套 server 才有的邏輯,既容易跟 server 對不上,換個語言的 client 還得重做一次。

另外,對維護者來說,每次修正要讓 client 的 merge 邏輯相容於 ± 1 個 server 版本,維護成本非常龐大。

2. 自己生長的 Strategic Merge Patch
#

目前 Kubernetes 提供的 patch type 有三種:

  • JSON Patch (RFC 6902)
  • JSON Merge Patch (RFC 7386)
  • Strategic Merge Patch

預設選項,美其名是 “Strategic” 的 Merge Patch,但這不是 RFC,也沒有版本管控。

另外一點,Custom Resources (CR) 無法使用 Strategic Merge Patch。因為規則是寫在 Go struct 的 tag 當中(patchStrategypatchMergeKey)。Custom Resource Definition (CRD) 也不是 Go struct,只是註冊的 OpenAPI v3 schema。最後會回退到 JSON Merge Patch。

3. Apply 的結果難以預測
#

以為一對一、自己一個人 apply 就萬無一失?其實連單人重複 apply,結果都未必如預期;一旦變成多人共同管理,更是直接失控。

Alice 和 Bob 共同管理某個 Deployment,Alice 只在意 resources,Bob 只在意 image version。CSA 情況下,兩邊都要寫完整的 manifest,沒辦法只宣告「負責的幾個欄位」,衝突就會發生。

兩人手上的 manifest 唯一差別在 image —— Alice 寫著 1.24,Bob 要升到 1.25,其餘(含 resources)完全一致。

alice.yaml
 1apiVersion: apps/v1
 2kind: Deployment
 3metadata:
 4  name: web
 5spec:
 6  replicas: 3
 7  selector:
 8    matchLabels:
 9      app: web
10  template:
11    metadata:
12      labels:
13        app: web
14    spec:
15      containers:
16      - name: nginx
17        image: nginx:1.24
18        resources:
19          requests:
20            memory: "256Mi"
21          limits:
22            memory: "512Mi"
bob.yaml
 1apiVersion: apps/v1
 2kind: Deployment
 3metadata:
 4  name: web
 5spec:
 6  replicas: 3
 7  selector:
 8    matchLabels:
 9      app: web
10  template:
11    metadata:
12      labels:
13        app: web
14    spec:
15      containers:
16      - name: nginx
17        image: nginx:1.25
18        resources:
19          requests:
20            memory: "256Mi"
21          limits:
22            memory: "512Mi"

Alice 先套用上去,kubectl.kubernetes.io/last-applied-configuration 內容會是 Alice 的版本(image 還停在 1.24)。

接著 Bob 把 image 升到 1.25 套用上去,annotation 變為 Bob 的內容。

問題來了:假設 Alice 想微調她負責的 resources,於是再次套用手上那份 manifest(image 仍寫著 1.24)。kubectl 會拿它跟 annotation 裡 Bob 的內容做 Three-Way Merge Patch —— 她明明只想動 resources,Bob 升上去的 image 1.25 卻被默默退回 1.24,系統也不會給你任何提示。

原本 KEP 的 Motivation 甚至是這樣寫的:

User does POST, then changes something and applies: surprise!
User does an apply, then kubectl edit, then applies again: surprise!
User does GET, edits locally, then apply: surprise!
User tweaks some annotations, then applies: surprise!
Alice applies something, then Bob applies something: surprise!

給了五種情境,全都是「驚喜」!

什麼 TMD 叫驚喜!來源:《讓子彈飛》

這個 KEP 想解決什麼?(Goals / Non-Goals)
#

前面舉的例子,應該能體會這個 KEP 的動機 (Motivation),它的 Goals 包括(原文較長,此處僅重點整理):

  • 更穩健處理其他使用者、系統、預設值設定(包含 Admission Webhook 等)和物件 schema 演進所做的變更。
  • 整合人員只需要學習一種 API 概念,而不是每個 API 物件都要學習。
  • 使用者對於這些 config 變更,能更直觀理解系統會做什麼,並且盡可能告訴使用者為何發生衝突。
  • Apply 可以由非 Go 語言或非 kubectl 呼叫(例如:curl 等)。

有些預設行為會令人困惑,imagePullPolicy 預設為 IfNotPresent,但映像檔 tag 如果是 latestimagePullPolicy 的預設會變為 Always

像這類「搞不清楚系統到底會怎樣」的困惑,並不是這次 KEP 要處理的範圍 (Non-Goals)

  • Multi-object apply will not be changed: it remains client side for now
  • Some sources of user confusion will not be addressed:
    • Changing the name field makes a new object rather than renaming an existing object
    • Changing fields that can’t really be changed (e.g., Service type).

因為這次 KEP 只處理 CSA 的 Three-Way Merge Patch + Strategic Merge Patch 所帶來的問題,降低使用者對 apply 的內容和最終結果不一致的情形。

改到 server 處理聽起來不錯,但該如何做才能解決上面的問題呢?

SSA 怎麼運作?(提案與實作細節)
#

KEP 在提案段落簡要的列幾個改動:

  • Apply will be moved to the control plane.

這句話不難理解,不過原始的設計文件和最後實作結果有所不同,原始設計內容我會簡要提及在後記中,不過文件所寫的範例或 schema 都是正確的。

  • Apply is invoked by sending a certain Content-Type with the verb PATCH.

建立新的 Content-TypePATCH 一起使用,也就是後來的 application/apply-patch+yaml

  • Instead of using a kubectl.kubernetes.io/last-applied-configuration annotation, the control plane will track a “manager” for every field.

不再依賴 kubectl.kubernetes.io/last-applied-configuration,讓 control plane 對每個欄位追蹤擁有者 (manager)。但如果有其他的修改像 kubectl edit 要怎麼辦?來看下一句話:

  • Apply is for users and/or ci/cd systems. We modify the POST, PUT (and non-apply PATCH) verbs so that when controllers or other systems make changes to an object, they are made “managers” of the fields they change.

要對其他 verbs 做修改,這樣才能知道是哪個 controller 或系統更動了欄位。那麼要如何解決這問題?後來就出現了 FieldManager,這些資訊都在 .metadata.managedFields

FieldManager
#

假設有個 ConfigMap 長這樣:

testcm.yaml
1apiVersion: v1
2kind: ConfigMap
3metadata:
4  name: test-cm
5  namespace: default
6  labels:
7    test-label: test
8data:
9  key: some value

用 SSA 送出,然後再列出來:

# Server Side Apply ConfigMap
kubectl apply -f testcm.yaml --server-side

# Print the ConfigMap & Managed Fields
kubectl get cm test-cm -o yaml --show-managed-fields

預設不會印出 managedFields,記得要加上 --show-managed-fields

 1apiVersion: v1
 2data:
 3  key: some value
 4kind: ConfigMap
 5metadata:
 6  creationTimestamp: "2026-06-25T15:19:09Z"
 7  labels:
 8    test-label: test
 9  managedFields:
10  - apiVersion: v1
11    fieldsType: FieldsV1
12    fieldsV1:
13      f:data:
14        f:key: {}
15      f:metadata:
16        f:labels:
17          f:test-label: {}
18    manager: kubectl
19    operation: Apply
20    time: "2026-06-25T15:19:09Z"
21  name: test-cm
22  namespace: default
23  resourceVersion: "1530170"
24  uid: 41a6b943-c67c-48e8-9ac2-07323f211cd9

每個 field 都會有 manager,表示這個 field 是由它管理。如果同個物件有多個 manager 就會像這樣:

 1apiVersion: v1
 2kind: ConfigMap
 3metadata:
 4  name: test-cm
 5  namespace: default
 6  labels:
 7    test-label: test
 8  managedFields:
 9  - manager: kubectl
10    operation: Apply
11    time: '2019-03-30T15:00:00.000Z'
12    apiVersion: v1
13    fieldsType: FieldsV1
14    fieldsV1:
15      f:metadata:
16        f:labels:
17          f:test-label: {}
18  - manager: kube-controller-manager
19    operation: Update
20    apiVersion: v1
21    time: '2019-03-30T16:00:00.000Z'
22    fieldsType: FieldsV1
23    fieldsV1:
24      f:data:
25        f:key: {}
26data:
27  key: new value

上面的情況就是,kube-controller-manager 管理 .data.keykubectl 管理 .metadata.labels.test-label

像 HPA 針對 Deployment 做擴展,kube-controller-manager 會去修改 replicas 數量,這時候就會由 kube-controller-manager 接手管理 replicas 欄位,不過使用者還是能更改 image 版本更新。

Status Wiping
#

不過,Alpha 剛推出的時候,使用者套用內容把 .status 包含進去,FieldManager 會認為使用者想要接管 .status,Controller 想要修改狀態時,就會發生衝突。(issue#75564

那要讓 FieldManager 直接無視 .status 嗎?.status 是欄位之一,理當也應該要有 manager,KEP 後面有一句話是這樣寫的:

Additionally ignoring status as a whole is not enough, as it should be possible to own status (and other fields) in some occasions.

FieldManager 竟然敢無視 Status —— 來源:《BanG Dream! It’s MyGO!!!!!》

status 問題該如何修正呢?我們可以看看 PR#99661 的實作:

1// ResetFieldsStrategy is an optional interface that a storage object can
2// implement if it wishes to provide the fields reset by its strategies.
3type ResetFieldsStrategy interface {
4 GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set
5}

新增了 ResetFieldsStrategy,讓 FieldManager 可以視情況去把 Field 重置。

舉例 Deployment,如果更新主要內容時,會把 .status 清除;如果是更新狀態,套用時就會把 .spec.metadata 清除。因此程式碼實作會像這樣:

 1// GetResetFields returns the set of fields that get reset by the strategy
 2// and should not be modified by the user.
 3func (deploymentStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set {
 4 fields := map[fieldpath.APIVersion]*fieldpath.Set{
 5  "apps/v1": fieldpath.NewSet(
 6   fieldpath.MakePathOrDie("status"),
 7  ),
 8 }
 9
10 return fields
11}
12
13// GetResetFields returns the set of fields that get reset by the strategy
14// and should not be modified by the user.
15func (deploymentStatusStrategy) GetResetFields() map[fieldpath.APIVersion]*fieldpath.Set {
16 return map[fieldpath.APIVersion]*fieldpath.Set{
17  "apps/v1": fieldpath.NewSet(
18   fieldpath.MakePathOrDie("spec"),
19   fieldpath.MakePathOrDie("metadata", "labels"),
20  ),
21 }
22}

回到 KEP 提案的下一個改動:

  • Dry-run will be implemented on control plane verbs (POST, PUT, PATCH).
    • Admission webhooks will have their API appended accordingly.

以前 client-side 的 --dry-run 是不會經過 Admission Webhook,拿不到真正的結果;改為 server-side 的 --dry-run,會完整跑過 Admission Webhook 修改和驗證。

  • The things our “Go IDL” describes are formalized: structured merge and diff
  • Existing Go IDL files will be fixed (e.g., by fixing the directives)

把 Kubernetes 的 Go IDL (Interface Definition Language) 內容正式化,並建立新的 repo 為 structured-merge-diff。修正掉或補齊現有的 Go IDL 標記資訊。

Go IDL
#

Go IDL 在這裡扮演的角色可說是舉足輕重,掌握整個 SSA 合併邏輯,要處理的資料型態有 List、Map、Struct。

List 有三種處理方式:

  • Atomic lists:預設方式,只能由一位 manager 管理,整個 list 都會被取代掉。
  • Sets:每個元素內容最多只會出現一次,每個元素都有各自的 manager。
  • Associative lists:本身是 list 但可以依照指定欄位,當作 dictionary 使用,Kubernetes 常用 name 來當主鍵,每個元素都有各自 manager。

至於它們各自的範例:

  • Pod 的 .spec.containers[].args 屬於 Atomic lists(相信你們不會想看到合併兩個人各自 apply 的 args 內容)。
  • .metadata.finalizers 屬於 Sets,資源被刪除時,每個 Finalizer 都有自己的 manager,每個 controller 結束流程後會各自把擁有的字串刪除。
  • Pod 的 .spec.containers[] 屬於 Associative lists,本身合併依照每個元素的 name 處理。

對於 Struct 和 Map 有兩種處理方式:

  • Granular:預設方式,底下的每個欄位都有自己的 manager。
  • Atomic:該元素只有一個 manager,底下某個欄位如果有變動就會全部替換。

它們的各自範例:

  • .metadata.labels 可以掛很多 Label,例如 Argo CD 可以管 app.kubernetes.io/instance,每個 Label 都可以獨立給各個 controller 管理,因此屬於 Granular。
  • Deployment 的 .spec.selector 底下有 matchLabelsmatchExpressions,兩個 struct 需要互相搭配且高度綁定,因此屬於 Atomic。
資料型態類型Go IDLOpenAPI Extension
ListAtomic// +listType=atomicx-kubernetes-list-type: atomic
ListSet// +listType=setx-kubernetes-list-type: set
ListMap (Associative)// +listType=map
// +listMapKey=<key>
x-kubernetes-list-type: map
x-kubernetes-list-map-keys: [<key>]
MapGranular// +mapType=granularx-kubernetes-map-type: granular
MapAtomic// +mapType=atomicx-kubernetes-map-type: atomic
StructGranular// +structType=granularx-kubernetes-map-type: granular
StructAtomic// +structType=atomicx-kubernetes-map-type: atomic

如何解決前面的問題?
#

我們看完 SSA 的機制,這裡就可以一一對回前面的問題:

  1. 合併計算邏輯依賴於 client 端版本

合併邏輯搬到 server,並以全新的 structured-merge-diff 處理,不再依賴 client 端的實作。

  1. 自己生長的 Strategic Merge Patch

Go IDL 清楚標記資料型態和合併邏輯,讓 CRD 可以一同使用,List 不再只有 Atomic 的選項。

  1. Apply 的結果難以預測

引入了 FieldManager,每個 Field 都有自己的 manager,多人協作時不再被互相覆蓋。

雖然儲存物件的大小會增加約 60%,但如果是為了穩定和消除歧異,加上使用者大多都是 IT 或 SRE 人員,這樣的 trade-off 是可以被接受的。

總結
#

SSA 不僅解決掉 CSA 複雜 Merge 規則帶來的不確定性、欄位 ownership 的問題,並且讓 CR 可以撰寫規則,不再只有預設的 JSON Merge Patch。

我也整理了以下表格給大家參考:

項目Server Side ApplyClient Side Apply
預設合併策略Structured Merge DiffStrategic Merge Patch
合併規則來源OpenAPI schema 標記
(x-kubernetes-*)
Go struct tag
(patchStrategy / patchMergeKey)
CR 合併策略結構化合併
(依 OpenAPI schema,支援 atomic/set/map)
JSON Merge Patch
合併邏輯獨立成函式庫
(可獨立版控)
last-applied-configuration annotation不需要Three-Way Merge Patch 需要
(缺少會變為 Two-Way Merge Patch,無法辨識需要刪除的欄位)
欄位擁有者追蹤
Server dry-run
(含 Admission Webhook)
儲存大小相比 CSA 增加 60%-

後記
#

翻原始設計文件的時候,最開始的做法是透過 Three-Way Merge 去推導出 field 的擁有者是誰。但看討論紀錄,擔心最後會成為下一個 SMP,後來的做法就是熟悉的 managedFields

雖然這個 KEP 已經標記為實作完成,但原本設計文件是有包含 Union 的比較和 discriminator,再回去翻 KEP-555,完全找不到 union 的痕跡。

到這裡我想留給大家伏筆和思考,為什麼沒有 Union?難道 Union 不重要?Union 到底去哪裡?

重要

如果想存取原始設計文件,要加入 [email protected][email protected] 群組,但前者已經無法加入,只剩後者,但很難保證 announce 會不會繼續存在。筆者已經先備份原本的文件出來,並保留原本討論,詳情可以點擊這裡

SSA 的設計沒辦法用一篇文章敘述完成,此問題還專門開 Working Group Apply 處理,共享文件中有很多本文章未提及的設計,有機會再來跟大家介紹。

資料來源
#

相關文章