xpeditis2.0/docs/mise-en-prod/12-sauvegardes-restauration.md
David b22f4e0b74 docs: procedure de mise en production pas a pas
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
2026-09-07 21:40:50 +02:00

316 lines
12 KiB
Markdown

# 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@<db_ip> "systemctl list-timers 'xpeditis-*' --no-pager"
ssh deploy@<db_ip> '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@<db_public_ipv4>
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@<db_ip> '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@<db_ip> '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@<db_ip>
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-<horodatage>`. 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@<db_ip>
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@<db_ip>
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 = ''<uuid>''')
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@<nouvelle_ip>:/tmp/
ssh deploy@<nouvelle_ip> '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@<nouvelle_ip>:/tmp/
ssh deploy@<nouvelle_ip> '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@<db_ip> 'sudo systemctl start xpeditis-backup.service'
ssh deploy@<db_ip> '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)**