# 12 — Sauvegardes et restauration **Durée : environ 2 h, dont un test de restauration réel.** > **Une sauvegarde qui n'a jamais été restaurée n'est pas une sauvegarde.** > C'est la seule phrase de ce dossier qui mérite d'être affichée au mur. --- ## 1. Objectifs | Indicateur | Cible | Ce que cela signifie | |---|---|---| | **RPO** (perte maximale) | **5 minutes** | `archive_timeout = 300` force la clôture d'un segment WAL toutes les 5 min, même sans activité. | | **RTO** (temps de remise en service) | **< 2 h** | Reconstruire db-01 et restaurer via WAL-G. | | Rétention PITR | 7 jours | 7 sauvegardes complètes + les WAL associés. | | Rétention des dumps | 7 j locaux, 30 j distants | Storage Box. | Pour une plateforme de réservation, perdre 5 minutes signifie perdre au plus quelques réservations, identifiables dans les journaux Loki et rejouables manuellement. C'est un compromis raisonnable à ce stade. --- ## 2. Trois mécanismes, volontairement redondants ``` db-01 │ ┌───────────────────────┼───────────────────────┐ │ │ │ WAL-G pg_dump -Fc Snapshots continu quotidien Hetzner │ │ │ │ chiffré libsodium │ chiffré age │ (image disque) ▼ ▼ ▼ Object Storage Storage Box Hetzner Cloud xpeditis-prod-pgbackup uXXXXXX/dumps (rétention 7 j) ``` **Pourquoi trois.** | Panne | Ce qui vous sauve | |---|---| | « J'ai supprimé les réservations de mars » | **WAL-G / PITR** — retour à l'instant précédant l'erreur. | | Corruption logique découverte 3 jours après | WAL-G ou dump du jour concerné. | | Compte Object Storage compromis ou supprimé | **Dump sur Storage Box** — autre service, autre identifiant, autre protocole. | | Bug WAL-G, ou montée de version majeure de PostgreSQL | **Dump logique** — restaurable dans n'importe quelle version. | | Perte totale du serveur | WAL-G + snapshot Hetzner. | | Rançongiciel sur db-01 | **Snapshots de la Storage Box** — voir avertissement ci-dessous. | > **Le point faible d'un rançongiciel.** db-01 possède les identifiants d'écriture > vers Object Storage **et** vers la Storage Box. Un attaquant ayant obtenu > root peut donc chiffrer ou effacer les deux. La seule protection réelle est > l'**instantané côté fournisseur** : activez les snapshots de la Storage Box > (gratuits, dans son interface) et gardez les sauvegardes Hetzner Cloud > activées. Ils ne sont pas accessibles depuis le serveur. --- ## 3. Ce qui tourne automatiquement | Unité | Quand | Fait quoi | |---|---|---| | `xpeditis-backup.timer` | tous les jours 02h30 | WAL-G `backup-push`, purge (7 rétentions), `pg_dump` chiffré, envoi Storage Box, purge locale, battement de cœur. | | `xpeditis-backup-verify.timer` | dimanche 04h00 | Restaure le dernier dump dans une base jetable et contrôle sa cohérence. | | `archive_command` | en continu | Chaque segment WAL part sur Object Storage dès sa clôture. | ```bash ssh deploy@ "systemctl list-timers 'xpeditis-*' --no-pager" ssh deploy@ 'sudo journalctl -u xpeditis-backup -n 40 --no-pager' ``` ### Contrôles de sécurité intégrés Le script `pg-backup.sh` refuse de considérer une sauvegarde comme réussie si : - le conteneur PostgreSQL n'est pas démarré ; - `wal-g backup-push` échoue ; - `pg_dump` échoue ; - le dump fait moins de 50 Ko (base vide ou erreur silencieuse) ; - le chiffrement `age` échoue ; - le `rsync` vers la Storage Box échoue. Chaque échec part sur Discord **et** le battement de cœur n'est pas envoyé, ce qui déclenche l'alerte externe. --- ## 4. Le test de restauration — à faire maintenant C'est l'étape que tout le monde saute et qui coûte le plus cher le jour venu. ### 4.1 Vérification automatique (sans impact) ```bash ssh deploy@ sudo /opt/xpeditis/data-node/backup/pg-restore.sh verify ``` Ce que fait le script : déchiffre le dernier dump, crée `xpeditis_restore_check`, restaure avec `--exit-on-error`, compte les tables et les lignes de `users`, `organizations`, `csv_bookings`, `migrations`, échoue si moins de 10 tables ont été restaurées, puis supprime la base jetable. Attendu : `SAUVEGARDE VALIDE`. ### 4.2 Restauration à un instant T — le vrai test **Faites-le avant l'ouverture au public**, pendant que l'enjeu est nul. ```bash # 1. Marqueur horodaté ssh deploy@ 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \ psql -d xpeditis_prod -c "CREATE TABLE test_pitr (t timestamptz DEFAULT now(), note text); INSERT INTO test_pitr(note) VALUES (:'"'"'avant'"'"'); SELECT * FROM test_pitr;"' # Notez l'horodatage renvoyé. sleep 360 # au-delà de archive_timeout, pour garantir l'archivage du WAL # 2. Destruction volontaire ssh deploy@ 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \ psql -d xpeditis_prod -c "DROP TABLE test_pitr;"' # 3. Arrêter l'application (sinon les écritures pendant la restauration sont perdues) kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0 # 4. Restaurer à l'instant précédant la suppression ssh deploy@ sudo /opt/xpeditis/data-node/backup/pg-restore.sh pitr "2026-09-01 14:32:00" # 5. Suivre le rejeu des WAL cd /opt/xpeditis/data-node && sudo docker compose logs -f postgres # Attendre : "database system is ready to accept connections" # 6. Vérifier sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c 'SELECT * FROM test_pitr;' # La table doit être revenue. # 7. Nettoyer et redémarrer sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c 'DROP TABLE test_pitr;' kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2 ``` **Chronométrez.** Le temps mesuré ici est votre RTO réel, pas celui du tableau. Le script conserve l'ancien répertoire de données dans `/var/lib/xpeditis/pgdata-avant-pitr-`. Supprimez-le **seulement** après avoir validé la restauration — c'est votre filet. --- ## 5. Restauration ### 5.1 Suppression accidentelle de données — PITR Le plus fréquent, et le plus stressant. ```bash # 1. NE RIEN FAIRE D'AUTRE. Chaque écriture supplémentaire complique le retour. kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0 # 2. Déterminer l'instant précis, dans les journaux # Grafana : {namespace="xpeditis-prod"} |= "DELETE" ou "DROP" # 3. Restaurer à la seconde qui précède ssh deploy@ sudo /opt/xpeditis/data-node/backup/pg-restore.sh pitr "AAAA-MM-JJ HH:MM:SS" # 4. Vérifier AVANT de rouvrir sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod \ -c 'SELECT count(*) FROM csv_bookings; SELECT max(created_at) FROM csv_bookings;' # 5. Rouvrir kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2 ``` > **La PITR ramène toute la base à l'instant T.** Les données créées *après* > cet instant sont perdues aussi. Si seules quelques lignes sont concernées, > préférez une restauration logique dans une base séparée (§5.2), puis copiez > uniquement ce qui manque. ### 5.2 Récupérer quelques lignes sans toucher à la production ```bash ssh deploy@ sudo /opt/xpeditis/data-node/backup/pg-restore.sh logical \ /var/lib/xpeditis/dumps/xpeditis-20260901T023000Z.dump.age \ xpeditis_recuperation # Copier ce qui manque, et rien d'autre sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c " INSERT INTO csv_bookings SELECT * FROM dblink('dbname=xpeditis_recuperation', 'SELECT * FROM csv_bookings WHERE id = ''''') AS t(...);" ``` Plus simple sans `dblink` : exporter les lignes en CSV depuis la base de récupération, puis les réimporter. La production n'est jamais touchée. C'est presque toujours la bonne approche. ### 5.3 Perte totale de db-01 RTO visé : moins de 2 h. ```bash # 1. Recréer le serveur cd infra/prod/terraform terraform apply -replace=hcloud_server.db # Le volume pgdata a prevent_destroy : il survit et sera rattaché. # 2. Réinstaller scp infra/prod/scripts/00-bootstrap-common.sh deploy@:/tmp/ ssh deploy@ 'sudo APP_PRIVATE_IP=10.10.1.10 bash /tmp/00-bootstrap-common.sh data' scp infra/prod/scripts/01-setup-data-node.sh deploy@:/tmp/ ssh deploy@ 'sudo bash /tmp/01-setup-data-node.sh' # 3. Redéposer configuration, .env.data (SOPS), clés age et Storage Box # → 04-noeud-donnees.md, sections 3 et 4 # 4a. Si le volume a survécu : démarrer, c'est tout sudo docker compose -f docker-compose.data.yml --env-file .env.data up -d --build # 4b. Si le volume est perdu : restaurer depuis WAL-G sudo docker compose run --rm -u postgres postgres \ wal-g backup-fetch /var/lib/postgresql/data/pgdata LATEST sudo docker compose run --rm -u postgres postgres bash -c \ "touch /var/lib/postgresql/data/pgdata/recovery.signal echo \"restore_command = 'wal-g wal-fetch \\\"%f\\\" \\\"%p\\\"'\" \ >> /var/lib/postgresql/data/pgdata/postgresql.auto.conf" sudo docker compose up -d postgres # 5. Vérifier puis rouvrir kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2 ``` > Si l'IP privée du nouveau serveur diffère de `10.10.1.20`, mettez à jour > `DATABASE_HOST` et `REDIS_HOST` dans le ConfigMap, la NetworkPolicy > `allow-egress-to-data-node`, `pg_hba.conf` et la cible Prometheus. > Terraform réutilise l'IP fixe déclarée : cela ne devrait pas arriver. ### 5.4 Perte totale de app-01 Beaucoup plus simple : **aucune donnée n'y réside**. ```bash cd infra/prod/terraform && terraform apply -replace=hcloud_server.app # Puis : 03-durcissement-serveurs.md → 05-cluster-k3s.md → 06 → 09 # Mettez à jour les enregistrements DNS Cloudflare avec la nouvelle IP. ``` Comptez une heure. C'est le bénéfice direct d'avoir gardé le nœud applicatif entièrement sans état. --- ## 6. Sauvegarde manuelle avant opération risquée Avant une migration lourde ou une montée de version majeure : ```bash ssh deploy@ 'sudo systemctl start xpeditis-backup.service' ssh deploy@ 'sudo journalctl -u xpeditis-backup -f' ``` Attendez la fin **avant** de lancer l'opération. Notez l'horodatage : c'est votre point de retour. --- ## 7. Ce qui n'est pas sauvegardé, et pourquoi | Élément | Sauvegardé ? | Justification | |---|---|---| | PostgreSQL | oui, 3 fois | données métier | | Documents Object Storage | par Hetzner (réplication) | pas de version antérieure : **une suppression est définitive**. Voir ci-dessous. | | Redis | AOF sur disque local | cache + sessions. Une perte déconnecte les utilisateurs, sans plus. | | Journaux Loki / métriques Prometheus | non | 31 j / 15 j de rétention, sans valeur au-delà. | | Configuration k3s | non | entièrement reconstructible depuis `infra/prod/`. | | État Terraform | manuellement | à chiffrer SOPS ou à stocker sur un backend S3. | > **Les documents ne sont pas versionnés.** Hetzner Object Storage réplique > mais ne conserve pas d'historique : un connaissement supprimé par erreur — ou > par une faille applicative — est définitivement perdu. Si ces documents ont > une valeur contractuelle (ils en ont), ajoutez une synchronisation > hebdomadaire vers la Storage Box : > > ```bash > # Sur db-01, tâche hebdomadaire > rclone sync hetzner:xpeditis-prod-documents \ > storagebox:xpeditis-prod/documents --backup-dir storagebox:xpeditis-prod/documents-anciens/$(date +%F) > ``` > À mettre en place dans le premier mois d'exploitation. --- ## 8. Contrôle ``` [ ] Timers xpeditis-backup et xpeditis-backup-verify actifs [ ] Une sauvegarde complète réussie, visible sur Object Storage ET Storage Box [ ] pg-restore.sh verify : SAUVEGARDE VALIDE [ ] TEST PITR RÉEL EFFECTUÉ ET RÉUSSI [ ] RTO réel chronométré et noté [ ] Snapshots de la Storage Box activés [ ] Sauvegardes Hetzner Cloud activées sur les deux serveurs [ ] Battement de cœur externe vert [ ] WALG_LIBSODIUM_KEY et backup-age.key sauvegardées HORS LIGNE [ ] État Terraform sauvegardé [ ] Synchronisation des documents planifiée (dans le premier mois) ``` → **Suite : [13 — Sécurité](./13-securite-durcissement.md)**