diff --git a/postgresql/cutover/postgresql-ha-final.yaml b/postgresql/cutover/postgresql-ha-final.yaml new file mode 100644 index 0000000..b412dde --- /dev/null +++ b/postgresql/cutover/postgresql-ha-final.yaml @@ -0,0 +1,245 @@ +# ────────────────────────────────────────────────────────────────────────── +# postgresql/cutover/postgresql-ha-final.yaml — ADR-0001 Phase 3, Stage 3 +# +# Deployed by cutover.sh (Phase 7) as an UPDATE to the SAME "postgresqlha" +# stack that postgresql-ha-staging.yaml created in Stage 2: +# docker stack deploy -c postgresql/cutover/postgresql-ha-final.yaml postgresqlha +# +# ⚠️ Do NOT deploy this before Patroni has been genuinely promoted +# (cutover.sh Phase 5) and confirmed healthy (Phase 6). Deploying it +# earlier adds the postgresql/db aliases to a backend HAProxy's own +# healthcheck (GET /primary) will correctly report as unhealthy (a +# standby_leader is read-only, not a real primary) — any client that +# Swarm's round-robin DNS routes to HAProxy during that window would +# hit zero healthy servers and fail outright. See RUNBOOK.md for the +# full rationale on phase ordering. +# +# What changed vs. postgresql-ha-staging.yaml (Stage 2): +# - etcd-1/2/3 and patroni-0/patroni-1 service specs are BYTE-IDENTICAL +# to the staging file, deliberately. `docker stack deploy` diffs each +# service's spec independently — since these five specs are unchanged, +# Swarm will NOT recreate/restart them. This matters enormously here: +# patroni-1 is the live, newly-promoted PRIMARY carrying real +# production traffic by the time this file is deployed, and +# patroni-0 is its live replica. Neither may be disrupted by this +# step. Only the haproxy service (below) has a spec change, so only +# it gets updated. +# - haproxy service: adds `postgresql` and `db` as network aliases on +# postgresql_db-backend — this is the actual DNS cutover mechanism. +# Still deliberately WITHOUT the external port publish (5430) or +# traefik_backend network/labels that the STEADY-STATE production +# postgresql.yaml's haproxy block eventually needs — taking over +# that external/Traefik-SNI path is a documented MANUAL, POST-cutover +# step (see RUNBOOK.md "Post-cutover follow-up"), not part of this +# automated procedure. Confirmed with the operator: no external +# consumer depends on port 5430 during the cutover window, so this +# is deferred deliberately, not an oversight. +# - Uses the same postgresql/haproxy.cfg already committed to git +# (Patroni-aware TCP router, GET /primary healthcheck, no changes +# needed for this stage). +# +# Once this is live and all consumers have verified (cutover.sh Phase 9), +# legacy is scaled to 0 (Phase 10) and this becomes the sole answer for +# the `postgresql`/`db` names on postgresql_db-backend. +# ────────────────────────────────────────────────────────────────────────── +version: "3.6" + +services: + + etcd-1: + image: quay.io/coreos/etcd:v3.5.9 + hostname: etcd-1 + command: + - etcd + - --name=etcd-1 + - --data-dir=/etcd-data + - --initial-advertise-peer-urls=http://etcd-1:2380 + - --listen-peer-urls=http://0.0.0.0:2380 + - --listen-client-urls=http://0.0.0.0:2379 + - --advertise-client-urls=http://etcd-1:2379 + - --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380 + - --initial-cluster-state=new + - --initial-cluster-token=postgresql-ha-etcd + volumes: + - /volume1/docker/PostgreSQL/etcd-1-data:/etcd-data + networks: + - postgresql_db-backend + deploy: + placement: + constraints: + - node.hostname == docker-1 + + etcd-2: + image: quay.io/coreos/etcd:v3.5.9 + hostname: etcd-2 + command: + - etcd + - --name=etcd-2 + - --data-dir=/etcd-data + - --initial-advertise-peer-urls=http://etcd-2:2380 + - --listen-peer-urls=http://0.0.0.0:2380 + - --listen-client-urls=http://0.0.0.0:2379 + - --advertise-client-urls=http://etcd-2:2379 + - --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380 + - --initial-cluster-state=new + - --initial-cluster-token=postgresql-ha-etcd + volumes: + - /volume1/docker/PostgreSQL/etcd-2-data:/etcd-data + networks: + - postgresql_db-backend + deploy: + placement: + constraints: + - node.hostname == docker-2 + + etcd-3: + image: quay.io/coreos/etcd:v3.5.9 + hostname: etcd-3 + command: + - etcd + - --name=etcd-3 + - --data-dir=/etcd-data + - --initial-advertise-peer-urls=http://etcd-3:2380 + - --listen-peer-urls=http://0.0.0.0:2380 + - --listen-client-urls=http://0.0.0.0:2379 + - --advertise-client-urls=http://etcd-3:2379 + - --initial-cluster=etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380,etcd-3=http://etcd-3:2380 + - --initial-cluster-state=new + - --initial-cluster-token=postgresql-ha-etcd + volumes: + - /volume1/docker/PostgreSQL/etcd-3-data:/etcd-data + networks: + - postgresql_db-backend + deploy: + placement: + constraints: + - node.hostname == docker-3 + + patroni-0: + image: ghcr.io/zalando/spilo-17:4.0-p3 + hostname: patroni-0 + command: + - /bin/sh + - -c + - | + export PGPASSWORD_SUPERUSER="$$(cat /run/secrets/postgresql_password)" + export PGPASSWORD_STANDBY="$$(cat /run/secrets/postgresql_replication_password)" + export PATRONI_RESTAPI_PASSWORD="$$(cat /run/secrets/postgresql_patroni_password)" + mkdir -p /scripts + cat > /scripts/post_init_wrapper.sh <<'WRAP' + #!/bin/bash + set -e + psql -d "$$2" -v ON_ERROR_STOP=1 -c 'DO $$do$$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = $$x$$postgres$$x$$) THEN CREATE ROLE postgres WITH SUPERUSER NOLOGIN; END IF; END $$do$$;' + exec /scripts/post_init.sh "$$@" + WRAP + chmod +x /scripts/post_init_wrapper.sh + exec /bin/sh /launch.sh init + environment: + SCOPE: postgres-ha + PATRONI_NAME: patroni-0 + ETCD3_HOSTS: '"etcd-1:2379","etcd-2:2379","etcd-3:2379"' + PGUSER_SUPERUSER: PGadmin + PGUSER_STANDBY: standby + PATRONI_RESTAPI_USERNAME: patroni + PGROOT: /home/postgres/pgdata/pgroot + SPILO_CONFIGURATION: | + bootstrap: + post_init: /scripts/post_init_wrapper.sh "zalandos" + dcs: + standby_cluster: + host: postgresql + port: 5432 + create_replica_methods: + - basebackup_fast_xlog + secrets: + - postgresql_password + - postgresql_replication_password + - postgresql_patroni_password + volumes: + - /volume1/docker/PostgreSQL/patroni-0-data:/home/postgres/pgdata + networks: + - postgresql_db-backend + deploy: + placement: + constraints: + - node.labels.pg-role == primary + + patroni-1: + image: ghcr.io/zalando/spilo-17:4.0-p3 + hostname: patroni-1 + command: + - /bin/sh + - -c + - | + export PGPASSWORD_SUPERUSER="$$(cat /run/secrets/postgresql_password)" + export PGPASSWORD_STANDBY="$$(cat /run/secrets/postgresql_replication_password)" + export PATRONI_RESTAPI_PASSWORD="$$(cat /run/secrets/postgresql_patroni_password)" + mkdir -p /scripts + cat > /scripts/post_init_wrapper.sh <<'WRAP' + #!/bin/bash + set -e + psql -d "$$2" -v ON_ERROR_STOP=1 -c 'DO $$do$$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = $$x$$postgres$$x$$) THEN CREATE ROLE postgres WITH SUPERUSER NOLOGIN; END IF; END $$do$$;' + exec /scripts/post_init.sh "$$@" + WRAP + chmod +x /scripts/post_init_wrapper.sh + exec /bin/sh /launch.sh init + environment: + SCOPE: postgres-ha + PATRONI_NAME: patroni-1 + ETCD3_HOSTS: '"etcd-1:2379","etcd-2:2379","etcd-3:2379"' + PGUSER_SUPERUSER: PGadmin + PGUSER_STANDBY: standby + PATRONI_RESTAPI_USERNAME: patroni + PGROOT: /home/postgres/pgdata/pgroot + SPILO_CONFIGURATION: | + bootstrap: + post_init: /scripts/post_init_wrapper.sh "zalandos" + dcs: + standby_cluster: + host: postgresql + port: 5432 + create_replica_methods: + - basebackup_fast_xlog + secrets: + - postgresql_password + - postgresql_replication_password + - postgresql_patroni_password + volumes: + - /volume1/docker/PostgreSQL/patroni-1-data:/home/postgres/pgdata + networks: + - postgresql_db-backend + deploy: + placement: + constraints: + - node.labels.pg-role == replica + + # ── Final-stage HAProxy: takes over the postgresql/db aliases ────────── + # Internal Swarm-DNS only at this stage — no published port, no + # traefik_backend network/labels. External access (port 5430 / + # Traefik SNI) is a deliberate, documented MANUAL post-cutover step, + # not part of this automated procedure — confirmed no external + # consumer depends on it during the cutover window. + haproxy: + image: haproxy:2.9-alpine + hostname: haproxy + volumes: + - /volume1/docker/compose-files/postgresql/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro + networks: + postgresql_db-backend: + aliases: + - postgresql + - db + deploy: + mode: global + +networks: + postgresql_db-backend: + external: true + +secrets: + postgresql_password: + external: true + postgresql_replication_password: + external: true + postgresql_patroni_password: + external: true