控制器(Controller)主動監控並把一組 Kubernetes 資源維持在期望狀態。Kubernetes 的核心本身就由一整批控制器組成,它們定期監看並調和「應用的當前狀態」與「宣告的目標狀態」。本章說明如何運用這個核心概念,依我們的需求擴充平台。

問題#

Kubernetes 是精巧而全面的平台,開箱即用地提供許多功能。但它是通用的編排平台,不可能涵蓋所有應用使用案例。所幸它提供了自然的擴充點,讓特定使用案例能優雅地建立在久經驗證的 Kubernetes 建構單元之上。

於是核心問題浮現:如何在不修改、不破壞 Kubernetes 的前提下擴充它,並把它的能力用於自訂的使用案例?

依設計,Kubernetes 基於宣告式、以資源為中心的 API。何謂宣告式?相對於命令式取徑,宣告式不告訴 Kubernetes 該怎麼做,而是描述目標狀態該長什麼樣。例如擴大一個 Deployment 時,我們不會主動叫 Kubernetes「建立一個新 Pod」,而是透過 API 把 Deployment 的 replicas 屬性改成期望的數字。

那麼新 Pod 是怎麼被建立的?答案是由控制器在內部完成

  1. 資源狀態每次改變(例如改動 Deployment 的 replicas),Kubernetes 就建立一個事件,廣播給所有感興趣的監聽者。
  2. 這些監聽者透過修改、刪除或建立新資源來回應,這又產生其他事件(例如 Pod 建立事件)。
  3. 這些事件可能再被其他控制器接手,執行它們各自的動作。

這整個過程稱為狀態調和(state reconciliation):目標狀態(期望的複本數)與當前狀態(實際執行的實例)有落差,控制器的任務就是調和兩者、重新達到期望的目標狀態。

從這個角度看,Kubernetes 本質上是一個分散式狀態管理器:你給它某個元件實例的期望狀態,它就在任何變化發生時設法維持該狀態。

解法#

Kubernetes 內建一批控制器,管理 ReplicaSet、DaemonSet、StatefulSet、Deployment、Service 等標準資源。這些控制器作為 Controller Manager 的一部分執行,部署在 master 節點上(獨立行程或 Pod)。它們彼此並不知道對方的存在,各自跑在無盡的調和迴圈中,監控自己資源的實際與期望狀態,並採取行動讓兩者靠攏。

除了這些開箱即用的控制器,Kubernetes 的事件驅動架構還讓我們能原生地插入自訂控制器。控制器的共同特徵是被動反應(reactive):對系統中的事件做出回應、執行各自的動作。宏觀來看,調和流程包含三個步驟:

  • 觀察(Observe):監看 Kubernetes 在受觀察資源改變時發出的事件,藉此發現實際狀態。
  • 分析(Analyze):判定它與期望狀態的差異。
  • 行動(Act):執行操作,把實際狀態推向期望狀態。

舉例來說,ReplicaSet 控制器監看 ReplicaSet 資源的變更,分析需要跑幾個 Pod,然後把 Pod 定義提交給 API Server 來行動;Kubernetes 後端接著負責在節點上啟動所請求的 Pod。

圖 22-1:Observe-Analyze-Act 循環——控制器把自己註冊為事件監聽者,觀察當前狀態,必要時呼叫 API Server 加以改變,使其更接近目標狀態

控制器與 Operator#

控制器是 Kubernetes 控制平面的一部分。人們很早就看出它們也能用來為平台擴充自訂行為,如今它已成為擴充平台、實現複雜應用生命週期管理的標準機制。新一代更精巧的控制器也隨之誕生,稱為 Operator。從演化與複雜度的角度,可以把主動調和的元件分成兩類:

控制器(Controller)Operator
調和流程簡單精巧
操作對象標準 Kubernetes 資源CustomResourceDefinition(CRD)
典型用途強化平台行為、增加新的平台功能封裝複雜的應用領域邏輯、管理完整的應用生命週期

為避免多個控制器同時作用在同一批資源上,控制器會採用「單例服務」模式。大多數控制器就以 Deployment 部署,但只設一個複本——因為 Kubernetes 在資源層級使用樂觀鎖定,以防止變更資源物件時發生並行問題。

說到底,控制器不過就是一個永遠在背景執行的應用程式。

由於 Kubernetes 本身以 Go 撰寫、完整的 Kubernetes 客戶端函式庫也是 Go,許多控制器同樣用 Go 寫成。但只要能向 Kubernetes API Server 發送請求,你可以用任何程式語言撰寫控制器——本章稍後就會看到一個純 shell script 寫成的控制器。

控制器資料該存在哪裡#

最直接的控制器擴充了 Kubernetes 管理資源的方式:它們操作同樣的標準資源、執行與內部控制器類似的任務,卻對叢集使用者不可見。控制器評估資源定義並有條件地執行動作;雖然它們可以監控並作用於資源定義中的任何欄位,但 metadata 與 ConfigMap 最適合這個用途

  • 標籤(Labels):作為資源 metadata 的一部分,可被任何控制器監看。它們在後端資料庫中建有索引,查詢時能有效率地搜尋。當需要類似選擇器的功能時(例如比對某 Service 或 Deployment 的 Pod)就該用標籤。限制是只能使用受限制的英數字元名稱與值。
  • 註解(Annotations):標籤的絕佳替代方案。當值不符合標籤的語法限制時,就必須改用註解。註解沒有索引,因此適合存放「不作為控制器查詢鍵」的非識別性資訊。對任意中繼資料偏好註解而非標籤還有一個好處:不會對 Kubernetes 內部效能造成負面影響
  • ConfigMap:有時控制器需要的額外資訊塞不進標籤或註解,此時可用 ConfigMap 承載目標狀態定義,再由控制器監看與讀取。

不過設計自訂目標狀態規格時,CRD 遠比純 ConfigMap 合適,也更被推薦。但註冊 CRD 需要提升過的叢集層級權限;若你沒有這些權限,ConfigMap 仍是 CRD 的最佳替代方案。

幾個可供研讀的實作範例
  • jenkins-x/exposecontroller:監看 Service 定義,若偵測到 metadata 中有名為 expose 的註解,就自動為該 Service 曝露一個 Ingress 物件以供外部存取;有人移除 Service 時也一併移除 Ingress 物件。
  • fabric8/configmapcontroller:監看 ConfigMap 物件的變更,並對其關聯的 Deployment 執行滾動升級。適用於「無法自行監看 ConfigMap 並動態套用新組態」的應用——尤其當 Pod 以環境變數消費該 ConfigMap,或應用無法快速可靠地即時更新而必須重啟時。
  • Container Linux Update Operator:偵測到節點上有特定註解時,重新啟動該 Kubernetes 節點。

實例:用 shell script 寫的 ConfigMap 控制器#

以下是一個具體例子:一個只由單一 shell script 組成的控制器,監看 Kubernetes API 上 ConfigMap 資源的變更。若我們為某個 ConfigMap 加上 k8spatterns.io/podDeleteSelector 註解,該 ConfigMap 變更時,所有符合註解值所指選擇器的 Pod 都會被刪除。假設這些 Pod 由 Deployment 或 ReplicaSet 這類高階資源支撐,它們會被重啟並取用改動後的組態。

apiVersion: v1
kind: ConfigMap
metadata:
  name: webapp-config
  annotations:
    # 此註解作為控制器的選擇器,用來找出要重啟的應用 Pod
    k8spatterns.io/podDeleteSelector: "app=webapp"
data:
  message: "Welcome to Kubernetes Patterns !"

控制器的主體是調和迴圈,它監聽 ConfigMap 的生命週期事件:

namespace=${WATCH_NAMESPACE:-default}   # 要監看的命名空間(未給則用 default)

base=http://localhost:8001              # 透過同 Pod 內的大使代理存取 Kubernetes API
ns=namespaces/$namespace

# 以 watch 監看 ConfigMap 事件的迴圈
curl -N -s $base/api/v1/${ns}/configmaps?watch=true | \
while read -r event
do
   # ...
done

環境變數 WATCH_NAMESPACE 指定控制器要監看 ConfigMap 更新的命名空間。本例用「自我察覺」模式的 Downward API,取得控制器自身所部署的命名空間:

env:
  - name: WATCH_NAMESPACE
    valueFrom:
      fieldRef:
        fieldPath: metadata.namespace

注意 watch=true 查詢參數:它告訴 API Server 不要關閉 HTTP 連線,而是在事件發生時沿著回應通道送出(這類技術也稱為 hanging GET 或 Comet)。迴圈逐一讀取抵達的每個事件加以處理。

控制器透過 localhost 聯繫 API Server——但我們並不會把腳本直接部署在 master 節點上,那怎麼能用 localhost?這裡又用上了另一個模式:我們把這個腳本與一個大使容器一同部署在 Pod 中,由大使在 localhost 曝露 8001 埠,並代理到真正的 Kubernetes Service。

這樣監看事件當然不夠健壯:連線隨時可能中斷,因此應該要有重啟迴圈的機制;也可能漏掉事件,所以正式等級的控制器不該只監看事件,還應不時向 API Server 查詢完整的當前狀態並以此為新基準。此處僅為示範模式而從簡。

迴圈內執行的邏輯如下:

curl -N -s $base/api/v1/${ns}/configmaps?watch=true | \
while read -r event
do
   # 從事件中取出 ConfigMap 的類型與名稱
   type=$(echo "$event"        | jq -r '.type')
   config_map=$(echo "$event" | jq -r '.object.metadata.name')
   annotations=$(echo "$event" | jq -r '.object.metadata.annotations')

  # 取出 ConfigMap 上 key 為 k8spatterns.io/podDeleteSelector 的註解
  if [ "$annotations" != "null" ]; then
    selector=$(echo $annotations | \
     jq -r "\
        to_entries                                           |\
        .[]                                                  |\
        select(.key == \"k8spatterns.io/podDeleteSelector\") |\
        .value                                               |\
         @uri                                                 \
     ")
  fi

  # 若事件表示 ConfigMap 被更新、且帶有我們的註解,就找出所有符合此標籤選擇器的 Pod
  if [ $type = "MODIFIED" ] && [ -n "$selector" ]; then
      pods=$(curl -s $base/api/v1/${ns}/pods?labelSelector=$selector |\
             jq -r .items[].metadata.name)

    # 刪除所有符合選擇器的 Pod
    for pod in $pods; do
       curl -s -X DELETE $base/api/v1/${ns}/pods/$pod
     done
  fi
done

腳本先取出事件類型,判斷 ConfigMap 發生了什麼動作;取得 ConfigMap 後,用 jq(優秀的命令列 JSON 解析工具)推導出註解。若 ConfigMap 帶有註解,就用一段較複雜的 jq 查詢檢查 k8spatterns.io/podDeleteSelector目的是把註解值轉換成下一步 API 查詢可用的 Pod 選擇器——註解 k8spatterns.io/podDeleteSelector: "app=webapp" 會被轉換成 app%3Dwebapp

拆解那段 jq 表達式
selector=$(echo $annotations | \
   jq -r "\
    to_entries                                           |\
    .[]                                                  |\
    select(.key == \"k8spatterns.io/podDeleteSelector\") |\
    .value                                               |\
     @uri                                                 \
 ")
  • $annotations 以 JSON 物件形式存放所有註解,註解名稱作為屬性。
  • to_entries{ "a": "b" } 這樣的 JSON 物件轉換成 { "key": "a", "value": "b" } 形式的陣列。
  • .[] 逐一選出陣列項目。
  • select(...) 從中只挑出 key 相符者——通過這個過濾的最多只有零或一筆。
  • 最後取出 .value,並以 @uri 轉換,使其可作為 URI 的一部分使用。

於是這段表達式把

{
  "k8spatterns.io/pattern": "Controller",
  "k8spatterns.io/podDeleteSelector": "app=webapp"
}

轉換成選擇器 app%3Dwebapp

取得選擇器後,就能直接用它挑出要刪除的 Pod:先查出所有符合的 Pod,再逐一以直接 API 呼叫刪除。

部署這個控制器#

控制器腳本本身存放在名為 config-watcher-controller 的 ConfigMap 中,日後需要時可輕鬆編輯。我們用 Deployment 為控制器建立一個含兩個容器的 Pod:

  • Kubernetes API 大使容器:在 localhost 的 8001 埠曝露 Kubernetes API。映像檔 k8spatterns/kubeapi-proxy 是一個安裝了本地 kubectl、並掛載適當 CA 與 token 後啟動 kubectl proxy 的 Alpine Linux。
  • 主容器:執行剛才建立的 ConfigMap 中的腳本,使用安裝了 curljq 的 Alpine 基礎映像檔。
apiVersion: apps/v1
kind: Deployment
# ....
spec:
  template:
    # ...
    spec:
      # 具備監看事件與重啟 Pod 權限的 ServiceAccount
      serviceAccountName: config-watcher-controller
      containers:
        # 大使容器:把 localhost 代理到 Kubernetes API server
        - name: kubeapi-proxy
          image: k8spatterns/kubeapi-proxy
        # 主容器:備齊所有工具並掛載控制器腳本
        - name: config-watcher
          image: k8spatterns/curl-jq
          # ...
          command: # 啟動指令即呼叫控制器腳本
            - "sh"
            - "/watcher/config-watcher-controller.sh"
          volumeMounts: # 把 ConfigMap 支撐的 volume 掛載進主容器
            - mountPath: "/watcher"
              name: config-watcher-controller
      volumes: # 對應到存放腳本之 ConfigMap 的 volume
        - name: config-watcher-controller
          configMap:
            name: config-watcher-controller

為求簡潔,此處省略了存活與就緒檢查、以及資源上限宣告。此外還需要一個被允許監看 ConfigMap 的 ServiceAccount config-watcher-controller

最後看控制器實際運作。我們用一個極簡的網頁伺服器,只把某個環境變數的值當作內容供應(基礎映像檔用純 nc):

apiVersion: v1
kind: ConfigMap
metadata:
  name: webapp-config # 存放待供應資料的 ConfigMap
  annotations:
    # 觸發網頁應用 Pod 重啟的註解
    k8spatterns.io/podDeleteSelector: "app=webapp"
data:
  message: "Welcome to Kubernetes Patterns !" # 網頁應用在 HTTP 回應中使用的訊息
---
apiVersion: apps/v1
kind: Deployment # 網頁應用的 Deployment
# ...
spec:
  # ...
  template:
    spec:
      containers:
        - name: app
          image: k8spatterns/mini-http-server # 用 netcat 提供 HTTP 服務的極簡映像檔
          ports:
            - containerPort: 8080
          env:
            # 作為 HTTP 回應主體的環境變數,取自被監看的 ConfigMap
            - name: MESSAGE
              valueFrom:
                configMapKeyRef:
                  name: webapp-config
                  key: message

這雖然大概是本書最複雜的範例,卻也顯示:寫一個基本的控制器並不需要太多工夫。

當然,真實情境下你應該用真正的程式語言撰寫這類控制器,以獲得更好的錯誤處理能力與其他進階特性。

討論#

總結來說,控制器是一個主動的調和流程:監控感興趣的物件、比對世界的期望狀態與實際狀態,然後送出指令,試圖把當前狀態改得更像期望狀態。Kubernetes 用這套機制驅動它的內部控制器,而你也能用同一套機制打造自訂控制器。

控制器之所以可行,源自 Kubernetes 架構高度模組化與事件驅動的本質。這種架構自然導向一種解耦、非同步的擴充點取徑,顯著好處是我們在 Kubernetes 本身與任何擴充之間有一條精確的技術邊界。

但控制器的非同步本質也帶來一個問題:它們往往難以除錯,因為事件流並不總是直觀的。你無法輕易在控制器中設中斷點、讓一切停下來檢視某個特定情境。

下一章的「Operator」模式建立在控制器模式之上,提供更靈活的維運配置方式。

更多資訊#

  • Controller Example
  • Writing Controllers
  • Writing a Custom Controller in Python
  • A Deep Dive into Kubernetes Controllers
  • Expose Controller
  • ConfigMap Controller
  • Writing a Custom Controller
  • Writing Kubernetes Custom Controllers
  • Contour Ingress Controller
  • AppController
  • Characters Allowed for Labels
  • Kubectl-Proxy