8

Tu primer servidor, del curl al hola mundo en producción

Vistas: 5
Tu primer servidor, del curl al hola mundo en producción

Te voy a confesar una cosa. Llevo años escribiendo scripts de Bash para consultar el estado de mis servidores. Un ssh aquí, un uptime allá, un df -h para ver si se me está llenando el disco. Funciona, sí. Pero tiene un problema gordo: tengo que estar yo delante, teclear el comando, esperar la respuesta. Y si quiero compartir esa información con alguien, tengo que hacerle un pantallazo o pegarle la salida en un correo. Menudo fenómeno estoy hecho, usando la terminal como si fuera 1995.

La cuestión es que llevaba tiempo dándole vueltas a la idea de convertir mis scripts de monitorización en algo más parecido a un servicio. Algo que se quedara corriendo en segundo plano, al que pudiera consultar desde cualquier lado con un curl, desde el navegador o desde otro servicio. Algo que se integrara con mi infraestructura self-hosted sin tener que andar con ssh a todas horas.

Y entonces pensé: ¿y si el crustáceo deja de ser una herramienta de terminal y se convierte en un servicio HTTP? ¿Y si en lugar de ejecutar comandos a mano, simplemente hago una petición y obtengo JSON limpio y estructurado?

Este capítulo es el primero del Bloque 3: Rust + Self-Hosted. Abandonas la terminal como única interfaz y empiezas a construir servicios HTTP de verdad. Vas a construir crustaceo-api, una API REST que expone información del sistema a través de HTTP. Nada de frameworks pesados ni magia. Rust con Axum, que es ligero, rápido y se siente como escribir Rust de verdad.

Si vienes de Bash, piensa en esto como pasar de esto,

ssh servidor "uptime; free -h; df -h"

a esto,

curl http://servidor:3000/info

Vamos allá.

La trinidad del HTTP en Rust

Antes de escribir una línea de código, necesitas entender tres piezas que trabajan juntas. No son opcionales, no son intercambiables. Son los cimientos sobre los que se sostiene cualquier servicio HTTP en Rust.

tokio, el runtime asíncrono

Rust no trae un runtime asíncrono incluido en el lenguaje. Esto es algo que choca bastante cuando vienes de Python o JavaScript, donde async/await está integrado en el propio intérprete. En Rust necesitas una biblioteca externa que proporcione el bucle de eventos, los temporizadores, la E/S asíncrona y el executor que ejecuta las funciones async.

tokio es el estándar de facto. Lo mantiene la misma gente que mantiene Rust y lo usan proyectos como Discord, Dropbox o AWS. No es una decisión que tengas que pensarte dos veces: si haces HTTP en Rust, usas tokio. Punto.

Con tokio::main marcas la función main como asíncrona y levantas el runtime:

#[tokio::main]
async fn main() {
    println!("Hola desde tokio");
}

Sin tokio no puedes hacer await en Rust. No hay asyncio como en Python ni async/await a nivel de intérprete. Todo pasa por el runtime. Y tokio es ese runtime.

Una de las cosas que más me gustan de tokio es que no se limita a ejecutar código asíncrono. Proporciona un montón de utilidades: temporizadores, canales de comunicación entre tareas, locks asíncronos, E/S de red y de archivos. Todo lo que necesitas para construir un servidor sin tener que ir a buscar dependencias adicionales.

axum, el framework web

axum es el framework web más popular de Rust en 2026, y con razón. Está construido sobre tokio, tower y hyper, y su diseño es modular, expresivo y muy rustacean.

Axum no es como Express (JavaScript) o Flask (Python). No hay objetos request/response mutables que vayas modificando a lo largo del handler. En Axum defines handlers como funciones que reciben parámetros y devuelven tipos que implementan IntoResponse. El framework se encarga de convertir tu respuesta en HTTP.

use axum::Router;

async fn handler() -> &'static str {
    "Hola, mundo!"
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", axum::routing::get(handler));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Eso es todo. Tu primer servidor HTTP en 10 líneas. Compila, ejecuta y responde. No hay magia, no hay configuración oculta. Es Rust directo al grano.

Lo que hace especial a Axum es el sistema de extractores. En lugar de recibir un objeto request y sacar datos de él manualmente, declaras en los parámetros del handler qué necesitas: Query<MiTipo>, State<AppState>, Json<Payload>, Path<Id>. Axum se encarga de extraer esos datos de la petición y pasártelos ya parseados y validados. Si algo falla, devuelve el error HTTP correspondiente sin que tengas que escribir ni una línea de validación.

tower-http, el middleware

tower-http es el ecosistema de middleware para Axum. Proporciona capas (layers) que envuelven tu router para añadir funcionalidad: CORS, logging de peticiones, compresión, timeouts, rate limiting, y un largo etcétera.

En este capítulo usas tower-http para dos cosas muy concretas,

  • CORS, para permitir peticiones desde el navegador en desarrollo
  • Trace, para logging de cada petición HTTP (método, ruta, código de respuesta, duración)

Pero la gracia de tower-http es que si mañana necesitas compresión gzip o un límite de peticiones por segundo, solo tienes que añadir otra capa. No tocas los handlers, no tocas el router. Añades un .layer() y listo.

Las crates del capítulo

Tu Cargo.toml necesita esto,

CrateVersiónPara qué
axum0.8El framework web. Rutas, handlers, estado, middlewares
tokio1 (features = [«full»])Runtime asíncrono. Sin esto nada funciona
serde1 (features = [«derive»])Serializar estructuras Rust a JSON
serde_json1Trabajar con JSON de forma explícita cuando necesites
tower-http0.6 (features = [«cors», «trace»])Middleware: CORS y logging
tracing0.1Trazas estructuradas para el logging
tracing-subscriber0.3Mostrar las trazas en la terminal
anyhow1Manejo de errores simplificado
chrono0.4Fechas y tiempos para el uptime

Fíjate en que no hay dependencias pesadas. No necesitas un ORM, no necesitas un cliente de base de datos, no necesitas un motor de plantillas. Solo HTTP puro y duro con Rust. El binario resultante, compilado en release, pesa unos 10 MB. Compáralo con cualquier servicio en Node.js que arrastra 200 MB de node_modules.

Aviso sobre edition = "2024": Este proyecto usa la edición 2024 de Rust, que requiere Rust 1.85 o superior. Si estás usando una versión anterior, actualiza Rust con rustup update stable o cambia a edition = "2021" en tu Cargo.toml. La edición 2024 trae mejoras en el sistema de tipos y en la ergonomía del lenguaje, pero no es estrictamente necesaria para este proyecto.

El proyecto: crustaceo-api

Vas a construir una API REST con estos endpoints,

MétodoRutaQuery paramsDescripción
GET/healthHealth check simple
GET/infoInformación del sistema
GET/logslines (opcional, default 50)Últimas líneas de journalctl
GET/diskUso de disco en JSON

Crea el proyecto,

cd ~
cargo new crustaceo-api
cd crustaceo-api

Cargo.toml

Edita Cargo.toml,

[package]
name = "crustaceo-api"
version = "0.1.0"
edition = "2024"

[dependencies]

axum = «0.8» tokio = { version = «1», features = [«full»] } serde = { version = «1», features = [«derive»] } serde_json = «1» tower-http = { version = «0.6», features = [«cors», «trace»] } tracing = «0.1» tracing-subscriber = { version = «0.3», features = [«env-filter»] } anyhow = «1» chrono = «0.4»

src/main.rs

Este es el corazón del proyecto. Lo construyes paso a paso, como si estuviéramos haciendo una cebolla. Cada capa añade una funcionalidad nueva sin romper lo anterior.

Paso 1: El esqueleto mínimo

Todo servicio HTTP empieza por un endpoint que responda. El health check es el hola mundo de las APIs. Es lo primero que implementas y lo último que dejas de usar.

use axum::{Router, routing::get};

async fn health() -> &'static str {
    "OK"
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/health", get(health));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    println!("🦀 crustaceo-api escuchando en http://0.0.0.0:3000");

    axum::serve(listener, app).await.unwrap();
}

Compila y prueba,

cargo run

En otra terminal,

curl http://localhost:3000/health

Verás OK. Texto plano, sin adornos. Pero funciona. Tienes un servidor HTTP corriendo en Rust.

Fíjate en un detalle importante. Usamos tokio::net::TcpListener::bind en lugar de axum::Server::bind (que era la forma antigua en Axum 0.7). A partir de Axum 0.8, la forma recomendada es crear un TcpListener de tokio y pasarle el listener a axum::serve. Es más explícito, más flexible y te permite configurar el socket antes de empezar a servir.

Pero esto es una respuesta en texto plano. Queremos JSON. Y queremos más de un endpoint. Vamos a escalar.

Paso 2: Respuestas JSON con serde

Para devolver JSON, necesitas estructuras que implementen Serialize. Es aquí donde serde entra en juego.

use serde::Serialize;

#[derive(Serialize)]
struct HealthResponse {
    status: String,
    version: String,
}

async fn health() -> axum::Json<HealthResponse> {
    axum::Json(HealthResponse {
        status: "ok".to_string(),
        version: "0.1.0".to_string(),
    })
}

axum::Json<T> envuelve cualquier tipo que implemente Serialize y lo devuelve con Content-Type: application/json. No necesitas llamar a serde_json::to_string() ni nada. Axum lo serializa por ti.

Esto es una de las cosas que más me gustan de Axum. En otros frameworks tienes que crear manualmente el objeto JSON, convertirlo a string, asignar la cabecera Content-Type. Aquí devuelves un Json<SaludResponse> y el framework hace el resto. El sistema de tipos de Rust te garantiza que la respuesta siempre será un JSON válido porque el compilador comprueba que todos los campos de HealthResponse están inicializados.

Paso 3: Información del sistema

El endpoint /info necesita ejecutar comandos del sistema y devolver su salida como JSON. Aquí es donde el capítulo 03 (comandos de sistema) y el capítulo 05 (parsear salidas) se juntan con HTTP.

use std::process::Command;
use chrono::Local;

#[derive(Serialize)]
struct SystemInfo {
    hostname: String,
    uptime: String,
    cpu: String,
    memory: String,
    timestamp: String,
}

async fn system_info() -> axum::Json<SystemInfo> {
    let hostname = String::from_utf8_lossy(
        &Command::new("hostname").output().unwrap().stdout
    ).trim().to_string();

    let uptime = String::from_utf8_lossy(
        &Command::new("uptime").arg("-p").output().unwrap().stdout
    ).trim().to_string();

    let cpu = String::from_utf8_lossy(
        &Command::new("sh")
            .args(["-c", "lscpu | grep 'Model name' | cut -d: -f2 | xargs"])
            .output().unwrap().stdout
    ).trim().to_string();

    let memory = String::from_utf8_lossy(
        &Command::new("free").arg("-h")
            .args(["--si"])
            .output().unwrap().stdout
    ).trim().to_string();

    axum::Json(SystemInfo {
        hostname,
        uptime,
        cpu,
        memory,
        timestamp: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
    })
}

Cada campo de SystemInfo se obtiene ejecutando un comando del sistema y parseando su salida. hostname devuelve el nombre del equipo. uptime -p da el tiempo que lleva encendido en formato legible. El combo lscpu | grep | cut extrae el modelo del procesador. free -h --si muestra la memoria en formato humano.

La función String::from_utf8_lossy convierte la salida del comando (que viene como Vec<u8>) a un String. El lossy del nombre indica que si la salida contiene bytes que no son UTF-8 válidos, los reemplaza con el carácter de reemplazo en lugar de petar. Para comandos del sistema suele ser suficiente.

¿Y por qué uso unwrap() aquí si luego voy a hablar de manejo de errores? Porque en este primer ejemplo quiero mantenerlo simple. En el código final, todos estos unwrap() desaparecen y se convierten en ? con nuestro AppError.

Paso 4: Query parameters para /logs

El endpoint /logs acepta un parámetro opcional lines para controlar cuántas líneas del journal devuelve. Aquí es donde los extractores de Axum empiezan a lucirse.

use axum::extract::Query;
use serde::Deserialize;

#[derive(Deserialize)]
struct LogQuery {
    lines: Option<u32>,
}

#[derive(Serialize)]
struct LogResponse {
    lines: u32,
    logs: Vec<String>,
}

async fn get_logs(Query(params): Query<LogQuery>) -> axum::Json<LogResponse> {
    let lines = params.lines.unwrap_or(50);
    let lines = lines.clamp(1, 500);

    let output = Command::new("journalctl")
        .args(["-n", &lines.to_string(), "--no-pager", "-o", "cat"])
        .output()
        .unwrap();

    let logs: Vec<String> = String::from_utf8_lossy(&output.stdout)
        .lines()
        .map(|l| l.to_string())
        .collect();

    axum::Json(LogResponse { lines, logs })
}

Fíjate en Query<LogQuery>. Axum parsea automáticamente los query parameters en una estructura Deserialize. Si no hay lines en la URL, usa el valor por defecto de Option::unwrap_or(50). Y con clamp(1, 500) evitas que pidan 10 millones de líneas y te fundan el servidor.

Este es un patrón que se repite en Axum: declaras un tipo que describe lo que esperas recibir, lo pones como parámetro del handler, y el framework hace el resto. Si el query parameter no es válido (por ejemplo, si alguien pasa lines=abc), Axum devuelve un error 400 automáticamente. No tienes que escribir ni una línea de validación.

Paso 5: Estado compartido con State

El endpoint /disk ejecuta df -h y parsea la salida. Pero en lugar de ejecutar el comando en cada petición, puedes inyectar estado compartido a través de axum::extract::State.

El State de Axum es un mecanismo para compartir datos entre handlers sin usar variables globales ni lazy_static. Le pasas un valor al crear el router y cada handler lo recibe como parámetro.

#[derive(Clone)]
struct AppState {
    start_time: chrono::DateTime<chrono::Local>,
}

async fn get_disk(State(state): State<AppState>) -> axum::Json<serde_json::Value> {
    let output = Command::new("df")
        .args(["-h", "--type=ext4", "--type=btrfs", "--type=xfs",
               "--exclude-type=tmpfs", "--exclude-type=devtmpfs"])
        .output()
        .unwrap();

    let stdout = String::from_utf8_lossy(&output.stdout);
    let mut disks = Vec::new();

    for line in stdout.lines().skip(1) {
        let parts: Vec<&str> = line.split_whitespace().collect();
        if parts.len() >= 6 {
            let disk = serde_json::json!({
                "filesystem": parts[0],
                "size": parts[1],
                "used": parts[2],
                "avail": parts[3],
                "use_percent": parts[4],
                "mounted_on": parts[5],
            });
            disks.push(disk);
        }
    }

    let uptime_secs = (chrono::Local::now() - state.start_time)
        .num_seconds();

    axum::Json(serde_json::json!({
        "disks": disks,
        "server_uptime_seconds": uptime_secs,
        "timestamp": chrono::Local::now().to_rfc3339(),
    }))
}

Y en main() pasas el estado al crear el router,

let state = AppState {
    start_time: chrono::Local::now(),
};

let app = Router::new()
    .route("/disk", get(get_disk))
    .with_state(state);

AppState debe implementar Clone. Axum clona el estado por cada petición. Si el estado es grande o contiene datos que no se pueden clonar alegremente, lo envuelves en Arc para que la clonación sea barata (solo incrementa un contador de referencias). En nuestro caso, chrono::DateTime<chrono::Local> es un tipo pequeño que se clona sin esfuerzo.

El estado compartido es una de las características más potentes de Axum. Te permite inyectar conexiones a bases de datos, configuraciones, clientes HTTP o cualquier otro recurso que necesiten tus handlers, todo sin variables globales. Es limpio, es seguro y el compilador te garantiza que no te olvidas de pasar el estado a un handler que lo necesita.

Paso 6: Manejo de errores con IntoResponse

Los comandos del sistema pueden fallar. El disco puede no estar montado, journalctl puede no existir, o puede que no tengas permisos. En lugar de usar unwrap() (que petaría el servidor y dejaría a tus usuarios con un error 500 genérico), vas a manejar los errores correctamente.

Axum permite que cualquier tipo que implemente IntoResponse se use como error en Result<T, E>. Creas un tipo AppError que convierte errores de anyhow en respuestas HTTP estructuradas,

use axum::response::{IntoResponse, Response};
use axum::http::StatusCode;

struct AppError(anyhow::Error);

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let body = serde_json::json!({
            "error": self.0.to_string(),
        });

        (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(body)).into_response()
    }
}

Ahora puedes devolver Result<T, AppError> desde cualquier handler,

async fn get_logs(Query(params): Query<LogQuery>) -> Result<axum::Json<LogResponse>, AppError> {
    let lines = params.lines.unwrap_or(50).clamp(1, 500);

    let output = Command::new("journalctl")
        .args(["-n", &lines.to_string(), "--no-pager", "-o", "cat"])
        .output()
        .map_err(|e| AppError(anyhow::anyhow!("Error ejecutando journalctl: {}", e)))?;

    if !output.status.success() {
        return Err(AppError(anyhow::anyhow!(
            "journalctl falló: {}",
            String::from_utf8_lossy(&output.stderr)
        )));
    }

    let logs: Vec<String> = String::from_utf8_lossy(&output.stdout)
        .lines()
        .map(|l| l.to_string())
        .collect();

    Ok(axum::Json(LogResponse { lines, logs }))
}

Con ? propagas el error, AppError lo envuelve, y Axum lo convierte en una respuesta JSON con código 500 y un mensaje descriptivo. El servidor no se cae nunca por un error interno.

Pero hay un detalle más fino. Si te fijas, en el código de system_info uso ? directamente sobre comandos que devuelven std::io::Error. Eso funciona gracias a una implementación genérica del trait From,

impl<E: Into<anyhow::Error>> From<E> for AppError {
    fn from(err: E) -> Self {
        AppError(err.into())
    }
}

Este impl From genérico le dice a Rust: cualquier error que se pueda convertir en anyhow::Error también se puede convertir en AppError. Como std::io::Error implementa Into<anyhow::Error> (porque anyhow proporciona esa conversión para todos los errores estándar), el operador ? puede convertir automáticamente un std::io::Error en AppError sin que tengas que llamar a map_err cada vez.

Para los casos donde quieres un mensaje de error personalizado (como en get_logs donde journalctl puede fallar de formas específicas), usas map_err con anyhow::anyhow! para crear el error manualmente. Ambas aproximaciones conviven en el mismo handler.

Esta es una de las mayores ventajas de Axum sobre frameworks como Express o Flask. El sistema de tipos de Rust te obliga a manejar cada posible error, y el compilador te guía para hacerlo bien. No hay try/catch olvidado, no hay excepciones no controladas. Cada error tiene un camino definido y una respuesta HTTP correspondiente.

Paso 6b: Extractores de Axum, una visión general

Axum usa extractores para obtener datos de la petición HTTP. Los has visto ya: Query, State. Pero hay más, y entenderlos es clave para sacarle partido al framework,

ExtractorDe dónde saca los datosEjemplo de uso
Query<T>Query parameters (?clave=valor)GET /logs?lines=10
Path<T>Parámetros de ruta (/users/:id)GET /users/42
Json<T>Cuerpo de la petición (POST/PUT)POST /data con body JSON
State<T>Estado compartido del routerDatos de configuración
HeadersCabeceras HTTPTokens de autenticación
StringCuerpo como texto planoWebhooks simples

Los extractores se combinan por orden en los parámetros del handler. Axum los resuelve en el orden en que los declaras,

async fn mi_handler(
    State(config): State<AppConfig>,
    Query(params): Query<MisParams>,
    Path(id): Path<u32>,
) -> impl IntoResponse {
    // ...
}

Si declaras dos extractores del mismo tipo, el compilador te dará un error. Cada tipo de extractor solo puede aparecer una vez por handler.

Anidando rutas con Router::nest

Cuando tu API crece, tener todas las rutas en una sola cadena de .route() se vuelve ilegible. Axum proporciona Router::nest() para agrupar rutas bajo un prefijo común,

let api_routes = Router::new()
    .route("/health", get(health))
    .route("/info", get(system_info))
    .route("/logs", get(get_logs))
    .route("/disk", get(get_disk));

let app = Router::new()
    .nest("/api", api_routes)
    .layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any))
    .layer(TraceLayer::new_for_http())
    .with_state(state);

Con nest, los endpoints quedan en /api/health, /api/info, /api/logs?lines=10 y /api/disk. Para este proyecto lo dejamos todo en la raíz, pero cuando añadas más rutas, nest es tu aliado. Y si quieres que el mismo conjunto de rutas funcione en dos prefijos distintos (por ejemplo, /api/v1 y /api/v2), solo necesitas un clone() del router.

Añadiendo un endpoint POST

Hasta ahora solo tienes endpoints GET. Pero una API REST de verdad necesita POST para crear recursos. Vamos a añadir un endpoint que ejecute comandos personalizados en el sistema (con cuidado, solo para demostración),

use axum::Json;
use serde::Deserialize;

#[derive(Deserialize)]
struct CommandRequest {
    command: String,
}

#[derive(Serialize)]
struct CommandResponse {
    stdout: String,
    stderr: String,
    exit_code: i32,
}

async fn run_command(
    Json(payload): Json<CommandRequest>,
) -> Result<Json<CommandResponse>, AppError> {
    let output = Command::new("sh")
        .args(["-c", &payload.command])
        .output()?;

    Ok(Json(CommandResponse {
        stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
        stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
        exit_code: output.status.code().unwrap_or(-1),
    }))
}

Y lo añades al router,

use axum::routing::post;

let app = Router::new()
    .route("/health", get(health))
    .route("/info", get(system_info))
    .route("/logs", get(get_logs))
    .route("/disk", get(get_disk))
    .route("/exec", post(run_command))
    // ... layers ...

Pruébalo con curl,

curl -s -X POST http://localhost:3000/exec \
  -H "Content-Type: application/json" \
  -d '{"command": "uname -a"}' | python3 -m json.tool

Respuesta,

{
    "stdout": "Linux servidor 6.8.0-124-generic #126-Ubuntu SMP ... x86_64 GNU/Linux",
    "stderr": "",
    "exit_code": 0
}

Json<CommandRequest> como extractor en un handler POST hace que Axum deserialice automáticamente el cuerpo de la petición. Si el JSON no es válido o falta el campo command, Axum devuelve un error 400 automáticamente, sin que tengas que escribir ni una línea de validación.

Advertencia: Este endpoint ejecuta cualquier comando sin restricciones. No lo expongas en producción sin autenticación y una lista blanca de comandos permitidos. Es solo para ilustrar cómo funciona POST + Json<T>.

El orden de las capas de middleware importa

Las capas (layers) en Axum se aplican de fuera hacia adentro. La primera capa que añades envuelve al router por fuera, y la última es la que está más cerca del handler,

Router::new()
    .route("/health", get(health))
    .layer(CorsLayer::new()...)
    .layer(TraceLayer::new_for_http())
    .with_state(state);

Esto significa que la traza de TraceLayer registra la petición antes de que CORS la procese, y registra la respuesta después. Si quisieras que CORS se ejecutara antes del logging (para no logear peticiones que CORS va a rechazar), invertirías el orden,

    .layer(TraceLayer::new_for_http())
    .layer(CorsLayer::new()...)

Piénsalo como cebollas: la última capa que añades es la primera que recibe la petición. Si no ves las trazas cuando esperas, revisa el orden de tus .layer().

Paso 7: CORS y logging con tower-http

Montas las capas de middleware con .layer(),

use tower_http::cors::{CorsLayer, Any};
use tower_http::trace::TraceLayer;

let app = Router::new()
    .route("/health", get(health))
    .route("/info", get(system_info))
    .route("/logs", get(get_logs))
    .route("/disk", get(get_disk))
    .layer(
        CorsLayer::new()
            .allow_origin(Any)
            .allow_methods(Any)
            .allow_headers(Any),
    )
    .layer(TraceLayer::new_for_http())
    .with_state(state);

CorsLayer::new().allow_origin(Any) permite peticiones desde cualquier origen. En producción querrías restringirlo a tu dominio, pero para desarrollo es perfecto. Te evitas los problemas típicos de CORS cuando estás desarrollando un frontend en localhost:5173 y tu API en localhost:3000.

TraceLayer::new_for_http() logea cada petición con método, ruta, código de respuesta y duración. Para ver las trazas necesitas configurar tracing-subscriber,

tracing_subscriber::fmt()
    .with_env_filter(
        tracing_subscriber::EnvFilter::try_from_default_env()
            .unwrap_or_else(|_| "crustaceo_api=debug,tower_http=debug".into())
    )
    .init();

La configuración de EnvFilter te permite controlar el nivel de logging mediante la variable de entorno RUST_LOG. Si no está definida, usa crustaceo_api=debug,tower_http=debug como valor por defecto. Esto significa que verás trazas de tu propia aplicación y del middleware HTTP.

El código completo

El código completo de src/main.rs incluye todos los pasos anteriores integrados en un solo archivo. Es un poco largo para ponerlo aquí entero, pero lo he subido a un gist para que puedas consultarlo y copiarlo fácilmente,

Si prefieres verlo directamente en el navegador, está disponible en este enlace.

Compilación y verificación

Compilar

cd ~/crustaceo-api
cargo build

Si todo va bien, verás algo como esto,

    Updating crates.io index
   Downloading crates ...
   Compiling ...
   Compiling crustaceo-api v0.1.0
    Finished `dev` profile [unoptimized + debuginfo]

La primera compilación tarda porque descarga y compila todas las dependencias. axum tira de hyper, tower, http-body y un montón de crates más. Las siguientes serán casi instantáneas gracias a la caché de compilación de Rust.

Ejecutar

cargo run

Verás las trazas de tracing-subscriber y el mensaje,

🦀 crustaceo-api escuchando en http://0.0.0.0:3000

Probar con curl

Health check

curl -s http://localhost:3000/health | python3 -m json.tool

Respuesta,

{
    "status": "ok",
    "version": "0.1.0"
}

Información del sistema

curl -s http://localhost:3000/info | python3 -m json.tool

Respuesta (ejemplo),

{
    "hostname": "servidor",
    "uptime": "up 3 hours, 15 minutes",
    "cpu": "Intel(R) Core(TM) i7-10700K CPU @ 3.80GHz",
    "memory": "              total        used        free      shared  buff/cache   available\nMem:           15Gi        6.2Gi        2.1Gi        0.5Gi        7.0Gi        8.5Gi\nSwap:          2.0Gi        0.0Gi        2.0Gi",
    "timestamp": "2026-06-24 18:30:00"
}

Logs del sistema

# Últimas 10 líneas
curl -s "http://localhost:3000/logs?lines=10" | python3 -m json.tool

Respuesta,

{
    "lines": 10,
    "logs": [
        "Jun 24 18:25:01 servidor systemd[1]: Started Session 42 of User lorenzo",
        "Jun 24 18:30:01 servidor CROND[1234]: (root) CMD (/usr/lib64/sa/sa1 1 1)"
    ]
}

Requiere permisos para leer journalctl. Si no ves logs, ejecuta el servidor como root o añade tu usuario al grupo systemd-journal,

sudo usermod -aG systemd-journal $USER
# Cierra sesión y vuelve a entrar

Uso de disco

curl -s http://localhost:3000/disk | python3 -m json.tool

Respuesta,

{
    "disks": [
        {
            "filesystem": "/dev/nvme0n1p2",
            "size": "457G",
            "used": "234G",
            "avail": "197G",
            "use_percent": "55%",
            "mounted_on": "/"
        },
        {
            "filesystem": "/dev/nvme0n1p1",
            "size": "511M",
            "used": "6.1M",
            "avail": "505M",
            "use_percent": "2%",
            "mounted_on": "/boot/efi"
        }
    ],
    "server_uptime_seconds": 11700,
    "timestamp": "2026-06-24T18:30:00+02:00"
}

Probar el manejo de errores

# Sin parámetro lines
curl -s "http://localhost:3000/logs" | python3 -m json.tool
# Usa 50 líneas por defecto

# Con valores extremos (se clamp a 500)
curl -s "http://localhost:3000/logs?lines=999999"
# Usa 500 líneas, el máximo permitido

Probar CORS

curl -s -X OPTIONS http://localhost:3000/health -H "Origin: http://localhost:5173" -H "Access-Control-Request-Method: GET" -v 2>&1 | grep -i "access-control"

Deberías ver cabeceras como access-control-allow-origin: *.

Dockerizando la API

Para integrarlo con tu infraestructura self-hosted, necesitas un contenedor Docker. Este Dockerfile multi-etapa genera un binario mínimo,

# Etapa 1: compilación
FROM rust:1.85-slim-bookworm AS builder

WORKDIR /app
COPY Cargo.toml Cargo.lock* ./
COPY src ./src

RUN apt-get update && apt-get install -y --no-install-recommends \
    pkg-config libssl-dev && \
    rm -rf /var/lib/apt/lists/*

RUN cargo build --release

# Etapa 2: imagen mínima
FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates systemd journalctl && \
    rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/crustaceo-api /usr/local/bin/

EXPOSE 3000

CMD ["crustaceo-api"]

El Dockerfile tiene dos etapas. En la primera, compilas el binario en una imagen completa de Rust. En la segunda, copias solo el binario a una imagen mínima de Debian. El resultado es un contenedor que pesa unos 80 MB en lugar de los 2 GB que pesaría si incluyeras el compilador de Rust.

Fíjate en que la segunda etapa instala systemd y journalctl. Son necesarios para que el endpoint /logs funcione dentro del contenedor. Si no necesitas ese endpoint, puedes omitirlos y la imagen pesará aún menos.

Compila la imagen,

docker build -t crustaceo-api .
docker run -d --name crustaceo-api --network host crustaceo-api

Con --network host el contenedor comparte la red del host, así que journalctl puede acceder a los logs del sistema. Si usas Docker en un servidor real, esta es la forma más sencilla de integrar el contenedor con la red existente.

Un apunte sobre la resolución de problemas

Si algo no funciona como esperas, aquí tienes los problemas más comunes y cómo solucionarlos.

El servidor no arranca porque el puerto 3000 está ocupado. Usa lsof -i :3000 o ss -tlnp | grep 3000 para ver qué proceso lo tiene ocupado. Puedes cambiar el puerto en el código o matar el proceso conflictivo.

El endpoint /logs devuelve un array vacío. Tu usuario no tiene permisos para leer journalctl. Ejecuta el servidor con sudo o añade tu usuario al grupo systemd-journal como expliqué antes. Si estás dentro de Docker, asegúrate de que el contenedor tiene acceso al socket de journald del host.

CORS no funciona y el navegador bloquea las peticiones. Revisa que CorsLayer esté configurado con allow_origin(Any) y que esté añadido al router antes de las rutas. Si usas un proxy como Traefik o Nginx delante del servidor, configura CORS también en el proxy.

El binario compilado en release no encuentra libssl.so. Si compilas en una máquina y ejecutas en otra, puede que falten bibliotecas compartidas. La solución es compilar con el target x86_64-unknown-linux-musl para obtener un binario estáticamente enlazado, o usar el Dockerfile multi-etapa que ya incluye todo lo necesario.

Lo que has aprendido

En este capítulo has dado el salto de las herramientas CLI a los servicios HTTP. Has aprendido,

  • tokio como runtime asíncrono, sin él no hay async ni servidor
  • axum para definir rutas y handlers de forma expresiva
  • serde para serializar respuestas JSON desde estructuras Rust
  • Query parameters con Query<T> de Axum
  • Estado compartido con State de Axum
  • tower-http para CORS y logging de peticiones
  • Manejo de errores con IntoResponse para devolver errores como JSON
  • Ejecución de comandos desde handlers HTTP para exponer información del sistema

Has construido crustaceo-api, una API REST funcional que expone health check, información del sistema, logs de journald y uso de disco. Y todo en un solo binario de unos 10 MB.

Pero esto es solo el principio. Tu crustáceo ahora habla HTTP, pero lo hace de forma pasiva: espera a que le pidan información y responde. ¿Y si pudiera empujar datos en tiempo real a los clientes? ¿Y si los dashboards se actualizaran solos sin necesidad de refrescar?

Lo que viene

En el próxima capítulo, vas a llevar esto al siguiente nivel: comunicación en tiempo real con WebSockets. Tu crustáceo no solo responderá peticiones, sino que empujará datos en vivo a los clientes. Indicadores de sistema en tiempo real, notificaciones push, y dashboards que se actualizan solos.


Más información,

Deja una respuesta