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

6.8 KiB

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.

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

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). 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 :

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 :

    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 versioningactivé 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.