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>
130 lines
6.8 KiB
Markdown
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.
|