背景
业务 PostgreSQL 部署在 Kubernetes 中,使用 CloudNativePG(CNPG)管理,集群名为 prod-postgres,命名空间为 postgres,共 3 个实例,存储使用本地卷 local-pg-retain。
故障现象是 node02、node03 上 PostgreSQL 占用的本地磁盘异常增大,进一步定位发现主要是 pg_wal 目录膨胀到 60GB 以上,存在明显磁盘打满风险。
集群信息
故障排查时,集群关键配置如下:
- 集群名:
prod-postgres - 命名空间:
postgres - PostgreSQL 版本:
16 - 实例数:
3 - 当前主库:
prod-postgres-2 - 存储类:
local-pg-retain wal_level=logical- 已开启 HA replication slots
- 已开启 replication slots 同步
- 未配置
max_slot_wal_keep_size
当时 Cluster 状态里还有一个很关键的信号:
prod-postgres-1的Time Line ID = 1prod-postgres-2/prod-postgres-3的Time Line ID = 2
这说明集群曾经发生过主从切换,而 prod-postgres-1 没有正常跟上新的 timeline。
故障现象
先检查各实例 pg_wal 目录大小:
kubectl -n postgres exec -it prod-postgres-1 -- bash -lc 'du -sh /var/lib/postgresql/data/pgdata/pg_wal'
kubectl -n postgres exec -it prod-postgres-2 -- bash -lc 'du -sh /var/lib/postgresql/data/pgdata/pg_wal'
kubectl -n postgres exec -it prod-postgres-3 -- bash -lc 'du -sh /var/lib/postgresql/data/pgdata/pg_wal'
输出如下:
[ecs-user@node01 ~]$ kubectl -n postgres exec -it prod-postgres-1 -- bash -lc 'du -sh /var/lib/postgresql/data/pgdata/pg_wal'
Defaulted container "postgres" out of: postgres, bootstrap-controller (init)
769M /var/lib/postgresql/data/pgdata/pg_wal
[ecs-user@node01 ~]$ kubectl -n postgres exec -it prod-postgres-2 -- bash -lc 'du -sh /var/lib/postgresql/data/pgdata/pg_wal'
Defaulted container "postgres" out of: postgres, bootstrap-controller (init)
63G /var/lib/postgresql/data/pgdata/pg_wal
[ecs-user@node01 ~]$ kubectl -n postgres exec -it prod-postgres-3 -- bash -lc 'du -sh /var/lib/postgresql/data/pgdata/pg_wal'
Defaulted container "postgres" out of: postgres, bootstrap-controller (init)
62G /var/lib/postgresql/data/pgdata/pg_wal
可以看到:
prod-postgres-2主库 WAL 达到63Gprod-postgres-3从库 WAL 达到62Gprod-postgres-1只有769M
这说明不是所有实例都同步膨胀,更像是某种 WAL 长期保留问题。
排查过程
1. 检查复制槽
在当前主库 prod-postgres-2 上检查复制槽:
SELECT
slot_name,
slot_type,
active,
restart_lsn,
confirmed_flush_lsn,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) AS retained_wal
FROM pg_replication_slots
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;
输出如下:
slot_name | slot_type | active | restart_lsn | confirmed_flush_lsn | retained_wal
----------------------+-----------+--------+-------------+---------------------+--------------
_cnpg_prod_postgres_1 | physical | f | 6/750000A0 | | 65 GB
_cnpg_prod_postgres_3 | physical | t | 16/D5000000 | | 0 bytes
(2 rows)
这个结果已经基本锁定问题:
_cnpg_prod_postgres_1是一个 inactive 的物理复制槽- 主库为了这个槽保留了
65 GBWAL
也就是说,prod-postgres-1 已经没有正常从当前主库同步,但主库仍在为它保留日志。
2. 检查复制状态
继续查看主库复制状态:
SELECT
application_name,
client_addr,
state,
sync_state,
write_lag,
flush_lag,
replay_lag,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS replay_gap
FROM pg_stat_replication
ORDER BY application_name;
输出如下:
application_name | client_addr | state | sync_state | write_lag | flush_lag | replay_lag | replay_gap
------------------+----------------+-----------+------------+-----------+-----------+------------+------------
prod-postgres-3 | 10.126.76.221 | streaming | quorum | | | | 0 bytes
(1 row)
只有 prod-postgres-3 正常复制,prod-postgres-1 已经不在复制链路中。
3. 排除逻辑复制槽和归档问题
先检查逻辑复制槽:
SELECT
slot_name,
plugin,
slot_type,
database,
active,
restart_lsn,
confirmed_flush_lsn,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) AS retained_wal
FROM pg_replication_slots
WHERE slot_type = 'logical'
ORDER BY pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) DESC;
输出为空:
slot_name | plugin | slot_type | database | active | restart_lsn | confirmed_flush_lsn | retained_wal
-----------+--------+-----------+----------+--------+-------------+---------------------+--------------
(0 rows)
再检查归档状态:
SELECT
archived_count,
failed_count,
last_archived_wal,
last_archived_time,
last_failed_wal,
last_failed_time
FROM pg_stat_archiver;
输出如下:
archived_count | failed_count | last_archived_wal | last_archived_time | last_failed_wal | last_failed_time
----------------+--------------+--------------------------+-------------------------------+------------------+------------------------------
4194 | 1 | 0000000200000016000000D4 | 2026-05-12 09:34:09.034098+00 | 00000002.history | 2026-04-10 09:14:26.632135+00
(1 row)
归档整体正常,因此这次故障不是逻辑复制槽,也不是归档失败导致的 WAL 堆积。
4. 检查异常副本 prod-postgres-1
先确认它是否仍处于恢复状态:
kubectl -n postgres exec -it prod-postgres-1 -- psql -U postgres -d postgres -c "SELECT pg_is_in_recovery();"
输出如下:
pg_is_in_recovery
-------------------
t
(1 row)
继续查看 pg_stat_wal_receiver:
kubectl -n postgres exec -it prod-postgres-1 -- psql -U postgres -d postgres -c "SELECT status, slot_name, conninfo, received_tli, latest_end_lsn, latest_end_time FROM pg_stat_wal_receiver;"
输出如下:
status | slot_name | conninfo | received_tli | latest_end_lsn | latest_end_time
--------+-----------+----------+--------------+----------------+-----------------
(0 rows)
这说明它当前没有接收 WAL。
查看日志时,出现了关键报错:
FATAL: could not receive data from WAL stream: ERROR: requested WAL segment 000000020000000600000075 has already been removed
waiting for WAL to become available at 6/750000B8
Refusing to restore future timeline history file
walName":"00000003.history","fileTimeline":3,"clusterTimeline":2
从这里可以得出几个结论:
prod-postgres-1需要的 WAL 文件已经被删除- 它无法依赖现有 WAL 继续追平
- 日志中还出现了 future timeline history file,说明恢复链路已经异常
- 这个副本已经不是“再等等就会自动恢复”的状态
5. 确认主库中所需 WAL 已不存在
继续在主库中检查复制槽状态:
SELECT slot_name, active, wal_status, restart_lsn, safe_wal_size
FROM pg_replication_slots;
输出如下:
slot_name | active | wal_status | restart_lsn | safe_wal_size
----------------------+--------+------------+-------------+---------------
_cnpg_prod_postgres_3 | t | reserved | 16/EC000000 |
_cnpg_prod_postgres_1 | f | extended | 6/750000A0 |
(2 rows)
再查询目标 WAL 文件:
SELECT name, size, modification
FROM pg_ls_waldir()
WHERE name LIKE '000000020000000600000075%';
输出为空:
name | size | modification
------+------+--------------
(0 rows)
再通过文件系统确认:
kubectl -n postgres exec -it prod-postgres-2 -- bash -lc \
'ls -lh /var/lib/postgresql/data/pgdata/pg_wal/000000020000000600000075*'
输出如下:
ls: cannot access '/var/lib/postgresql/data/pgdata/pg_wal/000000020000000600000075*': No such file or directory
command terminated with exit code 2
至此可以确认:
prod-postgres-1 所需的 WAL 已经不存在,无法靠增量追平,只能重建。
6. 确认从库也在保留异常槽位相关 WAL
因为 CNPG 开启了 replication slot 同步机制,所以还需要检查 prod-postgres-3。
kubectl -n postgres exec -it prod-postgres-3 -- psql -U postgres -d postgres -c \
"SELECT slot_name, slot_type, active, restart_lsn FROM pg_replication_slots;"
输出如下:
slot_name | slot_type | active | restart_lsn
----------------------+-----------+--------+-------------
_cnpg_prod_postgres_1 | physical | f | 7/6F000A28
(1 row)
这解释了为什么 prod-postgres-3 的 pg_wal 也达到 62G。
根因分析
综合整个过程,可以把根因链路整理为:
- 集群在
2026-04-10左右发生主从切换,主库切换到prod-postgres-2 prod-postgres-1没有成功切换到新 timeline,长期卡在异常恢复状态- 主库仍然保留
_cnpg_prod_postgres_1这个失活复制槽 - 该槽长期保留 WAL,导致主库
pg_wal膨胀到63G - 由于开启了 replication slot 同步,
prod-postgres-3也保留了对应 WAL,导致从库pg_wal膨胀到62G local-pg-retain又让旧 PVC/PV/宿主机目录无法自动清理,增加了恢复复杂度
一句话总结:
一个已经失效且无法追平的副本,叠加一个失活复制槽和 Retain 本地卷,最终把主从节点的 WAL 都撑大了。
处置过程
1. 初次处置:直接删除异常 Pod 和 PVC
最初尝试直接删除异常副本:
kubectl -n postgres delete pod prod-postgres-1
kubectl -n postgres delete pvc prod-postgres-1
表面看命令已经执行,但后续发现:
- PVC 删除过程并没有真正完成
- CNPG operator 会自动把
prod-postgres-1拉起 - 同时开始创建新的 join Job,例如
prod-postgres-4-join-*
operator 日志里也出现了以下信息:
Creating new Pod to reattach a PVC
pod":"prod-postgres-1","pvc":"prod-postgres-1"
Creating new Job
job":"prod-postgres-4-join","primary":false,"storageSource":null,"role":"join"
这说明 operator 在同时做两件事:
- 尝试把旧 PVC 重新挂回
prod-postgres-1 - 尝试补一个新的副本
prod-postgres-4
2. 先把期望实例数降到 2
为了先清理坏副本,先把集群期望实例数降到 2:
kubectl -n postgres patch cluster prod-postgres --type=merge -p '{"spec":{"instances":2}}'
检查状态:
kubectl -n postgres get cluster prod-postgres -o jsonpath='spec={.spec.instances} status={.status.instances} phase={.status.phase}{"\n"}'
输出如下:
spec=2 status=3 phase=Creating a new replica
需要注意的是:
spec=2表示期望值已经生效status=3表示集群当前状态尚未收敛
3. 复制槽已经切换到新实例编号
继续查看主库复制槽时,发现旧槽 _cnpg_prod_postgres_1 已经不存在,变成了新实例槽:
kubectl -n postgres exec -it prod-postgres-2 -- psql -U postgres -d postgres -c \
"SELECT slot_name, active, wal_status FROM pg_replication_slots;"
输出如下:
slot_name | active | wal_status
----------------------+--------+------------
_cnpg_prod_postgres_3 | t | reserved
_cnpg_prod_postgres_4 | f |
(2 rows)
说明 operator 已经按新的实例编号开始补副本。
4. 停止 CNPG operator,避免继续自动干预
为了彻底清掉旧副本和旧卷,先停止 operator:
kubectl -n cnpg-system scale deploy cnpg-controller-manager --replicas=0
停止后,删除正在 pending 的 join Pod:
kubectl -n postgres delete pod prod-postgres-4-join-hrznn
这一步之后,集群只剩下正常运行的 prod-postgres-2 和 prod-postgres-3。
5. 删除坏副本 Pod、PVC、PV
此时重新删除 prod-postgres-1:
kubectl -n postgres delete pod prod-postgres-1 --ignore-not-found=true --wait=true
kubectl -n postgres delete pvc prod-postgres-1 --ignore-not-found=true --wait=false
确认 PVC 已不存在:
Error from server (NotFound): persistentvolumeclaims "prod-postgres-1" not found
继续检查 PV:
kubectl get pv | grep prod-postgres-1
输出如下:
pg-local-pv-node01 10Gi RWO Retain Released postgres/prod-postgres-1 local-pg-retain
查看详情:
spec:
local:
path: /var/local/data/postgres
persistentVolumeReclaimPolicy: Retain
status:
phase: Released
这说明:
- PVC 已经删掉
- PV 因为
Retain策略还保留着 - 宿主机目录仍然存在,需要人工清理
随后删除 PV:
kubectl delete pv pg-local-pv-node01
6. 清理 node01 上遗留的本地目录
登录 node01 后进入本地卷目录:
sudo su
cd /var/local/data/postgres
ls
目录内容如下:
pgdata pgdata_20260315T013044Z pgdata_20260315T013117Z
可以看到目录里不止一个数据目录,说明本地卷路径下存在历史残留数据。
在确认 PV 已删除后,执行清理:
rm -rf ./*
这里需要特别注意,之所以要人工清理,是因为:
- 使用的是
local-pg-retain - 删除 PVC/PV 不会自动删除宿主机目录
- 如果不清理,后续新副本可能仍然挂到脏数据
7. 恢复 operator 后发现 join Pod 仍然 Pending
恢复 operator:
kubectl -n cnpg-system scale deploy cnpg-controller-manager --replicas=1
kubectl -n cnpg-system rollout status deploy/cnpg-controller-manager
operator 恢复后,新的 join Pod 被创建出来,但一直 Pending:
NAME READY STATUS RESTARTS AGE
prod-postgres-2 1/1 Running 0 58d
prod-postgres-3 1/1 Running 0 58d
prod-postgres-4-join-8xh2t 0/1 Pending 0 117s
查看 Pod 详情:
kubectl describe po prod-postgres-4-join-8xh2t -n postgres
事件中出现:
FailedScheduling: 0/3 nodes are available: 3 node(s) didn't find available persistent volumes to bind.
说明不是 CNPG 逻辑问题,而是新的副本没有可绑定的 PV。
8. 重新创建本地 PV,join Pod 成功运行
因为前面已经删除了 node01 对应的本地 PV,所以需要按原有定义重新创建一个新的 PV。
创建后执行:
kubectl apply -f temp.yaml
输出如下:
persistentvolume/pg-local-pv-node01 created
随后再观察 Pod 状态:
kubectl -n postgres get pod -o wide
输出如下:
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
prod-postgres-2 1/1 Running 0 58d 10.126.30.15 node02 <none> <none>
prod-postgres-3 1/1 Running 0 58d 10.126.76.32 node03 <none> <none>
prod-postgres-4-join-8xh2t 1/1 Running 0 4m16s 10.126.86.75 node01 <none> <none>
这说明新的副本已经开始以 prod-postgres-4 的身份加入集群。
为什么实例名称变成了 prod-postgres-4
这是 CNPG 的正常行为。
当旧实例 prod-postgres-1 被移除后,operator 不会复用已经删除的实例编号,而是继续用递增编号创建新实例。因此:
- 被移除的是
prod-postgres-1 - 新补的是
prod-postgres-4 prod-postgres-4-join-*是临时的 join Job Pod- join 完成后,正式实例会是
prod-postgres-4
这不会影响业务,因为业务通常连接的是 Service:
prod-postgres-rwprod-postgres-roprod-postgres-r
而不是具体的实例 Pod 名称。
最终结论
本次故障并不是普通的 WAL 自然增长,而是一次典型的:
失效副本 + 失活复制槽 + Retain 本地卷残留 的组合故障。
最终根因包括:
- failover 后
prod-postgres-1没有成功重新加入新主库 - 它所需的 WAL 已经被删除,无法靠现有增量日志恢复
_cnpg_prod_postgres_1失活复制槽长期保留大量 WAL- CNPG 的 slot 同步机制又让
prod-postgres-3也保留了大量 WAL local-pg-retain使得旧 PVC/PV/宿主机目录需要人工清理
一点思考
本次故障源于一个原本认为不可能的故障,因为该集群一直”稳如老狗“,而且磁盘占用非常少,根本就没有监控磁盘消耗,以致出现这样的故障。 为了更好的将来,故而有如下的改进措施。
1. 为 replication slot 设置 WAL 保留上限
增加如下参数:
postgresql:
parameters:
max_slot_wal_keep_size: "10GB"
否则副本长期失联时,复制槽可能无限制保留 WAL。
2. 定期巡检复制槽状态
定期检查以下 SQL:
SELECT slot_name, slot_type, active, restart_lsn
FROM pg_replication_slots;
重点关注:
active = false的物理复制槽- 长时间不活跃的槽
- 无人消费的逻辑复制槽
3. failover 后重点检查副本是否完成 rejoin
在主从切换后,建议重点检查:
pg_stat_replicationpg_stat_wal_receiver- CNPG operator 日志
- 各实例 timeline 是否一致
4. 为 Retain 本地卷建立标准清理流程
使用 local-pg-retain 时,需要明确:
- 删除 PVC 不等于删除宿主机数据
- 删除 PV 也不等于删除宿主机目录
- 需要在确认未被挂载后手工清理目录