Aller au contenu principal

Le backup de Schrödinger : rejouer son pg_dump avant d'en avoir besoin.

Testez vos sauvegardes PostgreSQL ! Apprenez à rejouer vos pg_dump quotidiens et mesurez votre RTO avant le sinistre.

MAJ 10 min de lecture
Sommaire · 10

Ce matin, j'ai restauré la base de ce blog pour la première fois. Pas parce que le chat avait mangé le câble réseau ou renversé sa gamelle d'eau dessus, mais pour écrire ce billet. Le pg_dump tournait pourtant toutes les nuits depuis début mai. Deux mois de dumps compressés, chiffrés, poussés hors site, rotatés proprement. Et zéro preuve que l'un d'eux savait redevenir une base de données.

À la fin de ce billet, vous saurez monter un exercice de restauration répétable, en une commande, chronomètre inclus. Avec la stack réelle de ce blog en exemple, les chiffres mesurés qui vont avec, et le récit de mon premier exercice, qui a échoué pour une raison que je n'avais pas vue venir.

Un backup qu'on n'a jamais rejoué est dans une superposition d'états : vivant et mort à la fois. Le cron vert ne dit rien du contenu de la boîte. La seule façon de savoir, c'est de l'ouvrir.

Le jour où un audit a refusé de me croire

En juin, j'ai passé l'infra de ce blog au crible du guide d'hygiène de l'ANSSI. Mesure 37 : les sauvegardes. J'avais tout, sur le papier : dumps quotidiens, copie hors site chiffrée, rotation sur trois horizons. La case est pourtant restée décochée, avec ce motif consigné dans l'issue : « tant que le restore n'a pas été exécuté, le RTO reste théorique ».

Deux sigles, deux natures. Le RPO (Recovery Point Objective) répond à « combien de données j'accepte de perdre » : c'est une décision. Chez moi, 24 heures, le rythme du dump quotidien, assumé pour un blog. Le RTO (Recovery Time Objective) répond à « combien de temps pour repartir » : ça ne se décide pas, ça se mesure. Et pour le mesurer, il faut restaurer.

Pour aller plus loin

Symfony

Doctrine sous EXPLAIN : profiler ce blog jusqu'à l'index

Lire le billet 9 min de lecture

Cadrons tout de suite ce que ce billet ne couvre pas : ni PITR, ni archivage WAL, ni réplication. Si chaque minute d'indisponibilité vous coûte de l'argent, votre incident mérite mieux que mon kit. On parle ici d'un pg_dump quotidien pour un blog ou un side project, autrement dit le cas de la majorité des bases qu'on laisse vivre sans témoin.

Sur le même sujet

DevOps

Symfony Scheduler en prod : j'ai attendu d'avoir Redis pour migrer, et j'ai bien fait

Lire le billet 7 min de lecture

Pièce 1 : un dump qui tourne sans moi

Première brique, un container dédié qui dumpe tout seul. Extrait du compose.yaml de ce blog :

Pour aller plus loin

DevOps

Comment FrankenPHP a relégué PHP-FPM et Nginx au stade de reliques

Lire le billet 13 min de lecture
YAML
postgres-backup:
    image: prodrigestivill/postgres-backup-local:16-alpine
    environment:
        POSTGRES_HOST: database
        POSTGRES_EXTRA_OPTS: "-Z9 --schema=public --blobs"
        SCHEDULE: "@daily"   # minuit UTC
        BACKUP_KEEP_DAYS: 7
        BACKUP_KEEP_WEEKS: 4
        BACKUP_KEEP_MONTHS: 6
    volumes:
        - app_backups:/backups

Pourquoi un container plutôt qu'un cron sur l'hôte : la config vit dans le même compose que le reste, versionnée, relue en PR, déployée avec l'app. L'hôte reste bête et remplaçable. La rotation est arbitrée par l'image elle-même : sept dumps quotidiens, quatre hebdomadaires, six mensuels, des points de retour à trois horizons sans script maison.

Sur ce blog, le dump du 3 juillet pèse 449 Ko compressés (gzip niveau 9) pour environ 10 Mo de base : 79 billets, 16 catégories, 8 pages, 111 redirections.

Pièce 2 : une copie qui survit au serveur

Un backup stocké sur le disque du serveur qu'il sauvegarde, c'est un parachute rangé dans l'avion qui s'écrase. Deuxième brique, donc : rclone et son remote « crypt » vers un bucket Backblaze B2.

Terminal
rclone sync /backups b2crypt:lecodeestdanslepre-backups/postgres \
    --config /config/rclone.conf \
    --b2-hard-delete

Le remote b2crypt chiffre le contenu et jusqu'aux noms de fichiers : l'hébergeur du bucket ne voit que des blobs anonymes. La passphrase vient du vault Ansible, et le compose de prod la déclare en ${BACKUP_ENCRYPTION_PASSPHRASE:?} : la syntaxe :? fait échouer le déploiement si la variable manque. Un deploy qui plante bruyamment vaut mieux qu'un push hors site silencieusement sauté.

Détail qui compte le jour du sinistre : si la passphrase ne vit que sur le serveur, elle brûle avec lui. La copie chiffrée ne vaut que si la clé, elle aussi, survit ailleurs.

Pièce 3 : le drill, ou comment ouvrir la boîte

Tout ce qui précède, votre hébergeur vous le vend et n'importe quel tutoriel le couvre. La pièce que personne ne livre, c'est celle-ci : l'exercice de restauration. Le principe tient en quatre temps.

  1. Récupérer le dump le plus récent : du volume local, ou de B2 avec déchiffrement, le vrai chemin du sinistre.
  2. Le restaurer dans un Postgres jetable, sans port publié, qui ne connaît ni la prod ni le dev.
  3. Vérifier le contenu : les tables clés existent et leur volumétrie est plausible. « psql a rendu la main » n'est pas un critère.
  4. Chronométrer, afficher le verdict, détruire le container.

Chez moi, ça tient dans un script bash de 150 lignes et deux cibles Make :

Terminal
make restore-test      # dump local, aucun secret requis
make restore-test-b2   # pull + déchiffrement depuis B2 : reprise après sinistre

Une seule commande, c'est le point non négociable. Un drill qui demande de retrouver un README, d'exporter quatre variables et d'enchaîner trois commandes ne sera jamais relancé : la friction est l'ennemie de la répétition.

Résultat mesuré le 3 juillet : restauration en moins d'une seconde, drill complet en trois secondes, démarrage et destruction du Postgres jetable compris. Oui, c'est un blog de 10 Mo, pas votre ERP. La valeur du chiffre importe moins que son existence : « moins d'une seconde, mesuré le 3 juillet » a remplacé « sans doute rapide » dans mon runbook, et c'est toute la différence entre un RTO et un vœu.

Ce que le dump ne contient pas

Restaurer la base ne suffit pas à faire revivre le service, et c'est là que beaucoup de plans de reprise mentent par omission. L'inventaire honnête, pour ce blog :

  • l'index Meilisearch est absent du dump, et c'est très bien : il se reconstruit depuis PostgreSQL (app:meili:reindex). Chez moi, 60 documents poussés en moins d'une seconde, indexation bouclée en moins de cinq. Mesuré en dev ; en prod, la vectorisation Gemini s'ajoute ;
  • les données Redis (sessions, rate limiter) sont volatiles par contrat : les perdre coûte une reconnexion à l'admin, je m'en remettrai ;
  • les uploads vivent dans un volume à part : le dump ne les couvrira jamais, ils doivent prouver leur propre plan de reprise ;
  • les secrets vivent dans le vault Ansible, qui est sa propre sauvegarde : chiffré, versionné, hors du serveur.

La grille de lecture tient en une phrase : chaque état du système est soit dans le dump, soit reconstructible, soit sauvegardé ailleurs. Ce qui ne rentre dans aucune des trois cases est un trou, et il vaut mieux le découvrir un matin de drill qu'un soir d'incident.

Les pièges rencontrés en vrai

Un restore qui « marche » en apparence peut mentir. Ceux que j'ai croisés :

  • Les rôles. Le dump rejoue des ALTER TABLE ... OWNER TO lecodeestdanslepre. Le Postgres jetable ne connaît pas ce rôle : le drill le crée à vide avant de restaurer, sinon chaque table crache son erreur. Symptôme classique d'un restore joué avec un autre utilisateur que celui du dump.
  • Les extensions. pg_dump --schema=public n'embarque pas les CREATE EXTENSION. Ici, unaccent et pg_trgm doivent préexister dans la cible, le drill les provisionne. Découvrir ça en test coûte deux lignes de script ; le découvrir en incident coûte une nuit.
  • Les erreurs bénignes qui camouflent les autres. Mon restore affiche une erreur, schema "public" already exists, inoffensive. Mais si le drill se contente de compter les erreurs sans les lire, la bénigne d'aujourd'hui masquera la fatale de demain. Le script déduplique le log d'erreurs et l'affiche : on lit.
  • Le worker. Ce blog tourne sous FrankenPHP en worker mode : après un import de base, on redémarre le worker, sinon il garde ses connexions mortes vers l'ancienne base et le site répond dans le vide. Chaque runtime persistant a son équivalent.

Le piège que je n'avais pas vu venir

Mon premier drill du 3 juillet a échoué. Verdict rouge, table manquante. Le réflexe, évidemment : suspecter le backup. Le coupable était ailleurs.

Le drill a été shippé le 17 juin, avec sa liste de tables clés : post, category, series, page, redirect_rule. Le 18 juin, lendemain, j'ai supprimé la fonctionnalité Séries du blog, table comprise. Le drill attendait donc un fossile. Pendant quinze jours, ma seule preuve de restaurabilité était elle-même périmée, et rien ne me l'avait dit : un test qui ne tourne pas ne se plaint jamais.

J'aime beaucoup ce que cet échec raconte : le vérificateur était dans la boîte avec le chat. Le drill se périme exactement comme le backup qu'il surveille, et pour la même raison : le système vivant a continué d'avancer sans lui. Le fix tient en une ligne, retirer la table morte de la liste. La leçon en une phrase : on ne rejoue pas seulement le backup, on rejoue aussi le test du backup.

La règle de rejeu

Reste à décider quand ouvrir la boîte. Ma règle, désormais écrite dans le runbook :

  • une cadence plancher : un drill par trimestre, calé sur rien d'autre que le calendrier ;
  • des déclencheurs événementiels : bump de version majeure de PostgreSQL, veille d'une migration destructive, changement dans la stack de backup, suppression d'une entité (dit le monsieur qui vient de se faire avoir) ;
  • une trace : le chiffre mesuré, daté, consigné au même endroit à chaque fois. Un RTO écrit nulle part redevient théorique en un trimestre.

La checklist

Celle que j'aurais aimé trouver avant de commencer :

  • Le dump tourne sans humain, avec rotation automatique.
  • Une copie chiffrée vit hors du serveur.
  • La clé de chiffrement survit au serveur : vault ou gestionnaire de secrets, jamais un fichier sur l'hôte.
  • Le restore complet tient en une commande et ne touche jamais la prod.
  • Le drill vérifie le contenu (tables clés, volumétrie), pas le code retour.
  • Les erreurs de restore se lisent une par une, même les bénignes.
  • Chaque état hors dump a une réponse écrite : reconstructible, volatil assumé, ou sauvegardé ailleurs.
  • Le RTO est un chiffre daté, pas un adjectif.
  • Le drill est rejoué : cadence fixe, plus chaque changement structurel du schéma.

Le mot de la fin

On mesure volontiers l'effort : le cron est vert, le bucket grossit, la case « sauvegardes » est cochée dans la revue d'infra. Le résultat, lui, un service qui repart, n'est observable qu'en le provoquant. C'est vrai des backups et de tout ce que l'ops écrit pour se rassurer, runbooks, plans de reprise, procédures d'incident : un artefact de sécurité qui n'est jamais exercé pourrit en silence, et son test pourrit avec lui.

Un backup n'existe que restauré. Le mien a existé pour la première fois un vendredi de juillet, deux mois après son premier cron, trois secondes de drill, une table fossile au passage.

Il me reste une moitié de boîte à ouvrir : rejouer le drill depuis la copie B2, passphrase du vault en main, pour chronométrer le vrai chemin du sinistre. Ce chiffre-là ira au prochain rejeu trimestriel. D'ici là, une question : votre dernier restore date de quand ? Pas votre dernier backup. Votre dernier restore.

Une coquille, une erreur dans ce billet ? Signale-la-moi.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques via Google Analytics (GA4) : pages vues, source du trafic, navigateur et interactions clés. Dépose des cookies de mesure, activés seulement avec votre accord (Consent Mode). Sans publicité ciblée, sans Google Signals, sans partage commercial.

Contenus externes

Affiche les GIF animés hébergés par Giphy (CDN aux États-Unis). À l'affichage d'un GIF, votre adresse IP et votre navigateur sont transmis à Giphy. Sans votre accord, les GIF ne s'affichent pas.