戰(zhàn):CloudNativePG部署與故障切換指南)
不少團(tuán)隊(duì)在K8s上部署高可用PostgreSQL時第一反應(yīng)是照搬裸機(jī)上的老經(jīng)驗(yàn)起三個Pod、配置流復(fù)制、再掛一層負(fù)載均衡或者VIP。結(jié)果折騰到半夜發(fā)現(xiàn)K8s的探針、調(diào)度、網(wǎng)絡(luò)模型每一樣都在跟數(shù)據(jù)庫的模式較勁。我做了一輪完整的部署、壓測和故障演練之后把整套方案固化成了下面這份文檔。這篇文章主要針對已經(jīng)有K8s基礎(chǔ)、想在集群內(nèi)部運(yùn)行生產(chǎn)級PostgreSQL的運(yùn)維或平臺工程師也會照顧到剛接觸有狀態(tài)服務(wù)的同學(xué)盡量把每個選擇背后的原因講透。我最終選型的是基于Operator的原生方案——CloudNativePG用它管理一個三副本的高可用PostgreSQL集群。選它的理由、部署步驟、參數(shù)含義、故障切換實(shí)測結(jié)果、備份恢復(fù)演練以及踩過的那些沒寫在官方文檔里的坑下面全部展開。1. 有狀態(tài)應(yīng)用進(jìn)K8s為什么比無狀態(tài)難一個量級無狀態(tài)服務(wù)在K8s里是好公民ReplicaSet只管Pod數(shù)量HPA只管擴(kuò)縮容ConfigMap一掛重啟完事。但數(shù)據(jù)庫進(jìn)了K8s至少要跨過三道坎。第一道坎是數(shù)據(jù)盤和節(jié)點(diǎn)的綁定關(guān)系。一個無狀態(tài)Pod在節(jié)點(diǎn)宕機(jī)后可以立刻在別處重建但數(shù)據(jù)庫Pod的數(shù)據(jù)目錄往往是本地卷或者RWOReadWriteOnce云盤新Pod要等存儲層的調(diào)度和掛載完成后才有機(jī)會真正啟動。換句話說K8s的調(diào)度器可以秒級重建一個空殼Pod但存儲層不可能秒級把幾百GB的數(shù)據(jù)“搬”到新節(jié)點(diǎn)。這也是為什么數(shù)據(jù)庫高可用不能指望“Pod重啟”來解決必須靠數(shù)據(jù)庫自己的復(fù)制機(jī)制。第二道坎是角色和拓?fù)涞拇嬖?。K8s的Deployment只能告訴你“我要3個副本”它不關(guān)心這三個副本誰是主、誰是備、誰在追趕WAL。但PostgreSQL高可用集群恰恰最關(guān)心這個。裸機(jī)上我們習(xí)慣用Keepalived搶VIP、用etcd做選主、用HAProxy做流量分發(fā)這套組件在K8s里面全部要重寫一遍而且K8s本身沒有VIP概念Service的Endpoints更新又是個異步過程。第三道坎是探針的誤判。K8s用livenessProbe和readinessProbe來管理Pod生命周期但數(shù)據(jù)庫有自己的健康語義——比如備庫短暫追不上主庫是正?,F(xiàn)象如果readiness探針在這種時候把備庫摘出去故障轉(zhuǎn)移發(fā)生時你可能連一個合格的備庫都找不到。反過來liveness探針如果過于激進(jìn)網(wǎng)絡(luò)抖動一次就可能把主庫Pod干掉觸發(fā)一次毫無必要的failover。所以結(jié)論很明確在K8s上做高可用數(shù)據(jù)庫本質(zhì)上不是“把數(shù)據(jù)庫塞進(jìn)容器”而是“讓K8s的調(diào)度、探針、網(wǎng)絡(luò)模型去適配數(shù)據(jù)庫復(fù)制、選主、腦裂防護(hù)這套自有邏輯”。能把這層適配做干凈的目前只有Operator模式。2. PostgreSQL高可用方案選型為什么最終選了CloudNativePG我最早考慮過三條路線踩過一輪評估之后才定的方向。簡單說自己搭Patroni全家桶、用托管類Operator、用云上全托管數(shù)據(jù)庫這三條路線各有各的取舍。2.1 三條路線的橫向?qū)Ρ确桨附M件復(fù)雜度故障轉(zhuǎn)移邏輯歸屬適合場景常見痛點(diǎn)Patroni etcd HAProxy 自建高外部編排器對選主邏輯有強(qiáng)定制需求etcd本身要3節(jié)點(diǎn)、升級鏈路長、排障牽扯面廣CloudNativePG 這類數(shù)據(jù)庫Operator中K8s控制器集群已經(jīng)重度K8s化、希望聲明式管理需要對CRD有信任跨大版本升級需謹(jǐn)慎云廠商全托管RDS低云平臺不想管理數(shù)據(jù)庫、能接受一定鎖定網(wǎng)絡(luò)路徑出集群、部分SQL能力受限我個人的判斷是如果你的業(yè)務(wù)已經(jīng)跑在K8s里而且短期不會遷走用Operator把數(shù)據(jù)庫納管進(jìn)來收益遠(yuǎn)大于成本。它把備份、故障轉(zhuǎn)移、滾動升級、存儲配置全部收口到CRD和控制器里你可以用管理Deployment的方式來管理數(shù)據(jù)庫集群這在運(yùn)維心智上是巨大的減負(fù)。2.2 CloudNativePG的架構(gòu)精髓一實(shí)例一Pod不共享數(shù)據(jù)盤CloudNativePG的核心設(shè)計(jì)跟傳統(tǒng)“共享存儲集群軟件”完全不同。它不給多個Pod掛同一個存儲卷而是每個PostgreSQL實(shí)例一個PodPod之間通過標(biāo)準(zhǔn)的流復(fù)制協(xié)議保持?jǐn)?shù)據(jù)一致。這套邏輯和Patroni在裸機(jī)上的主備同步非常像但編排層被Operator接管了。簡單類比Patroni加etcd像是在家里自己裝一套中央決策系統(tǒng)而CloudNativePG是把決策系統(tǒng)內(nèi)置進(jìn)了“物業(yè)公司”——控制器監(jiān)控每個Pod的狀態(tài)當(dāng)主節(jié)點(diǎn)不可達(dá)時控制器會根據(jù)一份預(yù)先定義的規(guī)則選出數(shù)據(jù)最新的備庫并提升為主庫然后自動修改Service的后端Endpoints。整個過程不需要人在半夜爬起來敲命令。還有一個容易被忽略的點(diǎn)它默認(rèn)不依賴etcd這類額外組件選主用的是一套基于quorum的機(jī)制。這意味著整個高可用方案的“可故障面”少了一大塊etcd集群自身出問題的概率被直接消除了。3. 先把環(huán)境磨平Operator安裝與存儲層準(zhǔn)備選型定了之后我開始準(zhǔn)備環(huán)境。這里多說一句不要急著創(chuàng)建Cluster資源先花十分鐘把Operator裝好、把StorageClass確定下來否則后面大概率要推倒重來。3.1 版本搭配與前置檢查我用的K8s版本是1.28左右CloudNativePG Operator對K8s版本和PostgreSQL鏡像版本都有一套兼容矩陣建議在官方release頁面確認(rèn)一下你的組合在支持列表內(nèi)。操作前先檢查集群狀態(tài)kubectl get nodes -o wide kubectl version --short kubectl get crd | grep cnpg如果沒有CRD說明Operator還沒裝按下一步安裝。這個前置檢查很重要我見過有人直接apply一個Cluster定義結(jié)果CRD不在報了一堆奇怪錯誤浪費(fèi)了不少時間。3.2 安裝Operator和kubectl插件官方支持Helm安裝和直接apply manifest兩種方式我用的是Helm好處是后續(xù)可以跟著operator的版本走h(yuǎn)elm repo add cnpg https://cloudnative-pg.github.io/charts helm repo update helm install cnpg cnpg/cloudnative-pg \ --namespace cnpg-system \ --create-namespace \ --set replicaCount2這里把replicaCount設(shè)為2是給控制器本身做了一份高可用副本避免控制器單點(diǎn)。Operator裝好后強(qiáng)烈建議再裝官方提供的kubectl插件cnpg后面查看集群狀態(tài)、發(fā)起備份、手動切換主庫都會用到kubectl krew install cnpg3.3 存儲層本地盤還是云盤以及最容易踩的ReclaimPolicy高可用數(shù)據(jù)庫對存儲延遲非常敏感。流復(fù)制本身不需要共享存儲但每個實(shí)例的WAL日志寫入要快。如果條件允許優(yōu)先給數(shù)據(jù)庫Pod掛本地NVMe盤如果用的是云上CSI卷要特別注意兩個參數(shù)。第一個是reclaimPolicy。我見過不少集群把默認(rèn)StorageClass的回收策略設(shè)成了Delete一旦誤刪PVC底層云盤跟著沒了數(shù)據(jù)直接清零。數(shù)據(jù)庫場景強(qiáng)烈建議改成Retain。apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: local-nvme provisioner: local.csi.openebs.io reclaimPolicy: Retain volumeBindingMode: WaitForFirstConsumer allowVolumeExpansion: true第二個是volumeBindingMode。這里用WaitForFirstConsumer是為了讓K8s先把Pod調(diào)度到某個節(jié)點(diǎn)再在那個節(jié)點(diǎn)上創(chuàng)建卷。這樣做的好處是卷和Pod在同一個節(jié)點(diǎn)上避免Pod已經(jīng)啟動、卷還在跨節(jié)點(diǎn)掛載的等待時間。如果你只是想快速驗(yàn)證整套流程跑一個小型本地測試集群也可以用local-path-provisioner這類輕量方案。但記住一條原則任何存儲層方案都必須支持WaitForFirstConsumer否則數(shù)據(jù)庫Pod的啟動時序會變得不可控。4. 三副本Cluster規(guī)格說明書一份能直接改的YAML環(huán)境準(zhǔn)備好之后真正的核心工作就是寫Cluster定義。這份YAML是整個高可用方案的靈魂我在這里踩的坑也最多所以逐字段拆開講。4.1 一份可直接實(shí)測的Cluster YAML下面這份配置我加了注釋你可以直接復(fù)制改參數(shù)但務(wù)必先看后面的字段解釋apiVersion: cnpg.io/v1 kind: Cluster metadata: name: postgres-ha namespace: database spec: instances: 3 imageName: ghcr.io/cloudnative-pg/postgresql:16.4 storage: size: 20Gi storageClass: local-nvme affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - topologyKey: kubernetes.io/hostname labelSelector: matchLabels: cnpg.io/cluster: postgres-ha postgresql: parameters: shared_buffers: 512MB max_connections: 300 synchronous_commit: on wal_keep_size: 512MB max_wal_senders: 10 pg_hba: - host all all all md5 bootstrap: initdb: database: appdb owner: appuser secret: name: app-db-secret backup: volumeSnapshot: className: local-nvme-snapshot monitoring: enablePodMonitor: true4.2 幾個關(guān)鍵字段為什么非配不可先看instances: 3。三副本是高可用數(shù)據(jù)庫的基礎(chǔ)形態(tài)。兩個副本雖然也能做主備切換但一旦備庫宕機(jī)主庫就失去了故障轉(zhuǎn)移的“后備力量”。三副本意味著即使一個節(jié)點(diǎn)下線仍然有足夠的備庫可以承擔(dān)選舉。再來看affinity這段。requiredDuringSchedulingIgnoredDuringExecution是硬反親和配合topologyKey: kubernetes.io/hostname可以強(qiáng)制三個副本落到三個不同節(jié)點(diǎn)上。這一步?jīng)]做對等于把三個雞蛋放在同一個籃子里——節(jié)點(diǎn)一宕整個集群可能全部不可用。然后看postgresql.parameters。這里有一個很微妙的權(quán)衡synchronous_commit: on保證主庫在收到備庫確認(rèn)后才提交事務(wù)可以做到RPO幾乎為零但對高并發(fā)寫入會有明顯性能損耗。如果業(yè)務(wù)允許幾秒內(nèi)的數(shù)據(jù)丟失可以把這一項(xiàng)改成off切換速度可以更快寫入性能也更接近單機(jī)。我在壓測中的經(jīng)驗(yàn)是默認(rèn)先開同步等業(yè)務(wù)反饋寫入延遲是否可以接受再決定是否降級。最后看bootstrap.initdb。初始化階段最好直接用Secret方式創(chuàng)建數(shù)據(jù)庫用戶而不是把明文密碼寫在YAML里。Secret可以先手動創(chuàng)建再在Cluster里引用Operator會在初始化時自動把賬號建好。kubectl create secret generic app-db-secret \ -n database \ --from-literalusernameappuser \ --from-literalpasswordYourStrongPassword4.3 探針和PDB很多人忽略的下半場Cluster里的實(shí)例Pod會由Operator自動生成對應(yīng)的探針配置但我還是建議在生成的StatefulSet或Deployment上再檢查一下探針參數(shù)。CloudNativePG默認(rèn)生成的readinessProbe用的是pg_isready這比直接執(zhí)行SQL更溫和。重點(diǎn)是把initialDelaySeconds適當(dāng)調(diào)大因?yàn)镻ostgreSQL實(shí)例剛剛啟動時要恢復(fù)內(nèi)存中的shared buffer一個健康的備庫也可能需要十幾秒才能完全進(jìn)入就緒狀態(tài)如果探針太急會出現(xiàn)備庫反復(fù)被摘除又加回來的現(xiàn)象。另一個必須提前創(chuàng)造的是PodDisruptionBudgetPDB。PDB的作用是告訴K8s節(jié)點(diǎn)維護(hù)時同一個時間點(diǎn)最多只能刪掉一個數(shù)據(jù)庫實(shí)例。否則你執(zhí)行kubectl drain的時候K8s調(diào)度器很可能一次性把多個副本都打掉集群就直接癱瘓了。apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: postgres-ha-pdb namespace: database spec: minAvailable: 2 selector: matchLabels: cnpg.io/cluster: postgres-ha這里minAvailable: 2意味著任何主動驅(qū)逐操作比如drain最多只能讓一個副本不可用給故障轉(zhuǎn)移留足了緩沖。5. 故障演練實(shí)錄把主節(jié)點(diǎn)拔掉之后發(fā)生了什么配置寫好了YAML apply上去Pod也起來了但這只是開始。真正檢驗(yàn)高可用方案的唯一標(biāo)準(zhǔn)是故障發(fā)生時的真實(shí)表現(xiàn)。所以我做了一輪完整的故障注入測試步驟和觀察點(diǎn)都記錄在這里。5.1 第一步確認(rèn)三副本同步狀態(tài)故障注入之前先看集群正常狀態(tài)。三個實(shí)例都Ready其中一個角色是Primary另外兩個是Standby彼此延遲極小。kubectl cnpg status postgres-ha正常情況下輸出里能看到類似下面的信息注意查看WAL延遲列實(shí)例角色狀態(tài)延遲postgres-ha-1PrimaryReady-postgres-ha-2StandbyReady0spostgres-ha-3StandbyReady0s如果延遲單位是秒或更大先不要做故障演練應(yīng)該先檢查網(wǎng)絡(luò)和磁盤IO。備庫追不上主庫的時候強(qiáng)行切換等于把數(shù)據(jù)丟失風(fēng)險直接暴露給生產(chǎn)。5.2 第二步模擬主庫物理故障最真實(shí)的故障是節(jié)點(diǎn)級宕機(jī)但為了可控我先做了一次Pod級刪除模擬“主庫進(jìn)程被誤殺”的場景# 先找到當(dāng)前主庫Pod PRIMARY_POD$(kubectl get pods -n database -l cnpg.io/clusterpostgres-ha \ -o jsonpath{.items[?(.metadata.labels.roleprimary)].metadata.name}) # 刪掉它 kubectl delete pod $PRIMARY_POD -n database刪除命令執(zhí)行后我開始計(jì)時并持續(xù)觀察集群行為。大約幾秒后Operator檢測到主庫不可達(dá)開始在兩個備庫之間發(fā)起選主。選主的邏輯并不復(fù)雜誰的WAL位置最新誰就優(yōu)先被提升為新的Primary。在我們這次模擬項(xiàng)目X的實(shí)測里切換耗時大概在20秒到40秒之間主要取決于探針掃描周期、選舉時間和Service Endpoints的傳播延遲。新主產(chǎn)生后Operator會自動把往舊主的Service流量切到新主上。你不需要手動調(diào)整任何東西這本身就是整個方案最值錢的地方。5.3 第三步觀察應(yīng)用側(cè)連接不中斷很多人在數(shù)據(jù)庫層做了高可用但應(yīng)用連接是固定的IP或者寫死的連接串這等于高可用只做了一半。業(yè)務(wù)側(cè)訪問數(shù)據(jù)庫應(yīng)該通過Service而不是直接打到某個Pod IP上。CloudNativePG默認(rèn)會生成兩個Servicepostgres-ha-rw指向當(dāng)前Primary負(fù)責(zé)讀寫流量postgres-ha-ro在所有實(shí)例之間做負(fù)載均衡負(fù)責(zé)只讀流量在故障切換過程中postgres-ha-rw的Endpoints會自動從舊主更新到新主。應(yīng)用如果用的是這個Service域名就能實(shí)現(xiàn)無縫切換。我在測試時用一個虛擬連接腳本循環(huán)建連、執(zhí)行select 1整個故障切換期間只出現(xiàn)了少量的連接超時和重連沒有出現(xiàn)徹底斷連的情況。5.4 腦裂怎么防quorum與fencing機(jī)制分布式的核心難題都在“腦裂”。如果網(wǎng)絡(luò)分區(qū)把集群分成兩半舊主和新主同時認(rèn)為自己是Primary就會出現(xiàn)雙寫數(shù)據(jù)一致性直接崩盤。CloudNativePG給出的方案是quorum思路備庫在提升之前會先嘗試確認(rèn)自己在多數(shù)派中。如果網(wǎng)絡(luò)分區(qū)導(dǎo)致一個備庫聯(lián)系不上其他副本它不會貿(mào)然提升。同時如果Operator檢測到舊主雖然不可達(dá)但進(jìn)程還活著它會嘗試對舊主執(zhí)行fencing操作把它的寫入能力“隔離掉”確保只有一個主節(jié)點(diǎn)在對外服務(wù)。這個機(jī)制我不建議你自己實(shí)現(xiàn)也不要試圖繞過去。我見過有人為了加快切換速度把quorum相關(guān)參數(shù)調(diào)得非常激進(jìn)甚至強(qiáng)制允許備庫在沒有任何確認(rèn)的情況下提升結(jié)果真的發(fā)生過雙主寫庫的慘案。6. 備份、恢復(fù)與日常維護(hù)高可用集群的最后一公里故障切換能跑通不等于集群可以不運(yùn)維了。高可用解決的是“無故障時不怕故障”備份恢復(fù)解決的是“故障已發(fā)生且數(shù)據(jù)損壞時還能兜底”。這兩件事是互補(bǔ)關(guān)系缺一不可。6.1 定期備份與WAL歸檔在Cluster定義里已經(jīng)配置了backup.volumeSnapshot這意味著Operator會基于CSI卷快照做物理備份。除了周期性快照還要配合持續(xù)性的WAL歸檔才能做到時間點(diǎn)恢復(fù)PITR。實(shí)際生產(chǎn)中我建議兩條腿走路基礎(chǔ)備份每天一次由ScheduledBackupCRD控制WAL歸檔持續(xù)開啟把每天的WAL文件增量保存到對象存儲ScheduledBackup的定義類似這樣apiVersion: cnpg.io/v1 kind: ScheduledBackup metadata: name: postgres-ha-daily-backup namespace: database spec: schedule: 0 2 * * * cluster: name: postgres-ha backupOwnerReference: self引用backupOwnerReference: self是為了防止集群刪除時備份資源被誤刪這個參數(shù)很容易被漏掉。6.2 一次真實(shí)的恢復(fù)演練備份如果不做恢復(fù)演練等于沒有備份。我強(qiáng)烈建議每季度至少做一次完整的恢復(fù)演練。實(shí)操方式很簡單用一個全新的名字從這個集群的備份中恢復(fù)出一個獨(dú)立集群然后驗(yàn)證數(shù)據(jù)。kubectl cnpg backup postgres-ha kubectl cnpg promote postgres-ha嚴(yán)格來說恢復(fù)演練用的不是promote而是下面這種通過bootstrap恢復(fù)的方式apiVersion: cnpg.io/v1 kind: Cluster metadata: name: postgres-ha-restore-test spec: instances: 1 bootstrap: recovery: source: postgres-ha recoveryTarget: backupID: latest storage: size: 20Gi這個恢復(fù)集群起來之后我會執(zhí)行幾條關(guān)鍵的SQL檢查數(shù)據(jù)表行數(shù)、最新寫入時間、以及是否恢復(fù)到目標(biāo)時間點(diǎn)。整個過程如果能在半小時完成備份體系才算真正可靠。6.3 節(jié)點(diǎn)維護(hù)和版本升級的推薦順序日常運(yùn)維里最常見的兩個動作是節(jié)點(diǎn)維護(hù)和PostgreSQL小版本升級。節(jié)點(diǎn)維護(hù)時正確流程是先cordon再drain。cordon讓調(diào)度器不再往該節(jié)點(diǎn)調(diào)度新Poddrain則會把已有Pod重新調(diào)度走。由于我們配置了PDB和反親和drain時Operator會等待故障轉(zhuǎn)移完成后才允許刪除Pod不會出現(xiàn)同時停兩個副本的情況。我的一次實(shí)測里兩節(jié)點(diǎn)同時維護(hù)耗時因?yàn)镻DB保護(hù)而被拉長了一些但換來的是集群全程沒有單點(diǎn)故障這個等待是值得的。PostgreSQL小版本升級更簡單修改Cluster里的imageName指向新版本鏡像apply之后Operator會按“先備庫、后主庫”的順序滾動更新。升級前一定要看release note尤其是大版本之間的升級跨大版本通常需要先做邏輯備份再重建集群不建議原地直接改鏡像。6.4 日常巡檢清單最后分享一份我每天會看的巡檢清單都是很輕量的命令但能提前暴露大多數(shù)隱患檢查項(xiàng)命令預(yù)期結(jié)果Pod狀態(tài)kubectl get pods -n database所有實(shí)例Ready且持續(xù)運(yùn)行集群健康度kubectl cnpg status postgres-ha主備角色正常WAL延遲接近0WAL歸檔是否積壓查看對象存儲上的WAL目錄沒有大量未歸檔文件備份是否成功kubectl get scheduledbackup -n database最后一次備份狀態(tài)為Completed存儲容量kubectl get pvc -n database使用率未超過告警閾值這套巡檢不需要專門做一個大平臺一條cron腳本就能把這些命令串起來輸出到一個統(tǒng)一的地方。我個人的經(jīng)驗(yàn)是高可用數(shù)據(jù)庫很少死于單次故障多半是死于日常小問題的累積——比如WAL歸檔悄悄失敗了一個星期、存儲使用率漲到90%沒人發(fā)現(xiàn)這些問題遠(yuǎn)比一次主庫宕機(jī)更致命。最后再分享一個小技巧數(shù)據(jù)庫這類有狀態(tài)服務(wù)的變更永遠(yuǎn)不要在業(yè)務(wù)高峰時段做??赡艿脑挵压收涎菥氁舶才旁诹璩?。你會感謝那個在夜深人靜時給自己留出試錯空間的決定。