Rust na infraestrutura: por que muitas ferramentas estão sendo reescritas

1. Visão geral

Nos últimos anos, muitas ferramentas de infraestrutura foram reescritas em Rust: utilitários de busca de arquivos, linters, formatadores, bundlers JavaScript, gerenciadores de pacotes Python, proxies e agentes de coleta de logs. Este artigo explica por que Rust é adequado para infraestrutura. Depois você vai escrever um CLI pequeno em Rust, empacotá-lo em um container e chamá-lo de uma aplicação Java. No final do artigo, você verá os casos em que você não deve escolher Rust.

2. Por que infraestrutura precisa de uma linguagem como Rust

Ferramentas de infraestrutura geralmente compartilham algumas características. Elas rodam muitas vezes, por exemplo cada vez que você salva um arquivo ou cada vez que roda uma build de CI. Elas processam grandes volumes de dados e precisam ser estáveis por muitos dias. Também precisam ser fáceis de distribuir para muitas máquinas diferentes.

Historicamente, as escolhas ficavam entre dois grupos:

  • C/C++: rápido e sem runtime pesado. Em contrapartida, erros de memória como use-after-free, buffer overflow e data race acontecem facilmente e são difíceis de rastrear.
  • Linguagens com GC ou interpretadas (Java, Python, Node.js): seguras e altamente produtivas. Em contrapartida, tempo de inicialização mais longo e maior consumo de memória. Ao distribuir, você precisa incluir o runtime também.

Rust visa preencher o espaço entre esses dois grupos: desempenho próximo ao C, mas segurança de memória verificada pelo compilador.

2.1. Segurança de memória sem GC

Rust gerencia memória usando ownership. Cada valor tem exatamente um proprietário. Quando o proprietário sai do escopo, a memória é liberada. O compilador verifica essas regras em tempo de compilação:

fn main() {
    let order_id = String::from("ORD-001");
    let moved = order_id;          // ownership é transferido para `moved`
    println!("{moved}");
    println!("{order_id}");        // reutilizar variável que foi movida
}

A linha 3 transfere o ownership da string para moved. Depois disso, order_id não é mais válido. A linha 5 tenta reutilizá-la, então o programa não compila:

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

Em C, um erro semelhante poderia compilar normalmente e depois causar um crash em tempo de execução, ou pior ainda, virar uma vulnerabilidade de segurança. Com ferramentas de infraestrutura rodando com altos privilégios ou processando dados de fora, como proxies e parsers, essa diferença é crítica.

2.2. Concorrência segura

Ferramentas de infraestrutura frequentemente precisam aproveitar múltiplos cores. Rust usa duas traits Send e Sync para o compilador rejeitar programas que possam ter data race:

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

O significado de cada parte:

  • Arc é um ponteiro de contagem de referência thread-safe.
  • Mutex garante que apenas uma thread acesse a variável contadora por vez.
  • move transfere um clone do Arc para a closure de cada thread.

O resultado sempre será:

processed = 4000

Se você tentar trocar Arc por Rc, um tipo que não é seguro entre threads, o compilador dirá `Rc<Mutex<u64>>` cannot be sent between threads safely. Você não precisa esperar pela produção para descobrir uma race condition.

2.3. Um binário único, inicialização instantânea

cargo build --release produz um executável nativo. Este arquivo não precisa de JVM, não precisa de interpretador, também não precisa de node_modules. Isso traz vários benefícios:

  • Instalação simples: apenas baixe um arquivo e execute.
  • Inicialização rápida: ideal para linters e formatadores chamados centenas de vezes por minuto em editores ou pre-commit hooks.
  • Image container pequeno: ideal para sidecars e agentes rodando em todo node.

3. Tipos de ferramentas sendo reescritas

Sem números específicos, você ainda consegue ver a tendência através de grupos de ferramentas populares:

  • CLIs de linha de comando: substituindo grep, find, cat, ls. Exemplos conhecidos são ripgrep e fd.
  • Ferramentas para outras linguagens: linters e formatadores para JavaScript/TypeScript, Python. Exemplos: Ruff, Biome, SWC. Recentemente até gerenciadores de pacotes Python como o uv.
  • Proxies, load balancers, service mesh data plane: onde desempenho e segurança de memória são igualmente importantes.
  • Agentes de observabilidade: coleta de logs e métricas, rodando em todas as máquinas então precisam ser leves.
  • Componentes do sistema operacional e runtime: drivers, container runtime, WebAssembly runtime.

Há um ponto em comum notável. A maioria dessas ferramentas não exige que usuários saibam Rust. Rust é um detalhe de implementação. Os usuários só veem uma ferramenta que roda rápido e é fácil de instalar.

4. Prática: escrever um CLI logstat em Rust

Suponha que você precise contar quantas vezes cada código de status HTTP aparece em um access log do OrderService. Cada linha de log tem o formato METHOD PATH STATUS DURATION_MS.

4.1. Declarando o projeto

Crie um projeto com cargo new logstat, depois adicione a biblioteca clap para fazer parsing de argumentos de linha de comando:

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

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

4.2. Código-fonte

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 status HTTP em access log")]
struct Args {
    /// Caminho para o arquivo 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\nlixo\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);
    }
}

Os pontos principais:

  • #[derive(Parser)] gera código para fazer parsing de argumentos e até o conteúdo --help a partir da struct Args.
  • parse_status retorna Option<u16>. O operador ? em Option ajuda a pular linhas que faltam uma coluna. .parse().ok() converte erro de parsing em None, então linhas ruins não fazem o programa crashear.
  • count_statuses aceita qualquer BufRead. O código lê linha por linha então não precisa carregar o arquivo inteiro na memória, mesmo se o log tiver muitos GB.
  • BTreeMap mantém as chaves em ordem crescente, então a saída é determinística.
  • A função de teste passa uma string como &[u8], que também implementa BufRead. Assim você consegue testar a lógica sem precisar de um arquivo real.

Execute os testes e faça build:

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

Com um arquivo de log de exemplo, a saída será assim:

200 2
201 1
500 1

4.3. Empacotando em um container

Um binário Rust funciona bem com multi-stage build:

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"]

O primeiro stage contém todo o toolchain para compilar. O último stage contém apenas o binário e poucas bibliotecas C básicas. A imagem final acaba sendo muito menor comparada a uma imagem que inclui JDK ou Node.js.

Nota: Se quiser usar a imagem scratch, você precisa fazer build estático com o target x86_64-unknown-linux-musl. Nesse caso, verifique cuidadosamente se suas crates dependem de bibliotecas C como OpenSSL.

5. Integrando uma ferramenta Rust em um sistema Java

Você não precisa reescrever toda a aplicação Spring Boot. A forma mais prática de aplicar isso geralmente é usar Rust para uma ferramenta especializada, e Java chamá-la como um processo 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 saiu com código " + exitCode);
        }
        return result;
    }
}

Explicação:

  • ProcessBuilder executa o binário com o caminho do arquivo de log como argumento.
  • redirectError(INHERIT) envia stderr da ferramenta para o log da aplicação Java. Assim, mensagens de erro não se misturam com stdout, onde Java está fazendo parsing do resultado.
  • process.inputReader() lê stdout linha por linha. Cada linha é dividida em status e contagem de ocorrências.
  • Um código de saída diferente de 0 é considerado erro. Por exemplo, se o arquivo não existir, main retorna Err, e o programa Rust sai com código 1.

Teste com JUnit 5 e 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), "Precisa compilar logstat antes");

        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 pular o teste em máquinas que ainda não compilaram o binário, em vez de fazer a build falhar. Num CI, você deve rodar cargo build --release antes de mvn test.

Além de chamar um processo, você tem mais duas opções:

  • Chamar diretamente via FFI: use a Foreign Function & Memory API do Java 21 para chamar funções Rust exportadas com ABI de C (extern "C"). Isso evita o custo de criar um processo. Em troca, gerenciar memória e tipos de dados na fronteira entre duas linguagens fica bem mais complexo.
  • Separar em um serviço dedicado: a ferramenta Rust roda independentemente e se comunica via HTTP/gRPC. Isso funciona bem quando a ferramenta precisa rodar continuamente, como um proxy ou agente.

Na maioria dos casos, chamar via processo é simples e fácil de debugar o suficiente.

6. Quando não usar Rust

Rust não é a escolha padrão para tudo. Pense cuidadosamente nos casos a seguir.

  • Aplicações CRUD convencionais de negócio: um serviço que gerencia Order e Customer com Spring Boot geralmente é limitado por database e network, não por CPU. Reescrever em Rust raramente melhora a experiência do usuário, mas definitivamente retarda o ritmo de desenvolvimento.
  • Time sem experiência: o borrow checker tem uma curva de aprendizado íngreme. No início, você vai passar muito tempo “brigando” com o compilador. Uma ferramenta interna mantida por uma única pessoa que sabe Rust é um risco operacional real.
  • Ecossistema ainda imaturo: Java tem bibliotecas empresariais muito maduras, como ORMs, integração com message brokers ou padrões de segurança. Em algumas áreas, Rust ainda tem menos opções.
  • Protótipo que muda constantemente: quando requisitos ainda não estão estáveis, um script Python ou Java vai deixar você experimentar mais rápido.
  • A única razão é “Rust é rápido”: meça primeiro. Muitas vezes profiling e otimizar o algoritmo na linguagem atual já é suficiente.

Alguns erros comuns ao começar:

Nota: Medir desempenho usando a build de debug (cargo build sem --release) dará resultados muito mais lentos que o real. Sempre faça benchmark com a build release.

Nota: Usar .unwrap() demais faz a ferramenta entrar em pânico com input ruim. Para ferramentas de infraestrutura, retorne Result e imprima mensagens de erro claras no stderr.

Nota: unsafe desativa parte da verificação do compilador. Limite-o a módulos pequenos e revise cuidadosamente. Não use unsafe só para “enganar” o borrow checker.

Além disso, o tempo de compilação do Rust é bem longo em projetos grandes. Ative cache para o diretório target e registry do Cargo em CI, ou o pipeline vai ficar significativamente mais lento.

7. Conclusão

Rust é escolhido para infraestrutura porque consegue unir três coisas que eram difíceis de ter ao mesmo tempo antes: desempenho nativo, segurança de memória garantida pelo compilador, e capacidade de distribuir como um único arquivo binário. É por isso que muitos CLIs, linters, bundlers, proxies e agentes estão sendo reescritos em Rust.

Neste artigo, você escreveu um CLI logstat com testes, o empacotou com multi-stage Docker build e o chamou de Java via ProcessBuilder. A abordagem mais sensata é usar Rust para componentes especializados, que rodam muitas vezes ou são sensíveis a recursos. A parte de negócio deve ficar na linguagem que seu time domina bem. Antes de reescrever qualquer coisa, meça e pergunte a si mesmo: os benefícios são grandes o bastante para compensar o custo de aprender e manter?

rusttech-trendsinfrastructureclidevops