Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
135 lines
5.9 KiB
Bash
Executable File
135 lines
5.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# =============================================================================
|
|
# Sauvegarde PostgreSQL - production Xpeditis
|
|
# =============================================================================
|
|
# Deux mecanismes complementaires, volontairement redondants :
|
|
#
|
|
# 1. WAL-G basebackup + archivage WAL continu -> Hetzner Object Storage
|
|
# Objectif : restauration a un instant T (PITR). RPO <= 5 min.
|
|
# C'est la sauvegarde qui compte en cas de corruption ou d'erreur humaine
|
|
# ("j'ai supprime les bookings de mars").
|
|
#
|
|
# 2. pg_dump logique chiffre -> Hetzner Storage Box (autre service, autre
|
|
# credential, autre protocole).
|
|
# Objectif : survivre a une compromission du compte Object Storage, a un
|
|
# bug WAL-G, ou a une montee de version majeure de PostgreSQL.
|
|
#
|
|
# Regle des 3-2-1 : 3 copies (volume + Object Storage + Storage Box), 2 supports
|
|
# distincts, 1 hors du perimetre de la VM. Cf. 12-sauvegardes-restauration.md.
|
|
#
|
|
# Execution : timer systemd `xpeditis-backup.timer`, tous les jours a 02h30.
|
|
set -euo pipefail
|
|
|
|
COMPOSE_DIR="${COMPOSE_DIR:-/opt/xpeditis/data-node}"
|
|
ENV_FILE="${ENV_FILE:-${COMPOSE_DIR}/.env.data}"
|
|
DUMP_DIR="${DUMP_DIR:-/var/lib/xpeditis/dumps}"
|
|
LOG_TAG="xpeditis-backup"
|
|
|
|
# shellcheck disable=SC1090
|
|
set -a; source "$ENV_FILE"; set +a
|
|
|
|
log() { logger -t "$LOG_TAG" -- "$*"; printf '[%s] %s\n' "$(date -Is)" "$*"; }
|
|
fail() { log "ECHEC: $*"; notify "ECHEC sauvegarde PostgreSQL" "$*"; exit 1; }
|
|
|
|
notify() {
|
|
# Alerte Discord (meme webhook que la CI/CD). Une sauvegarde qui echoue en
|
|
# silence est pire que pas de sauvegarde du tout.
|
|
local title="$1" body="$2"
|
|
[[ -n "${DISCORD_WEBHOOK_URL:-}" ]] || return 0
|
|
curl -sf -H 'Content-Type: application/json' \
|
|
-d "$(jq -nc --arg t "$title" --arg d "$body" \
|
|
'{embeds:[{title:$t,description:$d,color:15158332}]}')" \
|
|
"$DISCORD_WEBHOOK_URL" >/dev/null || true
|
|
}
|
|
|
|
dc() { docker compose -f "${COMPOSE_DIR}/docker-compose.data.yml" --env-file "$ENV_FILE" "$@"; }
|
|
|
|
# --- 0. Preconditions --------------------------------------------------------
|
|
command -v age >/dev/null || fail "age n'est pas installe (chiffrement des dumps)"
|
|
[[ -n "${BACKUP_AGE_RECIPIENT:-}" ]] || fail "BACKUP_AGE_RECIPIENT absent de .env.data"
|
|
mkdir -p "$DUMP_DIR"
|
|
|
|
dc ps --status running --services | grep -qx postgres \
|
|
|| fail "le conteneur postgres n'est pas demarre"
|
|
|
|
# --- 1. Sauvegarde physique WAL-G (base de la PITR) --------------------------
|
|
log "WAL-G basebackup : demarrage"
|
|
if ! dc exec -T -u postgres postgres wal-g backup-push /var/lib/postgresql/data/pgdata; then
|
|
fail "wal-g backup-push a echoue"
|
|
fi
|
|
log "WAL-G basebackup : termine"
|
|
|
|
# --- 2. Retention WAL-G ------------------------------------------------------
|
|
# 7 sauvegardes completes conservees (soit ~1 semaine de PITR a raison d'une
|
|
# par jour), les WAL anterieurs sont purges avec elles.
|
|
log "WAL-G : purge des sauvegardes au-dela de 7 retentions"
|
|
dc exec -T -u postgres postgres wal-g delete retain FULL 7 --confirm \
|
|
|| log "AVERTISSEMENT: la purge WAL-G a echoue (non bloquant)"
|
|
|
|
# --- 3. Dump logique chiffre -------------------------------------------------
|
|
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
|
DUMP_NAME="xpeditis-${STAMP}.dump"
|
|
DUMP_PATH="${DUMP_DIR}/${DUMP_NAME}"
|
|
|
|
log "pg_dump logique : demarrage"
|
|
# -Fc = format custom : compresse, restaurable table par table avec pg_restore.
|
|
if ! dc exec -T -u postgres postgres \
|
|
pg_dump -Fc -Z6 --no-owner --no-privileges \
|
|
-d "$POSTGRES_DB" > "$DUMP_PATH"; then
|
|
rm -f "$DUMP_PATH"
|
|
fail "pg_dump a echoue"
|
|
fi
|
|
|
|
DUMP_SIZE=$(stat -c%s "$DUMP_PATH")
|
|
# Un dump anormalement petit signale une base vide ou une erreur silencieuse.
|
|
if (( DUMP_SIZE < 51200 )); then
|
|
rm -f "$DUMP_PATH"
|
|
fail "dump suspect : ${DUMP_SIZE} octets (< 50 Ko)"
|
|
fi
|
|
log "pg_dump : ${DUMP_SIZE} octets"
|
|
|
|
log "Chiffrement age"
|
|
age -r "$BACKUP_AGE_RECIPIENT" -o "${DUMP_PATH}.age" "$DUMP_PATH" \
|
|
|| fail "chiffrement age echoue"
|
|
# Le dump en clair ne survit pas a la minute : seule la version chiffree part
|
|
# sur le reseau et reste sur disque.
|
|
shred -u "$DUMP_PATH" 2>/dev/null || rm -f "$DUMP_PATH"
|
|
sha256sum "${DUMP_PATH}.age" > "${DUMP_PATH}.age.sha256"
|
|
|
|
# --- 4. Copie vers la Storage Box -------------------------------------------
|
|
if [[ -n "${STORAGEBOX_HOST:-}" ]]; then
|
|
log "Envoi vers la Storage Box ${STORAGEBOX_HOST}"
|
|
rsync -a --protect-args \
|
|
-e "ssh -p ${STORAGEBOX_PORT:-23} -i ${STORAGEBOX_SSH_KEY:-/root/.ssh/storagebox} -o StrictHostKeyChecking=yes" \
|
|
"${DUMP_PATH}.age" "${DUMP_PATH}.age.sha256" \
|
|
"${STORAGEBOX_USER}@${STORAGEBOX_HOST}:./xpeditis-prod/dumps/" \
|
|
|| fail "rsync vers la Storage Box a echoue"
|
|
else
|
|
log "AVERTISSEMENT: STORAGEBOX_HOST non configure, pas de seconde copie hors VM"
|
|
fi
|
|
|
|
# --- 5. Retention locale -----------------------------------------------------
|
|
# 7 jours en local : de quoi restaurer vite sans re-telecharger. La retention
|
|
# longue (30 jours + 12 mensuels) vit sur la Storage Box.
|
|
find "$DUMP_DIR" -name 'xpeditis-*.dump.age*' -mtime +7 -delete
|
|
log "Retention locale appliquee (7 jours)"
|
|
|
|
# --- 6. Compte rendu ---------------------------------------------------------
|
|
LATEST=$(dc exec -T -u postgres postgres wal-g backup-list --detail 2>/dev/null | tail -n 1 || echo "?")
|
|
log "Sauvegarde terminee. Derniere sauvegarde WAL-G: ${LATEST}"
|
|
|
|
# Battement de coeur externe : c'est le SILENCE qui declenche l'alerte. Une
|
|
# supervision hebergee sur cette meme machine ne dirait rien si la machine
|
|
# etait eteinte -- exactement le cas ou l'alerte compte.
|
|
if [[ -n "${BACKUP_HEARTBEAT_URL:-}" ]]; then
|
|
curl -fsS --max-time 10 --retry 3 "$BACKUP_HEARTBEAT_URL" >/dev/null \
|
|
|| log "AVERTISSEMENT: le battement de coeur n'a pas pu etre envoye"
|
|
fi
|
|
|
|
if [[ -n "${DISCORD_WEBHOOK_URL:-}" ]]; then
|
|
curl -sf -H 'Content-Type: application/json' \
|
|
-d "$(jq -nc --arg d "Dump: ${DUMP_NAME}.age ($(numfmt --to=iec "$DUMP_SIZE"))" \
|
|
'{embeds:[{title:"Sauvegarde PostgreSQL OK",description:$d,color:3066993}]}')" \
|
|
"$DISCORD_WEBHOOK_URL" >/dev/null || true
|
|
fi
|