postgresql-ha-staging.yaml: switch from CLONE_WITH_BASEBACKUP to Patroni standby_cluster (continuous streaming, validated in pgha-test dry run) - closes pre-cutover write gap
ci/woodpecker/push/deploy Pipeline was successful

This commit is contained in:
2026-07-30 21:59:44 -07:00
parent dc6120ffd4
commit 4dcb3561ed
+60 -24
View File
@@ -14,16 +14,55 @@
# - Joins the EXISTING postgresql_db-backend overlay (external) — the # - Joins the EXISTING postgresql_db-backend overlay (external) — the
# live single-instance stack is untouched and keeps the "postgresql" # live single-instance stack is untouched and keeps the "postgresql"
# and "db" aliases. # and "db" aliases.
# - patroni-0/patroni-1 bootstrap a NEW cluster (scope: postgres-ha) by # - patroni-0/patroni-1 bootstrap a NEW cluster (scope: postgres-ha) as a
# CLONING the live instance via pg_basebackup (CLONE_WITH_BASEBACKUP, # Patroni STANDBY CLUSTER continuously streaming from the live
# CLONE_HOST=postgresql resolves to the OLD instance during staging). # instance (standby_cluster, host: postgresql resolves to the OLD
# CLONE_* is set on BOTH patroni nodes: whichever wins the initial # instance during staging). This REPLACES the earlier
# leader race performs the clone; the other syncs from it. # CLONE_WITH_BASEBACKUP design (one-shot snapshot) — see "REVISION"
# note below for why.
# - HAProxy runs WITHOUT production aliases and WITHOUT published ports. # - HAProxy runs WITHOUT production aliases and WITHOUT published ports.
# It only gets those in postgresql-ha-final.yaml at promote time. # It only gets those in postgresql-ha-final.yaml at promote time.
# - Superuser is PGadmin (matches the cloned data's existing superuser, # - Superuser is PGadmin (matches the cloned data's existing superuser,
# same password secret), NOT Spilo's default "postgres". # same password secret), NOT Spilo's default "postgres".
# #
# ─── REVISION (2026-08-01): switched from CLONE_WITH_BASEBACKUP to a
# Patroni "standby cluster" (continuous streaming) ───
#
# WHY: CLONE_WITH_BASEBACKUP is a ONE-SHOT snapshot — once pg_basebackup
# completes, the new cluster has ZERO further connection to the live
# database. Any write landing on production between the snapshot and the
# actual traffic cutover would be silently lost — a real risk given ~13
# active consumers and a 42GB production database. Patroni's standby_cluster
# mode (https://patroni.readthedocs.io/en/latest/standby_cluster.html)
# instead makes the new cluster's leader ("standby leader") continuously
# stream from the remote primary indefinitely, right up until an explicit
# promotion (done by removing the standby_cluster key from DCS config —
# there is no dedicated patronictl subcommand for this) — closing the gap
# to near-zero. Fully validated end-to-end in the pgha-test dry run
# (2026-08-01): bootstrap, continuous live-write propagation to the far
# cascade replica in <3s, and a full promote-with-zero-data-loss cycle —
# see "ADR-0001 Dry-Run Debugging Log" note for the complete play-by-play.
#
# ⚠️ NEW PRODUCTION PREREQUISITE THIS INTRODUCES — NOT YET DONE, must
# happen before this file can actually be deployed for real: the dry run's
# "legacy" stand-in creates a genuine replication role named "standby"
# (matching PGUSER_STANDBY below) via a fresh-initdb hook script, since
# standby_cluster streaming authenticates using the cluster's real
# replication identity (PGUSER_STANDBY/PGPASSWORD_STANDBY), NOT the
# PGadmin superuser that CLONE_WITH_BASEBACKUP used. The LIVE
# `postgresql_postgresql` container has NO such hook mechanism available
# (it's long since initialized, hooks only run on fresh initdb) and does
# NOT currently have a "standby" role. This must be created manually
# against the live database before real cutover, analogous to how the
# pg_hba.conf replication rule was added manually — e.g.:
# CREATE ROLE standby WITH REPLICATION LOGIN PASSWORD '<same value as
# the postgresql_replication_password secret>';
# This is NOT part of this compose file (can't be — it's a one-time SQL
# statement against already-running production, not something a compose
# file can express) but MUST be tracked and done as an explicit manual
# step in the real Phase 3 procedure. See ADR-0001 Dry-Run Debugging Log
# note, "Not yet done" section, for status.
#
# NOTE: command: blocks use $$(...) not $(...) — Compose's own variable # NOTE: command: blocks use $$(...) not $(...) — Compose's own variable
# interpolation parses $( as an attempted ${VAR} reference and fails with # interpolation parses $( as an attempted ${VAR} reference and fails with
# "invalid interpolation format" / "you may need to escape any $ with # "invalid interpolation format" / "you may need to escape any $ with
@@ -32,9 +71,8 @@
# block, including inside heredocs and nested quoting — Compose scans the # block, including inside heredocs and nested quoting — Compose scans the
# raw text before any shell/heredoc logic ever runs, so quoting context # raw text before any shell/heredoc logic ever runs, so quoting context
# doesn't exempt anything from this escaping requirement. (Confirmed the # doesn't exempt anything from this escaping requirement. (Confirmed the
# hard way in the pgha-test dry run — see "ADR-0001 Dry-Run Debugging Log" # hard way in the pgha-test dry run — this file originally had the
# note — this file originally had the unescaped $(...) bug, including on # unescaped $(...) bug too, before this standby_cluster rewrite.)
# the CLONE_PASSWORD line which is unique to this staging file.)
# #
# NOTE: ETCD3_HOSTS not ETCD_HOSTS — confirmed against zalando/spilo # NOTE: ETCD3_HOSTS not ETCD_HOSTS — confirmed against zalando/spilo
# configure_spilo.py: PATRONI_DCS includes both "etcd" (legacy v2 API, # configure_spilo.py: PATRONI_DCS includes both "etcd" (legacy v2 API,
@@ -42,7 +80,7 @@
# distinct DCS backends selected by env var prefix. Our etcd containers # distinct DCS backends selected by env var prefix. Our etcd containers
# (v3.5.9) have the v2 API disabled by default, so ETCD_HOSTS causes # (v3.5.9) have the v2 API disabled by default, so ETCD_HOSTS causes
# Patroni to hit /v2 endpoints that 404. ETCD3_HOSTS selects the correct # Patroni to hit /v2 endpoints that 404. ETCD3_HOSTS selects the correct
# v3-API client. (Also originally wrong in this file — fixed after dry run.) # v3-API client.
# #
# NOTE: patroni-0/patroni-1 override bootstrap.post_init via # NOTE: patroni-0/patroni-1 override bootstrap.post_init via
# SPILO_CONFIGURATION (Spilo's documented, supported mechanism for # SPILO_CONFIGURATION (Spilo's documented, supported mechanism for
@@ -65,7 +103,7 @@
# existence + SUPERUSER) were found and validated end-to-end in the # existence + SUPERUSER) were found and validated end-to-end in the
# pgha-test dry run before being ported here — see the dry-run debugging # pgha-test dry run before being ported here — see the dry-run debugging
# note for full detail. This is CRITICAL for this file specifically since # note for full detail. This is CRITICAL for this file specifically since
# it clones real production data with the real PGadmin superuser. # it streams real production data with the real PGadmin superuser.
# ───────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────
version: "3.6" version: "3.6"
@@ -150,7 +188,6 @@ services:
export PGPASSWORD_SUPERUSER="$$(cat /run/secrets/postgresql_password)" export PGPASSWORD_SUPERUSER="$$(cat /run/secrets/postgresql_password)"
export PGPASSWORD_STANDBY="$$(cat /run/secrets/postgresql_replication_password)" export PGPASSWORD_STANDBY="$$(cat /run/secrets/postgresql_replication_password)"
export PATRONI_RESTAPI_PASSWORD="$$(cat /run/secrets/postgresql_patroni_password)" export PATRONI_RESTAPI_PASSWORD="$$(cat /run/secrets/postgresql_patroni_password)"
export CLONE_PASSWORD="$$(cat /run/secrets/postgresql_password)"
mkdir -p /scripts mkdir -p /scripts
cat > /scripts/post_init_wrapper.sh <<'WRAP' cat > /scripts/post_init_wrapper.sh <<'WRAP'
#!/bin/bash #!/bin/bash
@@ -171,13 +208,12 @@ services:
SPILO_CONFIGURATION: | SPILO_CONFIGURATION: |
bootstrap: bootstrap:
post_init: /scripts/post_init_wrapper.sh "zalandos" post_init: /scripts/post_init_wrapper.sh "zalandos"
# Clone-from-live: only runs when PGDATA is empty (first bootstrap). dcs:
# "postgresql" resolves to the OLD single instance during staging. standby_cluster:
CLONE_METHOD: CLONE_WITH_BASEBACKUP host: postgresql
CLONE_SCOPE: legacy-single port: 5432
CLONE_HOST: postgresql create_replica_methods:
CLONE_PORT: "5432" - basebackup_fast_xlog
CLONE_USER: PGadmin
secrets: secrets:
- postgresql_password - postgresql_password
- postgresql_replication_password - postgresql_replication_password
@@ -201,7 +237,6 @@ services:
export PGPASSWORD_SUPERUSER="$$(cat /run/secrets/postgresql_password)" export PGPASSWORD_SUPERUSER="$$(cat /run/secrets/postgresql_password)"
export PGPASSWORD_STANDBY="$$(cat /run/secrets/postgresql_replication_password)" export PGPASSWORD_STANDBY="$$(cat /run/secrets/postgresql_replication_password)"
export PATRONI_RESTAPI_PASSWORD="$$(cat /run/secrets/postgresql_patroni_password)" export PATRONI_RESTAPI_PASSWORD="$$(cat /run/secrets/postgresql_patroni_password)"
export CLONE_PASSWORD="$$(cat /run/secrets/postgresql_password)"
mkdir -p /scripts mkdir -p /scripts
cat > /scripts/post_init_wrapper.sh <<'WRAP' cat > /scripts/post_init_wrapper.sh <<'WRAP'
#!/bin/bash #!/bin/bash
@@ -222,11 +257,12 @@ services:
SPILO_CONFIGURATION: | SPILO_CONFIGURATION: |
bootstrap: bootstrap:
post_init: /scripts/post_init_wrapper.sh "zalandos" post_init: /scripts/post_init_wrapper.sh "zalandos"
CLONE_METHOD: CLONE_WITH_BASEBACKUP dcs:
CLONE_SCOPE: legacy-single standby_cluster:
CLONE_HOST: postgresql host: postgresql
CLONE_PORT: "5432" port: 5432
CLONE_USER: PGadmin create_replica_methods:
- basebackup_fast_xlog
secrets: secrets:
- postgresql_password - postgresql_password
- postgresql_replication_password - postgresql_replication_password