大數據

你不瞭解的K8s資源更新機制,從一個OpenKruise用戶疑問開始

背景

OpenKruise 是阿里雲開源的大規模應用自動化管理引擎,在功能上對標了 Kubernetes 原生的 Deployment/StatefulSet 等控制器,但 OpenKruise 提供了更多的增強功能如 優雅原地升級、發佈優先級/打散策略、多可用區workload抽象管理、統一 sidecar 容器注入管理等,都是經歷了阿里巴巴超大規模應用場景打磨出的核心能力。這些 feature 幫助我們應對更加多樣化的部署環境和需求、為集群維護者和應用開發者帶來更加靈活的部署發佈組合策略。

目前在阿里巴巴內部雲原生環境中,絕大部分應用都統一使用 OpenKruise 的能力做 Pod 部署、發佈管理,而不少業界公司和阿里雲上客戶由於K8s原生 Deployment 等負載不能完全滿足需求,也轉而採用 OpenKruise 作為應用部署載體。

今天的分享文章就從一個阿里雲上客戶對接 OpenKruise 的疑問開始。這裡還原一下這位同學的用法(以下 YAML 數據僅為 demo):

  1. 準備一份 Advanced StatefulSet 的 YAML 文件,並提交創建。如:
apiVersion: apps.kruise.io/v1alpha1
kind: StatefulSet
metadata:
  name: sample
spec:
  # ...
  template:
    # ...
    spec:
      containers:
        - name: main
          image: nginx:alpine
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      podUpdatePolicy: InPlaceIfPossible

 

  1. 然後,修改了 YAML 中的 image 鏡像版本,然後調用 K8s api 接口做更新。結果收到報錯如下:
metadata.resourceVersion: Invalid value: 0x0: must be specified for an update
  1. 而如果使用 kubectl apply 命令做更新,則返回成功:
statefulset.apps.kruise.io/sample configured

問題在於,為什麼同一份修改後的 YAML 文件,調用 api 接口更新是失敗的,而用 kubectl apply 更新是成功的呢?這其實並不是 OpenKruise 有什麼特殊校驗,而是由 K8s 自身的更新機制所決定的。

從我們的接觸來看,絕大多數用戶都有通過 kubectl 命令或是 sdk 來更新 K8s 資源的經驗,但真正理解這些更新操作背後原理的人卻並不多。本文將著重介紹 K8s 的資源更新機制,以及一些我們常用的更新方式是如何實現的。

更新原理

不知道你有沒有想過一個問題:對於一個 K8s 資源對象比如 Deployment,我們嘗試在修改其中 image 鏡像時,如果有其他人同時也在對這個 Deployment 做修改,會發生什麼?

當然,這裡還可以引申出兩個問題:

  1. 如果雙方修改的是同一個字段,比如 image 字段,結果會怎樣?
  2. 如果雙方修改的是不同字段,比如一個修改 image,另一個修改 replicas,又會怎麼樣?

其實,對一個 Kubernetes 資源對象做“更新”操作,簡單來說就是通知 kube-apiserver 組件我們希望如何修改這個對象。而 K8s 為這類需求定義了兩種“通知”方式,分別是 updatepatch。在 update 請求中,我們需要將整個修改後的對象提交給 K8s;而對於 patch 請求,我們只需要將對象中某些字段的修改提交給 K8s。

那麼回到背景問題,為什麼用戶提交修改後的 YAML 文件做 update 會失敗呢?這其實是被 K8s 對 update 請求的版本控制機制所限制的。

Update 機制

Kubernetes 中的所有資源對象,都有一個全局唯一的版本號(metadata.resourceVersion)。每個資源對象從創建開始就會有一個版本號,而後每次被修改(不管是 update 還是 patch 修改),版本號都會發生變化。

官方文檔 告訴我們,這個版本號是一個 K8s 的內部機制,用戶不應該假設它是一個數字或者通過比較兩個版本號大小來確定資源對象的新舊,唯一能做的就是通過比較版本號相等來確定對象是否是同一個版本(即是否發生了變化)。而 resourceVersion 一個重要的用處,就是來做 update 請求的版本控制。

K8s 要求用戶 update 請求中提交的對象必須帶有 resourceVersion,也就是說我們提交 update 的數據必須先來源於 K8s 中已經存在的對象。因此,一次完整的 update 操作流程是:

  1. 首先,從 K8s 中拿到一個已經存在的對象(可以選擇直接從 K8s 中查詢;如果在客戶端做了list watch,推薦從本地 informer 中獲取)。
  2. 然後,基於這個取出來的對象做一些修改,比如將 Deployment 中的 replicas 做增減,或是將 image 字段修改為一個新版本的鏡像。
  3. 最後,將修改後的對象通過 update 請求提交給 K8s。
  4. 此時,kube-apiserver 會校驗用戶 update 請求提交對象中的 resourceVersion 一定要和當前 K8s 中這個對象最新的 resourceVersion 一致,才能接受本次 update。否則,K8s 會拒絕請求,並告訴用戶發生了版本衝突(Conflict)

上圖展示了多個用戶同時 update 某一個資源對象時會發生的事情。而如果如果發生了 Conflict 衝突,對於 User A 而言應該做的就是做一次重試,再次獲取到最新版本的對象,修改後重新提交 update。

因此,我們上面的兩個問題也都得到了解答:

  1. 用戶修改 YAML 後提交 update 失敗,是因為 YAML 文件中沒有包含 resourceVersion 字段。對於 update 請求而言,應該取出當前 K8s 中的對象做修改後提交。
  2. 如果兩個用戶同時對一個資源對象做 update,不管操作的是對象中同一個字段還是不同字段,都存在版本控制的機制確保兩個用戶的 update 請求不會發生覆蓋。

Patch 機制

相比於 update 的版本控制,K8s 的 patch 機制則顯得更加簡單。

當用戶對某個資源對象提交一個 patch 請求時,kube-apiserver 不會考慮版本問題,而是“無腦”地接受用戶的請求(只要請求發送的 patch 內容合法),也就是將 patch 打到對象上、同時更新版本號。

不過,patch 的複雜點在於,目前 K8s 提供了 4 種 patch 策略:json patch、merge patch、strategic merge patch、apply patch(從 K8s 1.14 支持 server-side apply 開始)。通過 kubectl patch -h 命令我們也可以看到這個策略選項(默認採用 strategic):

$ kubectl patch -h
# ...
  --type='strategic': The type of patch being provided; one of [json merge strategic]

篇幅限制這裡暫不對每個策略做詳細的介紹了,我們就以一個簡單的例子來看一下它們的差異性。如果針對一個已有的 Deployment 對象,假設 template 中已經有了一個名為 app 的容器:

  1. 如果要在其中新增一個 nginx 容器,如何 patch 更新?
  2. 如果要修改 app 容器的鏡像,如何 patch 更新?

json patch(RFC 6902

新增容器:

kubectl patch deployment/foo --type='json' -p \
  '[{"op":"add","path":"/spec/template/spec/containers/1","value":{"name":"nginx","image":"nginx:alpine"}}]'

修改已有容器image:

kubectl patch deployment/foo --type='json' -p \
  '[{"op":"replace","path":"/spec/template/spec/containers/0/image","value":"app-image:v2"}]'

可以看到,在 json patch 中我們要指定操作類型,比如 add 新增還是 replace 替換,另外在修改 containers 列表時要通過元素序號來指定容器。

這樣一來,如果我們 patch 之前這個對象已經被其他人修改了,那麼我們的 patch 有可能產生非預期的後果。比如在執行 app 容器鏡像更新時,我們指定的序號是 0,但此時 containers 列表中第一個位置被插入了另一個容器,則更新的鏡像就被錯誤地插入到這個非預期的容器中。

merge patch(RFC 7386

merge patch 無法單獨更新一個列表中的某個元素,因此不管我們是要在 containers 裡新增容器、還是修改已有容器的 image、env 等字段,都要用整個 containers 列表來提交 patch:

kubectl patch deployment/foo --type='merge' -p \
  '{"spec":{"template":{"spec":{"containers":[{"name":"app","image":"app-image:v2"},{"name":"nginx","image":"nginx:alpline"}]}}}}'

顯然,這個策略並不適合我們對一些列表深層的字段做更新,更適用於大片段的覆蓋更新。

不過對於 labels/annotations 這些 map 類型的元素更新,merge patch 是可以單獨指定 key-value 操作的,相比於 json patch 方便一些,寫起來也更加直觀:

kubectl patch deployment/foo --type='merge' -p '{"metadata":{"labels":{"test-key":"foo"}}}'

strategic merge patch

這種 patch 策略並沒有一個通用的 RFC 標準,而是 K8s 獨有的,不過相比前兩種而言卻更為強大的。

我們先從 K8s 源碼看起,在 K8s 原生資源的數據結構定義中額外定義了一些的策略註解。比如以下這個截取了 podSpec 中針對 containers 列表的定義,參考 Github

// ...
// +patchMergeKey=name
// +patchStrategy=merge
Containers []Container `json:"containers" patchStrategy:"merge" patchMergeKey:"name" protobuf:"bytes,2,rep,name=containers"`

可以看到其中有兩個關鍵信息:patchStrategy:"merge" patchMergeKey:"name" 。這就代表了,containers 列表使用 strategic merge patch 策略更新時,會把下面每個元素中的 name 字段看作 key

簡單來說,在我們 patch 更新 containers 不再需要指定下標序號了,而是指定 name 來修改,K8s 會把 name 作為 key 來計算 merge。比如針對以下的 patch 操作:

kubectl patch deployment/foo -p \
  '{"spec":{"template":{"spec":{"containers":[{"name":"nginx","image":"nginx:mainline"}]}}}}'

如果 K8s 發現當前 containers 中已經有名字為 nginx 的容器,則只會把 image 更新上去;而如果當前 containers 中沒有 nginx 容器,K8s 會把這個容器插入 containers 列表。

此外還要說明的是,目前 strategic 策略只能用於原生 K8s 資源以及 Aggregated API 方式的自定義資源,對於 CRD 定義的資源對象,是無法使用的。這很好理解,因為 kube-apiserver 無法得知 CRD 資源的結構和 merge 策略。如果用 kubectl patch 命令更新一個 CR,則默認會採用 merge patch 的策略來操作。

kubectl 封裝

瞭解完了 K8s 的基礎更新機制,我們再次回到最初的問題上。為什麼用戶修改 YAML 文件後無法直接調用 update 接口更新,卻可以通過 kubectl apply 命令更新呢?

其實 kubectl 為了給命令行用戶提供良好的交互體感,設計了較為複雜的內部執行邏輯,諸如 apply、edit 這些常用操作其實背後並非對應一次簡單的 update 請求。畢竟 update 是有版本控制的,如果發生了更新衝突對於普通用戶並不友好。以下簡略介紹下 kubectl 幾種更新操作的邏輯,有興趣可以看一下 kubectl 封裝的源碼

apply

在使用默認參數執行 apply 時,觸發的是 client-side apply。kubectl 邏輯如下:

  1. 首先解析用戶提交的數據(YAML/JSON)為一個 對象A
  2. 調用 Get 接口從 K8s 中查詢這個資源對象
    1. 如果查詢結果不存在,kubectl 將 本次用戶提交的數據記錄到 對象A 的 annotation 中(key 為 kubectl.kubernetes.io/last-applied-configuration),最後將 對象A 提交給 K8s 創建
    2. 如果查詢到 K8s 中已有這個資源,假設為 對象B
      1. kubectl 嘗試從 對象B 的 annotation 中取出 kubectl.kubernetes.io/last-applied-configuration 的值(對應了上一次 apply 提交的內容)
      2. kubectl 根據前一次 apply 的內容和本次 apply 的內容計算出 diff(默認為 strategic merge patch 格式,如果非原生資源則採用 merge patch)
      3. 將 diff 中添加本次的 kubectl.kubernetes.io/last-applied-configuration annotation,最後用 patch 請求提交給 K8s 做更新

這裡只是一個大致的流程梳理,真實的邏輯會更復雜一些,而從 K8s 1.14 之後也支持了 server-side apply,有興趣的同學可以看一下源碼實現。

edit

kubectl edit 邏輯上更簡單一些。在用戶執行命令之後,kubectl 從 K8s 中查到當前的資源對象,並打開一個命令行編輯器(默認用 vi)為用戶提供編輯界面。

當用戶修改完成、保存退出時,kubectl 並非直接把修改後的對象提交 update(避免 Conflict,如果用戶修改的過程中資源對象又被更新),而是會把修改後的對象和初始拿到的對象計算 diff,最後將 diff 內容用 patch 請求提交給 K8s。

總結

看了上述的介紹,大家應該對 K8s 更新機制有了一個初步的瞭解了。接下來想一想,既然 K8s 提供了兩種更新方式,我們在不同的場景下怎麼選擇 update 或 patch 來使用呢?這裡我們的建議是:

  • 如果要更新的字段只有我們自己會修改(比如我們有一些自定義標籤,並寫了 operator 來管理),則使用 patch 是最簡單的方式。
  • 如果要更新的字段可能會被其他方修改(比如我們修改的 replicas 字段,可能有一些其他組件比如 HPA 也會做修改),則建議使用 update 來更新,避免出現互相覆蓋。

 

Leave a Reply

Your email address will not be published. Required fields are marked *