Docker Compose es la lingua franca de las aplicaciones multi-contenedor. Cada proyecto de código abierto incluye un docker-compose.yml. Cada desarrollador ha usado uno. Y todo PaaS que ignore Compose obliga a sus usuarios a traducir sus configuraciones existentes a un formato propietario -- un impuesto que genera resentimiento y abandono.
Ya teníamos un sistema de plantillas que podía desplegar aplicaciones multi-servicio desde YAML personalizado. La pregunta era: ¿podíamos aceptar archivos Docker Compose estándar, parsearlos con todas sus idiosincrasias y canalizarlos al mismo pipeline de despliegue? La respuesta fue sí -- pero Compose v3 tenía más casos especiales de lo que esperábamos.
Por qué importa el soporte de Compose
Nuestro sistema de plantillas (Artículo 19) usaba un formato YAML limpio y construido a propósito. Era elegante. También era nuestro. Los usuarios que llegaban a sh0 con proyectos existentes no tenían nuestros archivos de plantilla -- tenían archivos docker-compose.yml. Cientos de ellos, en cientos de repositorios de GitHub, cada uno con convenciones de Compose ligeramente diferentes.
Decirle a estos usuarios "reescribe tu archivo Compose en nuestro formato" habría sido un factor decisivo para el abandono. En su lugar, construimos un parser que entendía Compose v3, un validador que capturaba errores antes del despliegue y un convertidor que transformaba los servicios Compose en la misma representación interna que usaba nuestro motor de plantillas.
Parsing de Compose v3: El campo minado de tipos
La especificación de Docker Compose es engañosamente compleja. Los campos que parecen simples tienen múltiples representaciones válidas. Las variables de entorno pueden ser un mapa o una lista. depends_on puede ser una lista de cadenas o un mapa con claves de condición. Los comandos pueden ser una cadena o un array. Tuvimos que modelar cada variante:
rust#[derive(Debug, Deserialize)]
pub struct ComposeService {
pub image: Option<String>,
pub build: Option<serde_yaml::Value>,
pub ports: Option<Vec<String>>,
pub environment: Option<ComposeEnv>,
pub volumes: Option<Vec<String>>,
pub depends_on: Option<ComposeDependsOn>,
pub command: Option<ComposeCommand>,
pub mem_limit: Option<String>,
pub restart: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ComposeEnv {
Map(HashMap<String, serde_yaml::Value>),
List(Vec<String>),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ComposeDependsOn {
List(Vec<String>),
Map(HashMap<String, DependsOnCondition>),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ComposeCommand {
String(String),
List(Vec<String>),
}El atributo #[serde(untagged)] fue crítico. Le indicaba a Serde que probara cada variante en orden hasta que una coincidiera. Cuando un archivo Compose tenía environment: ["DB_HOST=postgres"], se parseaba como ComposeEnv::List. Cuando tenía environment: { DB_HOST: postgres }, se parseaba como ComposeEnv::Map. Ambas eran sintaxis Compose válidas y ambas necesitaban funcionar.
El campo mem_limit fue otra trampa. Compose acepta valores como 512m, 1g, 256000000. Escribimos un parser que manejaba los tres formatos y los normalizaba a bytes para el parámetro de límite de memoria de Docker.
Validación: capturando lo que Serde no puede
Un parsing YAML exitoso era necesario pero no suficiente. Un archivo Compose podía parsearse limpiamente y aún así ser imposible de desplegar. El validador verificaba cuatro categorías de errores:
Presencia de imagen. Cada servicio necesitaba un campo image o una directiva build. Un servicio sin ninguno era imparseable por Docker, y lo rechazábamos con un mensaje claro en lugar de dejar que Docker devolviera un error críptico.
Referencias de dependencias. Si el servicio web declaraba depends_on: [db], el validador confirmaba que existía un servicio llamado db en el archivo Compose. Los errores tipográficos en nombres de dependencias -- depends_on: [postgre] en lugar de depends_on: [postgres] -- se capturaban antes de crear ningún contenedor.
Dependencias circulares. La misma detección de ciclos basada en DFS de nuestro validador de plantillas se aplicaba aquí. Si el servicio A dependía de B y B dependía de A, el validador rechazaba el archivo con un error listando el ciclo.
Formato de puertos. Los mapeos de puertos de Compose tienen múltiples formatos válidos: "8080:80", "8080:80/tcp", "127.0.0.1:8080:80". El validador parseaba cada formato y rechazaba entradas malformadas como "not-a-port" o "8080:".
Diecinueve tests unitarios cubrían cada ruta de validación. Cada test usaba un archivo Compose deliberadamente malformado y verificaba que se devolviera el mensaje de error correcto.
Conversión: De Compose a representación interna
El convertidor transformaba instancias de ComposeService en structs ResolvedService -- el mismo tipo que consumía nuestro pipeline de despliegue de plantillas. Este fue el puente que permitió a los archivos Compose reutilizar el 100% de la infraestructura de despliegue existente:
rustpub fn convert_to_services(
compose: &ComposeFile,
) -> Result<Vec<ResolvedService>, Vec<String>> {
let mut services = Vec::new();
let mut errors = Vec::new();
for (name, svc) in &compose.services {
let image = match &svc.image {
Some(img) => img.clone(),
None => {
errors.push(format!("Service '{}': no image specified", name));
continue;
}
};
let env = normalize_env(&svc.environment);
let ports = parse_ports(&svc.ports);
let memory_limit = svc.mem_limit.as_deref()
.map(parse_mem_limit)
.transpose()
.map_err(|e| errors.push(e))
.ok()
.flatten();
services.push(ResolvedService {
name: name.clone(),
image,
env,
ports,
volumes: parse_volumes(&svc.volumes),
depends_on: extract_depends_on(&svc.depends_on),
memory_limit,
command: normalize_command(&svc.command),
});
}
if errors.is_empty() { Ok(services) } else { Err(errors) }
}La normalización de variables de entorno era donde las variantes de Compose colapsaban en un formato único. Las entradas ComposeEnv::List como "DB_HOST=postgres" se dividían en el primer =. Las entradas ComposeEnv::Map se iteraban directamente. Ambas producían el mismo HashMap<String, String>.
Detección de stack: despliegues Git Push
El soporte de Compose no se limitaba a la API y el panel. Cuando un usuario hacía push de código a sh0 vía git, el detector de stack del pipeline de compilación verificaba archivos Compose:
rust// Prioridad de detección: Dockerfile > docker-compose.yml > detección de framework
if repo_contains("docker-compose.yml")
|| repo_contains("docker-compose.yaml")
|| repo_contains("compose.yml")
|| repo_contains("compose.yaml")
{
return Stack::DockerCompose {
label: "Docker Compose".to_string(),
default_port: 0,
needs_dockerfile: false,
};
}Cuando el detector de stack identificaba un archivo Compose, el pipeline de despliegue leía su contenido, lo pasaba por el parser, validador y convertidor, y luego ejecutaba el mismo flujo de despliegue multi-servicio. Los usuarios podían hacer git push a un repositorio que contenía solo un docker-compose.yml y sh0 lo desplegaba automáticamente.
La prioridad de detección era importante: un Dockerfile tenía precedencia sobre un archivo Compose. Si un repositorio tenía ambos, el usuario pretendía una compilación personalizada, no un despliegue Compose multi-servicio. Tres tests verificaban la lógica de detección, incluyendo casos especiales con nombres de archivo alternativos.
La API y el CLI
Los endpoints de Compose reflejaban la estructura de la API de plantillas pero aceptaban YAML crudo en lugar de nombres de plantillas:
bash# Validar sin desplegar
sh0 compose validate docker-compose.yml
# Desplegar un archivo Compose
sh0 compose deploy docker-compose.yml --app-name mystack
# Desplegar con sobreescrituras de variables
sh0 compose deploy ./compose.yaml \
--app-name production \
--var DB_PASSWORD=secure123El comando de validación fue deliberadamente separado del de despliegue. Permitía a los usuarios verificar sus archivos Compose en busca de errores antes de comprometerse con un despliegue. La respuesta incluía la lista de servicios detectados, sus imágenes, mapeos de puertos y cualquier advertencia sobre funcionalidades de Compose no soportadas.
El panel añadió un botón "Desplegar Compose" en la página de Aplicaciones. Al hacer clic se abría un modal con un textarea de YAML donde los usuarios podían pegar su archivo Compose, una sección de sobreescrituras de variables y una vista previa de validación que mostraba los servicios parseados antes de que comenzara el despliegue.
Reutilizando el pipeline de plantillas
La decisión arquitectónica más valiosa fue hacer reutilizable el pipeline de despliegue de plantillas. Cuando construimos el sistema de plantillas, los helpers de despliegue -- creación de red, aprovisionamiento de volúmenes, ordenamiento topológico, creación de contenedores, enrutamiento Caddy -- fueron todos implementados como funciones independientes. Para el soporte de Compose, cambiamos su visibilidad de privada a pub(crate):
rust// En handlers/templates.rs -- cambiado de privado a visible en el crate
pub(crate) fn ensure_network(docker: &DockerClient, name: &str) -> Result<()>;
pub(crate) fn create_volumes(docker: &DockerClient, volumes: &[VolumeSpec]) -> Result<()>;
pub(crate) fn topological_sort(services: &[ResolvedService]) -> Vec<Vec<&ResolvedService>>;
pub(crate) fn create_container(docker: &DockerClient, svc: &ResolvedService, ...) -> Result<()>;
pub(crate) fn configure_routing(proxy: &ProxyManager, app: &App, port: u16) -> Result<()>;Siete funciones se hicieron pub(crate). El handler de Compose las llamaba en el mismo orden que el handler de plantillas. Cero lógica de despliegue fue duplicada. Cuando luego corregimos un bug en la creación de contenedores o mejoramos la configuración de enrutamiento Caddy, ambas rutas de código se beneficiaron automáticamente.
Los números
Al final de la sesión, el sistema Compose añadió 19 tests unitarios a la suite, llevando el total a 327. El parser manejaba cada constructo de Compose v3 contra el que probamos: servicios, volúmenes, redes, variables de entorno en ambos formatos, depends_on en ambos formatos, comandos en ambos formatos, límites de memoria en tres formatos y mapeos de puertos en cuatro formatos.
La implementación completa -- parser, validador, convertidor, endpoints API, comandos CLI, interfaz de panel, i18n en cinco idiomas, detección de stack e integración con el pipeline -- se completó en una sola sesión. Compiló limpio, pasó los tests limpio y desplegó su primer archivo Compose en el primer intento.
No porque tuviéramos suerte. Porque el pipeline de plantillas ya había resuelto el despliegue multi-servicio, y el sistema Compose era un traductor, no un segundo motor.
Siguiente en la serie: Motor de respaldos: AES-256-GCM, 13 proveedores de almacenamiento y pesadillas FTP -- cómo construimos respaldos cifrados con almacenamiento conectable, y el bug de FTP IPv6 que nos obligó a escribir nuestro propio cliente.