分散式有狀態應用需要持久身分、網路、儲存與序位這些特性。「有狀態服務」模式描述的 StatefulSet 原語,正是提供這些建構單元、並帶有強保證的機制,非常適合管理有狀態應用。
問題#
到目前為止,我們看過許多用來打造分散式應用的 Kubernetes 原語:帶健康檢查與資源上限的容器、含多個容器的 Pod、叢集範圍的動態配置、批次工作、排程工作、單例……。這些原語的共同特徵是:把受管應用視為無狀態應用——由相同、可互換、可替換的容器組成,並遵守《The Twelve-Factor App》原則。
有平台代勞無狀態應用的配置、韌性與擴縮固然是巨大進步,但還有一大塊工作負載尚待處理:有狀態應用——其中每個實例都是獨一無二的,且具有長期存續的特性。
真實世界裡,每個高度可擴展的無狀態服務背後,都有一個有狀態服務,通常以某種資料儲存的形式存在。Kubernetes 早期缺乏對有狀態工作負載的支援,當時的解法是:把無狀態應用放上 Kubernetes 以享受雲原生模型的好處,而把有狀態元件留在叢集之外(公有雲或地端硬體),用傳統的非雲原生機制管理。考慮到每家企業都有大量有狀態工作負載(新舊皆有),這對一個號稱通用雲原生平台的 Kubernetes 是重大限制。
有狀態應用的典型需求#
我們或許可以這樣部署 Apache ZooKeeper、MongoDB、Redis 或 MySQL:用 Deployment 建立一個 replicas=1 的 ReplicaSet 讓它可靠、用 Service 發現其端點、用 PersistentVolumeClaim 與 PersistentVolume 作為狀態的永久儲存。
這對單實例有狀態應用大致成立,但也不完全正確——ReplicaSet 不保證「至多一個」語意,複本數可能暫時變動。這種情況可能是災難性的,導致資料遺失。
而真正的挑戰出現在由多個實例組成的分散式有狀態服務上。這類應用需要底層基礎設施提供多面向的保證:
儲存#
我們可以輕易把 ReplicaSet 的複本數調高,得到一個分散式有狀態應用。但儲存需求該怎麼定義?這類應用通常要求每個實例有專屬的持久儲存,然而 replicas=3 的 ReplicaSet 搭配一份 PVC 定義,會導致三個 Pod 全部掛載同一個 PV——儲存不是專屬的,而是所有 Pod 實例共享。
兩種變通做法都有代價:
- 應用實例共用儲存,並在應用內以子資料夾切分、避免衝突。可行,但單一儲存構成單點故障;而且擴縮時 Pod 數量改變,很容易出錯,在防止資料損毀或遺失上會遭遇嚴峻挑戰。
- 為每個實例各配一個
replicas=1的 ReplicaSet,各自取得自己的 PVC 與專屬儲存。缺點是人工作業繁重:每次擴大都得建立一整組新的 ReplicaSet、PVC 與 Service 定義,而且缺乏一個把所有實例當成整體來管理的抽象。
網路#
與儲存需求類似,分散式有狀態應用需要穩定的網路身分。除了把應用特定資料存進儲存空間,有狀態應用也會存放主機名稱、對等節點連線細節等組態資訊。這意味著每個實例都應能透過一個可預測、不會動態改變的位址被存取——而 ReplicaSet 中的 Pod IP 恰恰會變。
同樣有個變通做法:為每個 replicas=1 的 ReplicaSet 各建一個 Service。但這種配置的管理是純手工,而且應用本身無法依賴穩定的主機名稱,因為它每次重啟都會變,應用也不知道自己是被哪個 Service 名稱存取的。
身分#
從上述需求可以看出,叢集化的有狀態應用高度依賴「每個實例都握有自己的長期儲存與網路身分」。因為在有狀態應用中,每個實例都是獨特的、且知道自己的身分,而身分的主要成分就是長期儲存與網路座標。此外還可加上實例的名稱(某些有狀態應用要求唯一的持久名稱),在 Kubernetes 中即 Pod 名稱——但 ReplicaSet 建立的 Pod 名稱是隨機的,重啟後也不會保留該身分。
序位(Ordinality)#
除了獨特且長期的身分之外,叢集化有狀態應用的實例在整組實例中還有固定的位置。這個排序通常影響實例擴大與縮小的順序,也可用於資料分佈或存取,以及鎖、單例、主節點等叢集內的角色定位。
其他需求#
穩定長期的儲存、網路、身分與序位,是叢集化有狀態應用的共同需求。但管理有狀態應用還有許多因案例而異的特定要求:有些應用有法定人數(quorum)概念、要求隨時保有最低實例數;有些對序位敏感,有些則可平行部署;有些容忍重複實例,有些不行。
要為所有這些一次性案例預作規劃、並提供通用機制,是不可能的任務。這正是 Kubernetes 另外允許建立 CustomResourceDefinition 與 Operator 來管理有狀態應用的原因(見「Operator」)。
解法#
為了說明 StatefulSet 提供了什麼,我們不時拿它與熟悉的 ReplicaSet 對照。某種意義上,StatefulSet 管的是寵物,ReplicaSet 管的是牲口——「寵物 vs. 牲口」是 DevOps 圈著名(也具爭議)的類比:相同且可替換的伺服器叫牲口,不可替代、需要個別照料的獨特伺服器叫寵物。StatefulSet(最初受此類比啟發,曾命名為 PetSet)正是為管理不可替代的 Pod 而設計,與管理相同可替換 Pod 的 ReplicaSet 相對。
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: rg # StatefulSet 名稱會作為所產生節點名稱的前綴
spec:
serviceName: random-generator # 引用下方那個必要的 Service
replicas: 2 # StatefulSet 中的兩個 Pod 成員,命名為 rg-0 與 rg-1
selector:
matchLabels:
app: random-generator
template:
metadata:
labels:
app: random-generator
spec:
containers:
- image: k8spatterns/random-generator:1.0
name: random-generator
ports:
- containerPort: 8080
name: http
volumeMounts:
- name: logs
mountPath: /logs
volumeClaimTemplates: # 為每個 Pod 建立 PVC 的範本(類似 Pod 的 template)
- metadata:
name: logs
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Mi儲存#
雖然不是絕對必要,多數有狀態應用會儲存狀態,因而需要以實例為單位的專屬持久儲存。Kubernetes 中請求並關聯持久儲存的方式是 PV 與 PVC。StatefulSet 用 volumeClaimTemplates 元素來建立 PVC,就像它建立 Pod 一樣——這個額外屬性正是它與 ReplicaSet(使用 persistentVolumeClaim 元素)的主要差異之一。
StatefulSet 不是引用預先定義好的 PVC,而是在 Pod 建立過程中依範本即時產生 PVC。這讓每個 Pod 在初次建立時、以及在調高 replicas 擴大時,都能取得自己專屬的 PVC。
注意我們只說 PVC 被建立並與 Pod 關聯,卻沒提 PV——因為 StatefulSet 完全不管理 PV。Pod 的儲存必須由管理員事先佈建,或由 PV provisioner 依請求的 storage class 按需佈建,備妥後供有狀態 Pod 使用。
這裡有個不對稱行為:擴大 StatefulSet(調高
replicas)會建立新 Pod 與對應的 PVC;但縮小只刪除 Pod,不刪除任何 PVC(也不刪 PV)。這意味著 PV 無法被回收或刪除,Kubernetes 也無法釋放該儲存。這是刻意的設計,前提假設是「有狀態應用的儲存至關重要,意外的縮容不該造成資料遺失」。若你確定縮容是刻意為之、且資料已複製/排空到其他實例,可以手動刪除 PVC,後續 PV 才能被回收。
網路#
StatefulSet 建立的每個 Pod 都有穩定的身分,由 StatefulSet 名稱加上序數索引(從 0 開始)產生。以上例而言,兩個 Pod 名為 rg-0 與 rg-1——這種可預測的命名格式,與 ReplicaSet 那種帶隨機後綴的機制截然不同。
網路方面,我們定義一個 headless Service:
apiVersion: v1
kind: Service
metadata:
name: random-generator
spec:
clusterIP: None # 宣告此 Service 為 headless
selector:
app: random-generator
ports:
- name: http
port: 8080clusterIP: None 表示我們不要 kube-proxy 處理這個 Service,不要配置 cluster IP,也不要負載平衡。那我們為什麼還需要 Service?
因為 ReplicaSet 建立的無狀態 Pod 被假定為彼此相同,請求落在哪一個都無所謂(所以一般 Service 做負載平衡就好);但有狀態 Pod 彼此不同,我們可能需要依座標連到特定的某一個。
帶選擇器的 headless Service 正好做到這點:它在 API Server 中建立 Endpoint 記錄,並建立 DNS 條目回傳直接指向後端 Pod 的 A 記錄。長話短說,每個 Pod 都獲得一個 DNS 條目,客戶端能以可預測的方式直接連上它。例如若 random-generator Service 位於 default 命名空間,就能透過完整網域名稱 rg-0.random-generator.default.svc.cluster.local 連到 rg-0 Pod——Pod 名稱被前綴到 Service 名稱之前。
我們也可以查詢 SRV 記錄(例如 dig SRV random-generator.default.svc.cluster.local),發現所有註冊於該 StatefulSet 治理 Service 的執行中 Pod,達成動態的叢集成員發現。
headless Service 與 StatefulSet 的關聯不只靠選擇器:StatefulSet 還必須用
serviceName: "random-generator"反向連回該 Service。用
volumeClaimTemplates定義專屬儲存並非必要,但透過serviceName連結到 Service 則是必要的。這個治理 Service 必須在 StatefulSet 建立之前就存在,負責整組的網路身分。若你確實需要跨有狀態 Pod 做負載平衡,隨時可以另外建立其他類型的 Service。

圖 11-1:Kubernetes 上的分散式有狀態應用
身分#
身分是所有其他 StatefulSet 保證所依附的元建構單元。基於 StatefulSet 的名稱,我們能得到可預測的 Pod 名稱與身分,再用這個身分去命名 PVC、透過 headless Service 連到特定 Pod 等等。
你可以在 Pod 建立之前就預測它的身分,必要時把這項知識用在應用本身。
序位#
依定義,分散式有狀態應用由多個獨特且不可互換的實例組成。除了獨特性之外,實例之間可能還因實例化順序/位置而彼此相關,這就是序位需求的由來。
從 StatefulSet 的角度,序位唯一發揮作用的地方是擴縮。Pod 名稱帶有序數後綴(從 0 開始),而 Pod 的建立順序也定義了擴大與縮小的順序(縮小時反向,從 n−1 到 0)。
對照 ReplicaSet 就很清楚:
| ReplicaSet | StatefulSet | |
|---|---|---|
| 擴大 | 所有 Pod 一起被排程啟動,不等第一個成功啟動;啟動與就緒順序無保證 | 從索引 0 開始,該 Pod 成功啟動後才排程索引 1,依序進行 |
| 縮小 | 所有 Pod 同時開始關閉,彼此無順序與依賴 | 先關閉索引最高的 Pod,成功關閉後才停止次高者,直到索引 0 |
ReplicaSet 的做法可能完成得更快,但這並非有狀態應用所要的,尤其在實例之間涉及資料分區與分佈時。StatefulSet 預設執行循序啟動與關閉,正是為了讓擴縮期間能妥善同步資料。
其他可調整的特性#
每個有狀態應用都是獨特的,要套進 StatefulSet 模型都需要仔細斟酌。以下幾項 Kubernetes 功能在馴服有狀態應用時可能派上用場:
分區更新(Partitioned Updates)
更新已在執行的有狀態應用時(例如改動 .spec.template),StatefulSet 允許分階段推出(類似金絲雀發布),保證一定數量的實例維持原狀,同時對其餘實例套用更新。
使用預設的滾動更新策略時,可透過 .spec.updateStrategy.rollingUpdate.partition 指定分區點(預設值 0):序數索引大於或等於 partition 的所有 Pod 會被更新,小於的則不會——即使那些 Pod 被刪除,Kubernetes 也會以先前的版本重建它們。這讓你能對叢集化有狀態應用做部分更新(例如確保 quorum 得以保全),之後再把 partition 設回 0,把變更推展到叢集其餘部分。
平行部署(Parallel Deployments)
把 .spec.podManagementPolicy 設為 Parallel 時,StatefulSet 會平行啟動或終止所有 Pod,不等前一個進入 running 且 ready、或完全終止就處理下一個。若你的有狀態應用不需要循序處理,這個選項能加快維運程序。
At-Most-One 保證
獨特性是有狀態應用實例的根本屬性,Kubernetes 透過「確保同一 StatefulSet 中沒有兩個 Pod 具有相同身分、或綁定到同一個 PV」來保證這點。
兩者的語意根本不同:ReplicaSet 提供 At-Least-X 保證,StatefulSet 提供 At-Most-One 保證。
ReplicaSet 設兩個複本時,控制器的優先事項是「不讓 Pod 數低於指定值」,因此數量偶爾超出是可接受的——例如新 Pod 已在替換舊 Pod、而舊 Pod 尚未完全終止;或某節點處於 NotReady 卻仍跑著 Pod,控制器於是在健康節點上啟動新 Pod。
StatefulSet 控制器則做盡一切檢查以確保沒有重複 Pod:除非確認舊實例已完全關閉,否則不會重新啟動 Pod。節點故障時,除非 Kubernetes 能確認那些 Pod(也許是整個節點)已關閉,否則不會在別的節點排新 Pod。
這些保證仍可能被打破而產生重複 Pod,但需要人為主動介入,例如:
- 在實體節點仍在運行時,從 API Server 刪除一個不可達的 node 資源物件。這個動作只應在確認節點已死或斷電、且其上沒有 Pod 行程在跑時才執行。
- 用
kubectl delete pods <pod> --grace-period=0 --force強制刪除 Pod——它不等待 kubelet 確認 Pod 已終止,會立即從 API Server 清除該 Pod,導致 StatefulSet 控制器啟動替補 Pod,進而產生重複。
討論#
處理單實例有狀態應用相對容易,但處理分散式狀態是個多維度的挑戰。我們通常把「狀態」與「儲存」畫上等號,但本章顯示狀態有多重面向,而不同的有狀態應用需要不同的保證。
在這個領域,StatefulSet 是通用實作分散式有狀態應用的絕佳原語。它處理了持久儲存、網路(透過 Service)、身分、序位等需求,提供一組良好的建構單元以自動化方式管理有狀態應用,讓它們成為雲原生世界的一等公民。
StatefulSet 是好的起點與一大步,但有狀態應用的世界既獨特又複雜。除了為雲原生世界設計、能套進 StatefulSet 的應用之外,還有大量遺留的有狀態應用從未為雲原生平台而設計,需求更多。
所幸 Kubernetes 對此也有答案:社群意識到,與其用 Kubernetes 資源去模擬各種工作負載、再用通用控制器實作其行為,不如讓使用者實作自己的控制器,甚至更進一步——用自訂資源定義來模擬應用資源、用 Operator 來模擬行為。相關的「控制器」與「Operator」模式,更適合在雲原生環境中管理複雜的有狀態應用。
更多資訊#
- Stateful Service Example
- StatefulSet Basics
- StatefulSets
- Deploying Cassandra with Stateful Sets
- Running ZooKeeper, a Distributed System Coordinator
- Headless Services
- Force Delete StatefulSet Pods
- Graceful Scaledown of Stateful Apps in Kubernetes
- Configuring and Deploying Stateful Applications