In-cluster Database Backup & Restore — How It Works
This document explains how Magda’s in-cluster PostgreSQL backup and restore machinery works internally: the two cooperating mechanisms, the scripts involved, the recovery flow, and — importantly — the data-loss window (RPO) you can expect in different failure scenarios.
If you are looking for how to configure backup/restore (storage secrets, helm values, turning it on/off), see the companion how-to: How to Config Continuous Archiving and Point-in-Time Recovery (PITR). This document is the “how it works under the hood” counterpart.
Applies to the in-cluster PostgreSQL option (the
magda-postgreschart, included bycombined-db). If you use a managed cloud database service instead, backup/restore is handled by your provider and this document does not apply.Backup/restore is powered by wal-g. The behaviour described here is for the currently shipped wal-g 3.0.8; the mechanics (command names, env, storage layout) may shift with future wal-g upgrades.
Overview: two mechanisms, one scheme
When backup is enabled (backupRestore.backup.enabled = true), two mechanisms run and write to the same object store (S3/GCS/Azure/etc., configured via backupRestore.storageConfig / storageSecretName, exposed to wal-g as env files under /etc/wal-g.d/env):
| Mechanism | What it produces | Cadence | Driven by |
|---|---|---|---|
| Base backups | Full snapshots of the cluster (basebackups_005/… in the store) |
Periodic (default weekly) | A Kubernetes CronJob running wal-g backup-push |
| Continuous WAL archiving | Every completed 16 MB write-ahead-log segment (wal_005/…) |
Continuous — at least every archive_timeout (default 10 min) |
PostgreSQL’s archive_command calling wal-g wal-push |
They are not two alternatives you pick between — they are complementary layers of a single continuous-archiving scheme, and both switch on together with backup.enabled:
- A base backup is the anchor: a self-consistent full copy you can restore from.
- The WAL archive is the fine-grained stream between base backups. Each base backup needs a little WAL to become internally consistent, and the archive is also what makes roll-forward point-in-time recovery possible (recover to a moment after the last base backup rather than only to the backup itself).
Think of it as: base backup = periodic full snapshot; WAL archive = the continuous change-log that lets you replay forward from a snapshot.
Mechanism 1 — Base backups (the CronJob)
Template: deploy/helm/internal-charts/magda-postgres/templates/cronjob-backup.yaml
A Kubernetes CronJob (concurrencyPolicy: Forbid, backoffLimit: 3, restartPolicy: OnFailure) runs the magda-wal-g image on backup.schedule (default 0 15 * * 6 — 15:00 UTC every Saturday). Each run:
adduser.sh— adds a uid-1001 entry to/etc/passwd.wal-g(dynamically linked to libc) looks up the running uid on startup. The CronJob’s pod spec sets a Kubernetescommand:, which overrides the image’s bitnami entrypoint — so, unlike the DB pod, nss_wrapper is not initialised and uid 1001 would be unresolved.adduser.shwrites the missing entry sowal-gcan run. (The DB pod keeps the entrypoint, so it does not need this — see Mechanism 2.)wal-g backup-push— a remote base backup over PostgreSQL’s replication protocol (PGHOSTpoints at the DB service; no local data directory needed). This streams a full base backup tobasebackups_005/in the store.- Capture the exit code immediately (
BACKUP_PUSH_RC=$?). This is deliberate: any statement betweenbackup-pushand reading$?would reset it and make a failed backup look successful — which previously also let the retention step below prune the existing backup chain after a failure. (See issue #3746.) - Retention (success only) —
wal-g delete --confirm retain FULL <numberOfBackupToRetain>(default keep 7) trims older base backups. It distinguishes “fewer backups than the retention count” from a real error by string-matching wal-g’s"not found"output (fragile across wal-g versions; revisit on upgrade). On backup failure it exits 1 and does not prune.
Relevant values (chart magda-postgres):
backupRestore.backup.schedule— base-backup cron (default weekly).backupRestore.backup.numberOfBackupToRetain— base backups to keep (default 7).backupRestore.backup.walgTarSizeThreshold— backup bundle size (default 20 GB).
Mechanism 2 — Continuous WAL archiving
Config: deploy/helm/internal-charts/magda-postgres/templates/extended-config-configmap.yaml
When backup.enabled = true, the DB is configured with:
archive_mode = on
wal_level = replica
archive_command = /usr/bin/envdir /etc/wal-g.d/env /usr/local/bin/wal-g wal-push "$PGDATA/%p"
archive_timeout = 600 # seconds; values.yaml default = 10 minutes
Whenever PostgreSQL fills (or, via archive_timeout, force-closes) a 16 MB WAL segment, archive_command runs wal-g wal-push to upload it to wal_005/ in the store. archive_timeout = 600 guarantees at least one segment is closed and archived every ~10 minutes even on an idle database — this is the knob that bounds how much recent change can be un-archived at any instant.
The DB pod runs the image’s default bitnami entrypoint, which sets up nss_wrapper (
LD_PRELOAD+NSS_WRAPPER_PASSWD). Becausewal-gis dynamically linked to libc, its uid lookup is resolved by nss_wrapper even though uid 1001 is absent from/etc/passwd— so the DB pod’sarchive_commandworks without the CronJob’sadduser.shstep. (Verified end-to-end:pg_stat_archiverreports archived segments with zero failures, and WAL objects land underwal_005/.)
Also, at DB startup start.sh ensures a host replication all 0.0.0.0/0 md5 entry exists in pg_hba.conf so the CronJob’s remote backup-push can connect. (PostgreSQL’s all database keyword does not match physical-replication connections, so an explicit replication entry is required.)
Restore / recovery flow
Recovery is opt-in and only happens when an operator sets backupRestore.recoveryMode.enabled = true (→ env MAGDA_RECOVERY_MODE=true). On the next DB pod start, start.sh sees the flag (and that /wal-g/recovery.complete is absent) and runs the restore scripts baked into the image under magda-postgres/wal-g/:
recover.sh:- generates
recovery.confintoconf.dfromMAGDA_RECOVERY_TARGET(viagen-recovery-conf.sh); - swaps in a local-only
pg_hba.conf(blocks application traffic while recovering); - saves the current
$PGDATA/pg_walaside (preserving any WAL not yet archived); - wipes
$PGDATA; wal-g backup-fetch $PGDATA LATEST(or a pinnedMAGDA_RECOVERY_BASE_BACKUP_NAME);- restores the saved
pg_walback over the fetched (empty) one; touch recovery.signal→ PostgreSQL enters archive recovery.
- generates
- The generated
recovery.confgoverns replay. It always sets:restore_command = wal-g wal-fetch "%f" "$PGDATA/%p" # pulls archived WAL as needed recovery_end_command = /wal-g/post-recovery.shand the target depends on
recoveryMode.recoveryTarget(→MAGDA_RECOVERY_TARGET, defaultlatest):latest→ norecovery_target(replay to the end of the archived WAL, then promote);immediate→recovery_target = 'immediate'(stop at the base backup);- a timestamp →
recovery_target_time = '<value>'(point-in-time recovery). A target addsrecovery_target_action = 'promote'.
post-recovery.shruns after promotion: marks/wal-g/recovery.complete(so a later pod restart won’t re-enter recovery), removesrecovery.conf, restores the normalpg_hba.conf, and reloads to re-open remote connections. Backup, if it was on, resumes automatically.
You choose which base backup with recoveryMode.baseBackupName (default LATEST).
Data at risk (RPO) — the important part
The recovery-point objective (maximum data loss) depends on the failure mode and on recoveryMode.recoveryTarget (default latest):
| Scenario | Data-loss window |
|---|---|
| Pod restart / reschedule, data volume (PVC) intact | ≈ 0. Recovery mode is off by default; PostgreSQL just does normal crash recovery from its own pg_wal. Nothing is discarded. |
Disaster, default recovery (recoveryTarget: latest) |
≈ archive_timeout (~10 min) of the most recent, not-yet-archived WAL — or ~0 if the pod’s local pg_wal survived and was replayed. |
Disaster, recoveryTarget: immediate |
Up to one base-backup interval — default ≈ 7 days. |
Point-in-time (recoveryTarget: <timestamp>) |
recovers to the chosen instant (data after it is intentionally discarded). |
By default (recoveryTarget: latest) recovery rolls forward through the archived WAL to the newest segment and promotes, so the RPO is bounded by archive_timeout (~10 min) — the cadence at which WAL is archived — or ~0 when the failure preserved the pod’s local pg_wal.
Setting recoveryTarget: immediate restores to the base backup only and does not roll forward, so the RPO grows to one base-backup interval (weekly by default) — useful when you deliberately want a known-good older state. A timestamp target performs point-in-time recovery to that instant (pair it with a baseBackupName taken before the target time). Two levers still shrink the windows: shorten backup.schedule (smaller base-backup interval) and lower archiveTimeout (smaller un-archived WAL tail).
Two levers to shrink the default window:
- Shorten
backupRestore.backup.schedule→ more frequent base backups → smaller auto-recovery RPO. - Lower
backupRestore.backup.archiveTimeout→ smaller un-archived WAL tail (at the cost of more, smaller WAL objects), which is the floor a roll-forward recovery can reach.
Object-store layout (wal-g 3.0.8)
Under your configured WALG_S3_PREFIX:
<prefix>/basebackups_005/base_<segment>/metadata.json
<prefix>/basebackups_005/base_<segment>/tar_partitions/*.tar.lz4
<prefix>/basebackups_005/base_<segment>_backup_stop_sentinel.json
<prefix>/wal_005/<segment>.lz4 # one lz4 object per archived WAL segment
Automated test coverage
The wal-g mechanics are exercised as a regression oracle in magda-int-test-ts (src/tests/walgBackupRestore.spec.ts, added under #3747): base-backup → restore fidelity, WAL push/fetch byte-identity, and a point-in-time roll-forward restore that recovers writes made after the base backup (the property that gives continuous archiving its value). Those tests deliberately drive wal-g directly against a plain postgres:13.7 + MinIO — they validate the wal-g command behaviour, not the in-cluster automation wiring (the CronJob manifest and PostgreSQL’s archive_command), which is covered by the manual end-to-end scenario under #3750 against the real magda-postgres image.
Related
- How to Config Continuous Archiving and Point-in-Time Recovery (PITR) — configuration how-to (storage, helm values).
magda-postgreschart reference — allbackupRestore.*options.- PostgreSQL Continuous Archiving & PITR — upstream reference.
- wal-g storage options.