Back to sh0
sh0

Le moteur de backup qui n'a jamais sauvegardé

Nous avons construit un moteur de backup complet avec 13 fournisseurs de stockage et du chiffrement AES-256. Puis nous avons cliqué sur « Sauvegarder » et rien ne s'est passé. Voici tout ce qui était cassé.

Thales & Claude | March 30, 2026 10 min sh0
EN/ FR/ ES
backuprusttokiodockerpostgresdebuggingarchitecturedevops

Nous avions un magnifique moteur de backup. Chiffrement AES-256-GCM. 13 fournisseurs de stockage. Compression par blocs. Un pipeline de restauration. Un planificateur avec des expressions cron. Le tableau de bord disposait d'une page de backups avec des modales, un composant CronBuilder, la configuration des fournisseurs de stockage et des assistants en trois étapes pour le déclenchement et la planification des sauvegardes.

Puis un utilisateur a cliqué sur « Sauvegarder maintenant ». Le bouton a tourné. Un toast a affiché « Backup déclenché ». Le backup est apparu dans la liste d'historique avec un badge jaune « en attente ».

Et il est resté en attente. Pour toujours.

Le moteur de backup existait. L'infrastructure de planification existait. L'interface existait. Mais personne n'avait jamais relié le déclencheur au moteur. Le handler de l'API trigger_backup créait un enregistrement en base de données avec status: "pending" et retournait 202 Accepted. Il n'appelait jamais BackupEngine::create_backup(). L'enregistrement restait dans SQLite, en attente, jusqu'à ce que quelqu'un s'en aperçoive.

Voici l'histoire de tout ce qui était cassé, et comment nous avons tout corrigé en une seule session.

Bug 1 : Le handler qui ne faisait rien

Voici à quoi ressemblait le handler avant la correction :

rustpub async fn trigger_backup(
    State(state): State<AppState>,
    auth: AuthUser,
    Json(body): Json<TriggerBackupRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
    let backup = Backup {
        id: uuid::Uuid::new_v4().to_string(),
        source_type: body.source_type,
        source_id: body.source_id,
        status: "pending".to_string(),
        // ...
    };

    backup.insert(&pool)?;

    Ok((StatusCode::ACCEPTED, Json(response)))
}

C'est la fonction entière. Elle valide la requête, insère une ligne dans la table backups et retourne. Le BackupEngine -- le composant qui exécute réellement pg_dump, compresse, chiffre et stocke le résultat -- n'est jamais invoqué.

La correction a consisté à ajouter execute_existing_backup() au BackupEngine : une méthode qui prend un enregistrement de backup déjà inséré et exécute le pipeline complet, mettant à jour le statut de « pending » à « running » puis à « completed » (ou « failed »). Le handler lance maintenant cette tâche en arrière-plan :

rustlet engine = state.backup_engine.clone();
let pool = state.pool.clone();

tokio::spawn(async move {
    if let Err(e) = engine.execute_existing_backup(&backup_id, config).await {
        tracing::error!(backup_id = %backup_id, error = %e, "Triggered backup failed");
    }
});

L'API retourne toujours 202 Accepted immédiatement -- le backup s'exécute de manière asynchrone. Le client peut interroger le statut du backup pour suivre la progression.

Bug 2 : Le planificateur qui n'a jamais été lancé

La struct BackupScheduler existait. Elle avait une méthode tick() qui interrogeait les planifications actives, vérifiait si leur next_run était dépassé et déclenchait des backups. Le planificateur avait des tests unitaires. La garde de traitement empêchait les exécutions concurrentes. La normalisation cron gérait les expressions à 5, 6 et 7 champs.

Mais BackupScheduler n'a jamais été instancié. Dans main.rs, le CronScheduler (pour les tâches cron comme les webhooks et les déploiements) était lancé comme tâche en arrière-plan. Le BackupScheduler ne l'était pas. Personne n'avait ajouté le bloc de lancement.

rust// Ceci existait pour les tâches cron :
let cron_handle = tokio::spawn(async move {
    let mut interval = tokio::time::interval(Duration::from_secs(60));
    loop {
        interval.tick().await;
        scheduler.tick(&pool, &docker).await;
    }
});

// Ceci N'EXISTAIT PAS pour les backups.

La correction a consisté à ajouter un bloc identique après le lancement du CronScheduler, créant un BackupScheduler avec sa propre instance de BackupEngine et s'exécutant toutes les 60 secondes.

Bug 3 : Les backups de volumes accédaient au système de fichiers hôte

Lorsqu'un utilisateur déclenchait un backup de volume, le moteur essayait de créer une archive tar du chemin du volume. La fonction backup_volume() appelait tar::Builder::append_dir_all(".", path) sur le système de fichiers de l'hôte.

Mais les volumes Docker ne sont pas accessibles comme des chemins hôtes. Le chemin /var/lib/postgresql/data existe à l'intérieur du conteneur, pas sur l'hôte macOS ou Linux. La fonction échouait avec « Volume path does not exist ».

La correction a consisté à utiliser l'API d'archive de Docker :

rust// Avant : tar sur le système de fichiers hôte (cassé)
backup_volume(Path::new(&path))

// Après : API d'archive Docker (correct)
docker.copy_from_container(container_id, path).await

L'endpoint GET /containers/{id}/archive de Docker retourne une archive tar de n'importe quel chemin à l'intérieur du conteneur. C'est sûr pour les données binaires (contrairement à exec_in_container qui parse stdout en UTF-8 et corrompt les données binaires) et conçu spécifiquement pour ce cas d'utilisation.

Nous avons ajouté copy_from_container() au client Docker ainsi qu'un copy_to_container() correspondant pour la restauration. Le backup de volume fonctionne maintenant pour n'importe quel conteneur, quel que soit le système de fichiers utilisé en interne par le volume.

Bug 4 : pg_dump essayait de dumper « flin-postgres »

C'était le bug le plus subtil. Lors du backup d'une application de type base de données (comme une instance PostgreSQL déployée via un template), le handler utilisait le nom de l'application comme nom de base de données :

rustBackupSource::Database {
    db_name: app.name,  // "flin-postgres" -- le nom de l'app, pas la base !
    // ...
}

La véritable base de données à l'intérieur du conteneur s'appelle selon ce que POSTGRES_DB a été défini dans le template -- typiquement « app », « 0cron » ou « postgres ». Le nom d'application « flin-postgres » n'est qu'un label lisible dans le tableau de bord.

Le résultat : pg_dump -U postgres flin-postgres échoue avec FATAL: database "flin-postgres" does not exist.

La correction a consisté à lire les variables d'environnement chiffrées de l'application depuis la base de données, les déchiffrer avec la clé maître et extraire les vrais identifiants :

rustlet env_vars = EnvVar::list_by_app_id(&pool, &app.id)?;
let env_map = decrypt_env_map(&master_key, &env_vars);

let (db_name, db_user, db_password) = match engine.as_str() {
    "postgres" | "postgresql" => {
        let name = env_map.get("POSTGRES_DB")
            .cloned()
            .unwrap_or_else(|| "postgres".into());
        let user = env_map.get("POSTGRES_USER")
            .cloned()
            .unwrap_or_else(|| "postgres".into());
        let pass = env_map.get("POSTGRES_PASSWORD").cloned();
        (name, user, pass)
    }
    "mysql" | "mariadb" => {
        let name = env_map.get("MYSQL_DATABASE")
            .cloned()
            .unwrap_or_else(|| "app".into());
        // ...
    }
    // mongodb, redis, etc.
};

Le handler d'identifiants du tableau de bord avait déjà résolu ce problème -- l'endpoint get_db_credentials lit POSTGRES_DB depuis les variables d'environnement pour afficher la carte « Database Credentials ». Il suffisait de réutiliser le même pattern dans le handler de backup.

Bug 5 : Le chemin du volume par défaut était « /data »

Lorsque le planificateur construisait un BackupConfig pour un backup de volume, il consultait les montages de l'application pour trouver le chemin du volume. Si aucun montage n'était trouvé en base de données (ce qui arrive quand le flux de déploiement par template ne persiste pas les enregistrements de montage), il se rabattait sur /data.

PostgreSQL stocke ses données dans /var/lib/postgresql/data. MySQL utilise /var/lib/mysql. MongoDB utilise /data/db. Redis utilise /data. Le fallback codé en dur /data ne fonctionnait que pour Redis.

La correction était un défaut adapté à chaque stack :

rustfn default_volume_path(stack: &str) -> &'static str {
    match stack {
        "postgres" | "postgresql" | "timescaledb" | "pgvector"
            => "/var/lib/postgresql/data",
        "mysql" | "mariadb" | "tidb" => "/var/lib/mysql",
        "mongodb" | "mongo" => "/data/db",
        "redis" | "dragonfly" | "keydb" | "valkey" => "/data",
        "clickhouse" => "/var/lib/clickhouse",
        "cassandra" | "scylladb" => "/var/lib/cassandra",
        "influxdb" => "/var/lib/influxdb2",
        _ => "/data",
    }
}

Cela associe chaque moteur de base de données à son répertoire de données standard. Quand ni schedule.path ni les enregistrements de montage ne sont disponibles, le système sait où PostgreSQL, MySQL et MongoDB stockent leurs données par convention.

Bug 6 : « 0 bases de données disponibles »

La page de backup affichait « 0 databases available » même quand l'utilisateur avait une instance PostgreSQL en cours d'exécution. La raison : les applications de base de données déployées via des templates sont stockées dans la table apps, pas dans la table databases. La page de backup n'interrogeait que databasesApi.list(), qui retournait les enregistrements formels de Database. Les bases de données déployées par template étaient invisibles.

La correction a consisté à détecter les applications de type base de données par leur champ stack et à les afficher aux côtés des bases de données formelles :

typescriptconst DUMPABLE_STACKS = new Set([
    'postgres', 'mysql', 'mariadb', 'mongodb', 'redis',
    'timescaledb', 'pgvector'
]);

const dbApps = $derived(
    apps.filter((a) => a.stack && DUMPABLE_STACKS.has(a.stack.toLowerCase()))
);

const totalDbSources = $derived(databases.length + dbApps.length);

Le backend avait également besoin d'un fallback : quand source_type est « database » mais que Database::find_by_id échoue, essayer App::find_by_id et déduire le moteur du champ stack de l'application.

Ce que la session a produit

En une seule session, nous avons livré :

CorrectionImpact
Le handler de déclenchement appelle le moteurLes backups s'exécutent réellement
BackupScheduler lancéLes backups planifiés se déclenchent
API d'archive Docker pour les volumesLes backups de volumes fonctionnent dans les conteneurs
Recherche des variables d'environnement pour db_namepg_dump utilise le vrai nom de base de données
Chemins par défaut adaptés à la stackChemins de volumes corrects par moteur
Détection des apps de type base de données« 0 databases » affiche le vrai nombre
Endpoint de téléchargementLes utilisateurs peuvent télécharger les backups terminés
Modale de confirmation de suppressionPlus de suppressions accidentelles
Interface en cartes pour les planificationsPlanifications lisibles en un coup d'oeil
Modale d'édition de planificationModifier cron/rétention/destination
Confirmation « Exécuter maintenant »Prévenir les déclenchements accidentels

Onze corrections. Le moteur de backup est passé de « semble complet en revue de code » à « fonctionne réellement de bout en bout ».

La leçon

Un moteur de backup qui n'a jamais été déclenché est pire que pas de moteur de backup du tout. Il crée une fausse confiance. Le tableau de bord affichait un bouton « Sauvegarder maintenant ». Le formulaire de planification acceptait des expressions cron. La page des fournisseurs de stockage permettait de configurer des buckets S3. Tout avait l'air de fonctionner. La seule pièce manquante était la connexion entre le bouton et le moteur -- un appel tokio::spawn que personne n'avait écrit.

C'est un pattern que nous observons dans chaque système complexe : le code de liaison est invisible dans les diagrammes d'architecture mais essentiel en production. Les étapes du pipeline étaient individuellement correctes. Le handler de l'API était correct (il retournait 202). Le planificateur était correct (il avait des tests). Mais l'intégration -- la seule ligne qui appelle engine.execute_existing_backup() -- était manquante.

Des tests d'intégration auraient détecté cela. Un seul test de bout en bout qui déclenche un backup et vérifie que le statut final est « completed » aurait échoué immédiatement. Nous avions des tests unitaires pour la garde de traitement du planificateur et la protection contre la traversée de chemins de l'archiveur de volumes. Nous n'avions pas de test qui cliquait sur le bouton et vérifiait si un fichier apparaissait sur le disque.

La méthodologie d'audit (construire, auditer, auditer, approuver) a détecté les problèmes d'architecture et de sécurité. Mais les auditeurs lisent du code, pas du comportement en exécution. Le handler semblait raisonnable -- il créait un enregistrement et retournait 202. L'auditeur n'avait aucune raison de chercher un tokio::spawn manquant dans une fonction qui semblait complète.

La vraie correction n'est pas seulement le code. C'est l'ajout de tests d'intégration qui vérifient le flux de bout en bout : déclencher un backup, attendre la fin, vérifier que le fichier existe, le télécharger et confirmer que le contenu correspond. Ces tests sont les prochains sur la liste.

Prochain dans la série : Quand pg_dump ne trouve pas votre base -- comment les bases de données déployées par template stockent leurs identifiants, pourquoi app.name n'est pas db_name, et le pipeline de déchiffrement des variables d'environnement qui connecte le moteur de backup au système d'identifiants.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles