控制器(Controller)主動監控並把一組 Kubernetes 資源維持在期望狀態。Kubernetes 的核心本身就由一整批控制器組成,它們定期監看並調和「應用的當前狀態」與「宣告的目標狀態」。本章說明如何運用這個核心概念,依我們的需求擴充平台。
問題#
Kubernetes 是精巧而全面的平台,開箱即用地提供許多功能。但它是通用的編排平台,不可能涵蓋所有應用使用案例。所幸它提供了自然的擴充點,讓特定使用案例能優雅地建立在久經驗證的 Kubernetes 建構單元之上。
於是核心問題浮現:如何在不修改、不破壞 Kubernetes 的前提下擴充它,並把它的能力用於自訂的使用案例?
依設計,Kubernetes 基於宣告式、以資源為中心的 API。何謂宣告式?相對於命令式取徑,宣告式不告訴 Kubernetes 該怎麼做,而是描述目標狀態該長什麼樣。例如擴大一個 Deployment 時,我們不會主動叫 Kubernetes「建立一個新 Pod」,而是透過 API 把 Deployment 的 replicas 屬性改成期望的數字。
那麼新 Pod 是怎麼被建立的?答案是由控制器在內部完成:
- 資源狀態每次改變(例如改動 Deployment 的
replicas),Kubernetes 就建立一個事件,廣播給所有感興趣的監聽者。 - 這些監聽者透過修改、刪除或建立新資源來回應,這又產生其他事件(例如 Pod 建立事件)。
- 這些事件可能再被其他控制器接手,執行它們各自的動作。
這整個過程稱為狀態調和(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 中的腳本,使用安裝了
curl與jq的 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