在 3 台物理机 上,每台通过 docker-compose 部署一套:Patroni + etcd + HAProxy + PGBouncer + Keepalived,组成一个高可用 PostgreSQL 集群,并通过 VIP 自动漂移 对外提供单一入口。
客户端
│
▼
┌────────────────┐
│ VIP (漂移) │ 写:5432/5000/6432 读:5001
└────────┬───────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Node 1 │ │ Node 2 │ │ Node 3 │
│ etcd1 │ │ etcd2 │ │ etcd3 │ ← etcd 3 节点集群
│ patroni1│ │ patroni2│ │ patroni3│ ← Patroni PG 集群(1 主 2 从)
│ haproxy │ │ haproxy │ │ haproxy │ ← 写/读分离
│ pgbouncer │ pgbouncer │ pgbouncer
│ keepalived │ keepalived │ keepalived ← VIP 由当前 MASTER 持有
└─────────┘ └─────────┘ └─────────┘
- etcd:3 节点集群,Patroni 的 DCS;使用 端口映射(2379、2380),通过 NODE_IP 对外宣告。
- Patroni:每机 1 个 PostgreSQL 实例,自动主从复制与故障转移;使用 端口映射(5432、8008),通过环境变量
PATRONI_*_CONNECT_ADDRESS设为 NODE_IP,注册到 etcd 的地址为宿主机 IP,副本可跨节点连接。 - HAProxy:写入口(仅主)5000、读入口(负载均衡)5001、统计 8404。
- PGBouncer:连接池,端口 6432。
- Keepalived:VRRP 管理 VIP,仅当前“主”节点持有 VIP;该节点故障时 VIP 漂移到其他节点,实现入口高可用。
patroni-stack/
├── .env.example # 环境变量示例(默认测试 IP: 152/153/154, VIP: 161)
├── deploy.sh # 一键部署脚本(每台执行一次,Node1 先执行)
├── docker-compose.yaml # 单节点编排(etcd + patroni + haproxy + pgbouncer + keepalived)
├── patroni/
│ ├── patroni.yml.tpl # Patroni 配置模板(含 shared_preload_libraries、post_init)
│ ├── patroni.yml # 由 scripts/gen-patroni.sh 生成
│ └── init-extensions.sh # bootstrap 后在 managerdb 中创建扩展:plsh, postgres_fdw, pg_net, pg_cron, pg_stat_statements
├── haproxy/
│ ├── haproxy.cfg.tpl # HAProxy 配置模板(后端为三节点 IP)
│ └── haproxy.cfg # 由 scripts/gen-haproxy.sh 生成
├── keepalived/
│ ├── keepalived.conf.tpl # Keepalived 配置模板
│ ├── keepalived.conf # 由 scripts/gen-keepalived.sh 生成
│ └── check_haproxy.sh # VIP 持有条件:本机 HAProxy 存活
├── pgbouncer/
│ ├── pgbouncer.ini # PgBouncer 配置([databases] managerdb、postgres → haproxy:5000)
│ └── userlist.txt # 由 scripts/gen-pgbouncer.sh 生成(不入库)
├── scripts/
│ ├── bootstrap.sh # 本机初始化(生成配置 + 建目录)
│ ├── gen-patroni.sh # 生成 patroni/patroni.yml
│ ├── gen-haproxy.sh # 生成 haproxy/haproxy.cfg(支持 3~6 节点)
│ ├── gen-keepalived.sh # 生成 keepalived/keepalived.conf(支持 NODE_ID 1~6)
│ ├── gen-pgbouncer.sh # 生成 pgbouncer/userlist.txt
│ ├── build-etcd-initial-cluster.sh # 输出 ETCD_INITIAL_CLUSTER(扩展节点时在新节点 .env 使用)
│ └── reset-etcd-node1.sh # Node1 专用:etcd 单节点重新引导(修复 unhealthy cluster)
└── data/ # 本机数据(etcd、PostgreSQL)
默认测试 IP:节点 192.168.0.152 / 192.168.0.153 / 192.168.0.154,VIP 192.168.0.161。Keepalived 使用镜像 osixia/keepalived:stable。
在 每台机器 上克隆并执行(必须先 Node1,再 Node2、Node3):
git clone <repo> patroni-stack && cd patroni-stack
# 按本机修改 .env 中的 NODE_ID、NODE_IP、KEEPALIVED_INTERFACE(不修改则使用 .env.example 的测试 IP)
./deploy.sh- 首次运行若无
.env会从.env.example复制(含上述测试 IP),可按提示继续或编辑后重跑。 - etcd 启动顺序(避免死锁):Node1 先以 单节点集群 启动 etcd,然后
member add etcd2;添加 etcd3 前需 多数成员已启动,故 Node1 会等待 Node2 的 etcd(192.168.0.153:2379)可连通后再member add etcd3。因此:先启动 Node1 的 deploy,当 Node1 打印「等待 Node2 etcd…」时,在 Node2 上执行 ./deploy.sh;Node1 检测到 Node2 etcd 后会自动添加 etcd3 并继续。 deploy.sh会:生成配置 → Node1 单节点 etcd → member add etcd2 → 等待 Node2 etcd 可连 → member add etcd3(及可选 etcd4~6)→ Patroni(Node1 会等 Leader)→ HAProxy、PGBouncer、Keepalived(VIP)。- 不启用 VIP 时:
./deploy.sh --no-vip。
- 每台可被另外两台通过 IP 访问(防火墙放行:2379、2380、5432、8008、5000、5001、8404、6432,以及 VRRP 组播/单播)。
- 每台安装 Docker、Docker Compose,并拉取/构建所需镜像(见下文)。
在 每台机器 上:
git clone <repo> patroni-stack && cd patroni-stack
cp .env.example .env编辑 .env,三台机器的 NODE1_IP / NODE2_IP / NODE3_IP / VIP 必须完全一致,仅下列项按本机填写。每台节点都必须填写 NODE1_IP、NODE2_IP、NODE3_IP(用于生成 patroni/patroni.env 中的 etcd 地址),否则 Patroni 会报 "No host specified"。
| 变量 | 说明 | Node1 示例 | Node2 | Node3 |
|---|---|---|---|---|
| NODE_ID | 节点编号 | 1 | 2 | 3 |
| NODE_IP | 本机 IP | 192.168.0.152 | 192.168.0.153 | 192.168.0.154 |
| NODE1_IP / NODE2_IP / NODE3_IP | 三节点 IP(三台一致) | 152/153/154 | 同左 | 同左 |
| VIP | 虚拟 IP(三台一致) | 192.168.0.161 | 同左 | 同左 |
| KEEPALIVED_INTERFACE | VRRP 网卡名 | eth0 | eth0 | eth0 |
| ETCD_INITIAL_CLUSTER_STATE | 首启 etcd 的节点填 new,其余填 existing | new | existing | existing |
可选:修改 POSTGRES_SUPERUSER_PASSWORD、POSTGRESQL_PASSWORD、KEEPALIVED_AUTH_PASS 等(三台保持一致)。
在 每台机器 上:
./scripts/bootstrap.sh会生成 patroni/patroni.yml、haproxy/haproxy.cfg、keepalived/keepalived.conf 并创建 data/etcd、data/pg。
模板已启用上述 5 个扩展:patroni.yml.tpl 中配置了 shared_preload_libraries: pg_stat_statements,pg_cron,pg_net,并在 bootstrap 后通过 post_init 执行 patroni/init-extensions.sh,在默认数据库 managerdb 中创建扩展(脚本会先创建 managerdb 再安装扩展)。postgres_fdw 与 pg_stat_statements 来自 PostgreSQL 官方 contrib;plsh、pg_net、pg_cron 需使用带插件的镜像:在 docker/ 下构建 Dockerfile.plugin,并在 .env 中设置 PATRONI_IMAGE=<你的插件镜像> 后重新 ./scripts/bootstrap.sh 并启动。PGBouncer 使用自定义配置 pgbouncer/pgbouncer.ini(显式 [databases] managerdb、postgres → haproxy:5000),pgbouncer/userlist.txt 由 ./scripts/gen-pgbouncer.sh 根据 .env 中 POSTGRESQL_PASSWORD 生成,部署前需执行 ./scripts/bootstrap.sh 以生成该文件。
-
先启动一台(例如 Node1,且 .env 中
ETCD_INITIAL_CLUSTER_STATE=new):docker compose up -d etcd
等待 etcd 就绪(可
docker exec etcd etcdctl endpoint health)。 -
再启动另外两台(.env 中
ETCD_INITIAL_CLUSTER_STATE=existing):docker compose up -d etcd
三台 etcd 组成集群后,再启动 Patroni。
-
在 一台 上先起 Patroni,等待其成为 Leader(可
docker exec patroni patronictl -c /config/patroni.yml list)。 -
再在 其余两台 上:
docker compose up -d patroni
三台 Patroni 都就绪后,在 每台 上:
docker compose up -d haproxy pgbouncer在 每台 上:
docker compose --profile with-vip up -d keepalived默认 Node1 为 MASTER(优先级最高),持有 VIP;Node2/3 为 BACKUP。当持有 VIP 的节点或本机 HAProxy 不可用时,VIP 会漂移到其他节点。
若 etcd、Patroni 已按上述顺序初始化过,日常可在每台执行:
docker compose up -d
# 需要 VIP 时
docker compose --profile with-vip up -d- 写(主库):
postgresql://postgres:<密码>@<VIP>:5000/postgres或@<VIP>:6432/postgres(经 PGBouncer) - 读(负载均衡):
postgresql://postgres:<密码>@<VIP>:5001/postgres - 经 PGBouncer(推荐应用使用):
postgresql://postgres:<密码>@<VIP>:6432/postgres
将应用连接串中的主机改为 VIP,即可在单节点故障时自动切到其他节点,实现高可用。
- Keepalived 使用 VRRP:同一
virtual_router_id下,priority 最高 且 本机 check 脚本通过 的节点持有 VIP。 - 默认 Node1 为 MASTER(priority 103),Node2/3 为 BACKUP(102/101)。若 Node1 宕机或本机 HAProxy 不可用,
check_haproxy.sh失败会导致 priority 降低,VIP 漂到 Node2 或 Node3。 - 漂移后,客户端仍连接 VIP:5000 / VIP:6432,由新节点的 HAProxy/PGBouncer 提供服务,无需改应用配置。
- 查看 Patroni 集群:
docker exec patroni patronictl -c /config/patroni.yml list - 查看当前主节点:
curl -s http://<任意节点 IP>:8008/leader - HAProxy 统计:
http://<节点 IP 或 VIP>:8404/stats - 验证 VIP:在任一节点
ip addr或ping <VIP>,应在当前 MASTER 节点上看到 VIP。
-
Node1 本机:
curl -s http://192.168.0.152:2379/health失败时,可先确认 etcd 是否在容器内正常:docker exec etcd etcdctl --endpoints=http://localhost:2379 endpoint health若容器内正常,再试本机回环:
curl -s http://127.0.0.1:2379/health。若 127.0.0.1 可通而 192.168.0.152 不通,多为端口未绑定到外网或防火墙拦截;compose 已使用0.0.0.0:2379:2379,重启 etcd 后一般可通。 -
Patroni 镜像内 etcd:部分版本不提供 HTTP
/health,Node2/Node3 的等待脚本会优先用 TCP 端口检测(nc -z <Node1_IP> 2379),只要 2379 端口可连即可继续部署,不依赖/health。 -
跨节点连通:在 Node2/Node3 上执行
nc -zv 192.168.0.152 2379、nc -zv 192.168.0.152 2380,确认防火墙已放行。 -
Node2/Node3 报错 "failed to open stdout fifo ... no such file or directory":多为 Docker/containerd 运行时临时问题。可先
docker compose down,再docker compose up -d重试;必要时重启 Docker 服务。 -
Node2 报错 "member count is unequal":etcd 要求启动时的
initial_cluster与当前集群已有成员数一致。Node2 启动时集群只有 etcd1+etcd2,故 Node2 的ETCD_INITIAL_CLUSTER只能含 etcd1 和 etcd2;deploy 脚本已按etcd1..etcd${NODE_ID}自动生成,无需在 .env 中手写完整 3 节点列表。若曾手写过完整列表,删掉 .env 中的ETCD_INITIAL_CLUSTER后重跑 deploy 即可。 -
etcd 里成员 conn_url/api_url 全是 172.18.x、副本 pg_basebackup connection refused:Patroni 在容器内会把自己的地址解析成 Docker 网桥 IP(如 172.18.0.3)。compose 通过环境变量
PATRONI_POSTGRESQL_CONNECT_ADDRESS、PATRONI_RESTAPI_CONNECT_ADDRESS强制使用 .env 中的NODE_IP;若未传入或为空,Patroni 会用容器 IP 注册。排查(在报错节点上执行):
- 看容器内是否拿到正确地址:
docker exec patroni env | grep -E 'PATRONI_POSTGRESQL_CONNECT_ADDRESS|PATRONI_RESTAPI_CONNECT_ADDRESS'
应为PATRONI_POSTGRESQL_CONNECT_ADDRESS=本机物理IP:5432(如 192.168.0.152:5432)。若为空或为 172.18.x,说明 compose 未从 .env 展开 NODE_IP。 - connect_address 来自 patroni.env(由
./scripts/bootstrap.sh按本机 NODE_IP 生成)。compose 不再在 environment 里传 PATRONI_*_CONNECT_ADDRESS,避免从错误目录起 compose 时传入空值覆盖 patroni.env 导致注册成 172.18.x。每台节点必须先在本机执行./scripts/bootstrap.sh(本机 .env 中 NODE_IP 为本机物理 IP),再docker compose up -d。 - 确认本机 .env 中
NODE_IP=为本机物理 IP;cat patroni/patroni.env中应有PATRONI_POSTGRESQL_CONNECT_ADDRESS=本机IP:5432;grep -A2 etcd3 patroni/patroni.yml中hosts:应为三节点 IP:2379,connect_address应为本机 NODE_IP。
修复(etcd 里已是 172.18.x 时需先清成员再重注册):
- 在任意一台能连 etcd 的节点执行,清除 etcd 中错误的成员键:
./scripts/clear-etcd-patroni-members.sh
- 在每台节点依次执行(先 Node1,再 Node2,再 Node3),确保本机 .env 中
NODE_IP=为本机物理 IP:./scripts/bootstrap.sh docker compose up -d --force-recreate patroni
- 等约 15 秒后检查:
docker exec etcd etcdctl --endpoints=192.168.0.152:2379,192.168.0.153:2379,192.168.0.154:2379 get /service/pg-cluster/members/ --prefix,conn_url/api_url 应为 192.168.0.152、192.168.0.153、192.168.0.154。 - 若未先清成员,也可在每台执行
./scripts/fix-patroni-connect-address.sh(先 Leader 所在节点,再其他),但若 Patroni 不覆盖旧 key,需先执行步骤 1。
- 看容器内是否拿到正确地址:
-
Patroni 报错 "http://:2379"、"No host specified":说明容器内
PATRONI_ETCD3_HOSTS为空或格式错误。每台节点的 .env 必须包含 NODE1_IP、NODE2_IP、NODE3_IP(与 .env.example 一致,三台机器的这三项完全一致),否则gen-patroni.sh会生成空的 etcd 列表。处理:1)确认本机 .env 有NODE1_IP=192.168.0.152、NODE2_IP=...、NODE3_IP=...;2)在本机执行./scripts/bootstrap.sh(或./scripts/gen-patroni.sh)重新生成patroni/patroni.env;3)检查cat patroni/patroni.env应包含PATRONI_ETCD3_HOSTS=192.168.0.152:2379,192.168.0.153:2379,192.168.0.154:2379;4)重启 Patroni:docker compose up -d --force-recreate patroni。 -
etcd 中 conn_url 一直为 172.18.x:compose 中 etcd、patroni 使用 端口映射 + bridge,通过环境变量将
PATRONI_POSTGRESQL_CONNECT_ADDRESS、PATRONI_RESTAPI_CONNECT_ADDRESS设为 NODE_IP,注册到 etcd 的 conn_url 为宿主机地址。HAProxy、PGBouncer 通过 NODE*_IP 连各机 5432/8008。若曾用 host 网络,切到端口映射后需重建 etcd 与 patroni:docker compose up -d --force-recreate etcd patroni。 -
从节点 Patroni 日志出现
WARNING: Exception happened during processing of request from ...:来自 Patroni REST API(Python socketserver),多为对方(HAProxy 健康检查或其它节点)在响应写完前关闭连接,触发Connection reset by peer/Broken pipe。若完整 traceback 里是这类异常且集群主从、HAProxy 均正常,可视为良性告警,无需处理;若伴随 API 无响应或选主异常,再查网络/防火墙或升级 Patroni 版本。
出现该错误说明 etcd 曾以 3 节点 initial_cluster 启动,数据目录里仍认为自己是 3 节点之一,在等 etcd2、etcd3 形成 quorum,单节点无法提交,所以一直 unhealthy。
处理(仅在 Node1 上执行):清空 etcd 数据并以单节点重新引导
./scripts/reset-etcd-node1.sh脚本会:停止 etcd → 清空 data/etcd → 以 单节点(仅 etcd1)启动 → 执行 member add 将 etcd2、etcd3 加入集群。完成后 Node1 的 etcd 会变为健康,再在 Node1 上执行 ./deploy.sh 继续部署,或在 Node2/Node3 上执行 ./deploy.sh。
当前方案支持 3~6 节点,通过 .env 中可选 NODE4_IP、NODE5_IP、NODE6_IP 与 ETCD_INITIAL_CLUSTER 扩展,无需改代码。
1. 在现有 3 台节点上统一增加第 4 节点信息
- 在 所有现有节点 的
.env中增加(或取消注释):NODE4_IP=192.168.0.155
- 在 所有现有节点 上重新生成配置并重载 HAProxy、可选重启 Patroni 以连新 etcd:
./scripts/bootstrap.sh docker compose exec haproxy kill -HUP 1 # 若希望 Patroni 使用新 etcd 列表,可:docker compose restart patroni
2. 在现有 etcd 集群中注册第 4 个 etcd 成员
在 任意一台已有节点 上执行(需替换为实际 NODE4_IP):
docker exec etcd etcdctl member add etcd4 --peer-urls=http://192.168.0.155:2380
# 记下输出中的 ETCD_INITIAL_CLUSTER 或按下面步骤在新节点 .env 中设置3. 在新节点(第 4 台机器)上部署
- 克隆项目,复制并编辑
.env:NODE_ID=4NODE_IP=192.168.0.155(本机 IP)NODE1_IP~NODE4_IP与现有节点一致(含本机)ETCD_INITIAL_CLUSTER_STATE=existing- 设置完整 etcd 集群列表(可从任意现有节点生成):
或手动设置,例如:
# 在任意现有节点执行,将输出追加到新节点的 .env ./scripts/build-etcd-initial-cluster.shETCD_INITIAL_CLUSTER=etcd1=http://192.168.0.152:2380,etcd2=http://192.168.0.153:2380,etcd3=http://192.168.0.154:2380,etcd4=http://192.168.0.155:2380
- 在新节点执行:
./scripts/bootstrap.sh docker compose up -d etcd # etcd 健康后 docker compose up -d patroni docker compose up -d haproxy pgbouncer docker compose --profile with-vip up -d keepalived
4. 验证
docker exec -it etcd etcdctl --endpoints=192.168.0.152:2379,192.168.0.153:2379,192.168.0.154:2379 endpoint status
docker exec -it etcd etcdctl --endpoints=192.168.0.152:2379,192.168.0.153:2379,192.168.0.154:2379 endpoint health --write-out=table
docker exec -it etcd etcdctl --endpoints=192.168.0.152:2379,192.168.0.153:2379,192.168.0.154:2379 get "" --prefix
docker exec patroni patronictl -c /config/patroni.yml list
# 应看到 4 个节点,1 个 Leader、3 个 Replica| 步骤 | 现有节点 | 新节点 |
|---|---|---|
| .env | 增加 NODE4_IP,三台一致 |
NODE_ID=4、NODE_IP、NODE1_IP~NODE4_IP、ETCD_INITIAL_CLUSTER(完整列表)、ETCD_INITIAL_CLUSTER_STATE=existing |
| etcd | 执行 etcdctl member add etcd4 ... |
启动 etcd(existing) |
| 配置 | ./scripts/bootstrap.sh,重载 HAProxy |
./scripts/bootstrap.sh 后按顺序启动各服务 |
继续加第 5、6 节点时重复上述流程,在 .env 中增加 NODE5_IP、NODE6_IP,并在 build-etcd-initial-cluster.sh 输出中纳入对应 etcd5、etcd6 即可。
- 修改默认密码(
.env与patroni.yml中保持一致)。 - 按需收紧
pg_hba、防火墙规则。 - 生产环境建议 etcd、Patroni 通信使用 TLS。
- 定期备份与恢复演练。
若某节点日志出现 system ID mismatch, node patroniX belongs to a different cluster: XXXXX != YYYYY,说明该节点本地 PostgreSQL 数据来自另一次 bootstrap(或曾为主节点),与当前 etcd 中记录的集群不是同一套。该节点必须清空本地数据并从当前 Leader 重新克隆。
处理步骤(仅在该报错节点执行):
- 确认当前 Leader(例如
patroni2):docker exec patroni patronictl -c /config/patroni.yml list - 在该节点清空数据目录并设置属主,让 Patroni 从当前 Leader 做 basebackup:
cd patroni-stack docker compose stop patroni sudo rm -rf ./data/pg/data ./data/pg/* mkdir -p ./data/pg sudo chown -R 999:999 ./data/pg docker compose up -d patroni
- 用
patronictl list确认该节点状态变为running(Replica)。
若从节点日志出现 FileNotFoundError: '/home/postgres/data/postmaster.opts'、data directory "/home/postgres/data" has wrong ownership 或 data directory "/home/postgres/data" has invalid permissions,说明该节点仍在使用旧数据目录路径 /home/postgres/data,而当前配置应为 /home/postgres/pgdata/data(与 docker-compose 挂载 ./data/pg:/home/postgres/pgdata 一致)。三台节点的 patroni.yml 中 data_dir 必须一致且为 /home/postgres/pgdata/data。
处理步骤(在每台报错节点上执行):
-
确认使用最新模板并重新生成配置
cd patroni-stack ./scripts/bootstrap.sh grep 'data_dir:' patroni/patroni.yml # 必须为:data_dir: /home/postgres/pgdata/data
-
清空从节点数据目录并设置属主(让 Patroni 从主节点重新做 basebackup)
sudo rm -rf ./data/pg/data ./data/pg/* mkdir -p ./data/pg sudo chown -R 999:999 ./data/pg(若镜像中 postgres 用户 UID 非 999,可用
docker run --rm ${PATRONI_IMAGE:-patroni:v4.1.0} id postgres查看。) -
重启 Patroni
docker compose up -d --force-recreate patroni
-
用
docker exec patroni patronictl -c /config/patroni.yml list确认从节点状态变为running。
- 每台仅跑单实例 etcd、单实例 Patroni;支持 3~6 节点扩展(见上文节点扩展)。
- etcd、Patroni 使用端口映射:宿主机 2379、2380、5432、8008 端口需空闲(或与映射一致),不能与其他进程冲突。
- VIP 依赖 VRRP,同一二层网络内需能组播/单播;跨机房需单独设计。