Q先生的世界

面朝大海,春暖花开

背景

业务 PostgreSQL 部署在 Kubernetes 中,使用 CloudNativePG(CNPG)管理,集群名为 prod-postgres,命名空间为 postgres,共 3 个实例,存储使用本地卷 local-pg-retain

故障现象是 node02node03 上 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-1Time Line ID = 1
  • prod-postgres-2 / prod-postgres-3Time 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 达到 63G
  • prod-postgres-3 从库 WAL 达到 62G
  • prod-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 GB WAL

也就是说,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

从这里可以得出几个结论:

  1. prod-postgres-1 需要的 WAL 文件已经被删除
  2. 它无法依赖现有 WAL 继续追平
  3. 日志中还出现了 future timeline history file,说明恢复链路已经异常
  4. 这个副本已经不是“再等等就会自动恢复”的状态

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-3pg_wal 也达到 62G

根因分析

综合整个过程,可以把根因链路整理为:

  1. 集群在 2026-04-10 左右发生主从切换,主库切换到 prod-postgres-2
  2. prod-postgres-1 没有成功切换到新 timeline,长期卡在异常恢复状态
  3. 主库仍然保留 _cnpg_prod_postgres_1 这个失活复制槽
  4. 该槽长期保留 WAL,导致主库 pg_wal 膨胀到 63G
  5. 由于开启了 replication slot 同步,prod-postgres-3 也保留了对应 WAL,导致从库 pg_wal 膨胀到 62G
  6. 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-2prod-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-rw
  • prod-postgres-ro
  • prod-postgres-r

而不是具体的实例 Pod 名称。

最终结论

本次故障并不是普通的 WAL 自然增长,而是一次典型的:

失效副本 + 失活复制槽 + Retain 本地卷残留 的组合故障。

最终根因包括:

  1. failover 后 prod-postgres-1 没有成功重新加入新主库
  2. 它所需的 WAL 已经被删除,无法靠现有增量日志恢复
  3. _cnpg_prod_postgres_1 失活复制槽长期保留大量 WAL
  4. CNPG 的 slot 同步机制又让 prod-postgres-3 也保留了大量 WAL
  5. 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_replication
  • pg_stat_wal_receiver
  • CNPG operator 日志
  • 各实例 timeline 是否一致

4. 为 Retain 本地卷建立标准清理流程

使用 local-pg-retain 时,需要明确:

  • 删除 PVC 不等于删除宿主机数据
  • 删除 PV 也不等于删除宿主机目录
  • 需要在确认未被挂载后手工清理目录