xpeditis2.0/infra/prod/data-node/backup/pg-backup.sh
2026-09-07 21:40:50 +02:00

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