#!/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