· 12 min de lectura · Por Grover Cordova
Tu primer microservicio en Rust, y cuánto rinde de verdad
Un microservicio en Rust con Actix Web, paso a paso, medido con oha frente a Go, Node.js y Python. Cifras reales, con su método y sus límites.
Hay muchas comparaciones de rendimiento en internet. Esta la hicimos nosotros, con código que puedes copiar. Construimos un microservicio pequeño, lo medimos frente a tres alternativas y publicamos también lo que no favorece a Rust.
Qué vamos a construir
Un servicio que devuelve clientes en JSON, con un endpoint de salud, registro estructurado y apagado ordenado. Son las piezas básicas de un servicio de verdad.
Requisitos
Necesitas Rust instalado. Crea el proyecto:
cargo new clientes
cd clientes
Las dependencias
Este es el Cargo.toml. El perfil de compilación final importa: optimiza al máximo y deja un binario pequeño.
[package]
name = "clientes"
version = "0.1.0"
edition = "2024"
[dependencies]
actix-web = "4"
anyhow = "1"
clap = { version = "4", features = ["derive"] }
mimalloc = { version = "0.1", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-actix-web = "0.7"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[build-dependencies]
chrono = { version = "0.4", features = ["clock"] }
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
strip = true
debug = 0
Cada pieza cumple una función:
| Paquete | Para qué sirve |
|---|---|
actix-web | El servidor HTTP |
tokio | El entorno asíncrono |
serde, serde_json | Convertir a JSON |
tracing | Registro estructurado |
clap | Argumentos de línea de comandos |
anyhow, thiserror | Manejo de errores |
mimalloc | Un asignador de memoria rápido |
El código
src/cli.rs define los argumentos. Con -v muestra la versión y con config imprime la configuración:
use clap::{Parser, Subcommand};
pub const PKG_NAME: &str = env!("CARGO_PKG_NAME");
pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const GIT_HASH: &str = env!("GIT_HASH");
pub const BUILD_DATE: &str = env!("BUILD_DATE");
pub const PKG_ARCH: &str = env!("ARCH");
#[derive(Parser)]
#[command(name = "clientes", about = "Microservicio de ejemplo", disable_version_flag = true)]
pub struct Cli {
/// Muestra la versión y termina
#[arg(short = 'v', long = "version")]
pub version: bool,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub enum Commands {
/// Muestra la configuración como JSON
Config,
}
pub fn get_version() -> String {
format!("{PKG_NAME} {PKG_VERSION} ({GIT_HASH} {BUILD_DATE}) {PKG_ARCH}")
}
src/main.rs contiene el servicio. Fíjate en los errores: AppError define qué respuesta recibe el cliente, y un cliente inexistente devuelve un 404 en JSON. Al recibir SIGTERM o Ctrl+C, el servicio termina lo que tiene en curso antes de apagarse.
mod cli;
use std::collections::HashMap;
use actix_web::{App, HttpResponse, HttpServer, ResponseError, get, http::StatusCode, web};
use anyhow::{Context, Result};
use clap::Parser;
use serde::Serialize;
use tracing_actix_web::TracingLogger;
use cli::{Cli, Commands, get_version};
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[derive(Serialize)]
struct Config {
host: String,
port: u16,
workers: usize,
}
impl Default for Config {
fn default() -> Self {
let workers = std::env::var("WORKERS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(num_cpus);
let port = std::env::var("PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8080);
Self { host: "127.0.0.1".into(), port, workers }
}
}
fn num_cpus() -> usize {
std::thread::available_parallelism().map_or(1, |n| n.get())
}
#[derive(Clone, Serialize)]
struct Cliente {
id: u32,
nombre: String,
activo: bool,
}
#[derive(Debug, Serialize)]
struct ErrorBody {
error: String,
message: String,
}
#[derive(Debug, thiserror::Error)]
enum AppError {
#[error("cliente {0} no encontrado")]
NoEncontrado(u32),
}
impl ResponseError for AppError {
fn status_code(&self) -> StatusCode {
StatusCode::NOT_FOUND
}
fn error_response(&self) -> HttpResponse {
let status = self.status_code();
HttpResponse::build(status).json(ErrorBody {
error: status.canonical_reason().unwrap_or("error").to_string(),
message: self.to_string(),
})
}
}
type Clientes = web::Data<HashMap<u32, Cliente>>;
#[get("/salud")]
async fn salud() -> &'static str {
"ok"
}
#[get("/clientes/{id}")]
async fn cliente(id: web::Path<u32>, datos: Clientes) -> Result<web::Json<Cliente>, AppError> {
let id = id.into_inner();
let cliente = datos.get(&id).cloned().ok_or(AppError::NoEncontrado(id))?;
Ok(web::Json(cliente))
}
fn init_logger() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_target(true)
.with_thread_ids(false)
.compact()
.init();
}
async fn shutdown(handle: actix_web::dev::ServerHandle) {
use tokio::signal::unix::{SignalKind, signal};
let mut sigterm = signal(SignalKind::terminate()).expect("no se pudo escuchar SIGTERM");
tokio::select! {
_ = sigterm.recv() => {}
_ = tokio::signal::ctrl_c() => {}
}
tracing::info!("shutdown signal received, initiating graceful shutdown");
handle.stop(true).await;
}
#[actix_web::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
if cli.version {
println!("{}", get_version());
return Ok(());
}
let config = Config::default();
if let Some(Commands::Config) = cli.command {
println!("{}", serde_json::to_string_pretty(&config)?);
return Ok(());
}
init_logger();
tracing::info!(version = %get_version(), "starting service");
let total: u32 = std::env::var("CLIENTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000);
let datos: HashMap<u32, Cliente> = (1..=total)
.map(|id| (id, Cliente { id, nombre: format!("Cliente {id}"), activo: id % 2 == 0 }))
.collect();
let datos = web::Data::new(datos);
let servidor = HttpServer::new(move || {
App::new()
.wrap(TracingLogger::default())
.app_data(datos.clone())
.service(salud)
.service(cliente)
})
.workers(config.workers)
.disable_signals()
.bind((config.host.as_str(), config.port))
.with_context(|| format!("failed to bind to port {}", config.port))?
.run();
tracing::info!(host = %config.host, port = config.port, "server running");
tokio::spawn(shutdown(servidor.handle()));
servidor.await.context("server error")?;
tracing::info!("server shutdown complete");
Ok(())
}
Por último, build.rs graba en el binario el commit, la fecha y la arquitectura, para saber siempre qué versión corre en producción:
use std::process::Command;
fn main() {
let git_hash = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown".to_string());
let build_date = chrono::Utc::now().format("%Y-%m-%d").to_string();
let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_else(|_| "unknown".to_string());
println!("cargo:rustc-env=GIT_HASH={git_hash}");
println!("cargo:rustc-env=BUILD_DATE={build_date}");
println!("cargo:rustc-env=ARCH={arch}");
println!("cargo:rerun-if-changed=.git/HEAD");
}
Compilar y probar
cargo build --release
./target/release/clientes
Desde otra terminal, con xh o curl:
xh :8080/clientes/500
{ "id": 500, "nombre": "Cliente 500", "activo": true }
Cómo lo medimos
Para generar carga usamos oha:
oha -z 10s -c 64 http://127.0.0.1:8080/clientes/500
Escribimos el mismo endpoint en Go, Node.js y Python, y en todos usamos un solo núcleo, para que la comparación sea justa. Node y Python ya funcionan así por defecto. Rust y Go se limitaron con Go: Node.js: Python:WORKERS=1 y GOMAXPROCS=1.Ver el método completo y el código de las alternativas
net/http, Node.js 26.8.1 con node:http, Python 3.9.6 con FastAPI 0.128.8 y Uvicorn 0.39.0.package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
)
type Cliente struct {
ID int `json:"id"`
Nombre string `json:"nombre"`
Activo bool `json:"activo"`
}
func main() {
datos := map[int]Cliente{}
total, err := strconv.Atoi(os.Getenv("CLIENTES"))
if err != nil {
total = 1000
}
for id := 1; id <= total; id++ {
datos[id] = Cliente{id, fmt.Sprintf("Cliente %d", id), id%2 == 0}
}
http.HandleFunc("GET /clientes/{id}", func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
c, ok := datos[id]
w.Header().Set("Content-Type", "application/json")
if !ok {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error":"no encontrado"}`))
return
}
json.NewEncoder(w).Encode(c)
})
http.ListenAndServe("127.0.0.1:8082", nil)
}
import http from 'node:http';
const datos = new Map();
const total = Number(process.env.CLIENTES ?? 1000);
for (let id = 1; id <= total; id++) datos.set(id, { id, nombre: `Cliente ${id}`, activo: id % 2 === 0 });
http.createServer((req, res) => {
const m = req.url.match(/^\/clientes\/(\d+)$/);
const c = m && datos.get(Number(m[1]));
if (!c) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"no encontrado"}'); }
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(c));
}).listen(8081, '127.0.0.1');
from fastapi import FastAPI, HTTPException
app = FastAPI()
datos = {i: {"id": i, "nombre": f"Cliente {i}", "activo": i % 2 == 0} for i in range(1, 1001)}
@app.get("/clientes/{id}")
async def cliente(id: int):
if id not in datos:
raise HTTPException(status_code=404, detail="no encontrado")
return datos[id]
Primer resultado: un servicio pequeño
Con 1000 registros, esto es lo que obtuvimos:
| Con 1 núcleo | Rust | Go | Node.js | Python |
|---|---|---|---|---|
| Capacidad máxima (pet/s) | 104 567 | 101 489 | 90 580 | 14 024 |
| Memoria (MB) | 4,7 | 15,5 | 65,9 | 38,5 |
| CPU a 2000 pet/s | 6,6 % | 8,0 % | 8,1 % | 34,5 % |
| Latencia p50 a 2000 pet/s (ms) | 0,13 | 0,11 | 0,12 | 0,39 |
| Latencia p99 a 2000 pet/s (ms) | 1,72 | 1,27 | 1,27 | 3,64 |
| Tamaño del binario (MB) | 3,4 | 9,4 | n/a | n/a |
Lo que dicen estos números:
- En latencia no hay ganador. A carga moderada, Rust, Go y Node.js están empatados. Las diferencias del p99 entran en el ruido de la medición.
- La capacidad está cerca. Rust, Go y Node.js superan las 90 000 peticiones por segundo en un núcleo. Muy pocos servicios llegan a eso.
- La memoria es la gran diferencia: Rust usa un tercio que Go y unas catorce veces menos que Node.js.
- Python queda atrás, pero no es lento para muchos casos. Sigue atendiendo 14 000 peticiones por segundo. Si haces veinte, da lo mismo.
Conviene decirlo sin rodeos: si este fuera tu sistema, ninguna de estas cifras justifica por sí sola una migración.
Segundo resultado: muchos datos en memoria
Aquí es donde se separan los caminos. Repetimos la prueba con 5 millones de registros en memoria y 20 000 peticiones por segundo, durante 25 a 30 segundos. Es el caso difícil para un recolector, como explicamos en esta nota.
| 5 millones de registros | Rust | Go | Node.js |
|---|---|---|---|
| Memoria real (MB) | 408 | 865 | 875 |
| p99 con 1 núcleo (ms) | 2,2 a 10,9 | 81 a 127 | 2,1 a 2,9 |
| p99.9 con 1 núcleo (ms) | 6,8 a 37 | 152 a 206 | 5,6 a 28 |
| p99 con 4 núcleos (ms) | 2,3 | 2,6 | n/a |
| p99.9 con 4 núcleos (ms) | 5,3 | 17,4 | n/a |
Mostramos rangos porque hicimos cuatro ejecuciones con un núcleo, y variaron bastante. Con cuatro núcleos hicimos solo una, de modo que esas cifras son orientativas.
Este resultado nos parece más útil que uno que solo elogie a Rust:
- Go con un núcleo sufre. Su p99 sube a más de 80 ms, unas 40 veces lo normal. Es lo que predice su guía oficial: el recolector recorre los datos vivos y necesita CPU. Con un solo núcleo, compite con las peticiones.
- Con 4 núcleos, casi desaparece. El p99 de Go baja a 2,6 ms. Antes de reescribir, a veces basta con darle CPU, aunque esos núcleos también se pagan.
- Node.js sorprendió. Su p99 igualó o superó al de Rust. Una explicación probable es que V8 separa objetos jóvenes y viejos, y no recorre todos los datos en cada ciclo. No perfilamos este punto, así que tómalo como hipótesis. No todos los recolectores se comportan igual.
- Rust no fue el más estable en p99. Varió de 2,2 a 10,9 ms, y no investigamos la causa. Su p99.9 fue el mejor en la prueba con cuatro núcleos, pero Node.js estuvo a su nivel en algunas ejecuciones.
- La memoria se mantiene: Rust necesitó menos de la mitad que Go y que Node.js para los mismos datos.
Una lección sobre medir: ps nos mostró 360 MB para Node.js, porque no contaba la memoria comprimida por macOS. Con footprint, eran 875 MB. Por eso ese dato de la tabla difiere de lo que verías en ps.
Qué significa para tu negocio
Si lo traducimos a decisiones:
- Servicios sencillos: no migres por rendimiento. Cualquiera de estas opciones te sirve.
- Muchas instancias pequeñas: aquí la memoria pesa. Un servicio de 5 MB frente a uno de 66 MB cambia cuántos caben en un servidor, y cuánta energía consumes.
- Grandes datos en memoria con poca CPU: ahí es donde un recolector puede dar picos, y donde Rust ofrece más margen.
- Python en la ruta crítica: suele ser el cambio con más retorno, y no siempre exige reescribir todo. Mira cómo migrar por partes.
Lo que esta prueba no dice
Un benchmark es una foto, no un veredicto:
- El endpoint casi no hace trabajo. Un sistema real pasa la mayor parte del tiempo en la base de datos y en la red.
- El generador de carga compartió máquina con el servicio, lo que añade ruido.
- macOS no es tu servidor de producción, y Python 3.9 no es la versión más reciente.
- Limitar a un núcleo favorece a Node.js y Python, que por defecto usan uno, y perjudica a Go, que está pensado para usar varios.
- No ajustamos parámetros como
GOGCni la memoria de V8.
Por eso lo más valioso de esta nota no son las cifras, sino el método. Repite la prueba con tu endpoint, tus datos y tu carga.
Fuentes
- Actix WebProyecto Actix
- oha: generador de carga HTTPhatoo
- A Guide to the Go Garbage CollectorThe Go Authors
- Trash talk: the Orinoco garbage collectorV8, 2019
- mimallocMicrosoft