Back to sh0
sh0

Despliegues bleu-vert: construir un pipeline de cero downtime en Rust

El pipeline de despliegue en 8 pasos que impulsa sh0: clonar, analizar, construir, desplegar, health check, enrutar, intercambiar y limpiar -- con intercambios de contenedores bleu-vert y gestion automatica de disco.

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

Un pipeline de despliegue es una promesa. Promete que cuando un desarrollador suba codigo, algo predecible sucedera: su app sera analizada, construida, probada, enrutada y puesta en produccion -- o el fallo sera claro y recuperable. Rompe esa promesa una sola vez y la confianza se evapora.

Construimos el pipeline de despliegue de sh0 como una unica funcion asincrona de Rust -- aproximadamente 350 lineas que orquestan ocho pasos discretos desde el git clone hasta el trafico en vivo. El pipeline soporta cuatro tipos de fuente (repositorio Git, Dockerfile, imagen Docker, subida de archivos), realiza intercambios de contenedores bleu-vert con cero downtime e incluye un sistema de gestion de disco nacido de ver demasiados servidores quedarse sin espacio.

Esta es la historia de esas 350 lineas.


Los ocho pasos

Cada despliegue en sh0 fluye a traves de la misma secuencia de ocho pasos. Los pasos son los mismos independientemente del tipo de fuente; solo los primeros pasos difieren en implementacion.

Clonar --> Analizar --> Construir --> Desplegar --> Health Check --> Enrutar --> Intercambiar --> Finalizar

Cada paso actualiza el registro de despliegue en la base de datos con su estado y anade lineas de log estructuradas que el dashboard parsea en una barra de progreso. El helper append_step_log() escribe marcadores como [STEP 3/6] Building Docker image... que la funcion parseDeploySteps() del frontend recoge automaticamente.

Recorramos cada paso.

Paso 1: Clonar

Para despliegues Git, esto llama a GitRepo::clone_or_pull() en repos_dir/{app_id}/. Si el repositorio ya existe localmente, hace pull de los ultimos cambios en lugar de clonar desde cero. Para despliegues de imagen Docker, este paso es un no-op. Para subidas de archivos, el archivo ya ha sido extraido.

Paso 2: Analizar

La funcion sh0_builder::check() produce un informe de salud para el codigo fuente. Detecta el stack (Node.js, Python, Go, Rust, sitio estatico o Dockerfile crudo), verifica si hay un Dockerfile valido o genera uno, valida la estructura del proyecto y asigna una puntuacion de confianza sobre 100.

Si la puntuacion cae por debajo de 60, el despliegue falla inmediatamente. Esta es una compuerta deliberada: construir una imagen que ciertamente fallara desperdicia minutos de computo y espacio en disco. Es mejor fallar rapido con un mensaje claro como "No se encontro package.json y no hay Dockerfile presente" que dejar que el build de Docker tropiece con errores.

Paso 3: Construir

Aqui es donde Docker toma el control. El builder construye una imagen etiquetada sh0-{app_name}:{commit_short} usando el Dockerfile detectado o proporcionado. Los logs del build se transmiten al registro de despliegue en tiempo real.

El formato de la etiqueta de imagen importa para el sistema de limpieza que discutiremos despues -- el prefijo del nombre de la app nos permite identificar y podar imagenes antiguas por app.

Paso 4: Desplegar

Se crea un nuevo contenedor a partir de la imagen construida y se inicia en la red Docker sh0-net. La configuracion del contenedor incluye variables de entorno (obtenidas de la base de datos), limites de recursos y definiciones de health check.

Aqui esta el detalle critico: el nuevo contenedor se ejecuta junto al antiguo. Ambos estan activos en la red Docker simultaneamente. Este es el "bleu-vert" de los despliegues bleu-vert -- el nuevo contenedor (verde) arranca mientras el antiguo contenedor (azul) continua sirviendo trafico.

Paso 5: Health Check

El pipeline espera a que el nuevo contenedor se vuelva saludable antes de enrutar trafico hacia el. La estrategia de health check depende de la configuracion del contenedor:

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(_) => {
                // Arrancando -- seguir esperando
                tokio::time::sleep(Duration::from_secs(2)).await;
            }
            None => {
                // Sin HEALTHCHECK definido -- verificar ejecutandose por 5s
                if state.running && state.started_at.elapsed() > Duration::from_secs(5) {
                    return Ok(());
                }
                tokio::time::sleep(Duration::from_secs(1)).await;
            }
        }
    }
}

Si el contenedor tiene una instruccion HEALTHCHECK de Docker, sondeamos hasta que reporte saludable (o timeout despues de 60 segundos). Si no hay health check, usamos una heuristica mas simple: verificar que el contenedor ha estado ejecutandose al menos cinco segundos sin caerse. Esto maneja el caso comun de una app mal configurada que se cae inmediatamente al arrancar.

Paso 6: Enrutar

Una vez que el health check pasa, actualizamos el reverse proxy Caddy para que apunte al nuevo contenedor. El pipeline inspecciona el nuevo contenedor buscando su direccion IP en la red sh0-net y llama a proxy.set_app_route() con el upstream actualizado:

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),
};

// Error suave -- el despliegue fue exitoso incluso si el enrutamiento falla temporalmente
match proxy.set_app_route(&app.id, route).await {
    Ok(()) => append_step_log(&pool, deploy_id, "Rutas actualizadas"),
    Err(e) => {
        tracing::error!("Actualizacion de ruta fallo (no fatal): {}", e);
        append_step_log(&pool, deploy_id, "Actualizacion de ruta fallo -- se reintentara");
    }
}

Observa el manejo de errores suave. Un fallo de enrutamiento no falla el despliegue. El contenedor esta ejecutandose y saludable; el monitor de salud eventualmente re-aplicara la ruta. Esta fue la Correccion 5 de la cascada de fiabilidad descrita en el articulo anterior.

Paso 7: Intercambiar

Ahora decomisionamos el contenedor antiguo. Aqui es donde los despliegues de cero downtime se hacen reales:

rustif let Some(old_container_id) = previous_container_id {
    // Dar al contenedor antiguo un periodo de gracia de 30 segundos
    docker.stop_container(&old_container_id, Duration::from_secs(30)).await?;
    docker.remove_container(&old_container_id).await?;
}

El periodo de gracia de 30 segundos es un SIGTERM seguido de una espera. Esto le da al contenedor antiguo tiempo para terminar de procesar peticiones en vuelo, cerrar conexiones de base de datos y apagarse con gracia. Despues de 30 segundos, Docker envia SIGKILL. Como Caddy ya enruta el nuevo trafico al contenedor verde (Paso 6), las unicas peticiones que llegan al contenedor azul son las que estaban en vuelo cuando la ruta cambio.

Paso 8: Finalizar

El pipeline actualiza el registro de la app con el nuevo ID de contenedor e ID de imagen, marca el despliegue como exitoso y limpia el directorio de build. La secuencia completa -- desde el git clone hasta el trafico en vivo -- tipicamente se completa en 30 a 90 segundos dependiendo de la complejidad del build de Docker.


La maquina de estados del despliegue

Un despliegue en sh0 se mueve a traves de un conjunto definido de estados:

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

Cualquier paso puede transicionar a failed. El estado se almacena en la base de datos y se expone a traves de la API, para que el dashboard pueda mostrar el progreso en tiempo real. Cada transicion tambien anade una linea de log estructurada, dando a los usuarios un rastro de auditoria detallado de lo que sucedio y cuando.

La maquina de estados no es solo para la UI -- tambien impulsa el mecanismo de rollback. Cuando un usuario dispara un rollback, el sistema busca el image_id del despliegue objetivo y crea un nuevo despliegue que reutiliza esa imagen. El pipeline detecta la imagen pre-existente y salta los pasos de clonar, analizar y construir, yendo directamente al despliegue. Esto significa que los rollbacks se completan en segundos en lugar de minutos.


El sistema de limpieza de disco

Lo llamamos la funcionalidad "anti-Coolify". Vimos a Coolify (un PaaS de codigo abierto) llenar discos de servidores con imagenes Docker huerfanas y cache de build, eventualmente haciendo caer toda la plataforma. Nos negamos a enviar el mismo bug.

El sistema de limpieza tiene tres capas: verificacion proactiva, limpieza periodica y retencion inteligente.

Verificacion de disco pre-despliegue

Antes de cada despliegue, el pipeline verifica el espacio de disco disponible usando la llamada de sistema 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!("Uso de disco al {:.1}% -- acercandose a la capacidad", usage_pct);
        }
        Ok(DiskStatus { usage_pct, available_gb: available as f64 / 1e9 })
    }
}

Por encima del 90% de uso, el despliegue falla inmediatamente con un mensaje de error claro. Entre el 80% y el 90%, procede pero registra una advertencia. Este comportamiento de fallo rapido previene el escenario catastrofico donde un build de Docker llena el ultimo gigabyte de espacio en disco y derrumba todo el servidor.

Limpieza periodica en segundo plano

Una tarea tokio en segundo plano se ejecuta cada seis horas (configurable via --cleanup-interval-hours, o desactivar con 0). Realiza tres operaciones:

  1. Podar contenedores detenidos -- elimina cualquier contenedor detenido con la etiqueta sh0-managed
  2. Podar imagenes colgantes -- elimina imagenes sin etiquetar y sin usar dejadas por builds multi-etapa
  3. Podar cache de build -- limpia la cache del builder de Docker

Retencion inteligente de imagenes por app

La pieza mas sofisticada es prune_old_app_images(keep_per_app). Lista todas las imagenes con el prefijo sh0-, las agrupa por nombre de app, las ordena por fecha de creacion y elimina todo excepto las N imagenes mas recientes por app (por defecto 3, 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?;

    // Agrupar por nombre de app (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 })
}

Esto significa que cada app conserva sus tres imagenes mas recientes (para rollback) mientras todo lo mas antiguo se limpia automaticamente. La convencion de nombres sh0-{app_name}:{commit_short} hace que la agrupacion sea trivial.


El mecanismo de rollback

Los rollbacks en sh0 son despliegues disfrazados. Cuando un usuario pulsa el boton de rollback, la API crea un nuevo registro de despliegue apuntando al image_id del despliegue objetivo:

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

Esto lanza el pipeline estandar, que detecta que la imagen Docker ya existe y salta los pasos de clonar/analizar/construir. El despliegue procede desde el Paso 4: iniciar un nuevo contenedor desde la imagen existente, ejecutar health checks, actualizar rutas, intercambiar el contenedor actual. Un rollback tipicamente se completa en menos de diez segundos.

La belleza de este enfoque es que los rollbacks usan exactamente la misma ruta de codigo que los despliegues nuevos. No hay una "logica de rollback" separada que mantener ni casos extremos donde los rollbacks se comportan diferente a los despliegues. El pipeline es el pipeline.


Variantes del pipeline

El pipeline de ocho pasos se adapta a cuatro tipos de fuente de despliegue:

Tipo de fuentePasosNotas
Repositorio Git6 pasosClonar, analizar, construir, desplegar, health check, enrutar
Dockerfile5 pasosConstruir, desplegar, health check, enrutar, completar
Imagen Docker4 pasosDescargar, desplegar, health check, enrutar
Subida de archivos5 pasosAnalizar, construir, desplegar, health check, enrutar

Cada variante escribe sus propios marcadores de paso ([STEP 1/6], [STEP 1/4], etc.) para que la barra de progreso del dashboard se escale correctamente. La logica central -- desplegar, health check, enrutar, intercambiar -- se comparte entre todas las variantes.


Concurrencia y manejo de errores

Los despliegues se ejecutan como tareas tokio creadas, no en linea con la peticion API. Cuando un usuario dispara un despliegue (o un webhook se activa), el endpoint de la API crea el registro de despliegue, establece su estado a pending y lanza el pipeline:

rusttokio::spawn(async move {
    if let Err(e) = run_pipeline(state, app_id, deploy_id).await {
        tracing::error!("Despliegue {} fallo: {}", deploy_id, e);
        // Actualizar estado del despliegue a fallido con mensaje de error
        update_deploy_status(&pool, deploy_id, "failed", &e.to_string()).await;
    }
});

La API devuelve inmediatamente el ID del despliegue. El dashboard sondea las actualizaciones de estado, renderizando la barra de progreso desde los marcadores de paso en el log del build.

Esta arquitectura significa que multiples despliegues pueden ejecutarse concurrentemente para diferentes apps. El RwLock en el estado de rutas del proxy asegura que no corrompan el enrutamiento del otro, y el cliente Docker maneja operaciones de contenedor concurrentes de forma nativa.


Lecciones aprendidas

El pipeline deberia ser la unica ruta de codigo. Rollbacks, despliegues por webhook, despliegues manuales y despliegues disparados por API fluyen todos a traves de la misma funcion. Esto elimina toda una clase de bugs donde "el rollback funciona diferente" o "los webhooks se saltan el health check".

Fallar rapido en espacio de disco. La verificacion statvfs anade latencia despreciable pero previene el modo de fallo mas comun de un PaaS: un disco lleno. Toda plataforma auto-hospedada eventualmente se queda sin espacio. La pregunta es si falla con gracia o catastroficamente.

Conservar N imagenes, no N dias. La retencion basada en tiempo (por ejemplo, "eliminar imagenes de mas de 7 dias") es impredecible -- una app raramente desplegada podria ver su unica imagen funcional eliminada. La retencion basada en cantidad ("conservar las 3 mas recientes por app") garantiza la capacidad de rollback independientemente de la frecuencia de despliegue.


Lo que viene despues

El pipeline de despliegue estaba funcionando. El proxy estaba enrutando trafico. Todo funcionaba -- durante unos cinco minutos seguidos. Luego Caddy se congelaba, el monitor de salud lo mataba, las rutas se re-aplicaban, y cinco minutos despues se congelaba de nuevo. El siguiente articulo cuenta la historia del bug de 16KB: un clasico deadlock de pipe Unix escondido en nuestro moderno codebase Rust.

Esta es la Parte 6 de la serie "Como construimos sh0.dev". sh0 es una plataforma PaaS construida enteramente por un CEO en Abidjan y un CTO de IA, sin ningun ingeniero humano.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles