Back to sh0
sh0

Escribir un cliente Docker Engine desde cero en Rust

Por que escribimos un cliente personalizado de la API Docker Engine usando hyper y sockets Unix en lugar de invocar el CLI de Docker, y el parseo de flujos multiplexados que lo hizo funcionar.

Thales & Claude | March 30, 2026 12 min sh0
EN/ FR/ ES
rustdockerunix-sockethypersystems-programmingcontainers

Hay un momento en cada proyecto de programacion de sistemas en el que te das cuenta de que el camino facil no existe. Para sh0.dev, ese momento llego cuando intentamos comunicarnos con Docker.

El Docker Engine expone una API REST. Acepta JSON, devuelve JSON y se comporta como cualquier otro servicio HTTP -- con una diferencia critica. Escucha en un socket de dominio Unix en /var/run/docker.sock, no en un puerto TCP. Este unico detalle arquitectonico nos obligo a escribir nuestro propio cliente Docker desde cero, y el resultado se convirtio en una de las piezas de codigo mas satisfactorias de todo el codebase de sh0.

Por que no simplemente invocar el CLI?

El enfoque obvio: llamar al CLI de docker desde Rust usando std::process::Command. Ejecutar docker ps, parsear la salida, listo.

Este enfoque es una trampa.

El CLI de Docker esta disenado para humanos. Su formato de salida cambia entre versiones. Su modo de salida JSON (--format '{{json .}}') es inconsistente entre comandos. El manejo de errores se convierte en parseo de cadenas de texto. Y cada invocacion del CLI genera un nuevo proceso, se conecta al daemon, se autentica, ejecuta el comando, serializa la salida y termina. Cuando gestionas docenas de contenedores, descargas imagenes, transmites logs y recopilas estadisticas en tiempo real, la sobrecarga se acumula.

Mas fundamentalmente, invocar el CLI significa que tu PaaS depende de que el CLI de Docker este instalado, sea la version correcta y este en el PATH. sh0 es un unico binario. No queremos decirle a los usuarios "tambien instala Docker CLI version 24.0.7 o posterior".

La API del Docker Engine es la interfaz correcta. Tiene versionado, es estable, esta documentada y devuelve JSON estructurado. La unica pregunta era como llamarla a traves de un socket Unix.

Por que no reqwest?

El cliente HTTP por defecto del ecosistema Rust es reqwest. Es excelente para llamar APIs web sobre TCP. Pero no soporta sockets de dominio Unix. No hay opcion de configuracion, ni feature flag, ni solucion alternativa. reqwest usa hyper internamente, y su capa de conexion esta codificada para TCP.

Podriamos haber usado el crate bollard, una libreria de cliente Docker para Rust. Pero bollard trae su propia capa de abstraccion, su propio sistema de tipos, su propia opinion sobre como deberia funcionar la gestion de contenedores. Cuando construyes un PaaS, necesitas control preciso sobre cada llamada a la API de Docker -- timeouts, streaming, manejo de errores, logica de reintentos. Las abstracciones de otra libreria se convierten en restricciones.

Asi que bajamos un nivel mas: hyper 1.x, la implementacion HTTP sobre la que reqwest esta construido, con un conector personalizado que habla sockets Unix.

El UnixConnector: 40 lineas que hicieron que todo funcionara

Toda la integracion con sockets Unix es una unica struct que implementa tower::Service<Uri>. Aqui esta el nucleo:

rustuse hyper::Uri;
use hyper_util::rt::TokioIo;
use tokio::net::UnixStream;
use tower::Service;
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

#[derive(Clone)]
pub struct UnixConnector {
    path: String,
}

impl UnixConnector {
    pub fn new(path: impl Into<String>) -> Self {
        Self { path: path.into() }
    }
}

impl Service<Uri> for UnixConnector {
    type Response = TokioIo<UnixStream>;
    type Error = std::io::Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        Poll::Ready(Ok(()))
    }

    fn call(&mut self, _uri: Uri) -> Self::Future {
        let path = self.path.clone();
        Box::pin(async move {
            let stream = UnixStream::connect(&path).await?;
            Ok(TokioIo::new(stream))
        })
    }
}

Ese es el conector completo. Ignora la URI (porque no hay resolucion DNS ni puerto con el que lidiar -- siempre nos conectamos al mismo path del socket) y devuelve un UnixStream de tokio envuelto en el adaptador TokioIo de hyper.

El DockerClient luego usa este conector con el pool de conexiones de hyper-util:

rustpub struct DockerClient {
    client: Client<UnixConnector, Full<Bytes>>,
    base: String,
}

impl DockerClient {
    pub fn new() -> Self {
        let connector = UnixConnector::new("/var/run/docker.sock");
        let client = Client::builder(TokioExecutor::new())
            .pool_idle_timeout(Duration::from_secs(30))
            .build(connector);

        Self {
            client,
            base: "http://localhost/v1.44".to_string(),
        }
    }
}

La URL base usa http://localhost como host ficticio -- el enrutamiento real ocurre a traves del socket Unix, por lo que el host es irrelevante. El sufijo /v1.44 nos ancla a la version 1.44 de la API del Docker Engine, asegurando un comportamiento consistente independientemente de la version de Docker instalada en el host.

Los helpers HTTP internos

Cada llamada a la API de Docker pasa a traves de un pequeno conjunto de metodos internos en DockerClient:

rustimpl DockerClient {
    async fn get(&self, path: &str) -> Result<Bytes, DockerError> {
        let uri = format!("{}{}", self.base, path).parse::<Uri>()?;
        let req = Request::builder()
            .method(Method::GET)
            .uri(uri)
            .header("Host", "localhost")
            .body(Full::new(Bytes::new()))?;

        let resp = self.client.request(req).await?;
        let status = resp.status();
        let body = resp.into_body().collect().await?.to_bytes();

        if !status.is_success() {
            return Err(DockerError::Api {
                status: status.as_u16(),
                message: String::from_utf8_lossy(&body).to_string(),
            });
        }
        Ok(body)
    }

    async fn post<T: Serialize>(&self, path: &str, body: &T) -> Result<Bytes, DockerError> {
        let json = serde_json::to_vec(body)?;
        let uri = format!("{}{}", self.base, path).parse::<Uri>()?;
        let req = Request::builder()
            .method(Method::POST)
            .uri(uri)
            .header("Host", "localhost")
            .header("Content-Type", "application/json")
            .body(Full::new(Bytes::from(json)))?;

        let resp = self.client.request(req).await?;
        // ... mismo patron de verificacion de estado
    }
}

Simple, explicito, sin magia. Cada peticion incluye el header Host (requerido por HTTP/1.1), y cada respuesta verifica el codigo de estado antes de devolver el cuerpo. El tipo de error lleva tanto el estado HTTP como el mensaje de error del daemon Docker, para que los llamadores obtengan diagnosticos accionables.

Parseo de flujos multiplexados: la parte dificil

La API de Docker tiene una complejidad sutil que atrapa a todos. Cuando solicitas logs de contenedores o salida de exec, el cuerpo de la respuesta no es texto plano. Es un flujo multiplexado donde stdout y stderr estan intercalados, cada fragmento precedido por un header de 8 bytes:

[stream_type: 1 byte] [0x00: 3 bytes] [size: 4 bytes big-endian] [payload: size bytes]

El tipo de flujo 1 es stdout. El tipo de flujo 2 es stderr. Si intentas leer esto como texto plano, obtienes basura binaria mezclada con la salida de tus logs.

La logica de parseo maneja esto frame por frame:

rustpub fn parse_multiplexed_stream(raw: &[u8]) -> (String, String) {
    let mut stdout = String::new();
    let mut stderr = String::new();
    let mut pos = 0;

    while pos + 8 <= raw.len() {
        let stream_type = raw[pos];
        let size = u32::from_be_bytes([
            raw[pos + 4],
            raw[pos + 5],
            raw[pos + 6],
            raw[pos + 7],
        ]) as usize;

        pos += 8; // saltar header

        if pos + size > raw.len() {
            break; // frame incompleto
        }

        let payload = String::from_utf8_lossy(&raw[pos..pos + size]);
        match stream_type {
            1 => stdout.push_str(&payload),
            2 => stderr.push_str(&payload),
            _ => {} // ignorar otros tipos de flujo
        }

        pos += size;
    }

    (stdout, stderr)
}

Esta funcion es pura -- sin I/O, sin async, sin estado. Toma un slice de bytes y devuelve dos cadenas de texto. Eso la hizo trivialmente testeable:

rust#[test]
fn test_parse_multiplexed_stdout_stderr() {
    let mut data = Vec::new();
    // frame stdout: "hello"
    data.push(1); // tipo de flujo
    data.extend_from_slice(&[0, 0, 0]); // relleno
    data.extend_from_slice(&5u32.to_be_bytes()); // tamano
    data.extend_from_slice(b"hello");
    // frame stderr: "error"
    data.push(2);
    data.extend_from_slice(&[0, 0, 0]);
    data.extend_from_slice(&5u32.to_be_bytes());
    data.extend_from_slice(b"error");

    let (out, err) = parse_multiplexed_stream(&data);
    assert_eq!(out, "hello");
    assert_eq!(err, "error");
}

Sin daemon Docker necesario. Sin contenedores ejecutandose. Solo bytes de entrada, cadenas de salida. Esta prueba se ejecuta en microsegundos y nunca fallara de forma intermitente.

Porcentaje de CPU: replicando la formula propia de Docker

Las estadisticas de contenedores son otra area donde la API de Docker es enganosamente compleja. El endpoint /containers/{id}/stats devuelve un blob JSON con contadores de CPU -- pero estos son valores acumulativos en nanosegundos desde que el contenedor inicio, no porcentajes. Convertirlos a un porcentaje de CPU legible para humanos requiere el mismo calculo basado en deltas que usa el propio CLI de Docker:

rustpub fn compute_cpu_percent(stats: &ContainerStats) -> f64 {
    let cpu_delta = stats.cpu_stats.cpu_usage.total_usage as f64
        - stats.precpu_stats.cpu_usage.total_usage as f64;

    let system_delta = stats.cpu_stats.system_cpu_usage.unwrap_or(0) as f64
        - stats.precpu_stats.system_cpu_usage.unwrap_or(0) as f64;

    if system_delta <= 0.0 || cpu_delta < 0.0 {
        return 0.0;
    }

    let num_cpus = stats.cpu_stats.online_cpus
        .or_else(|| {
            stats.cpu_stats.cpu_usage.percpu_usage
                .as_ref()
                .map(|v| v.len() as u64)
        })
        .unwrap_or(1) as f64;

    (cpu_delta / system_delta) * num_cpus * 100.0
}

La formula es: (cpu_delta / system_delta) <em> num_cpus </em> 100. Los deltas son entre la instantanea actual de estadisticas y la anterior (Docker proporciona ambas en una sola respuesta como cpu_stats y precpu_stats). El manejo de casos extremos -- deltas cero, online_cpus ausente, fallback a la longitud de percpu_usage -- replica exactamente lo que docker stats hace internamente.

Probamos los casos extremos explicitamente:

rust#[test]
fn test_cpu_percent_zero_delta() {
    // Cuando system_delta es cero, CPU% deberia ser 0.0, no NaN o infinito
    let stats = make_stats(1000, 1000, 5000, 5000, 4);
    assert_eq!(compute_cpu_percent(&stats), 0.0);
}

Una division por cero en un sistema de monitorizacion significa NaN propagandose por tus dashboards. Nos aseguramos de que eso no pueda ocurrir.

Ciclo de vida de contenedores: la superficie API completa

Con la fontaneria de bajo nivel en su lugar, la API de gestion de contenedores fue directa pero exhaustiva. La superficie completa:

  • create -- Aceptar una configuracion de contenedor, POST a /containers/create, devolver el ID del contenedor
  • start / stop / restart -- POST al endpoint correspondiente con parametros de timeout
  • remove -- DELETE con opciones de forzado y eliminacion de volumenes
  • inspect -- GET del estado completo del contenedor (en ejecucion, codigo de salida, direccion IP, montajes)
  • list -- GET de todos los contenedores con filtros opcionales (estado, etiqueta)
  • wait -- Bloquear hasta que un contenedor termine y devolver el codigo de salida
  • logs -- Obtener logs con parseo de flujo multiplexado
  • exec -- Crear una instancia exec, iniciarla, capturar stdout/stderr por separado

El helper sh0_container_config() merece mencion. Genera una configuracion de contenedor Docker con los valores por defecto estandar de sh0:

rustpub struct Sh0ContainerParams {
    pub image: String,
    pub name: String,
    pub port: u16,
    pub env: Vec<String>,
    pub network: Option<String>,
    pub memory_limit: Option<u64>,
    pub cpu_quota: Option<u64>,
}

Esta struct existe porque la configuracion cruda del contenedor de la API Docker tiene docenas de campos, y Clippy correctamente se queja de funciones con 8 o mas parametros. La struct es el patron builder sin la ceremonia.

Gestion de redes y volumenes

Dos modulos mas pequenos pero esenciales completaron el cliente Docker.

La pieza central del modulo de red es ensure_sh0_network() -- una funcion idempotente que crea la red bridge sh0 si no existe ya. Cada contenedor gestionado por sh0 se une a esta red, dandoles descubrimiento de servicios basado en DNS (el contenedor A puede alcanzar al contenedor B por nombre) sin exponer nada a la red del host.

El modulo de volumenes gestiona almacenamiento persistente con etiquetas sh0.managed, para que sh0 pueda distinguir sus propios volumenes de los creados por el usuario y limpiar adecuadamente.

El sistema de tipos: 40 structs de Serde

La API del Docker Engine devuelve JSON profundamente anidado. Definimos aproximadamente 40 structs de Rust con las macros derive de serde para deserializar cada tipo de respuesta que necesitabamos:

rust#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ContainerInspect {
    pub id: String,
    pub name: String,
    pub state: ContainerState,
    pub config: ContainerConfig,
    pub network_settings: NetworkSettings,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct ContainerState {
    pub status: String,
    pub running: bool,
    pub exit_code: i64,
    pub started_at: String,
    pub finished_at: String,
}

El atributo #[serde(rename_all = "PascalCase")] maneja la convencion de Docker de claves JSON en PascalCase (un artefacto de que Docker esta escrito en Go, donde los campos exportados se capitalizan). Sin el, cada campo necesitaria una anotacion individual #[serde(rename = "...")].

Pruebas sin Docker

Un objetivo de diseno clave: las pruebas unitarias deben ejecutarse sin Docker instalado. Lo logramos manteniendo la logica pura -- parseo de flujos, calculo de CPU, generacion de configuracion -- en funciones separadas de las operaciones de I/O.

Las seis pruebas unitarias cubren:

  1. Parseo de flujo multiplexado (separacion stdout/stderr)
  2. Parseo de flujo de logs
  3. Calculo de porcentaje de CPU (caso normal)
  4. Porcentaje de CPU con delta cero (caso extremo)
  5. Agregacion de I/O de red a traves de multiples interfaces
  6. Valores por defecto de sh0_container_config

Estas pruebas se ejecutan en cargo test en cualquier maquina, incluyendo entornos CI sin Docker.

Los cinco archivos de pruebas de integracion estan controlados por feature gate detras de #[cfg(feature = "docker-tests")] y requieren un daemon Docker en ejecucion. Prueban la API real: ping al daemon, creacion y destruccion de contenedores, descarga de imagenes, creacion y eliminacion de redes, gestion de volumenes. Se ejecutan explicitamente con cargo test --features docker-tests cuando tienes Docker disponible.

Lo que aprendimos

Escribir un cliente Docker desde cero nos enseno tres cosas.

Los sockets Unix no son especiales. El UnixConnector son 40 lineas porque conectarse a un socket Unix es, fundamentalmente, lo mismo que conectarse a un puerto TCP -- obtienes un flujo bidireccional de bytes. A hyper no le importa a que tipo de flujo escribe frames HTTP. El limite de abstraccion es exactamente el correcto.

La API de Docker es mejor que su CLI. Respuestas JSON estructuradas, codigos de estado HTTP adecuados, soporte de streaming, endpoints versionados. El CLI es una capa de conveniencia para humanos; la API es la interfaz real para maquinas.

Los flujos multiplexados son un problema real. Cada libreria de cliente Docker tiene que resolver esto. Muchas lo hacen mal, especialmente en torno a frames incompletos y separacion del flujo de errores. Al escribir nuestro propio parser, entendimos exactamente que significaban los bytes, y pudimos probar cada caso extremo.

El cliente Docker fue la pieza de codigo mas dificil que escribimos en el Dia Cero. Pero tambien fue la base de todo lo que vino despues: el pipeline de despliegue construye imagenes a traves de el, el sistema de monitorizacion recopila estadisticas a traves de el, el gestor de proxy inspecciona redes de contenedores a traves de el. Cada contenedor que sh0 gestiona pasa por estas 40 lineas de codigo de socket Unix.


Esta es la Parte 2 de la serie "Como construimos sh0.dev".

Navegacion de la serie: - [1] Dia Cero: 10 crates Rust en 24 horas - [2] Escribir un cliente Docker Engine desde cero en Rust (estas aqui) - [3] Deteccion automatica de 19 stacks tecnologicos desde el codigo fuente - [4] 34 reglas para detectar errores de despliegue antes de que ocurran

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles