Rust en infraestructura: por qué muchas herramientas se reescriben

1. Resumen

En los últimos años, muchas herramientas de infraestructura se han reescrito en Rust: herramientas de búsqueda de archivos, linters, formateadores, bundlers de JavaScript, administradores de paquetes de Python, proxies y agentes de recopilación de logs. Este artículo explica por qué Rust es adecuado para la infraestructura. Luego escribirás tu propio CLI pequeño en Rust, lo empaquetarás en un contenedor y lo llamarás desde una aplicación Java. Al final del artículo hay casos en los que no debes elegir Rust.

2. Por qué la infraestructura necesita un lenguaje como Rust

Las herramientas de infraestructura suelen tener varias características en común. Se ejecutan muy a menudo, por ejemplo cada vez que guardas un archivo o cada vez que ejecutas un build de CI. Procesan grandes volúmenes de datos y deben ser estables durante muchos días. También necesitan ser fáciles de distribuir a muchas máquinas diferentes.

Anteriormente, la elección solía estar entre dos grupos:

  • C/C++: rápido y sin runtime pesado. Sin embargo, los errores de memoria como use-after-free, buffer overflow o data race son muy fáciles de cometer y difíciles de encontrar.
  • Lenguajes con GC o intérprete (Java, Python, Node.js): seguro y altamente productivo. Sin embargo, el tiempo de inicio es más largo y consume más memoria. Al distribuir, también tienes que incluir el runtime.

Rust apunta a llenar el espacio entre estos dos grupos: rendimiento cercano a C, pero con seguridad de memoria verificada por el compilador.

2.1. Seguridad de memoria sin GC

Rust gestiona la memoria mediante ownership. Cada valor tiene exactamente un propietario. Cuando el propietario sale del scope, la memoria se libera. El compilador verifica estas reglas en tiempo de compilación:

fn main() {
    let order_id = String::from("ORD-001");
    let moved = order_id;          // la propiedad se transfiere a `moved`
    println!("{moved}");
    println!("{order_id}");        // reutilizar una variable que fue movida
}

La línea 3 transfiere la propiedad de la cadena a moved. Desde entonces, order_id ya no es válida. La línea 5 intenta reutilizarla, por lo que el programa no se compila:

error[E0382]: borrow of moved value: `order_id`

En C, un error similar podría compilarse normalmente y causar un crash en tiempo de ejecución, o peor, convertirse en una vulnerabilidad de seguridad. Con herramientas de infraestructura que se ejecutan con permisos elevados o procesan datos de fuentes externas como proxies y parsers, esta diferencia es muy importante.

2.2. Concurrencia segura

Las herramientas de infraestructura a menudo necesitan aprovechar varios núcleos. Rust utiliza dos traits Send y Sync para que el compilador rechace programas que tengan data races:

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let processed = Arc::new(Mutex::new(0u64));
    let mut handles = Vec::new();

    for _ in 0..4 {
        let processed = Arc::clone(&processed);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                *processed.lock().unwrap() += 1;
            }
        }));
    }

    for h in handles {
        h.join().unwrap();
    }
    println!("processed = {}", *processed.lock().unwrap());
}

El significado de cada parte:

  • Arc es un puntero de conteo de referencias seguro entre threads.
  • Mutex asegura que solo un thread pueda modificar el contador a la vez.
  • move transfiere un clon de Arc al closure de cada thread.

El resultado siempre es:

processed = 4000

Si reemplazas Arc con Rc, un tipo que no es seguro entre threads, el compilador reportará `Rc<Mutex<u64>>` cannot be sent between threads safely. No tienes que esperar a producción para descubrir una condición de carrera.

2.3. Un único archivo binario, inicio instantáneo

cargo build --release produce un archivo ejecutable nativo. Este archivo no necesita JVM, no necesita intérprete, y tampoco necesita node_modules. Esto proporciona varias ventajas:

  • Instalación simple: solo necesitas descargar un archivo y ejecutarlo.
  • Inicio rápido: es ideal para linters o formateadores que se llaman cientos de veces por minuto en editores o hooks pre-commit.
  • Imagen de contenedor pequeña: es ideal para sidecars y agentes que se ejecutan en cada nodo.

3. Tipos de herramientas que se están reescribiendo

Sin necesidad de estadísticas específicas, aún puedes ver la tendencia a través de grupos de herramientas populares:

  • CLI de línea de comandos: reemplazos para grep, find, cat, ls. Ejemplos familiares son ripgrep y fd.
  • Herramientas para otros lenguajes: linters y formateadores para JavaScript/TypeScript, Python. Ejemplos: Ruff, Biome, SWC. Recientemente también administradores de paquetes de Python como uv.
  • Proxies, load balancers, data planes de service mesh: donde tanto el rendimiento como la seguridad de memoria son importantes.
  • Agentes de observabilidad: recopilación de logs y métricas, se ejecutan en cada máquina por lo que necesitan ser ligeros.
  • Componentes del sistema operativo y runtime: drivers, container runtimes, WebAssembly runtimes.

Hay un punto común notable. La mayoría de estas herramientas no requieren que los usuarios sepan Rust. Rust es un detalle de implementación. Los usuarios solo ven una herramienta que se ejecuta rápido y es fácil de instalar.

4. Práctica: escribir el CLI logstat en Rust

Supongamos que necesitas contar cuántas veces aparece cada código de estado HTTP en el access log de OrderService. Cada línea de log tiene la forma METHOD PATH STATUS DURATION_MS.

4.1. Declarar el proyecto

Crea un proyecto con cargo new logstat, luego añade la librería clap para parsear argumentos de línea de comandos:

[package]
name = "logstat"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4", features = ["derive"] }

4.2. Código fuente

use clap::Parser;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{self, BufRead, BufReader};

#[derive(Parser)]
#[command(name = "logstat", about = "Contar códigos de estado HTTP en access log")]
struct Args {
    /// Ruta al archivo de log
    path: String,
}

fn parse_status(line: &str) -> Option<u16> {
    line.split_whitespace().nth(2)?.parse().ok()
}

fn count_statuses<R: BufRead>(reader: R) -> io::Result<BTreeMap<u16, u64>> {
    let mut counts = BTreeMap::new();
    for line in reader.lines() {
        let line = line?;
        if let Some(status) = parse_status(&line) {
            *counts.entry(status).or_insert(0) += 1;
        }
    }
    Ok(counts)
}

fn main() -> io::Result<()> {
    let args = Args::parse();
    let file = File::open(&args.path)?;
    let counts = count_statuses(BufReader::new(file))?;
    for (status, n) in counts {
        println!("{status} {n}");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn counts_statuses_and_skips_bad_lines() {
        let log = "GET /orders 200 12\nPOST /orders 201 30\nGET /orders 200 9\nbasura\n";
        let counts = count_statuses(log.as_bytes()).unwrap();

        assert_eq!(counts.get(&200), Some(&2));
        assert_eq!(counts.get(&201), Some(&1));
        assert_eq!(counts.len(), 2);
    }
}

Los puntos clave:

  • #[derive(Parser)] genera código para parsear argumentos e incluso el contenido de --help a partir de la struct Args.
  • parse_status devuelve Option<u16>. El operador ? en Option ayuda a ignorar líneas que les falta una columna. .parse().ok() convierte un error de parseo en None, por lo que las líneas basura no hacen que el programa se bloquee.
  • count_statuses acepta cualquier BufRead. El código lee línea por línea por lo que no tiene que cargar el archivo completo en memoria, incluso si el log pesa varios GB.
  • BTreeMap mantiene las claves en orden ascendente, por lo que la salida es consistente.
  • La función de prueba pasa una cadena como &[u8], que también implementa BufRead. De esta manera puedes probar la lógica sin necesidad de un archivo real.

Ejecuta las pruebas y construye:

cargo test
cargo build --release
./target/release/logstat access.log

Con un archivo de log de ejemplo, la salida tendrá una forma como:

200 2
201 1
500 1

4.3. Empaquetar en un contenedor

El binario de Rust es ideal para builds de múltiples etapas:

FROM rust:slim AS build
WORKDIR /app
COPY . .
RUN cargo build --release

FROM gcr.io/distroless/cc-debian12
COPY --from=build /app/target/release/logstat /usr/local/bin/logstat
ENTRYPOINT ["logstat"]

La primera etapa contiene todo el toolchain para compilar. La segunda etapa solo contiene el binario y unas pocas librerías C básicas. La imagen final es por lo tanto mucho más pequeña que una imagen que incluya JDK o Node.js.

Nota: Si deseas usar una imagen scratch, necesitas construir estáticamente con el target x86_64-unknown-linux-musl. En ese caso, verifica cuidadosamente si algún crate depende de librerías C como OpenSSL.

5. Integrar herramientas Rust en sistemas Java

No necesitas reescribir la aplicación completa de Spring Boot. La forma más práctica de aplicarlo es típicamente usar Rust para una herramienta especializada, mientras que Java la llama como un proceso externo:

import java.io.IOException;
import java.nio.file.Path;
import java.util.Map;
import java.util.TreeMap;

public class LogStatClient {

    private final Path binary;

    public LogStatClient(Path binary) {
        this.binary = binary;
    }

    public Map<Integer, Long> count(Path logFile) throws IOException, InterruptedException {
        Process process = new ProcessBuilder(binary.toString(), logFile.toString())
                .redirectError(ProcessBuilder.Redirect.INHERIT)
                .start();

        Map<Integer, Long> result = new TreeMap<>();
        try (var reader = process.inputReader()) {
            reader.lines().forEach(line -> {
                String[] parts = line.split(" ");
                result.put(Integer.parseInt(parts[0]), Long.parseLong(parts[1]));
            });
        }

        int exitCode = process.waitFor();
        if (exitCode != 0) {
            throw new IllegalStateException("logstat salió con código " + exitCode);
        }
        return result;
    }
}

Explicación:

  • ProcessBuilder ejecuta el binario con la ruta del archivo de log como argumento.
  • redirectError(INHERIT) envía stderr de la herramienta al log de la aplicación Java. De esta manera, los mensajes de error no se mezclan con stdout, donde Java está parseando el resultado.
  • process.inputReader() lee stdout línea por línea. Cada línea se divide en estado y número de apariciones.
  • Un código de salida diferente de 0 se considera un error. Por ejemplo, si el archivo no existe, entonces main devuelve Err, y el programa Rust sale con código 1.

Prueba con JUnit 5 y AssertJ:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import java.nio.file.Files;
import java.nio.file.Path;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.entry;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

class LogStatClientTest {

    @TempDir
    Path tempDir;

    @Test
    void countsStatusCodesUsingRustBinary() throws Exception {
        Path binary = Path.of("tools/logstat/target/release/logstat");
        assumeTrue(Files.isExecutable(binary), "Necesitas construir logstat primero");

        Path log = tempDir.resolve("access.log");
        Files.writeString(log, """
                GET /orders 200 12
                GET /orders 500 40
                GET /customers 200 8
                """);

        var client = new LogStatClient(binary);

        assertThat(client.count(log))
                .containsExactly(entry(200, 2L), entry(500, 1L));
    }
}

assumeTrue permite omitir la prueba en máquinas que aún no han construido el binario, en lugar de fallar la construcción. En CI, debes ejecutar cargo build --release antes del paso mvn test.

Además de llamar a través de un proceso, tienes dos opciones más:

  • Llamar directamente a través de FFI: usa la Foreign Function & Memory API de Java 21 para llamar funciones Rust exportadas según la ABI de C (extern "C"). Este enfoque evita el costo de crear un proceso. Sin embargo, gestionar la memoria y los tipos de datos en el límite entre los dos lenguajes es mucho más complejo.
  • Separar en un servicio: la herramienta Rust se ejecuta de forma independiente y se comunica a través de HTTP/gRPC. Este enfoque es apropiado cuando la herramienta necesita ejecutarse continuamente, como un proxy o agente.

En la mayoría de los casos, llamar a través de un proceso es lo suficientemente simple y fácil de depurar.

6. Cuándo no usar Rust

Rust no es la opción predeterminada para todo. Considera cuidadosamente en los siguientes casos.

  • Aplicaciones CRUD de negocio típicas: un servicio que gestiona Order y Customer con Spring Boot generalmente se ve limitado por la base de datos y la red, no por la CPU. Reescribir en Rust rara vez mejora la experiencia del usuario, pero definitivamente ralentiza la velocidad de desarrollo.
  • Equipo sin experiencia: el borrow checker tiene una curva de aprendizaje pronunciada. En las primeras etapas, pasarás mucho tiempo “discutiendo” con el compilador. Una herramienta interna mantenida por una sola persona que conoce Rust es un riesgo operativo real.
  • Ecosistema insuficiente: Java tiene librerías empresariales muy maduras, como ORMs, integraciones con message brokers o estándares de seguridad. En algunas áreas, Rust aún tiene menos opciones.
  • Prototipos que cambian constantemente: cuando los requisitos no son estables, un script Python o Java te ayudará a experimentar más rápido.
  • La única razón es “Rust es rápido”: mide primero. A menudo, profiling y optimizar el algoritmo en el lenguaje actual es suficiente.

Algunos errores comunes al comenzar:

Nota: Medir el rendimiento con la versión debug (cargo build sin --release) dará resultados mucho más lentos que la realidad. Siempre haz benchmark con la versión release.

Nota: Abusar de .unwrap() hace que la herramienta entre en pánico cuando encuentre entrada deficiente. Para herramientas de infraestructura, devuelve Result e imprime un mensaje de error claro en stderr.

Nota: unsafe desactiva parte de la verificación del compilador. Limítalo a módulos pequeños y revisa cuidadosamente. No uses unsafe solo para “engañar” al borrow checker.

Además, el tiempo de compilación de Rust es bastante largo en proyectos grandes. Activa cache para el directorio target y el registro de Cargo en CI, de lo contrario el pipeline será significativamente más lento.

7. Conclusión

Rust se elige para infraestructura porque combina tres cosas que antes era difícil tener al mismo tiempo: rendimiento nativo, seguridad de memoria garantizada por el compilador, y la capacidad de distribuir como un único archivo binario. Esa es la razón por la que muchos CLIs, linters, bundlers, proxies y agentes se reescriben en Rust.

En este artículo, escribiste el CLI logstat con pruebas, lo empaquetaste usando un build de Docker de múltiples etapas y lo llamaste desde Java a través de ProcessBuilder. El enfoque más sensato es usar Rust para componentes especializados que se ejecutan muchas veces o son sensibles a los recursos. La lógica de negocio debería mantenerse en el lenguaje que tu equipo domina. Antes de reescribir cualquier cosa, mide y pregúntate: ¿son los beneficios lo suficientemente grandes para compensar el costo de aprender y mantener?

rusttech-trendsinfrastructureclidevops