ADR-0001 Phase 3: add cutover RUNBOOK (dependency map, phased procedure, rollback)
ci/woodpecker/push/deploy Pipeline was successful
ci/woodpecker/push/deploy Pipeline was successful
This commit is contained in:
@@ -0,0 +1,244 @@
|
|||||||
|
# ADR-0001 Phase 3 Cutover — RUNBOOK
|
||||||
|
|
||||||
|
**Status:** Ready to execute. All prerequisites (standby role, pg_hba.conf,
|
||||||
|
architecture validation, dry-run drills) are complete — see companion note
|
||||||
|
"ADR-0001 Dry-Run Debugging Log — Resume Point" for full history.
|
||||||
|
|
||||||
|
**Scope:** Cut production traffic over from the single-instance legacy
|
||||||
|
`postgresql` container (stack: `postgresql`, service: `postgresql`) to the
|
||||||
|
new Patroni/etcd/HAProxy HA stack (`postgresqlha`), using Patroni's
|
||||||
|
`standby_cluster` continuous-streaming mode — NOT a one-shot basebackup —
|
||||||
|
so the pre-cutover write gap is near-zero.
|
||||||
|
|
||||||
|
**Critical constraint:** This chat (Open WebUI + LiteLLM, stack `ai`) is
|
||||||
|
itself a consumer of the database being migrated. **The chat will not
|
||||||
|
survive the cutover if anything goes wrong that requires interactive
|
||||||
|
debugging mid-procedure.** Every phase from "legacy read-only" onward is
|
||||||
|
scripted with automatic rollback specifically so no human intervention via
|
||||||
|
this chat is required to reach a safe state. `cutover.sh` and
|
||||||
|
`rollback.sh` are both directly runnable on docker-2 via SSH/LXC-exec,
|
||||||
|
independent of Woodpecker — **do not rely on a CI pipeline run to execute
|
||||||
|
or recover from this**, since Woodpecker's own DB (via `WOODPECKER_DATABASE_DATASOURCE`)
|
||||||
|
is itself a consumer and could be part of what's disrupted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Dependency map (confirmed against live compose files)
|
||||||
|
|
||||||
|
All consumers below reach the DB via the Swarm-DNS name `postgresql`
|
||||||
|
(alias `db` also exists on the legacy service) on the external overlay
|
||||||
|
network `postgresql_db-backend`. Confirmed by reading each stack's
|
||||||
|
compose file directly — not assumed.
|
||||||
|
|
||||||
|
| Stack | Service | How it depends on `postgresql` | Blast radius if broken |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ai` | `open-webui` | `WEBUI_DB_HOST=postgresql`, `DATABASE_URL=${OPEN_WEBUI_DATABASE_URL}` | **This chat.** Session/user/chat state. |
|
||||||
|
| `ai` | `litellm` | `DATABASE_URL=${DATABASE_URL}` | LLM routing layer this chat calls through. |
|
||||||
|
| `ai` | `n8n` | attached to network, no explicit `DB_*` vars found in `ai.yaml` | Unconfirmed — likely SQLite. **Preflight must verify live, not assume.** |
|
||||||
|
| `ai` | `mcpo` / `mcpo-critical` | **NOT attached to `postgresql_db-backend`** | None. Confirmed no dependency — off-limits stack unaffected either way. |
|
||||||
|
| `auth` | `authentik-server`, `authentik-worker` | `AUTHENTIK_POSTGRESQL__HOST=postgresql`, DB name `authentik` | SSO — breaks all authenticated services cluster-wide. |
|
||||||
|
| `git` | `gitea-server` | attached to network; actual DB config lives in un-committed `git.env` | Preflight must inspect live env, don't assume driver. |
|
||||||
|
| `woodpecker` | `woodpecker-server` | explicit DSN `postgres://${DB_USER}:${DB_PASS}@${POSTGRES_HOST}:${POSTGRES_PORT}/woodpecker` | CI/CD pipeline itself. **See special-case note above — do not depend on Woodpecker to run/recover this cutover.** |
|
||||||
|
| `postgresql` (same stack) | `pgadmin`, `databasus` | same-stack, same network | Lower risk, co-located. |
|
||||||
|
|
||||||
|
**Not yet individually confirmed by direct compose-file read** (ADR
|
||||||
|
mentions ~13 total consumers): `immich`, `mealie`, `vaultwarden`,
|
||||||
|
`guacamole`, `homeassistant`, `meshcentral`, `maintenance` (Cronicle /
|
||||||
|
Uptime Kuma may only monitor, not store, in Postgres — verify). Do not
|
||||||
|
trust this table as exhaustive — `preflight.sh` enumerates consumers
|
||||||
|
**authoritatively** via `docker network inspect postgresql_db-backend`
|
||||||
|
(live truth: every container actually attached right now) cross-checked
|
||||||
|
against a repo-wide grep for `postgresql_db-backend` / `POSTGRES` /
|
||||||
|
`DATABASE_URL` patterns (catches anything not currently running). Treat
|
||||||
|
any mismatch between the two as a hard stop, not a warning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture summary
|
||||||
|
|
||||||
|
- **New stack:** `postgresqlha` — 3x etcd (Raft quorum, one per node,
|
||||||
|
hostname-pinned), 2x Patroni/Spilo (`patroni-0` primary-labeled,
|
||||||
|
`patroni-1` replica-labeled, via `node.labels.pg-role` constraints),
|
||||||
|
1x HAProxy (`deploy.mode: global`, TCP health-routes to whichever
|
||||||
|
Patroni node answers `GET /primary` with 200).
|
||||||
|
- **Bootstrap method:** `standby_cluster` (continuous streaming from live
|
||||||
|
legacy primary) — NOT `CLONE_WITH_BASEBACKUP`. Validated end-to-end in
|
||||||
|
`pgha-test` dry run: bootstrap, cascade replication, live write
|
||||||
|
propagation (<3s lag), promotion (zero data loss), and both graceful +
|
||||||
|
hard-crash failover drills post-promotion. See ADR-0001 note for full
|
||||||
|
validation detail.
|
||||||
|
- **Auth prerequisite (already live in production):** `standby` role
|
||||||
|
(REPLICATION LOGIN) + matching `pg_hba.conf` line
|
||||||
|
(`host replication standby 10.0.4.0/24 scram-sha-256`) — created and
|
||||||
|
network-proven against the live `postgresql_postgresql` container.
|
||||||
|
Nothing further needed here.
|
||||||
|
- **File:** `postgresql/cutover/postgresql-ha-staging.yaml` — the exact,
|
||||||
|
already-rewritten (standby_cluster, no CLONE_*) compose file this
|
||||||
|
cutover deploys. Not yet deployed for real prior to this procedure.
|
||||||
|
|
||||||
|
### Why the phased order looks the way it does (the actual fix over the failed attempt)
|
||||||
|
|
||||||
|
The prior failed attempt scaled legacy to 0 **before** HAProxy could
|
||||||
|
answer to the `postgresql`/`db` aliases — a DNS gap where no backend
|
||||||
|
answered at all for a window. The fix is not simply "add the alias
|
||||||
|
first" in isolation — that alone introduces two *worse* failure modes:
|
||||||
|
|
||||||
|
1. If HAProxy joins the alias before Patroni is genuinely promoted,
|
||||||
|
HAProxy's own healthcheck (`GET /primary`, expects 200) correctly
|
||||||
|
reports the standby_leader as unhealthy (it's read-only, not a real
|
||||||
|
primary) — so any connection Swarm's round-robin DNS routes to
|
||||||
|
HAProxy during that window hits **zero healthy backends** and fails
|
||||||
|
outright. Worse than the original gap, not better.
|
||||||
|
2. If legacy is still accepting writes at the exact moment of promotion,
|
||||||
|
any write landing on legacy after the replica's last synced LSN is
|
||||||
|
**silently lost** the instant Patroni becomes the new source of
|
||||||
|
truth — a split-brain data-loss risk, not just a DNS problem.
|
||||||
|
|
||||||
|
The corrected order below closes both gaps: legacy is flipped read-only
|
||||||
|
*before* promotion (closing gap #2, fully reversible with one command),
|
||||||
|
and the alias is only added *after* HAProxy has something genuinely
|
||||||
|
healthy to route to (closing gap #1, and incidentally also fixing the
|
||||||
|
original DNS-gap bug since legacy is never removed from DNS until a
|
||||||
|
healthy replacement already answers).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Phased cutover procedure
|
||||||
|
|
||||||
|
Executed by `cutover.sh`, run directly on docker-2. Each phase has an
|
||||||
|
explicit, automatic check; **any failed check triggers immediate
|
||||||
|
invocation of `rollback.sh`** and the script exits nonzero — no manual
|
||||||
|
intervention required to reach a safe state.
|
||||||
|
|
||||||
|
| Phase | Action | Verification before proceeding | Rollback if this phase fails |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | Run `preflight.sh` (backup, catalog sanity, disk space, consumer enumeration) | All checks pass | Abort before touching anything live — nothing to roll back |
|
||||||
|
| 1 | Deploy `postgresqlha` stack (`postgresql-ha-staging.yaml`) | All 6 services (`etcd-1/2/3`, `patroni-0`, `patroni-1`, `haproxy`) running; `patroni-1` reaches `standby_leader` via `/cluster` REST | `docker stack rm postgresqlha`, wipe fresh data dirs it created |
|
||||||
|
| 2 | Wait for streaming lag = 0 (`patroni-0` replica of `patroni-1`, both streaming) | `/cluster` REST shows `lag: 0` on both nodes for 3 consecutive polls | Same as Phase 1 — nothing on legacy touched yet |
|
||||||
|
| 3 | **Canary write/propagation check (pre-promotion, sanity only)** — insert a timestamped row into a disposable scratch table on legacy, confirm it appears on `patroni-0` (far cascade replica) within 5s | Row appears | Same as Phase 1 |
|
||||||
|
| 4 | **Flip legacy read-only**: `ALTER SYSTEM SET default_transaction_read_only = on; SELECT pg_reload_conf();` against legacy | Confirm via `SHOW default_transaction_read_only;` returns `on`; confirm one more streaming-lag poll = 0 (drains any last in-flight write) | **Reverse immediately**: `ALTER SYSTEM SET default_transaction_read_only = off; SELECT pg_reload_conf();` — legacy resumes normal read-write service, consumers were never touched, no visible outage |
|
||||||
|
| 5 | **Promote Patroni**: remove `standby_cluster` key from DCS via `etcdctl` (validated method — no dedicated `patronictl` subcommand exists) | `patroni-1` transitions `standby_leader` → `leader` within ~30s (dry run: ~15s); `pg_is_in_recovery()` returns `false` on `patroni-1` | Reverse Phase 4 (read-only off) — legacy is still the only thing anything is talking to, so this is a clean abort |
|
||||||
|
| 6 | Confirm HAProxy backend health | `curl` the HAProxy stats page (`:7000/`, internal-only) or `GET /primary` directly against each Patroni node shows exactly one healthy backend (the new leader) | Reverse Phase 4 — legacy resumes r/w, new HA stack is simply torn down (it never took live traffic) |
|
||||||
|
| 7 | **Add `postgresql`/`db` aliases to HAProxy** on `postgresql_db-backend` (deploy HAProxy with the alias config now, or `docker network connect --alias` if already running) | `getent hosts postgresql` from a disposable probe container on that network resolves to **both** legacy's IP and HAProxy's IP | Remove the alias (`docker network disconnect`/redeploy without it) — legacy is still answering `postgresql` alone the whole time, zero consumer impact |
|
||||||
|
| 8 | **Canary write/propagation check through the alias** — connect to `postgresql:5432` (not directly to a Patroni node), write a timestamped row, confirm visible on both `patroni-0` and `patroni-1` | Round-trip succeeds, row visible on both nodes | Remove alias (Phase 7 rollback) — legacy still primary of record |
|
||||||
|
| 9 | Verify each known consumer reconnects and can write through the alias — for each stack in the dependency map, run its lightest real health check (e.g., Authentik `ak healthcheck`, Gitea `/api/healthz`, Woodpecker `/healthz`, Open WebUI `/health`, LiteLLM `/health`) | All pass | Full rollback via `rollback.sh` (see below) — treat any consumer failure here as cutover failure, not a per-consumer issue to patch around |
|
||||||
|
| 10 | **Stop legacy**: `docker service scale postgresql_postgresql=0` | HAProxy backend still healthy post-legacy-stop; `getent hosts postgresql` now resolves to HAProxy only; repeat consumer health checks from Phase 9 | Restore legacy: `docker service scale postgresql_postgresql=1`, confirm it rejoins DNS; since it was read-only since Phase 4 and never diverged from the new leader's data, no data reconciliation needed at this point — **but past this phase, treat rollback as symmetric-but-not-free**: HAProxy has been serving real production writes, so rolling back here means picking legacy back up as primary of record and tearing down `postgresqlha`, not simply reversing a flag. Use `rollback.sh`, do not improvise. |
|
||||||
|
| 11 | Final verification pass — all consumers, disk space, `docker stack ps postgresqlha` clean, HAProxy stats page shows steady state | All green | N/A — cutover complete |
|
||||||
|
|
||||||
|
**Design note on why the canary check happens twice (Phase 3 and Phase
|
||||||
|
8):** Phase 3 is a pre-promotion sanity check that streaming genuinely
|
||||||
|
works (matches the dry-run validation methodology). Phase 8 is the actual
|
||||||
|
point of the exercise — proving a write through the *real client path*
|
||||||
|
(the alias, exactly as every consumer will use it) round-trips
|
||||||
|
correctly, not just that Patroni's internal replication works. Do not
|
||||||
|
skip either on the assumption the other covers it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Rollback procedure
|
||||||
|
|
||||||
|
`rollback.sh` is standalone and callable independently **at any point**,
|
||||||
|
not just as a `cutover.sh` failure handler. It detects current state and
|
||||||
|
takes the minimum action to restore the pre-cutover configuration:
|
||||||
|
|
||||||
|
1. If legacy is scaled to 0 → scale back to 1, wait for healthy.
|
||||||
|
2. If `default_transaction_read_only` is `on` on legacy → set back to
|
||||||
|
`off`, reload conf.
|
||||||
|
3. If HAProxy is holding the `postgresql`/`db` aliases → remove them
|
||||||
|
(redeploy HAProxy without the alias, or disconnect/reconnect the
|
||||||
|
network attachment).
|
||||||
|
4. Tear down the `postgresqlha` stack entirely (`docker stack rm
|
||||||
|
postgresqlha`), wait for task shutdown (verify via `docker stack ps`
|
||||||
|
— don't assume immediate), then wipe its data dirs so a future
|
||||||
|
attempt starts clean (mirrors the `pgha-test` teardown precedent from
|
||||||
|
the dry run — data dirs must be wiped, not just the parent dir left
|
||||||
|
behind, since Swarm bind mounts don't auto-create missing host parent
|
||||||
|
dirs; use `mkdir -p` per-path afterward, no brace expansion in this
|
||||||
|
exec environment).
|
||||||
|
5. Re-run the Phase 9-equivalent consumer health checks to confirm
|
||||||
|
everything is back on legacy and functioning.
|
||||||
|
6. Print a clear final state summary (which phase it rolled back from,
|
||||||
|
what's now running, what if anything still needs manual follow-up).
|
||||||
|
|
||||||
|
**If `rollback.sh` itself fails partway** (e.g., legacy won't scale back
|
||||||
|
up): this is the one scenario requiring manual intervention. In that
|
||||||
|
case:
|
||||||
|
- Do **not** attempt further automation blind — check
|
||||||
|
`docker service logs postgresql_postgresql --tail 100` and `docker
|
||||||
|
stack ps postgresql --no-trunc` first.
|
||||||
|
- The legacy data directory (`/volume1/docker/PostgreSQL/data-17`) is
|
||||||
|
never deleted by any script in this procedure — only `postgresqlha`'s
|
||||||
|
own dirs are wiped. Worst case, legacy's data is intact and it's a
|
||||||
|
matter of getting the container/service healthy again, not data
|
||||||
|
recovery.
|
||||||
|
- If this chat (Open WebUI/LiteLLM) is unreachable because of this,
|
||||||
|
fall back to direct SSH: `ssh root@192.168.4.32`, then work from
|
||||||
|
`docker service ls`, `docker stack ps postgresql --no-trunc`, `docker
|
||||||
|
service logs postgresql_postgresql --tail 100 -f`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Known gotchas carried forward from the dry run (still apply)
|
||||||
|
|
||||||
|
- **`$(...)` vs `$$(...)`** in any `command:` block — Compose parses
|
||||||
|
`$(` as attempted `${VAR}` interpolation and fails. Already fixed in
|
||||||
|
`postgresql-ha-staging.yaml`; don't reintroduce if editing.
|
||||||
|
- **`ETCD3_HOSTS` not `ETCD_HOSTS`** — our etcd v3.5.9 has the v2 API
|
||||||
|
disabled.
|
||||||
|
- **Docker secrets are immutable** — rotating a secret's value does not
|
||||||
|
propagate to running containers or to Woodpecker's provisioning logic
|
||||||
|
until an actual pipeline run re-executes `create_or_update_secret`.
|
||||||
|
Not expected to matter for this cutover (no secret rotation planned),
|
||||||
|
flagged in case a rollback attempt considers regenerating one.
|
||||||
|
- **LXC exec is scoped to docker-2 only** and has a ~30s timeout for
|
||||||
|
long-running commands (e.g. `docker service create` may exceed it even
|
||||||
|
though the operation succeeds) — check `docker service ps`/`docker
|
||||||
|
service logs --raw` separately rather than trusting a timeout as
|
||||||
|
failure. To reach containers on docker-1/docker-3, proxy through
|
||||||
|
another container on the same overlay network (e.g. `docker exec
|
||||||
|
<legacy> psql -h patroni-1 ...`).
|
||||||
|
- **Shell globs inside `docker exec <container> <cmd>`** get expanded by
|
||||||
|
the *outer* shell — wrap in `sh -c '...'` with the glob inside quotes
|
||||||
|
if one is ever needed.
|
||||||
|
- **`docker stack rm` returns immediately; tasks take several seconds to
|
||||||
|
actually exit** — always re-check `docker stack ps <name>` after a
|
||||||
|
brief wait before wiping any bind-mount data dirs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. File manifest
|
||||||
|
|
||||||
|
All in `postgresql/cutover/` unless noted:
|
||||||
|
|
||||||
|
- `RUNBOOK.md` — this file.
|
||||||
|
- `preflight.sh` — pre-flight checks (backup, catalog sanity, disk
|
||||||
|
space, authoritative consumer enumeration). Run standalone before
|
||||||
|
`cutover.sh`; `cutover.sh` also invokes it as Phase 0.
|
||||||
|
- `cutover.sh` — the phased script described above, Phases 1–11,
|
||||||
|
automatic rollback on any failed check.
|
||||||
|
- `rollback.sh` — standalone, idempotent, callable independently at any
|
||||||
|
point (also invoked automatically by `cutover.sh` on failure).
|
||||||
|
- `postgresql-ha-staging.yaml` — the HA stack compose file this
|
||||||
|
procedure deploys (already written, standby_cluster design, not yet
|
||||||
|
deployed for real prior to this procedure).
|
||||||
|
- `post_init_wrapper.sh` — Spilo post_init override (superuser
|
||||||
|
placeholder role fix), referenced by `postgresql-ha-staging.yaml`.
|
||||||
|
- `test/pgha-dryrun.yaml` — the disposable dry-run design, kept for
|
||||||
|
reference/future re-testing. Not part of the real cutover path.
|
||||||
|
- `../haproxy.cfg` — HAProxy config (Patroni-aware TCP router), shared
|
||||||
|
between steady-state and this cutover; no changes needed for the
|
||||||
|
cutover itself beyond the alias timing described above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Post-cutover follow-up (manual, not scripted)
|
||||||
|
|
||||||
|
- Confirm `postgresql_postgresql` (legacy service) can eventually be
|
||||||
|
fully decommissioned (remove from `postgresql.yaml`) once the new
|
||||||
|
stack has run stable for a reasonable burn-in period — not part of
|
||||||
|
this procedure, a deliberate later step.
|
||||||
|
- Re-point `postgresql.yaml`'s `pgadmin`/`databasus` services at the new
|
||||||
|
topology if they need updating (they connect via the same
|
||||||
|
`postgresql` alias, so likely no change needed — verify post-cutover).
|
||||||
|
- Revisit the deferred PG18/Spilo major-version upgrade project (see
|
||||||
|
ADR-0001 note) — explicitly out of scope here.
|
||||||
Reference in New Issue
Block a user