比:有狀態(tài)應(yīng)用的關(guān)鍵差異)
StatefulSet 和 Deployment 的區(qū)別是我做了快十年容器平臺(tái)架構(gòu)和排障之后被問到的頻率最高的一個(gè)問題。這個(gè)問題表面上是兩個(gè) Kubernetes 對(duì)象的對(duì)比實(shí)際上背后是有狀態(tài)應(yīng)用怎么上云原生這個(gè)更大的話題。我見過把 MySQL 直接掛在 Deployment 上跑的生產(chǎn)集群遇到節(jié)點(diǎn)宕機(jī)后數(shù)據(jù)庫(kù)連不回來最后整個(gè)項(xiàng)目組花了兩個(gè)通宵處理數(shù)據(jù)也見過把所有 Web 應(yīng)用都改成 StatefulSet 的團(tuán)隊(duì)結(jié)果一次發(fā)布更新要等大半天因?yàn)槊總€(gè) Pod 都在排隊(duì)。兩種方式?jīng)]有絕對(duì)的對(duì)錯(cuò)關(guān)鍵是搞清楚你到底在管理什么樣的應(yīng)用。這篇文章里我會(huì)用實(shí)際踩坑的經(jīng)驗(yàn)和完整的例子把這兩個(gè)工作負(fù)載在設(shè)計(jì)上的差異講清楚。1. 先搞清楚兩類工作負(fù)載管理的 Pod本質(zhì)差在哪1.1 DeploymentPod 是流水線上的耗材Deployment 面向的是無狀態(tài)應(yīng)用。它底下真正干活的是 ReplicaSetDeployment 只負(fù)責(zé)聲明我要多少個(gè)副本副本數(shù)、版本、Pod 模板都由它統(tǒng)一管理。你創(chuàng)建一個(gè) Deployment實(shí)際運(yùn)行起來的 Pod 名字長(zhǎng)這樣web-7d8f9cbf74-abc12 web-7d8f9cbf74-def34 web-7d8f9cbf74-ghi56前綴的web-后面跟著 ReplicaSet 的哈希和一段隨機(jī)字符串。注意這個(gè)隨機(jī)字符串它是 Deployment 對(duì) Pod 態(tài)度的直觀體現(xiàn)Pod 只是個(gè)臨時(shí)冒出來的實(shí)例名字不重要身份不重要IP 也不重要。任意一個(gè) Pod 掛了ReplicaSet 馬上拉起一個(gè)新的名字、IP 全變了但只要總數(shù)維持正確就行。一個(gè)很典型的場(chǎng)景Deployment 管理的 Nginx 如果從節(jié)點(diǎn) A 被重新調(diào)度到節(jié)點(diǎn) B客戶端通過 Service 訪問它完全感知不到變化。因?yàn)?Service 本身是個(gè)負(fù)載均衡入口后面一堆 Pod 都是等價(jià)的請(qǐng)求打到哪個(gè) Pod 都行。這就是無狀態(tài)的核心所有副本對(duì)請(qǐng)求等價(jià)數(shù)據(jù)要么不落到本地要么落到外置共享存儲(chǔ)。所以 Deployment 下面掛一些共享 NFS 或云盤也是常見的但那是所有副本共用一份數(shù)據(jù)而不是每個(gè)副本各管各自的數(shù)據(jù)。從這段描述你應(yīng)該能感覺到Deployment 的設(shè)計(jì)哲學(xué)是把 Pod 用完即棄。1.2 StatefulSetPod 帶著編號(hào)和檔案StatefulSet 恰恰相反。它的 Pod 名字是固定的、有規(guī)律的mysql-0 mysql-1 mysql-2編號(hào)從 0 開始依次遞增。每個(gè)編號(hào)不僅僅是個(gè)數(shù)字而是這個(gè) Pod 的永久身份Pod 被刪除后重新創(chuàng)建出來的 Pod 還叫mysql-0不會(huì)變成別的名字這個(gè) Pod 掛的存儲(chǔ)卷也是固定的數(shù)據(jù)不會(huì)丟集群里的其他組件可以通過固定的主機(jī)名找到它不用關(guān)心它跑到哪臺(tái)節(jié)點(diǎn)上。這個(gè)設(shè)計(jì)的意義在分布式系統(tǒng)里極其明顯。以 MySQL 主從集群來說從節(jié)點(diǎn)初始化時(shí)需要知道主節(jié)點(diǎn)的地址節(jié)點(diǎn)重啟后集群里其他成員需要知道它還是不是原來那個(gè)節(jié)點(diǎn)復(fù)制關(guān)系建立起來了不能因?yàn)橐淮沃貑⒕蛿嗟?。如果所有?jié)點(diǎn)都像 Deployment 那樣隨機(jī)命名、隨機(jī)調(diào)度一旦某個(gè)節(jié)點(diǎn)換了身份整個(gè)集群的拓?fù)渚蛠y了。用生活化的類比Deployment 管理的 Pod 像是流水線上的臨時(shí)工走了一個(gè)隨便補(bǔ)人工號(hào)不需要固定StatefulSet 管理的 Pod 則是有工牌、有檔案的正式員工走了要留檔回來還是原來的工號(hào)他手里的紙質(zhì)檔案也跟著他。所以處理數(shù)據(jù)庫(kù)、消息隊(duì)列、配置中心這類應(yīng)用時(shí)你需要的恰恰是后者這種認(rèn)身份的能力。2. 網(wǎng)絡(luò)身份那點(diǎn)事固定 IP 是謠言穩(wěn)定 DNS 才是真本事2.1 為什么說 StatefulSet 不提供固定 IP很多文章和視頻在講 StatefulSet 的時(shí)候都會(huì)說提供穩(wěn)定的網(wǎng)絡(luò)標(biāo)識(shí)于是有人直接理解成Pod 有固定 IP。這個(gè)理解是錯(cuò)的而且會(huì)帶來兩個(gè)隱患一是排查問題的時(shí)候總按著 IP 去找結(jié)果發(fā)現(xiàn) IP 變了二是在設(shè)計(jì)內(nèi)部通信時(shí)把 IP 硬編碼進(jìn)配置節(jié)點(diǎn)一重啟就全斷。實(shí)際上StatefulSet 里的 Pod 每次重建IP 都是重新分配的。它保證的不是 IP 不變而是主機(jī)名也就是 Pod 名不變以及基于主機(jī)名生成的 DNS 記錄不變。這一點(diǎn)我建議每個(gè)用 StatefulSet 的人都先記住能省掉后面很多排查時(shí)間。那穩(wěn)定的網(wǎng)絡(luò)標(biāo)識(shí)到底是怎么實(shí)現(xiàn)的Kubernetes 里的 Pod 默認(rèn)沒有對(duì)外可達(dá)的穩(wěn)定主機(jī)名訪問一個(gè) Pod 一般是走 Service。普通 Service 用一個(gè) ClusterIP 做負(fù)載均衡Pod 的 IP 變化對(duì)客戶端不可見。但 StatefulSet 的場(chǎng)景里客戶端往往需要直連某一個(gè)具體節(jié)點(diǎn)這時(shí)候就需要另一種 Service。2.2 Headless Service 與每個(gè) Pod 獨(dú)一無二的 DNSStatefulSet 的 spec 里有個(gè)必填字段叫serviceName它要求你提供一個(gè) Service通常是一個(gè) Headless Service。所謂 Headless就是把clusterIP設(shè)為NoneapiVersion: v1 kind: Service metadata: name: mysql-headless spec: clusterIP: None selector: app: mysql ports: - port: 3306這個(gè) Service 不提供統(tǒng)一的 ClusterIP 負(fù)載均衡入口而是把域名直接解析到各個(gè) Pod 的 IP。配合 StatefulSet 的命名規(guī)則每個(gè) Pod 會(huì)得到一條獨(dú)有的 DNS 記錄mysql-0.mysql-headless.default.svc.cluster.local mysql-1.mysql-headless.default.svc.cluster.local mysql-2.mysql-headless.default.svc.cluster.local格式是pod-name.headless-service-name.namespace.svc.cluster.local。也就是說只要通過這個(gè)域名訪問不管 Pod 換了多少個(gè) IP都能準(zhǔn)確找到那個(gè)人。你可以在集群里隨便找個(gè) Pod 用nslookup或者dig驗(yàn)證返回的不是一個(gè) VIP而是一串 Pod 的 A 記錄。2.3 穩(wěn)定 DNS 在分布式組件里到底解決什么問題舉幾個(gè)我實(shí)際部署過的例子。ZooKeeper 的配置里要寫每個(gè)節(jié)點(diǎn)的地址節(jié)點(diǎn)啟動(dòng)后會(huì)根據(jù)配置里的主機(jī)名互相建立會(huì)話Etcd 集群?jiǎn)?dòng)參數(shù)里也是用 peer 地址互相發(fā)現(xiàn)Kafka 的 broker 注冊(cè)到元數(shù)據(jù)服務(wù)之后客戶端和其他 broker 要能按同一個(gè)地址找到它。這些組件如果跑在 Deployment 里Pod 每次重啟名字都變集群的一致性和成員列表就得靠額外的服務(wù)發(fā)現(xiàn)機(jī)制來維護(hù)維護(hù)成本極高。StatefulSet 的做法等于把誰是誰這件事固化了下來。節(jié)點(diǎn)從機(jī)器 1 搬到機(jī)器 2主機(jī)名不變DNS 指向新 IP其他成員重新連接時(shí)還是按老名字找連接關(guān)系不中斷。這是分布式系統(tǒng)最需要的一條底層能力。注意穩(wěn)定 DNS 不是說 Pod 永遠(yuǎn)不宕機(jī)而是說它宕機(jī)重建之后身份不會(huì)變。這兩件事有本質(zhì)區(qū)別。當(dāng)然穩(wěn)定 DNS 不是沒有代價(jià)。它要求你的應(yīng)用開發(fā)時(shí)就支持主機(jī)名配置而不是硬編碼 IP而且如果應(yīng)用自身不做成員發(fā)現(xiàn)你依然需要手寫初始節(jié)點(diǎn)列表。StatefulSet 只是給了你地基蓋什么樣的房子還得看應(yīng)用自己。3. 存儲(chǔ)綁定volumeClaimTemplates 才是 StatefulSet 的殺手锏3.1 Deployment 為什么做不到每個(gè)副本一塊獨(dú)立盤從名字就能看出來Deployment 關(guān)心的是部署這個(gè)動(dòng)作它不關(guān)心每個(gè)副本的狀態(tài)。所以 Deployment 的 Pod 模板里存儲(chǔ)卷一般是下面兩種情況之一所有副本共享同一個(gè) PVC或者每個(gè)副本掛同一個(gè)只讀配置卷。共享一個(gè) PVC 沒問題但你要想清楚數(shù)據(jù)語義如果三個(gè)副本同時(shí)往一個(gè) RWOReadWriteOnce卷上寫數(shù)據(jù)絕大多數(shù)存儲(chǔ)后端會(huì)直接報(bào)錯(cuò)。而改成 RWXReadWriteMany共享卷又會(huì)面臨并發(fā)寫入的一致性問題。所以 Deployment 里跑數(shù)據(jù)庫(kù)要么單副本硬扛要么就得在應(yīng)用層面自己搞定多副本數(shù)據(jù)同步Kubernetes 層面幫不了你。StatefulSet 提供了volumeClaimTemplates翻譯過來是卷聲明模板apiVersion: apps/v1 kind: StatefulSet metadata: name: mysql spec: serviceName: mysql-headless replicas: 3 selector: matchLabels: app: mysql template: metadata: labels: app: mysql spec: containers: - name: mysql image: mysql:8.0 volumeMounts: - name: data mountPath: /var/lib/mysql volumeClaimTemplates: - metadata: name: data spec: accessModes: [ReadWriteOnce] resources: requests: storage: 10Gi這里聲明了一個(gè)名為data的 PVC 模板。StatefulSet 控制器會(huì)為每個(gè)編號(hào)的 Pod 自動(dòng)生成對(duì)應(yīng)的 PVC名字是>spec: persistentVolumeClaimRetentionPolicy: whenDeleted: Retain whenScaled: RetainwhenScaled可以設(shè)成Delete這樣縮容時(shí)對(duì)應(yīng) PVC 會(huì)被自動(dòng)清理避免殘留一堆沒人用的云盤持續(xù)收費(fèi)whenDeleted控制 StatefulSet 刪除時(shí) PVC 是否跟隨刪除。這兩個(gè)策略建議在測(cè)試環(huán)境先驗(yàn)證一遍再上生產(chǎn)因?yàn)镈elete意味著數(shù)據(jù)直接消失配置錯(cuò)了代價(jià)很大。4. 擴(kuò)縮容、更新、刪除順序性才是最有爭(zhēng)議的行為差異4.1 創(chuàng)建和縮容要排隊(duì)Deployment 擴(kuò)縮容的時(shí)候ReplicaSet 是并行創(chuàng)建或銷毀 Pod 的用戶幾乎感受不到先后順序。StatefulSet 默認(rèn)的podManagementPolicy是OrderedReady意思非常直白必須等序號(hào)小的 Pod 進(jìn)入 Ready 狀態(tài)才會(huì)創(chuàng)建下一個(gè)。所以創(chuàng)建一個(gè) 3 副本的 StatefulSet你執(zhí)行kubectl get pods觀察會(huì)看到mysql-0先 Running 并 Ready然后mysql-1才開始創(chuàng)建mysql-1Ready 之后mysql-2才會(huì)出現(xiàn)。縮容則是反著來先刪編號(hào)最大的一個(gè)等一個(gè)。這種排隊(duì)的意義在于很多有狀態(tài)應(yīng)用強(qiáng)依賴啟動(dòng)順序。比如一主兩從的 MySQL 集群主節(jié)點(diǎn)必須先起來客戶端和從節(jié)點(diǎn)才能知道往哪連Etcd 啟動(dòng)時(shí)也需要有初始成員先就位。如果你確定自己的應(yīng)用不依賴任何啟動(dòng)順序可以把podManagementPolicy改成Parallel讓所有 Pod 同時(shí)創(chuàng)建能省下不少等待時(shí)間。我做過一個(gè) Kafka 集群就是這樣三個(gè) broker 之間沒有嚴(yán)格的啟動(dòng)依賴改用 Parallel 策略之后整體拉起時(shí)間縮短了一半還多。4.2 滾動(dòng)更新策略和 Partition 灰度Deployment 的滾動(dòng)更新有maxSurge和maxUnavailable兩個(gè)參數(shù)控制節(jié)奏可以一次多起幾個(gè)新副本再逐步替換舊的。StatefulSet 的默認(rèn)更新策略RollingUpdate則沒有這種多副本同時(shí)切換的靈活性它按編號(hào)從大到小一次只更新一個(gè) Pod更新完一個(gè)并確認(rèn) Ready才繼續(xù)下一個(gè)。這種一次一個(gè)的行為對(duì)于數(shù)據(jù)類應(yīng)用是必要的因?yàn)閿?shù)據(jù)庫(kù)節(jié)點(diǎn)不能同時(shí)全部替換總得有節(jié)點(diǎn)在對(duì)外服務(wù)。但也帶來了代價(jià)如果副本數(shù)多整個(gè)更新周期會(huì)很長(zhǎng)。我見過一個(gè) Elasticsearch 集群 15 個(gè)節(jié)點(diǎn)一次版本升級(jí)跑了接近一小時(shí)。如果你只想灰度更新部分節(jié)點(diǎn)StatefulSet 支持partition參數(shù)spec: updateStrategy: type: RollingUpdate rollingUpdate: partition: 2設(shè)置partition: 2之后控制器只會(huì)更新序號(hào)大于等于 2 的 Pod。也就是說序號(hào) 0、1 保持舊版本序號(hào) 2 及以上的節(jié)點(diǎn)先升級(jí)。這在數(shù)據(jù)庫(kù)這類需要先升級(jí)從庫(kù)、觀察一段時(shí)間、再升級(jí)主庫(kù)的場(chǎng)景下非常實(shí)用。等從庫(kù)驗(yàn)證沒問題再把partition改成 1 或 0讓剩下的節(jié)點(diǎn)陸續(xù)跟上。還有OnDelete策略更新時(shí)不自動(dòng)替換 Pod只有你手動(dòng)刪除某個(gè) Pod控制器才會(huì)按新模板重建。適合那種想完全控制變更節(jié)奏的團(tuán)隊(duì)但日常用得少我一般只在需要配合外部數(shù)據(jù)遷移工具時(shí)才用。4.3 Pod 被刪、節(jié)點(diǎn)宕機(jī)后會(huì)發(fā)生什么先說主動(dòng)刪 Pod你執(zhí)行kubectl delete pod mysql-0StatefulSet 控制器會(huì)立即創(chuàng)建一個(gè)新的mysql-0名字不變網(wǎng)絡(luò)標(biāo)識(shí)不變重新掛上原來的 PVC。這在很多場(chǎng)景下可以當(dāng)作重啟單個(gè)節(jié)點(diǎn)來用比如某個(gè)從庫(kù)連接數(shù)異常刪掉讓它重新初始化挺管用的。再說節(jié)點(diǎn)宕機(jī)。如果承載mysql-0的節(jié)點(diǎn)失聯(lián)默認(rèn)情況下 kubelet 會(huì)對(duì)該節(jié)點(diǎn)上的 Pod 打上終止標(biāo)記但不會(huì)立刻讓 Pod 離開節(jié)點(diǎn)因?yàn)樾枰却?jié)點(diǎn)恢復(fù)以確認(rèn) Pod 是否還活著。如果節(jié)點(diǎn)長(zhǎng)時(shí)間不恢復(fù)mysql-0會(huì)一直處于 Terminating 狀態(tài)而且由于 StatefulSet 默認(rèn)同一個(gè)編號(hào)在同一時(shí)間只能有一個(gè) Pod新 Pod 也無法創(chuàng)建整個(gè)集群的副本數(shù)就缺了。遇到這種情況常規(guī)操作是確認(rèn)節(jié)點(diǎn)確實(shí)回不來之后對(duì)這個(gè) Pod 執(zhí)行強(qiáng)制刪除kubectl delete pod mysql-0 --force --grace-period0然后控制器會(huì)用新 Pod 接管這個(gè)名字和 PVC。注意強(qiáng)制刪除有風(fēng)險(xiǎn)如果原 Pod 還活著可能產(chǎn)生兩個(gè)同名 Pod 短暫并存、同時(shí)訪問同一塊存儲(chǔ)的情況。對(duì)數(shù)據(jù)庫(kù)來說兩個(gè)進(jìn)程寫同一塊數(shù)據(jù)盤是大忌所以執(zhí)行前一定要確認(rèn)那個(gè)節(jié)點(diǎn)徹底失聯(lián)或者已經(jīng)從集群中移除。這個(gè)風(fēng)險(xiǎn)我在文章開頭提到的那個(gè) MySQL 事故里就踩過提醒各位格外小心。5. 選型判斷到底什么業(yè)務(wù)該用 StatefulSet5.1 三個(gè)判斷標(biāo)準(zhǔn)經(jīng)過前面幾輪的對(duì)比選型其實(shí)可以收斂成三個(gè)問題數(shù)據(jù)丟了能重建嗎如果你的服務(wù)掛了之后數(shù)據(jù)可以從外部源重新同步、可以重新生成、可以從備份恢復(fù)且業(yè)務(wù)能接受短暫不可用那不一定要用 StatefulSet。每個(gè)副本需要獨(dú)立的持久化存儲(chǔ)嗎如果每個(gè)實(shí)例都必須有一塊自己的數(shù)據(jù)盤而且實(shí)例間不能共享那 Deployment 幫不了你StatefulSet 的volumeClaimTemplates幾乎是唯一直接支持的方式。實(shí)例之間是否需要穩(wěn)定的網(wǎng)絡(luò)身份分布式組件之間要互相發(fā)現(xiàn)、要建立固定連接需要每個(gè)節(jié)點(diǎn)有穩(wěn)定主機(jī)名那 StatefulSet 就很有必要。三個(gè)問題里只要有兩個(gè)回答是基本就該認(rèn)真考慮 StatefulSet 了。如果全是否Deployment 完全夠用。5.2 典型場(chǎng)景對(duì)照表應(yīng)用場(chǎng)景有獨(dú)立存儲(chǔ)需求有穩(wěn)定身份需求推薦工作負(fù)載備注Web 前端 / API 網(wǎng)關(guān)否否Deployment無狀態(tài)隨便擴(kuò)縮容批處理一次性任務(wù)否否Job / CronJob不需要常駐MySQL 主從 / PostgreSQL 集群是是StatefulSet典型數(shù)據(jù)集群Redis 哨兵 / 集群是是StatefulSetAOF/RDB 需要獨(dú)立盤ZooKeeper / Etcd是是StatefulSet配置中心、協(xié)調(diào)服務(wù)Kafka / Pulsar是是StatefulSet分區(qū)數(shù)據(jù)需要獨(dú)立盤Mongo 副本集 / Elasticsearch是是StatefulSet分片副本各管各的盤MinIO 單節(jié)點(diǎn)是否看架構(gòu)多數(shù)建議 StatefulSet列這么多是想提醒你別光記住數(shù)據(jù)庫(kù)用 StatefulSet這一句結(jié)論。緩存、隊(duì)列、協(xié)調(diào)服務(wù)、對(duì)象存儲(chǔ)這類中間件只要滿足有獨(dú)立存儲(chǔ) 有穩(wěn)定身份兩個(gè)條件都應(yīng)該往 StatefulSet 上靠。5.3 從 Deployment 遷到 StatefulSet 的經(jīng)驗(yàn)如果你現(xiàn)在有一個(gè)重要應(yīng)用正跑在 Deployment 上而它其實(shí)應(yīng)該有狀態(tài)怎么平滑遷移我說幾個(gè)自己用過的思路。如果只有單副本遷移成本很低。先把應(yīng)用停掉確保數(shù)據(jù)盤沒有被占用然后把原來的 PVC 改一下標(biāo)簽裝進(jìn) StatefulSet 的 selector新建同名的 StatefulSet讓控制器認(rèn)領(lǐng)這個(gè) PVC。只要 PVC 的名字符合templateName-stsName-ordinal的規(guī)則且標(biāo)簽匹配控制器會(huì)直接接手不需要重新拷數(shù)據(jù)。這個(gè)方法我在多個(gè)單實(shí)例應(yīng)用上驗(yàn)證過。如果是多副本集群就不能只靠改名了。穩(wěn)妥的做法是新的 StatefulSet 先用一套新的 PVC 和存儲(chǔ)類建起來通過備份恢復(fù)或從舊集群同步數(shù)據(jù)跑一段時(shí)間確認(rèn)數(shù)據(jù)一致后再把流量切過去。整個(gè)過程可以做成藍(lán)綠發(fā)布前提是兩套集群之間有足夠的網(wǎng)絡(luò)和存儲(chǔ)帶寬。把遷移時(shí)間安排在業(yè)務(wù)低峰給數(shù)據(jù)同步留足余量。最后提醒一個(gè)遷移中容易踩的坑StatefulSet 的selector是寫在 spec 里不可變的創(chuàng)建之后不能改。所以創(chuàng)建前務(wù)必想清楚標(biāo)簽怎么寫尤其別為了圖省事把selector寫成空匹配規(guī)則否則控制器會(huì)把集群里一堆無關(guān)的 PVC 全認(rèn)領(lǐng)過來后面的問題會(huì)非常難排查。我當(dāng)初就是因?yàn)檫@個(gè)吃了虧在幾個(gè)環(huán)境的共享集群里差點(diǎn)把別人的存儲(chǔ)卷?yè)屪摺W詈笳f一個(gè)我現(xiàn)在一直保持的習(xí)慣每次新建 StatefulSet我都會(huì)在驗(yàn)收清單里加一項(xiàng)——把 PVC 的reclaimPolicy手動(dòng)確認(rèn)一遍并在測(cè)試環(huán)境跑一次完整的縮容、擴(kuò)容、刪 StatefulSet、再重建的演練。這套流程走完心里才有底。有狀態(tài)應(yīng)用出事往往不是配置寫錯(cuò)而是沒提前驗(yàn)證過異常路徑。有空的話建議你也找個(gè)周末把你的 StatefulSet 在測(cè)試環(huán)境里故意折騰一遍這比看十篇文檔都管用。