[Backend][DR] Backups are written by pg_dump 17 but the database server is 16 — they cannot be restored #429
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: HIGH (disaster recovery). Found while trying to recover settings from a dev backup.
The defect
The backup job runs
pg_dumpfrom the backend image; restores would runpg_restoreagainst the db image. They are different major versions, and the format is not backwards compatible.Attempting to restore a dev backup with the db container's own tooling:
pg_dump 17 writes archive format 1.16;
pg_restore16 refuses it outright. Not a warning — it reads nothing at all. I had to pull apostgres:17image to read the file.Production has the same skew and two dump files sitting in
/app/backupsunder it.Why it matters
The backup job reports success.
backup_logsrecords success. The files are the right size and look fine. They are simply not restorable by anything on the host that produced them — which is discovered at exactly the moment nobody can afford to discover it.This also silently constrains recovery: restoring requires a Postgres 17 client, which is not part of the stack, so a DR runbook that says "restore the latest dump" does not work as written.
Proposed fix
Pick one and make it explicit rather than incidental:
postgres-clientmatching the server's major version in the backend image, so dumps are always readable by the server that will restore them. Simplest and least surprising.CLAUDE.mdalready applies to Python.--format=plain), accepting the loss of selective restore.Whichever is chosen, the important part is the check:
A backup nobody has restored is not a backup. The job should verify its own output — restore the dump into a scratch database and confirm it loads — rather than trusting that a zero exit code from
pg_dumpmeans recoverable.Acceptance criteria
/app/backupson production are confirmed restorable, or replaceddocs/OPERATIONS.mdstates which client version is required to restore, and the restore procedure is tested end to endRelated
Found in the same investigation as the
SECRET_KEYrestore hazard. Both are cases where the backup exists and the restore is what does not work.Confirmed during the v4.0.0 production deploy, with one finding that makes the fix much cheaper than the options in the description.
The correct client is already on the host
The
dbcontainer ispostgres:16-alpineand carries pg_dump 16.15 and psql 16.15 — an exact match for the server it runs. The version skew exists only because the backup job runs from the backend image.So "align the versions" needs no new package in the backend image and no server upgrade: run the dump from the
dbservice instead. That is a change of which container executespg_dump, nothing more.Verified end to end
Took a pre-migration backup of production this way before applying v4.0.0's six migrations:
304 KB gzipped from a 13 MB database, 46 tables. Then restored it into a scratch database with
ON_ERROR_STOP=1and compared:alembic_versionPlain format also removes the client-version constraint entirely —
psqlof any major version can load it — at the cost of selective restore, which the description already weighs.The file is on production at
/home/ryan/qb-pre-v4.0.0-20260828-232744.sql.gzas the rollback point for this release.The documentation is affected, not just the job
docs/OPERATIONS.md→ "Updating Production" tells the operator to runmake backup-nowbefore a major upgrade, and "Backups and Migration Safety" describes those custom-format dumps as "the authoritative record". Both currently point at the unrestorable path, so anyone following the runbook for a risky upgrade gets a file that cannot be restored by anything on the host — at precisely the moment the doc exists to protect them.That is worth calling out in the fix beyond the acceptance criteria already listed: the runbook step needs to change alongside the job.
Suggested amendment to the acceptance criteria
pg_dumpfrom thedbservice, so client and server are the same image and cannot drift apartdocs/OPERATIONS.md's "take a snapshot first" step is updated in the same change, since that is the instruction an operator actually followsRelated: this is also why the v4.0.0 CHANGELOG's advice to snapshot before applying six migrations needed a manual workaround this time.
Fixed in
9698b4confix/v4.0.1-retention-safety.Took the "align the versions" option from the description. The
dbcontainer's ownpg_dumpis already 16.15 (noted in my earlier comment), but the backup runs inside the backend container and cannot reach it, so the alignment had to happen in the backend image.Two changes, and the second is only safe because of the first
Aligned.
ARG PG_MAJOR=16, installingpostgresql-client-16. Debian 13 carries only 17, so this needs the PGDG repo;gnupgis purged afterwards so the build dependency does not persist into the runtime image.A test parses
ARG PG_MAJORout of theDockerfileand the image tag out ofdocker-compose.ymland asserts they agree. They live in different files and nothing else connected them, which is precisely how they drifted apart.Refuses an unrestorable backup. Both the scheduled task and
backup_nowcomparepg_dump's major againstserver_version_numbefore writing anything, so a mismatch is a failed backup with an explanation rather than a success recording a file nobody can read.Ordering matters and is asserted: the version probe must run before the dump, since a check afterwards prevents nothing.
⚠️ Shipping that check without the alignment would have turned "unrestorable backups" into "no backups at all" — strictly worse. They have to go together, which is why they are one commit.
Direction is deliberate: a client newer than the server raises; an older client produces an archive the newer server reads fine, so it is allowed and merely noted. Unknown versions proceed with a warning — refusing to back up because a version string could not be parsed would be a worse failure than the one being guarded against.
Verified end to end, not by inspection
Built the image, then ran the exact operation that used to fail:
Also confirmed ffmpeg 7.1.5 and libmagic still work in the built image, and that
gpgis gone from it.Acceptance criteria
/app/backupson production are confirmed restorable, or replaced — confirmed not restorable. All eight were written bypg_dump 17.10:questboard_manual_20260617T005829Z.dump … 20260806T171446Z.dump (8 files) backend pg_dump: 17.10 (Debian 17.10-0+deb13u1)They will be replaced by the first backup taken after this deploys. I have not deleted them — that is your call, and until a fresh one exists they are the only dumps there, unreadable or not. The verified plain-SQL pre-v4.0.0 snapshot at
/home/ryan/qb-pre-v4.0.0-20260828-232744.sql.gzremains the usable rollback point in the meantime.docs/OPERATIONS.mdstates which client version is required, how to check it, and that a failing backup with a mismatch message means the two have driftedClosed — the last criterion is now met on production
v4.0.1 (
db77a4e) is deployed. The backend image reports:matching
postgres:16-alpine, where it was 17.10 before.The outstanding item: existing dumps replaced and proven
Took a fresh backup through the app's own path (
python -m app.cli backup_now) and then ran the operation that used to fail — restoring it with the server's ownpg_restore, inside thedbcontainer:Identical to production. The probe database was dropped afterwards, so nothing was left behind.
Previously this exact sequence failed with
unsupported version (1.16) in file header, having read nothing at all.Acceptance criteria
Dockerfile/docker-compose.ymlconsistency test plus the pre-write version check. A full restore rehearsal is still not in CI — it needs a backend image build alongside a postgres service, which the current job set does not do. Worth its own issue if you want it gated rather than done by hand at release time./app/backupson production are confirmed restorable, or replaced — a restorable dump now exists and is the newest. The eight olderpg_dump 17files are still present and still unreadable by this server; they age out naturally underretention_count, and I have not deleted them since that is a data-retention call rather than a defect fix.docs/OPERATIONS.mdstates which client version is required to restore, and the restore procedure is tested end to endShipped in v4.0.1.