Databases¶
'DATABASES': {
'BACKEND': 'offsite_backup.databases.postgres.PostgresSource',
'ALIASES': ['default'],
'FORMAT': 'plain',
},
Three sources, one per engine — each a BACKEND of its own:
| engine | backend | dumps with | object suffix |
|---|---|---|---|
| PostgreSQL | offsite_backup.databases.postgres.PostgresSource |
pg_dump |
.sql.zst.age / .dump.zst.age |
| MySQL, MariaDB | offsite_backup.databases.mysql.MySQLSource |
mysqldump / mariadb-dump --single-transaction |
.sql.zst.age |
| SQLite | offsite_backup.databases.sqlite.SQLiteSource |
Python's online backup API | .sqlite3.zst.age |
For each alias: dump | zstd | age into one file in the workspace, upload, delete, next.
Nothing goes through Python memory. Most of this page is PostgreSQL's; what differs for
the others is under MySQL and MariaDB and SQLite.
Which databases¶
ALIASES names entries of Django's DATABASES:
'DATABASES': {'BACKEND': 'offsite_backup.databases.postgres.PostgresSource'},
ALIASES defaults to ['default'].
'DATABASES': {
'BACKEND': 'offsite_backup.databases.postgres.PostgresSource',
'ALIASES': ['default', 'analytics'],
},
'DATABASES': {
'BACKEND': 'offsite_backup.databases.postgres.PostgresSource',
'ALIASES': 'acme.tenancy.backup_aliases',
},
A dotted path to a callable that takes no arguments and returns the aliases — for a
project that adds a database per tenant. It is called at the start of every pass and
of every morning check. Aliases added to connections.databases at run time count.
Each alias is its own kind: db/ for default, db-<alias>/ for every other — a
sibling, not a child, or db/'s rotation would count it. Each kind rotates by itself.
One database that cannot be dumped does not cost the others — nor the media, nor the configuration — their night. The pass goes on, uploads the rest, rotates, and then fails naming every alias that did not go up:
backup_offsite: 1 database(s) not backed up, the rest went up — analytics: PipelineError: `pg_dump` exited with status 1: ...
The connection¶
From DATABASES[alias] — or, when the source has a CONNECTIONS block, from that block's
CONNECTIONS[alias], with the same keys. CONNECTIONS is how a server with no Django
project names its databases (Without Django), and how a Django project
names one it does not want in DATABASES:
'DATABASES': {
'BACKEND': 'offsite_backup.databases.postgres.PostgresSource',
'ALIASES': ['default'],
'CONNECTIONS': {
'default': {'HOST': 'db', 'PORT': '5432', 'USER': 'app', 'PASSWORD': os.environ.get('DB_PASSWORD', ''), 'NAME': 'app'},
},
},
With CONNECTIONS set, Django's DATABASES is not read at all for this source, and an
alias missing from it is refused by name. ALIASES still says which are dumped.
HOST, PORT, USER and NAME become pg_dump flags;
PASSWORD travels in PGPASSWORD, never on the command line — a command line is
visible in ps, and an error text is posted to a chat. OPTIONS['sslmode'] and
OPTIONS['sslrootcert'] become PGSSLMODE and PGSSLROOTCERT. The connection shows up
in pg_stat_activity as application_name = offsite_backup. Every question the package asks
the server — whether a restore target is empty, how many rows a table holds, the server's
version for E004 — goes through psql with the same connection, so it needs no Django
connection either.
Formats¶
FORMAT |
pg_dump |
object suffix | restored with |
|---|---|---|---|
plain (default) |
plain SQL | .sql.zst.age |
psql --single-transaction |
custom |
--format=custom --compress=0 |
.dump.zst.age |
pg_restore --single-transaction |
The suffix names the format. A custom-format archive under a .sql. name gets fed to
psql in a hurry, and fails. A restore does not trust the name anyway: it tells the
formats apart by the archive's magic bytes (PGDMP), and decompresses whatever starts with
zstd's — so objects written under an older naming scheme restore too.
Switching from custom-format dumps named .sql…. A project whose own backups were
custom-format dumps under a .sql.zst.age name, and which now sets FORMAT: 'custom', sees
both names in a database's folder for one retention window — the old .sql.zst.age
objects, the new .dump.zst.age ones — until the old ones rotate out. That is expected and
harmless: rotation reads nights from the stamps, not from the suffix, and a restore
detects the format from the magic bytes. There is no option to keep the old suffix: a
suffix that does not name the format is how a custom dump ends up fed to psql.
The custom format is dumped with pg_dump's own compression off and compressed by
zstd: zstd compresses better, and compressed data does not compress twice. It is what you
want when you need pg_restore --list (an integrity read of the whole table of contents,
which backup_restore runs) or to restore one table from a dump. Plain SQL is readable with
zstd -dc | less and loads into anything that speaks SQL.
By default both carry --no-owner --no-privileges, so a dump restores into a database
owned by a different role. NO_OWNER: False keeps ownership and grants. EXTRA_ARGS is
appended to the command — ['--exclude-table-data=django_session'], for example.
How a dump is known to be whole¶
The pipeline runs with pipefail semantics and no shell: every stage's exit status is read,
so a pg_dump that dies half way fails the pass instead of leaving a small, valid,
truncated .age. The tail of the failing stage's stderr goes into the error.
A restore checks again: a plain dump must end with PostgreSQL database dump complete; a
custom one must pass pg_restore --list.
Versions¶
pg_dump refuses to dump a server newer than itself. python manage.py check --deploy
connects to each alias and compares:
?: (offsite_backup.E004) pg_dump is 16, older than the server of 'default' (18).
HINT: Install the PostgreSQL 18 client (e.g. postgresql-client-18) in the image.
An alias it cannot connect to is skipped by this check rather than reported.
A one-off backup of one database¶
For the moment a database is about to go — removed, put to sleep, handed back:
from offsite_backup import back_up_database
key = back_up_database('tenant_42')
It raises when backups are not configured, so a removal that cannot be backed up does
not happen. The dump goes to <PREFIX>/db-tenant_42/ like a nightly one; nothing rotates
that folder once the alias is no longer among the pass's targets.
extra_recipients=['age1...']seals this one dump to more keys as well — for a database the server itself must be able to restore later (waking a tenant), seal it to a key the server holds. Every other backup stays readable off the machine only. Restoring it on the server:restore_database(..., identity_text=...)(Restore).keep_apart=Truewrites it under<PREFIX>/released/db-<alias>/instead, where no rotation ever reads — so an alias reused later never mixes two databases' history.
keep_apart(key) does the same for a backup already stored: it copies the object under
<PREFIX>/released/, locked afresh, and answers the new key. The original stays where its
own rotation will take it.
Restoring¶
backup_restore --apply and restore_database() — see Restore.
MySQL and MariaDB¶
'DATABASES': {
'BACKEND': 'offsite_backup.databases.mysql.MySQLSource',
'ALIASES': ['default'],
'CLIENT': 'mariadb', # 'mysql' (default): mysqldump and mysql
},
ALIASES works as for PostgreSQL. Each dump is
mysqldump --defaults-extra-file=<0600 file> --single-transaction --quick --hex-blob --triggers
--no-tablespaces --default-character-set=utf8mb4 [--set-gtid-purged=OFF] <EXTRA_ARGS> <NAME>
- One moment of the database.
--single-transactionreads every InnoDB table as of the dump's start, without locking a table against the application. A MyISAM table is not covered by it — it is read as it is while the dump runs; convert it, or accept that. - The password never reaches argv, nor
MYSQL_PWD(deprecated by MySQL):HOST(a path starting with/is a socket),PORT,USER,PASSWORD,OPTIONS['unix_socket']andOPTIONS['ssl']go into an option file readable by this process only, passed as--defaults-extra-fileand removed when the dump ends. --set-gtid-purged=OFF(MySQL only, whichmariadb-dumpdoes not know): a dump carrying the server's GTID set refuses to load into any other server.--no-tablespacesspares the dump user thePROCESSprivilege. Routines and events are not dumped by default — Django does not create them;'EXTRA_ARGS': ['--routines', '--events']adds them for a project that does.- Whole: a restore checks the dump ends with
-- Dump completed, the line both programs write last (so do not pass--skip-comments). - No Python driver needed: the source runs only the client programs. A restore asks the
server through
mysql/mariadbwhether the target holds any table, refuses one that does unless--clean— which drops every table and view first, foreign key checks off — then feeds the dump to the client, which stops at the first error.
The client should be at least as new as the server: mysql-client for MySQL,
mariadb-client for MariaDB (mysqldump 8 against a MariaDB server trips on statistics
tables MariaDB does not have).
SQLite¶
'DATABASES': {
'BACKEND': 'offsite_backup.databases.sqlite.SQLiteSource',
'ALIASES': ['default'],
},
Each alias's NAME is a file. It is copied with sqlite3.Connection.backup — the online
backup API — into the pass's private workspace, then compressed and sealed from there and
the copy removed. The backup API copies page by page and starts over when a writer touches
a page it has copied, so the copy is one moment of the database while the application
keeps writing; copying the file itself could catch a transaction half way, or miss what
only the write-ahead log (-wal) holds. No binary is needed.
- The workspace holds one plain copy of the database for the length of its sealing — mind
WORKDIRon a machine whose temporary directory is small or shared. - A database file that does not exist fails that database (
SourceMissingError) instead of creating an empty one;check --deployreports it asW005, orE009with'REQUIRED': True. An alias that is not SQLite, or an in-memory database, isE001. - Verified on restore: an SQLite header and
PRAGMA integrity_checkansweringok. - Restored with the same backup API over the target alias's file, which must hold no
table unless
--clean. Close the application's connections to it first.
Other databases¶
A subclass of offsite_backup.databases.base.DatabaseSource named in BACKEND works
without any change to the package: targets() (the ALIASES logic is the base class's),
suffix(), dump(), verify() and restore().