The compressed zip defeated restic's content-defined chunking: each day's dump looked completely different to restic even when the repo content barely changed, causing repo growth of ~full dump size (821 MB at last measurement) every day until forget/prune aged snapshots out. Uncompressed tar is dedup-friendly. After the first snapshot, daily incrementals cost only the actual new-data delta — typically a few MB for an active repo. Tradeoff: stage file on the client host is ~2-3x the zip size while the dump is in flight, but that's transient (purged at the start of each run). Repo-side storage is much smaller over time.
158 lines
6.7 KiB
Bash
Executable File
158 lines
6.7 KiB
Bash
Executable File
#!/bin/bash
|
||
# pre-backup.sh — ana-docker.
|
||
# Runs as root from resticprofile's `run-before`, before `restic backup`.
|
||
#
|
||
# Produces DB dumps in /var/lib/restic/stage/ so the nightly restic
|
||
# snapshot captures consistent point-in-time data for services whose
|
||
# raw volume files are not safe to back up live.
|
||
#
|
||
# Containers handled here:
|
||
# - synapse-db (internal Postgres 16 — pg_dump)
|
||
# - seafile-mysql (internal MariaDB 10.6 — mysqldump)
|
||
# - vaultwarden (external Postgres on PFI-Postgres 10.250.50.80)
|
||
# - gitea (`gitea dump` captures DB + repos + config + LFS)
|
||
# - openwebui (local SQLite × 2 — main db + ChromaDB vector store)
|
||
#
|
||
# External DB credentials live in /etc/restic/dbcreds.env (root:600).
|
||
# Template: configs/restic/ana-docker/dbcreds.env.example in the repo.
|
||
#
|
||
# Required tooling on the host:
|
||
# docker — always
|
||
# pg_dump — for vaultwarden external Postgres; install via
|
||
# `apt install postgresql-client`. Without it,
|
||
# the vaultwarden block logs a warning and skips.
|
||
#
|
||
# Intentionally NOT handled:
|
||
# - mattermost (retired 2026-04-21 — stack dir lingers but is not running)
|
||
#
|
||
# Idempotent: clears and recreates its staging files each run.
|
||
# Errors in individual blocks are logged as WARN but don't abort the whole
|
||
# script — partial dumps are better than no dumps.
|
||
|
||
set -euo pipefail
|
||
|
||
STAGE=/var/lib/restic/stage
|
||
install -d -o root -g root -m 0700 "$STAGE"
|
||
|
||
log() { printf '%s pre-backup(ana-docker): %s\n' "$(date -Is)" "$*"; }
|
||
warn() { log "WARN: $*" >&2; }
|
||
|
||
# Purge previous stage so stale dumps don't pile up into the snapshot.
|
||
find "$STAGE" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||
|
||
# Load external-DB creds. Silently skipped if missing — individual blocks
|
||
# that need them will log their own WARN.
|
||
if [ -r /etc/restic/dbcreds.env ]; then
|
||
set -a; . /etc/restic/dbcreds.env; set +a
|
||
fi
|
||
|
||
# ---------- synapse (internal Postgres) ---------------------------------------
|
||
if docker inspect synapse-db >/dev/null 2>&1; then
|
||
log "dumping synapse postgres"
|
||
docker exec synapse-db \
|
||
pg_dump -U synapse -d synapse -Fc --clean --if-exists \
|
||
> "$STAGE/synapse.pg_dump" \
|
||
|| warn "synapse pg_dump failed"
|
||
else
|
||
log "skip synapse: container not present"
|
||
fi
|
||
|
||
# ---------- seafile (internal MariaDB) ----------------------------------------
|
||
if docker inspect seafile-mysql >/dev/null 2>&1; then
|
||
log "dumping seafile mariadb"
|
||
docker exec seafile-mysql sh -c \
|
||
'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases --single-transaction --quick 2>/dev/null' \
|
||
| gzip -c > "$STAGE/seafile.sql.gz" \
|
||
|| warn "seafile mysqldump failed"
|
||
else
|
||
log "skip seafile: container not present"
|
||
fi
|
||
|
||
# ---------- vaultwarden (external Postgres on PFI-Postgres 10.250.50.80) ------
|
||
# The vault moved from SQLite to external Postgres (date unclear). Any
|
||
# /data/db.sqlite3* files in the container are stale leftovers and should
|
||
# be deleted separately — this hook captures the live Postgres data only.
|
||
if docker inspect vaultwarden >/dev/null 2>&1; then
|
||
if [ -z "${VW_PGPASS:-}" ]; then
|
||
warn "vaultwarden: VW_PGPASS unset in /etc/restic/dbcreds.env — skipping"
|
||
elif ! command -v pg_dump >/dev/null 2>&1; then
|
||
warn "vaultwarden: pg_dump not installed — skipping (apt install postgresql-client)"
|
||
else
|
||
log "dumping vaultwarden postgres (external: ${VW_PGHOST}:${VW_PGPORT:-5432})"
|
||
PGPASSWORD="$VW_PGPASS" pg_dump \
|
||
-h "$VW_PGHOST" -p "${VW_PGPORT:-5432}" \
|
||
-U "$VW_PGUSER" -d "$VW_PGDB" \
|
||
-Fc --clean --if-exists \
|
||
> "$STAGE/vaultwarden.pg_dump" \
|
||
|| warn "vaultwarden pg_dump failed"
|
||
fi
|
||
else
|
||
log "skip vaultwarden: container not present"
|
||
fi
|
||
|
||
# ---------- gitea (native `gitea dump`) ---------------------------------------
|
||
# `gitea dump` produces a single archive with the DB dump, repo trees,
|
||
# config, LFS objects and attachments. The in-container command reads
|
||
# its own DB creds (from GITEA__database__* env vars) — no external
|
||
# creds needed.
|
||
#
|
||
# `--type tar` produces an UNCOMPRESSED tarball. Compressed formats
|
||
# (zip/tar.gz) defeat restic's content-defined chunking: each day's
|
||
# dump looks completely different to restic even when the underlying
|
||
# data barely changed, so repo grows by ~full dump size every day.
|
||
# Uncompressed tar lets restic dedup aggressively — after the first
|
||
# snapshot, daily incrementals only cost the actual new-data delta.
|
||
#
|
||
# Tradeoff: on-disk stage file is larger (~2-3x the zip size) but that's
|
||
# transient (deleted next run). Repo-side storage is much smaller.
|
||
#
|
||
# Size trimming flags if the tar becomes excessive: --skip-lfs-data,
|
||
# --skip-repository, --skip-attachment-data.
|
||
if docker inspect gitea >/dev/null 2>&1; then
|
||
log "dumping gitea (gitea dump, uncompressed tar)"
|
||
if docker exec -u git gitea sh -c \
|
||
'rm -f /tmp/gitea-dump.tar && gitea dump -c /data/gitea/conf/app.ini -f /tmp/gitea-dump.tar --type tar' \
|
||
>/dev/null 2>&1; then
|
||
docker cp gitea:/tmp/gitea-dump.tar "$STAGE/gitea-dump.tar" \
|
||
&& docker exec -u git gitea rm -f /tmp/gitea-dump.tar \
|
||
|| warn "gitea dump copy/cleanup failed"
|
||
else
|
||
warn "gitea dump command failed"
|
||
fi
|
||
else
|
||
log "skip gitea: container not present"
|
||
fi
|
||
|
||
# ---------- openwebui (local SQLite × 2) --------------------------------------
|
||
# Two SQLite databases: main app (/app/backend/data/webui.db) and the
|
||
# ChromaDB vector store (.../vector_db/chroma.sqlite3). Uses SQLite's
|
||
# .backup command for a consistent snapshot if sqlite3 is available in
|
||
# the container; falls back to restic's volume-level capture otherwise.
|
||
OWUI_CONTAINER=openwebui-open-webui-1
|
||
if docker inspect "$OWUI_CONTAINER" >/dev/null 2>&1; then
|
||
if docker exec "$OWUI_CONTAINER" sh -c 'command -v sqlite3 >/dev/null 2>&1'; then
|
||
log "dumping openwebui sqlite (webui.db + chroma.sqlite3) via .backup"
|
||
for pair in \
|
||
"/app/backend/data/webui.db:webui.db" \
|
||
"/app/backend/data/vector_db/chroma.sqlite3:chroma.sqlite3"; do
|
||
src=${pair%:*}; dst=${pair#*:}
|
||
if docker exec "$OWUI_CONTAINER" sqlite3 "$src" ".backup /tmp/$dst" 2>/dev/null; then
|
||
docker cp "$OWUI_CONTAINER:/tmp/$dst" "$STAGE/openwebui.$dst" \
|
||
&& docker exec "$OWUI_CONTAINER" rm -f "/tmp/$dst" \
|
||
|| warn "openwebui copy/cleanup failed for $dst"
|
||
else
|
||
warn "openwebui .backup failed for $src (missing file or locked?)"
|
||
fi
|
||
done
|
||
else
|
||
warn "openwebui: sqlite3 not in container — relying on restic volume-level capture"
|
||
fi
|
||
else
|
||
log "skip openwebui: container not present"
|
||
fi
|
||
|
||
# ---------- summary -----------------------------------------------------------
|
||
size=$(du -sh "$STAGE" 2>/dev/null | awk '{print $1}')
|
||
count=$(find "$STAGE" -type f | wc -l)
|
||
log "stage ready: $count files, $size total"
|