Thomas Fransolet c9580fd0a8 Scripts de sauvegarde Postgres
Dump -Fc avec vérification de relecture et rotation, test de restauration qui
compare les comptages table par table, extraction par instance, contrôle de
fraîcheur, units systemd et procédure de restauration écrite.

Le dump Mongo est volontairement en lecture seule : aucun mongorestore, pour
qu'aucune erreur de manipulation ne puisse écrire dans la prod.

Ce qui manque pour que ce soit une sauvegarde : BACKUP_DEST n'est pas tranché.
Tant qu'il est vide, le dump reste sur la machine qui héberge la base et ne
protège de rien — le script le dit à chaque exécution. Et notify.sh ne fait
qu'un logger, donc les trois surveillances écrivent dans un journal que
personne ne lit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:11:33 +02:00

130 lines
6.8 KiB
Markdown

# Sauvegardes — installation
Complète le snapshot quotidien OVH, qui restaure un **disque** mais ne garantit pas un
Postgres cohérent. `pg_dump` prend un snapshot MVCC : c'est une sauvegarde au niveau base.
Pour restaurer, voir **[RESTORE.md](RESTORE.md)**.
## Scripts
| Script | Rôle |
|---|---|
| `backup-postgres.sh` | dump `-Fc`, vérifie qu'il est relisible, rotation 7 jours, copie hors site |
| `restore-check.sh` | restaure dans une base jetable et compare les comptages — le seul test qui vaille |
| `extract-instance.sh` | extrait une seule instance d'un dump global (RGPD, récupération d'un client) |
| `check-freshness.sh` | alerte si la dernière sauvegarde a plus de 48 h |
| `dump-mongo-prod.sh` | sauvegarde de la prod Mongo, lecture seule, jusqu'à la bascule |
| `notify.sh` | point unique de notification, **à brancher sur un canal réel** |
## Installation sur le VPS
```sh
install -d /opt/myinfomate /var/backups/myinfomate
cp -r backup /opt/myinfomate/
install -m 600 backup/systemd/myim-backup.env.example /etc/myim-backup.env
# renseigner BACKUP_DEST et BACKUP_ALERT_WEBHOOK
cp backup/systemd/*.service backup/systemd/*.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now myim-backup.timer myim-backup-freshness.timer myim-restore-check.timer
systemctl start myim-backup.service && journalctl -u myim-backup.service -n 30
```
`rclone` est requis dès que `BACKUP_DEST` est un remote (`gcs:`, `s3:`).
## Pourquoi systemd et pas cron
cron échoue en silence. `OnFailure=` appelle `notify.sh` sur tout échec, et les logs vont
dans journald. `Persistent=true` rattrape le tir si la machine était éteinte à l'heure prévue.
## Pourquoi pas Hangfire
Huit jobs récurrents existent déjà dans l'application ([Startup.cs](../../Startup.cs)). Mais une
sauvegarde de base pilotée par l'app qui dépend de cette base ne tourne pas quand l'app est
tombée — c'est-à-dire le jour où on en a besoin. Le timer reste indépendant du conteneur.
## Les trois modes de panne, et ce qui les couvre
| Panne | Couverture |
|---|---|
| Le script échoue | `OnFailure=``notify.sh` |
| Le script ne tourne plus depuis des semaines | `myim-backup-freshness.timer` |
| Les dumps existent mais ne se restaurent pas | `myim-restore-check.timer`, mensuel |
Le deuxième est le mode réel, et c'est le seul qu'un `OnFailure` n'attrape pas.
## Décisions
**`ContentEmbeddings` exclu** (données seulement, le schéma reste) : régénérable depuis le
contenu CMS, et mesuré à 12 Mo → 1,7 Mo sur la base de dev. Contrepartie : réindexer après
restauration, étape 4 de RESTORE.md.
**Rétention en deux étages** : 7 quotidiens sur le VPS, hebdo/mensuels par lifecycle rule côté
bucket. Le VPS ne garde pas son propre historique, puisque c'est lui qu'on suppose perdu.
**Un dump global, pas un par client.** Une restauration par client se fait par extraction
depuis le dump global (`extract-instance.sh`). Des sauvegardes par client multiplieraient par
N les jobs à planifier, les échecs à surveiller et les restaurations à tester, pour la même
capacité de récupération.
## La destination — arrêtée le 2026-09-07
| | |
|---|---|
| Projet GCP | `myinfomate-backups` (séparé de `mymuseum-3b97f`, qui porte la prod) |
| Bucket | `gs://unov-myinfomate-backups`, `EU` multi-région, versioning activé, accès public interdit |
| Préfixes | `pg/` pour les dumps Postgres, `media/` pour les médias |
| Identité | service account `backup-writer`, `objectCreator` + `objectViewer` |
| Rétention | lifecycle rule : suppression à 400 jours **sous `pg/` uniquement**, versions périmées à 30 jours |
**Le service account ne peut pas supprimer, et c'est vérifié** — pas seulement voulu. Test du
2026-09-07 : l'écriture passe, le `rm` renvoie `HTTPError 403 storage.objects.delete denied`.
À rejouer si les rôles changent :
```sh
export CLOUDSDK_CONFIG=$(mktemp -d) # config isolée, ne touche pas au compte courant
gcloud auth activate-service-account --key-file=backup-writer.json
gcloud storage cp t.txt gs://unov-myinfomate-backups/pg/t.txt # doit réussir
gcloud storage rm gs://unov-myinfomate-backups/pg/t.txt # doit échouer en 403
```
Deux conséquences voulues de cette absence de droit de suppression :
- la rotation longue **ne peut pas** venir du VPS : seule la lifecycle rule efface, côté Google,
hors d'atteinte d'une machine compromise ;
- la copie des médias ne pourra être qu'**additive** (`rclone copy`, jamais `sync`). Un fichier
effacé côté Firebase reste dans la sauvegarde — le bon comportement pour un backup, garanti
par les permissions et non par la discipline.
Bonus non demandé : GCS active une `soft_delete_policy` de 7 jours par défaut sur les nouveaux
buckets. Seconde couche sous la précédente.
## Ce qui manque encore
- **`rclone` n'est pas installé ni configuré sur le VPS** — c'est la dernière pièce entre les
scripts et une sauvegarde réelle.
- **`notify.sh` n'est branché sur rien** — sans canal réel, les trois surveillances écrivent
dans le journal et personne ne les lit.
- **La copie des médias a été faite une fois, le 2026-09-07 — mais elle n'est pas planifiée.**
1,43 Go, 2407 objets, serveur à serveur, 170 Mio/s :
```sh
gcloud storage rsync -r gs://mymuseum-3b97f.appspot.com gs://unov-myinfomate-backups/media/
```
Vérifiée : **taille identique à l'octet** des deux côtés. Le seul objet non copié est
`pictures/65ccc67265373befd15be511/`, un marqueur de dossier de 0 octet créé par la console
Firebase — `rsync` l'ignore à juste titre.
⚠️ Toujours `rsync` **sans** `--delete-unmatched-destination-objects` : un fichier effacé côté
Firebase doit rester dans la sauvegarde.
**Comment la planifier — décision du 2026-09-07.** Storage Transfer Service a été envisagé puis
écarté : il aurait fallu 5 attributions de rôles IAM et un agent de service à qui Google
recommande le droit de suppression, pour automatiser une commande à relancer toutes les
quelques semaines sur des données qui bougent de **~2 ressources par mois** (mesuré : 9 créées
en 5 mois). Disproportionné. Le mécanisme reste ce `rsync`, qui tourne avec un compte humain et
ne demande **aucun changement de permission**. Il sera ajouté aux timers du VPS en même temps
que la sauvegarde Postgres — un seul endroit, une seule mécanique.
- ✅ ~~Le bucket des médias n'a pas le versioning~~ → **activé le 2026-09-07**, avec purge des
versions non courantes à 30 jours. Le filet est donc en place **avant** que
`Firebase:StorageBucket` soit renseignée — jour où `Delete` commencera réellement à supprimer
les blobs. Aujourd'hui la clé est vide : supprimer une ressource dans manager-app retire la
ligne en base et **laisse le fichier**, ce qui est la source des orphelins.