Back to sh0
sh0

Déploiements bleu-vert : construire un pipeline zéro-downtime en Rust

Le pipeline de déploiement en 8 étapes qui propulse sh0 : clone, analyse, build, déploiement, health check, routage, swap et nettoyage -- avec des swaps de conteneurs bleu-vert et une gestion automatique du disque.

Thales & Claude | March 30, 2026 13 min sh0
EN/ FR/ ES
deploymentblue-greenrustdockerzero-downtimedevopspipeline

Un pipeline de déploiement est une promesse. Il promet que lorsqu'un développeur pousse du code, quelque chose de prévisible se produira : son application sera analysée, construite, testée, routée et mise en production -- ou l'échec sera clair et récupérable. Brisez cette promesse ne serait-ce qu'une fois et la confiance s'évapore.

Nous avons construit le pipeline de déploiement de sh0 comme une seule fonction Rust asynchrone -- environ 350 lignes qui orchestrent huit étapes discrètes du clone git au trafic en production. Le pipeline supporte quatre types de source (dépôt Git, Dockerfile, image Docker, upload de fichier), effectue des swaps de conteneurs bleu-vert avec zéro downtime, et inclut un système de gestion de disque né de l'observation de trop de serveurs à court d'espace.

Voici l'histoire de ces 350 lignes.


Les huit étapes

Chaque déploiement dans sh0 suit la même séquence en huit étapes. Les étapes sont les mêmes quel que soit le type de source ; seules les premières étapes diffèrent dans l'implémentation.

Clone --> Analyse --> Build --> Déploiement --> Health Check --> Routage --> Swap --> Finalisation

Chaque étape met à jour l'enregistrement de déploiement dans la base de données avec son statut et ajoute des lignes de log structurées que le tableau de bord analyse en barre de progression. Le helper append_step_log() écrit des marqueurs comme [STEP 3/6] Building Docker image... que la fonction parseDeploySteps() du frontend récupère automatiquement.

Parcourons chaque étape.

Étape 1 : Clone

Pour les déploiements Git, cela appelle GitRepo::clone_or_pull() dans repos_dir/{app_id}/. Si le dépôt existe déjà localement, il tire les derniers changements plutôt que de cloner à zéro. Pour les déploiements d'images Docker, cette étape est un no-op. Pour les uploads de fichiers, l'archive a déjà été extraite.

Étape 2 : Analyse

La fonction sh0_builder::check() produit un rapport de santé pour le code source. Elle détecte la stack (Node.js, Python, Go, Rust, site statique ou Dockerfile brut), vérifie la présence d'un Dockerfile valide ou en génère un, valide la structure du projet et attribue un score de confiance sur 100.

Si le score tombe en dessous de 60, le déploiement échoue immédiatement. C'est une barrière délibérée : construire une image qui échouera certainement gaspille des minutes de calcul et de l'espace disque. Mieux vaut échouer vite avec un message clair comme « Pas de package.json trouvé et pas de Dockerfile présent » que de laisser Docker build trébucher sur des erreurs.

Étape 3 : Build

C'est là que Docker prend le relais. Le builder construit une image taguée sh0-{app_name}:{commit_short} en utilisant le Dockerfile détecté ou fourni. Les logs de build sont streamés vers l'enregistrement de déploiement en temps réel.

Le format du tag d'image est important pour le système de nettoyage que nous verrons plus tard -- le préfixe du nom d'application nous permet d'identifier et de purger les anciennes images par application.

Étape 4 : Déploiement

Un nouveau conteneur est créé à partir de l'image construite et démarré sur le réseau Docker sh0-net. La configuration du conteneur inclut les variables d'environnement (récupérées depuis la base de données), les limites de ressources et les définitions de health check.

Voici le détail crucial : le nouveau conteneur tourne à côté de l'ancien. Les deux sont actifs simultanément sur le réseau Docker. C'est le « bleu-vert » des déploiements bleu-vert -- le nouveau conteneur (vert) démarre pendant que l'ancien conteneur (bleu) continue de servir le trafic.

Étape 5 : Health Check

Le pipeline attend que le nouveau conteneur devienne sain avant de lui router le trafic. La stratégie de health check dépend de la configuration du conteneur :

rustasync fn wait_for_healthy(
    docker: &DockerClient,
    container_id: &str,
    timeout: Duration,
) -> Result<()> {
    let deadline = Instant::now() + timeout;

    loop {
        if Instant::now() > deadline {
            return Err(DeployError::HealthCheckTimeout);
        }

        let state = docker.inspect_container(container_id).await?;

        match state.health {
            Some(health) if health.status == "healthy" => return Ok(()),
            Some(health) if health.status == "unhealthy" => {
                return Err(DeployError::HealthCheckFailed);
            }
            Some(_) => {
                // En démarrage -- continuer à attendre
                tokio::time::sleep(Duration::from_secs(2)).await;
            }
            None => {
                // Pas de HEALTHCHECK défini -- vérifier qu'il tourne depuis 5s
                if state.running && state.started_at.elapsed() > Duration::from_secs(5) {
                    return Ok(());
                }
                tokio::time::sleep(Duration::from_secs(1)).await;
            }
        }
    }
}

Si le conteneur a une instruction Docker HEALTHCHECK, nous interrogeons jusqu'à ce qu'il rapporte sain (ou timeout après 60 secondes). S'il n'y a pas de health check, nous utilisons une heuristique plus simple : vérifier que le conteneur tourne depuis au moins cinq secondes sans planter. Cela gère le cas courant d'une application mal configurée qui plante immédiatement au démarrage.

Étape 6 : Routage

Une fois le health check passé, nous mettons à jour le reverse proxy Caddy pour pointer vers le nouveau conteneur. Le pipeline inspecte le nouveau conteneur pour son adresse IP sur le réseau sh0-net et appelle proxy.set_app_route() avec l'upstream mis à jour :

rustlet container_info = docker.inspect_container(&new_container_id).await?;
let ip = container_info
    .network_settings
    .networks
    .get("sh0-net")
    .ok_or(DeployError::NoNetworkIp)?
    .ip_address
    .clone();

let route = AppRoute {
    domains: app_domains.clone(),
    upstream: format!("{}:{}", ip, app.port),
};

// Erreur douce -- le déploiement a réussi même si le routage échoue temporairement
match proxy.set_app_route(&app.id, route).await {
    Ok(()) => append_step_log(&pool, deploy_id, "Routes mises à jour"),
    Err(e) => {
        tracing::error!("Mise à jour des routes échouée (non fatale) : {}", e);
        append_step_log(&pool, deploy_id, "Mise à jour des routes échouée -- sera réessayé");
    }
}

Notez la gestion d'erreur douce. Un échec de routage ne fait pas échouer le déploiement. Le conteneur tourne et est sain ; le moniteur de santé finira par ré-appliquer la route. C'était le Correctif 5 de la cascade de fiabilité décrite dans l'article précédent.

Étape 7 : Swap

Maintenant nous décommissionnons l'ancien conteneur. C'est là que les déploiements zéro-downtime deviennent réels :

rustif let Some(old_container_id) = previous_container_id {
    // Donner à l'ancien conteneur une période de grâce de 30 secondes
    docker.stop_container(&old_container_id, Duration::from_secs(30)).await?;
    docker.remove_container(&old_container_id).await?;
}

La période de grâce de 30 secondes est un SIGTERM suivi d'une attente. Cela donne à l'ancien conteneur le temps de terminer le traitement des requêtes en cours, fermer les connexions à la base de données et s'arrêter gracieusement. Après 30 secondes, Docker envoie SIGKILL. Comme Caddy route déjà le nouveau trafic vers le conteneur vert (Étape 6), les seules requêtes qui atteignent le conteneur bleu sont celles qui étaient en cours au moment du changement de route.

Étape 8 : Finalisation

Le pipeline met à jour l'enregistrement de l'application avec le nouvel ID de conteneur et d'image, marque le déploiement comme réussi et nettoie le répertoire de build. La séquence complète -- du clone git au trafic en production -- se termine typiquement en 30 à 90 secondes selon la complexité du build Docker.


La machine à états du statut de déploiement

Un déploiement dans sh0 passe par un ensemble défini d'états :

pending --> cloning --> analyzing --> building --> deploying
    --> health_checking --> routing --> swapping --> succeeded
                                                  \
                                                   --> failed

N'importe quelle étape peut transitionner vers failed. L'état est stocké dans la base de données et exposé via l'API, permettant au tableau de bord d'afficher la progression en temps réel. Chaque transition ajoute aussi une ligne de log structurée, donnant aux utilisateurs une piste d'audit détaillée de ce qui s'est passé et quand.

La machine à états n'est pas juste pour l'interface -- elle pilote aussi le mécanisme de rollback. Quand un utilisateur déclenche un rollback, le système récupère l'image_id du déploiement cible et crée un nouveau déploiement qui réutilise cette image. Le pipeline détecte l'image préexistante et saute les étapes de clone, analyse et build, passant directement au déploiement. Cela signifie que les rollbacks se terminent en secondes plutôt qu'en minutes.


Le système de nettoyage disque

Nous appelons cela la fonctionnalité « anti-Coolify ». Nous avons regardé Coolify (un PaaS open source) remplir les disques de serveurs avec des images Docker orphelines et du cache de build, finissant par faire planter toute la plateforme. Nous avons refusé de livrer le même bug.

Le système de nettoyage a trois couches : vérification proactive, nettoyage périodique et rétention intelligente.

Vérification du disque avant déploiement

Avant chaque déploiement, le pipeline vérifie l'espace disque disponible en utilisant l'appel système statvfs :

rustfn check_disk_space(path: &Path) -> Result<DiskStatus> {
    let stat = nix::sys::statvfs::statvfs(path)?;
    let total = stat.blocks() * stat.fragment_size();
    let available = stat.blocks_available() * stat.fragment_size();
    let usage_pct = ((total - available) as f64 / total as f64) * 100.0;

    if usage_pct > 90.0 {
        Err(DeployError::DiskFull {
            usage: format!("{:.1}%", usage_pct),
        })
    } else {
        if usage_pct > 80.0 {
            tracing::warn!("Utilisation disque à {:.1}% -- approche de la capacité", usage_pct);
        }
        Ok(DiskStatus { usage_pct, available_gb: available as f64 / 1e9 })
    }
}

Au-dessus de 90 % d'utilisation, le déploiement échoue immédiatement avec un message d'erreur clair. Entre 80 % et 90 %, il continue mais enregistre un avertissement. Ce comportement d'échec rapide empêche le scénario catastrophique où un build Docker remplit le dernier gigaoctet d'espace disque et fait tomber tout le serveur.

Nettoyage périodique en arrière-plan

Une tâche tokio en arrière-plan s'exécute toutes les six heures (configurable via --cleanup-interval-hours, ou désactivée avec 0). Elle effectue trois opérations :

  1. Purger les conteneurs arrêtés -- supprime tout conteneur arrêté avec le label sh0-managed
  2. Purger les images pendantes -- supprime les images non taguées et inutilisées laissées par les builds multi-étapes
  3. Purger le cache de build -- vide le cache du builder Docker

Rétention intelligente par application

La pièce la plus sophistiquée est prune_old_app_images(keep_per_app). Elle liste toutes les images avec le préfixe sh0-, les regroupe par nom d'application, les trie par date de création et supprime tout sauf les N images les plus récentes par application (3 par défaut, configurable via --cleanup-keep-images) :

rustpub async fn prune_old_app_images(
    docker: &DockerClient,
    keep_per_app: usize,
) -> Result<PruneReport> {
    let images = docker.list_images_with_prefix("sh0-").await?;

    // Regrouper par nom d'application (sh0-{app_name}:{tag})
    let mut by_app: HashMap<String, Vec<ImageInfo>> = HashMap::new();
    for img in images {
        if let Some(app_name) = img.tag.split(':').next() {
            by_app.entry(app_name.to_string()).or_default().push(img);
        }
    }

    let mut removed = 0;
    for (app, mut imgs) in by_app {
        imgs.sort_by(|a, b| b.created.cmp(&a.created));
        for old in imgs.into_iter().skip(keep_per_app) {
            docker.remove_image(&old.id).await?;
            removed += 1;
        }
    }

    Ok(PruneReport { images_removed: removed })
}

Cela signifie que chaque application conserve ses trois images les plus récentes (pour le rollback) tandis que tout ce qui est plus ancien est automatiquement nettoyé. La convention de nommage sh0-{app_name}:{commit_short} rend le regroupement trivial.


Le mécanisme de rollback

Les rollbacks dans sh0 sont des déploiements déguisés. Quand un utilisateur appuie sur le bouton de rollback, l'API crée un nouvel enregistrement de déploiement pointant vers l'image_id du déploiement cible :

POST /api/v1/deployments/:id/rollback

Cela lance le pipeline standard, qui détecte que l'image Docker existe déjà et saute les étapes clone/analyse/build. Le déploiement reprend à l'Étape 4 : démarrer un nouveau conteneur à partir de l'image existante, exécuter les health checks, mettre à jour les routes, remplacer le conteneur actuel. Un rollback se termine typiquement en moins de dix secondes.

La beauté de cette approche est que les rollbacks utilisent exactement le même chemin de code que les déploiements frais. Il n'y a pas de « logique de rollback » séparée à maintenir et pas de cas limites où les rollbacks se comportent différemment des déploiements. Le pipeline est le pipeline.


Variantes du pipeline

Le pipeline en huit étapes s'adapte à quatre types de source de déploiement :

Type de sourceÉtapesNotes
Dépôt Git6 étapesClone, analyse, build, déploiement, health check, routage
Dockerfile5 étapesBuild, déploiement, health check, routage, complet
Image Docker4 étapesPull, déploiement, health check, routage
Upload de fichier5 étapesAnalyse, build, déploiement, health check, routage

Chaque variante écrit ses propres marqueurs d'étape ([STEP 1/6], [STEP 1/4], etc.) pour que la barre de progression du tableau de bord s'adapte correctement. La logique centrale -- déploiement, health check, routage, swap -- est partagée entre toutes les variantes.


Concurrence et gestion d'erreurs

Les déploiements tournent comme des tâches tokio spawned, pas en ligne avec la requête API. Quand un utilisateur déclenche un déploiement (ou qu'un webhook se déclenche), le endpoint API crée l'enregistrement de déploiement, met son statut à pending et spawn le pipeline :

rusttokio::spawn(async move {
    if let Err(e) = run_pipeline(state, app_id, deploy_id).await {
        tracing::error!("Déploiement {} échoué : {}", deploy_id, e);
        // Mettre à jour le statut du déploiement en échec avec le message d'erreur
        update_deploy_status(&pool, deploy_id, "failed", &e.to_string()).await;
    }
});

L'API retourne immédiatement avec l'ID de déploiement. Le tableau de bord interroge pour les mises à jour de statut, affichant la barre de progression à partir des marqueurs d'étape dans le log de build.

Cette architecture signifie que plusieurs déploiements peuvent tourner en parallèle pour différentes applications. Le RwLock sur l'état des routes du proxy s'assure qu'ils ne corrompent pas le routage de l'autre, et le client Docker gère nativement les opérations concurrentes sur les conteneurs.


Leçons apprises

Le pipeline devrait être le seul chemin de code. Les rollbacks, les déploiements par webhook, les déploiements manuels et les déploiements déclenchés par API passent tous par la même fonction. Cela élimine toute une classe de bugs où « le rollback fonctionne différemment » ou « les webhooks sautent le health check ».

Échouer vite sur l'espace disque. La vérification statvfs ajoute une latence négligeable mais empêche le mode de défaillance le plus courant des PaaS : un disque plein. Chaque plateforme auto-hébergée finit par manquer d'espace. La question est de savoir si elle échoue gracieusement ou catastrophiquement.

Conserver N images, pas N jours. La rétention basée sur le temps (ex : « supprimer les images de plus de 7 jours ») est imprévisible -- une application rarement déployée pourrait voir sa seule image fonctionnelle supprimée. La rétention par comptage (« garder les 3 plus récentes par application ») garantit la capacité de rollback quelle que soit la fréquence de déploiement.


Ce qui vient ensuite

Le pipeline de déploiement ronronnait. Le proxy routait le trafic. Tout fonctionnait -- pendant environ cinq minutes à chaque fois. Puis Caddy se figeait, le moniteur de santé le tuait, les routes étaient ré-appliquées, et cinq minutes plus tard il se figeait à nouveau. Le prochain article raconte l'histoire du bug de 16 Ko : un deadlock classique de pipe Unix caché dans notre base de code Rust moderne.

Ceci est la Partie 6 de la série « Comment nous avons construit sh0.dev ». sh0 est une plateforme PaaS construite entièrement par un CEO à Abidjan et un CTO IA, avec zéro ingénieur humain.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles