2026/10/21 17:12:43

Kubernetes上高可用PostgreSQL实战:CloudNativePG部署与故障切换指南

Kubernetes上高可用PostgreSQL实战:CloudNativePG部署与故障切换指南 不少团队在K8s上部署高可用PostgreSQL时第一反应是照搬裸机上的老经验起三个Pod、配置流复制、再挂一层负载均衡或者VIP。结果折腾到半夜发现K8s的探针、调度、网络模型每一样都在跟数据库的模式较劲。我做了一轮完整的部署、压测和故障演练之后把整套方案固化成了下面这份文档。这篇文章主要针对已经有K8s基础、想在集群内部运行生产级PostgreSQL的运维或平台工程师也会照顾到刚接触有状态服务的同学尽量把每个选择背后的原因讲透。我最终选型的是基于Operator的原生方案——CloudNativePG用它管理一个三副本的高可用PostgreSQL集群。选它的理由、部署步骤、参数含义、故障切换实测结果、备份恢复演练以及踩过的那些没写在官方文档里的坑下面全部展开。1. 有状态应用进K8s为什么比无状态难一个量级无状态服务在K8s里是好公民ReplicaSet只管Pod数量HPA只管扩缩容ConfigMap一挂重启完事。但数据库进了K8s至少要跨过三道坎。第一道坎是数据盘和节点的绑定关系。一个无状态Pod在节点宕机后可以立刻在别处重建但数据库Pod的数据目录往往是本地卷或者RWOReadWriteOnce云盘新Pod要等存储层的调度和挂载完成后才有机会真正启动。换句话说K8s的调度器可以秒级重建一个空壳Pod但存储层不可能秒级把几百GB的数据“搬”到新节点。这也是为什么数据库高可用不能指望“Pod重启”来解决必须靠数据库自己的复制机制。第二道坎是角色和拓扑的存在。K8s的Deployment只能告诉你“我要3个副本”它不关心这三个副本谁是主、谁是备、谁在追赶WAL。但PostgreSQL高可用集群恰恰最关心这个。裸机上我们习惯用Keepalived抢VIP、用etcd做选主、用HAProxy做流量分发这套组件在K8s里面全部要重写一遍而且K8s本身没有VIP概念Service的Endpoints更新又是个异步过程。第三道坎是探针的误判。K8s用livenessProbe和readinessProbe来管理Pod生命周期但数据库有自己的健康语义——比如备库短暂追不上主库是正常现象如果readiness探针在这种时候把备库摘出去故障转移发生时你可能连一个合格的备库都找不到。反过来liveness探针如果过于激进网络抖动一次就可能把主库Pod干掉触发一次毫无必要的failover。所以结论很明确在K8s上做高可用数据库本质上不是“把数据库塞进容器”而是“让K8s的调度、探针、网络模型去适配数据库复制、选主、脑裂防护这套自有逻辑”。能把这层适配做干净的目前只有Operator模式。2. PostgreSQL高可用方案选型为什么最终选了CloudNativePG我最早考虑过三条路线踩过一轮评估之后才定的方向。简单说自己搭Patroni全家桶、用托管类Operator、用云上全托管数据库这三条路线各有各的取舍。2.1 三条路线的横向对比方案组件复杂度故障转移逻辑归属适合场景常见痛点Patroni etcd HAProxy 自建高外部编排器对选主逻辑有强定制需求etcd本身要3节点、升级链路长、排障牵扯面广CloudNativePG 这类数据库Operator中K8s控制器集群已经重度K8s化、希望声明式管理需要对CRD有信任跨大版本升级需谨慎云厂商全托管RDS低云平台不想管理数据库、能接受一定锁定网络路径出集群、部分SQL能力受限我个人的判断是如果你的业务已经跑在K8s里而且短期不会迁走用Operator把数据库纳管进来收益远大于成本。它把备份、故障转移、滚动升级、存储配置全部收口到CRD和控制器里你可以用管理Deployment的方式来管理数据库集群这在运维心智上是巨大的减负。2.2 CloudNativePG的架构精髓一实例一Pod不共享数据盘CloudNativePG的核心设计跟传统“共享存储集群软件”完全不同。它不给多个Pod挂同一个存储卷而是每个PostgreSQL实例一个PodPod之间通过标准的流复制协议保持数据一致。这套逻辑和Patroni在裸机上的主备同步非常像但编排层被Operator接管了。简单类比Patroni加etcd像是在家里自己装一套中央决策系统而CloudNativePG是把决策系统内置进了“物业公司”——控制器监控每个Pod的状态当主节点不可达时控制器会根据一份预先定义的规则选出数据最新的备库并提升为主库然后自动修改Service的后端Endpoints。整个过程不需要人在半夜爬起来敲命令。还有一个容易被忽略的点它默认不依赖etcd这类额外组件选主用的是一套基于quorum的机制。这意味着整个高可用方案的“可故障面”少了一大块etcd集群自身出问题的概率被直接消除了。3. 先把环境磨平Operator安装与存储层准备选型定了之后我开始准备环境。这里多说一句不要急着创建Cluster资源先花十分钟把Operator装好、把StorageClass确定下来否则后面大概率要推倒重来。3.1 版本搭配与前置检查我用的K8s版本是1.28左右CloudNativePG Operator对K8s版本和PostgreSQL镜像版本都有一套兼容矩阵建议在官方release页面确认一下你的组合在支持列表内。操作前先检查集群状态kubectl get nodes -o wide kubectl version --short kubectl get crd | grep cnpg如果没有CRD说明Operator还没装按下一步安装。这个前置检查很重要我见过有人直接apply一个Cluster定义结果CRD不在报了一堆奇怪错误浪费了不少时间。3.2 安装Operator和kubectl插件官方支持Helm安装和直接apply manifest两种方式我用的是Helm好处是后续可以跟着operator的版本走helm 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设为2是给控制器本身做了一份高可用副本避免控制器单点。Operator装好后强烈建议再装官方提供的kubectl插件cnpg后面查看集群状态、发起备份、手动切换主库都会用到kubectl krew install cnpg3.3 存储层本地盘还是云盘以及最容易踩的ReclaimPolicy高可用数据库对存储延迟非常敏感。流复制本身不需要共享存储但每个实例的WAL日志写入要快。如果条件允许优先给数据库Pod挂本地NVMe盘如果用的是云上CSI卷要特别注意两个参数。第一个是reclaimPolicy。我见过不少集群把默认StorageClass的回收策略设成了Delete一旦误删PVC底层云盘跟着没了数据直接清零。数据库场景强烈建议改成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调度到某个节点再在那个节点上创建卷。这样做的好处是卷和Pod在同一个节点上避免Pod已经启动、卷还在跨节点挂载的等待时间。如果你只是想快速验证整套流程跑一个小型本地测试集群也可以用local-path-provisioner这类轻量方案。但记住一条原则任何存储层方案都必须支持WaitForFirstConsumer否则数据库Pod的启动时序会变得不可控。4. 三副本Cluster规格说明书一份能直接改的YAML环境准备好之后真正的核心工作就是写Cluster定义。这份YAML是整个高可用方案的灵魂我在这里踩的坑也最多所以逐字段拆开讲。4.1 一份可直接实测的Cluster YAML下面这份配置我加了注释你可以直接复制改参数但务必先看后面的字段解释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 几个关键字段为什么非配不可先看instances: 3。三副本是高可用数据库的基础形态。两个副本虽然也能做主备切换但一旦备库宕机主库就失去了故障转移的“后备力量”。三副本意味着即使一个节点下线仍然有足够的备库可以承担选举。再来看affinity这段。requiredDuringSchedulingIgnoredDuringExecution是硬反亲和配合topologyKey: kubernetes.io/hostname可以强制三个副本落到三个不同节点上。这一步没做对等于把三个鸡蛋放在同一个篮子里——节点一宕整个集群可能全部不可用。然后看postgresql.parameters。这里有一个很微妙的权衡synchronous_commit: on保证主库在收到备库确认后才提交事务可以做到RPO几乎为零但对高并发写入会有明显性能损耗。如果业务允许几秒内的数据丢失可以把这一项改成off切换速度可以更快写入性能也更接近单机。我在压测中的经验是默认先开同步等业务反馈写入延迟是否可以接受再决定是否降级。最后看bootstrap.initdb。初始化阶段最好直接用Secret方式创建数据库用户而不是把明文密码写在YAML里。Secret可以先手动创建再在Cluster里引用Operator会在初始化时自动把账号建好。kubectl create secret generic app-db-secret \ -n database \ --from-literalusernameappuser \ --from-literalpasswordYourStrongPassword4.3 探针和PDB很多人忽略的下半场Cluster里的实例Pod会由Operator自动生成对应的探针配置但我还是建议在生成的StatefulSet或Deployment上再检查一下探针参数。CloudNativePG默认生成的readinessProbe用的是pg_isready这比直接执行SQL更温和。重点是把initialDelaySeconds适当调大因为PostgreSQL实例刚刚启动时要恢复内存中的shared buffer一个健康的备库也可能需要十几秒才能完全进入就绪状态如果探针太急会出现备库反复被摘除又加回来的现象。另一个必须提前创造的是PodDisruptionBudgetPDB。PDB的作用是告诉K8s节点维护时同一个时间点最多只能删掉一个数据库实例。否则你执行kubectl drain的时候K8s调度器很可能一次性把多个副本都打掉集群就直接瘫痪了。apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: postgres-ha-pdb namespace: database spec: minAvailable: 2 selector: matchLabels: cnpg.io/cluster: postgres-ha这里minAvailable: 2意味着任何主动驱逐操作比如drain最多只能让一个副本不可用给故障转移留足了缓冲。5. 故障演练实录把主节点拔掉之后发生了什么配置写好了YAML apply上去Pod也起来了但这只是开始。真正检验高可用方案的唯一标准是故障发生时的真实表现。所以我做了一轮完整的故障注入测试步骤和观察点都记录在这里。5.1 第一步确认三副本同步状态故障注入之前先看集群正常状态。三个实例都Ready其中一个角色是Primary另外两个是Standby彼此延迟极小。kubectl cnpg status postgres-ha正常情况下输出里能看到类似下面的信息注意查看WAL延迟列实例角色状态延迟postgres-ha-1PrimaryReady-postgres-ha-2StandbyReady0spostgres-ha-3StandbyReady0s如果延迟单位是秒或更大先不要做故障演练应该先检查网络和磁盘IO。备库追不上主库的时候强行切换等于把数据丢失风险直接暴露给生产。5.2 第二步模拟主库物理故障最真实的故障是节点级宕机但为了可控我先做了一次Pod级删除模拟“主库进程被误杀”的场景# 先找到当前主库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删除命令执行后我开始计时并持续观察集群行为。大约几秒后Operator检测到主库不可达开始在两个备库之间发起选主。选主的逻辑并不复杂谁的WAL位置最新谁就优先被提升为新的Primary。在我们这次模拟项目X的实测里切换耗时大概在20秒到40秒之间主要取决于探针扫描周期、选举时间和Service Endpoints的传播延迟。新主产生后Operator会自动把往旧主的Service流量切到新主上。你不需要手动调整任何东西这本身就是整个方案最值钱的地方。5.3 第三步观察应用侧连接不中断很多人在数据库层做了高可用但应用连接是固定的IP或者写死的连接串这等于高可用只做了一半。业务侧访问数据库应该通过Service而不是直接打到某个Pod IP上。CloudNativePG默认会生成两个Servicepostgres-ha-rw指向当前Primary负责读写流量postgres-ha-ro在所有实例之间做负载均衡负责只读流量在故障切换过程中postgres-ha-rw的Endpoints会自动从旧主更新到新主。应用如果用的是这个Service域名就能实现无缝切换。我在测试时用一个虚拟连接脚本循环建连、执行select 1整个故障切换期间只出现了少量的连接超时和重连没有出现彻底断连的情况。5.4 脑裂怎么防quorum与fencing机制分布式的核心难题都在“脑裂”。如果网络分区把集群分成两半旧主和新主同时认为自己是Primary就会出现双写数据一致性直接崩盘。CloudNativePG给出的方案是quorum思路备库在提升之前会先尝试确认自己在多数派中。如果网络分区导致一个备库联系不上其他副本它不会贸然提升。同时如果Operator检测到旧主虽然不可达但进程还活着它会尝试对旧主执行fencing操作把它的写入能力“隔离掉”确保只有一个主节点在对外服务。这个机制我不建议你自己实现也不要试图绕过去。我见过有人为了加快切换速度把quorum相关参数调得非常激进甚至强制允许备库在没有任何确认的情况下提升结果真的发生过双主写库的惨案。6. 备份、恢复与日常维护高可用集群的最后一公里故障切换能跑通不等于集群可以不运维了。高可用解决的是“无故障时不怕故障”备份恢复解决的是“故障已发生且数据损坏时还能兜底”。这两件事是互补关系缺一不可。6.1 定期备份与WAL归档在Cluster定义里已经配置了backup.volumeSnapshot这意味着Operator会基于CSI卷快照做物理备份。除了周期性快照还要配合持续性的WAL归档才能做到时间点恢复PITR。实际生产中我建议两条腿走路基础备份每天一次由ScheduledBackupCRD控制WAL归档持续开启把每天的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是为了防止集群删除时备份资源被误删这个参数很容易被漏掉。6.2 一次真实的恢复演练备份如果不做恢复演练等于没有备份。我强烈建议每季度至少做一次完整的恢复演练。实操方式很简单用一个全新的名字从这个集群的备份中恢复出一个独立集群然后验证数据。kubectl cnpg backup postgres-ha kubectl cnpg promote postgres-ha严格来说恢复演练用的不是promote而是下面这种通过bootstrap恢复的方式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这个恢复集群起来之后我会执行几条关键的SQL检查数据表行数、最新写入时间、以及是否恢复到目标时间点。整个过程如果能在半小时完成备份体系才算真正可靠。6.3 节点维护和版本升级的推荐顺序日常运维里最常见的两个动作是节点维护和PostgreSQL小版本升级。节点维护时正确流程是先cordon再drain。cordon让调度器不再往该节点调度新Poddrain则会把已有Pod重新调度走。由于我们配置了PDB和反亲和drain时Operator会等待故障转移完成后才允许删除Pod不会出现同时停两个副本的情况。我的一次实测里两节点同时维护耗时因为PDB保护而被拉长了一些但换来的是集群全程没有单点故障这个等待是值得的。PostgreSQL小版本升级更简单修改Cluster里的imageName指向新版本镜像apply之后Operator会按“先备库、后主库”的顺序滚动更新。升级前一定要看release note尤其是大版本之间的升级跨大版本通常需要先做逻辑备份再重建集群不建议原地直接改镜像。6.4 日常巡检清单最后分享一份我每天会看的巡检清单都是很轻量的命令但能提前暴露大多数隐患检查项命令预期结果Pod状态kubectl get pods -n database所有实例Ready且持续运行集群健康度kubectl cnpg status postgres-ha主备角色正常WAL延迟接近0WAL归档是否积压查看对象存储上的WAL目录没有大量未归档文件备份是否成功kubectl get scheduledbackup -n database最后一次备份状态为Completed存储容量kubectl get pvc -n database使用率未超过告警阈值这套巡检不需要专门做一个大平台一条cron脚本就能把这些命令串起来输出到一个统一的地方。我个人的经验是高可用数据库很少死于单次故障多半是死于日常小问题的累积——比如WAL归档悄悄失败了一个星期、存储使用率涨到90%没人发现这些问题远比一次主库宕机更致命。最后再分享一个小技巧数据库这类有状态服务的变更永远不要在业务高峰时段做。可能的话把故障演练也安排在凌晨。你会感谢那个在夜深人静时给自己留出试错空间的决定。