Definición del Component Model
Cortemos con el marketing. Un WASM Component es:
- Un binario
.wasmque sigue la spec del Component Model (no un módulo wasm "core") - Declara imports tipados (lo que necesita) y exports (lo que provee)
- Estos tipos se definen en archivos WIT (WebAssembly Interface Type)
- Los componentes se pueden componer, conectar en sus boundaries de import/export
- Cada componente tiene su propia memoria lineal (sandboxeado por default)
Acá va una definición WIT mínima para un componente de autenticación:
// auth.wit
package myorg:auth@1.0.0;
interface authenticate {
record auth-request {
token: string,
required-scopes: list<string>,
}
record auth-result {
authenticated: bool,
user-id: option<string>,
granted-scopes: list<string>,
error: option<string>,
}
verify: func(req: auth-request) -> auth-result;
}
world auth-component {
export authenticate;
}
Y la implementación en Rust:
// src/lib.rs
wit_bindgen::generate!({
world: "auth-component",
path: "../wit/auth.wit",
});
struct AuthComponent;
impl Guest for AuthComponent {
fn verify(req: AuthRequest) -> AuthResult {
let claims = match decode_jwt(&req.token) {
Ok(c) => c,
Err(e) => return AuthResult {
authenticated: false,
user_id: None,
granted_scopes: vec![],
error: Some(format!("JWT decode failed: {e}")),
},
};
let granted: Vec<String> = req.required_scopes
.iter()
.filter(|s| claims.scopes.contains(s))
.cloned()
.collect();
let all_granted = granted.len() == req.required_scopes.len();
AuthResult {
authenticated: all_granted,
user_id: Some(claims.sub.clone()),
granted_scopes: granted,
error: if all_granted { None } else {
Some("Insufficient scopes".into())
},
}
}
}
export!(AuthComponent);
La macro wit_bindgen::generate! lee el archivo WIT y genera todas las definiciones de tipos y el código glue. Se implementa el trait, se exporta el struct, lo buildeas:
cargo component build --release
Eso produce un componente .wasm (no un módulo core) que cualquier runtime del Component Model puede cargar y llamar.
Composición Cross-Language: El Poder Real
Acá resulta interesante. Supongamos que tu componente de auth está en Rust, pero tu API gateway está en Go, y tu lógica de negocio está en Python (usando componentize-py). Cada uno se compila a un componente WASM separado.
Primero, el WIT del componente gateway en Go:
// gateway.wit
package myorg:gateway@1.0.0;
interface handle-request {
record http-request {
method: string,
path: string,
headers: list<tuple<string, string>>,
body: option<list<u8>>,
}
record http-response {
status: u16,
headers: list<tuple<string, string>>,
body: list<u8>,
}
handle: func(req: http-request) -> http-response;
}
world gateway-component {
import myorg:auth/authenticate@1.0.0;
import myorg:bizlogic/process@1.0.0;
export handle-request;
}
La implementación en Go importa la interfaz de auth y la llama como una función local, sin HTTP, sin serialización:
package main
import (
"myorg/gateway/gen"
auth "myorg/auth/gen"
bizlogic "myorg/bizlogic/gen"
)
type Gateway struct{}
func (g *Gateway) Handle(req gen.HttpRequest) gen.HttpResponse {
token := extractBearerToken(req.Headers)
// Llamar al componente auth de Rust, mismo proceso, sin red
authResult := auth.Verify(auth.AuthRequest{
Token: token,
RequiredScopes: []string{"read:users"},
})
if !authResult.Authenticated {
return gen.HttpResponse{
Status: 401,
Body: []byte(`{"error": "unauthorized"}`),
}
}
// Llamar al componente de lógica de negocio en Python
result := bizlogic.Process(bizlogic.ProcessRequest{
UserId: *authResult.UserId,
Action: "list_users",
Payload: req.Body,
})
return gen.HttpResponse{
Status: 200,
Headers: []gen.Header{{"content-type", "application/json"}},
Body: result.Data,
}
}
Ahora los componemos:
# Buildear cada componente
cargo component build --release -p auth-component
tinygo build -target=wasip2 -o gateway.wasm ./gateway
componentize-py -d ./wit -w bizlogic-component ./bizlogic.py -o bizlogic.wasm
# Componer en un solo componente linkeado
wasm-tools compose \
--definitions auth-component.wasm \
--definitions bizlogic.wasm \
-o composed-service.wasm \
gateway.wasm
El composed-service.wasm es un solo binario conteniendo los tres componentes, con sus imports/exports conectados. Lo es posible correr en wasmtime, Cloudflare Workers, Fermyon Spin, o cualquier runtime del Component Model.
El Pipeline de Build en Detalle
Hacer funcionar esto en CI toma más esfuerzo del esperado. Acá está un Makefile que realmente funciona:
WIT_DIR := ./wit
BUILD_DIR := ./build
WASI_ADAPTER := ./adapters/wasi_snapshot_preview1.reactor.wasm
.PHONY: all clean validate compose
all: validate compose
validate:
wasm-tools component wit $(WIT_DIR) --check
$(BUILD_DIR)/auth.wasm: auth/src/lib.rs $(WIT_DIR)/auth.wit
cd auth && cargo component build --release
cp auth/target/wasm32-wasip2/release/auth_component.wasm $@
wasm-opt -Oz $@ -o $@
$(BUILD_DIR)/gateway.wasm: gateway/main.go $(WIT_DIR)/gateway.wit
cd gateway && tinygo build -target=wasip2 -o ../$@
wasm-tools component new $@ --adapt $(WASI_ADAPTER) -o $@
$(BUILD_DIR)/bizlogic.wasm: bizlogic/app.py $(WIT_DIR)/bizlogic.wit
componentize-py -d $(WIT_DIR) -w bizlogic-component bizlogic/app.py -o $@
compose: $(BUILD_DIR)/auth.wasm $(BUILD_DIR)/gateway.wasm $(BUILD_DIR)/bizlogic.wasm
wasm-tools compose \
-d $(BUILD_DIR)/auth.wasm \
-d $(BUILD_DIR)/bizlogic.wasm \
-o $(BUILD_DIR)/composed-service.wasm \
$(BUILD_DIR)/gateway.wasm
@echo "Binario compuesto: $$(du -h $(BUILD_DIR)/composed-service.wasm | cut -f1)"
clean:
rm -rf $(BUILD_DIR)/*
Dos lecciones importantes:
-
La validación de WIT tiene que ir primero. Un typo en un archivo WIT produce errores inescrutables downstream.
wasm-tools component wit --checklos atrapa temprano. -
El adapter WASI es sensible a la versión. TinyGo, el target
wasm32-wasip2de Rust, y componentize-py cada uno espera una versión específica de WASI preview. El binario del adapter se debe pinar en el repo y referenciarlo explícitamente.
Rendimiento: Análisis de Compromisos
Acá van números reales de un deployment de producción. El setup tiene 12 componentes WASM (mezcla de Rust, Go y Python) compuestos en 3 servicios, corriendo en un orquestador custom basado en wasmtime 18 en bare metal.
Overhead de llamada entre componentes:
| Tamaño del payload | Latencia | Notas |
|---|---|---|
| Vacío (sin args) | 0.8μs | Costo base de dispatch |
| Struct de 1KB | 2.1μs | Incluye serialización ABI canónica |
| Blob de 64KB | 18μs | Dominado por memcpy entre memorias lineales |
| Blob de 1MB | 156μs | Lineal con el tamaño, como se espera |
Para contexto, una llamada gRPC por loopback es ~50μs para un mensaje vacío, y ~200μs para 1KB. Así que las llamadas entre componentes son ~25x más rápidas que gRPC para payloads chicos. La brecha se achica con payloads grandes porque ambos están bottlenecked en la copia de datos.
Overhead de CPU vs nativo (benchmark compute-bound, sin I/O):
| Lenguaje del componente | vs nativo | Notas |
|---|---|---|
| Rust → WASM | 1.15x | Overhead mínimo, bounds checks |
| Go → WASM (TinyGo) | 1.45x | El GC de TinyGo es más simple, faltan algunas opts |
| Python → WASM | 3.2x | componentize-py incluye intérprete CPython |
El número de Python se ve feo, pero recordar: este componente Python corre en el mismo proceso, sin overhead de container, sin cold start, y con sandboxing completo. Si la alternativa es un container Python con 800ms de cold start, 3.2x de overhead de CPU en una computación de 5ms es un trade fantástico.
Huella de memoria:
| Deployment | Memoria por instancia |
|---|---|
| 12 containers Docker (1 por componente) | ~3.2GB total |
| 3 servicios WASM compuestos (4 componentes c/u) | ~420MB total |
| Un solo WASM compuesto (los 12 componentes) | ~180MB |
Los ahorros de memoria son dramáticos porque los componentes WASM comparten el runtime (wasmtime) y solo alocan su propia memoria lineal. La memoria lineal de un componente Rust empieza en 64KB y crece bajo demanda.
La Historia del Sandboxing
Esta es la parte que convence a los equipos de seguridad. Cada componente WASM:
- No puede acceder al filesystem a menos que se le dé explícitamente una capability
wasi:filesystem - No puede hacer llamadas de red a menos que se le dé
wasi:sockets - No puede leer la memoria de otro componente (memorias lineales separadas)
- No puede ejecutar syscalls arbitrarios (modelo de capabilities de WASI)
Consideremos un vendor tercero que provee un componente de "enrichment de datos". En el mundo de containers, se necesitaría correrlo en un namespace de red separado, configurar políticas AppArmor/SELinux, armar filtrado de egress, etc. Con WASM, el componente se puede componer en el servicio con cero capabilities, puede computar sobre los datos que se le pasan y devolver resultados. Eso nomás. Si el código intenta llamar a casa, la llamada falla en el boundary de WASI, no en un firewall de red.
# Componer componente del vendor SIN capabilities WASI
wasm-tools compose \
--definitions vendor-enrichment.wasm \
--definitions our-service.wasm \
-o final.wasm \
orchestrator.wasm
Errores Comunes a Evitar
Error 1: Poner parsing de JSON en el boundary del componente. Un primer diseño común tiene componentes intercambiando strings JSON. Esto significaba que cada llamada cross-componente pagaba por serialize + deserialize + dos alocaciones de string. Cambiar a tipos record de WIT con el ABI canónico eliminó esto completamente.
Error 2: Un componente gigante en vez de composición. Un enfoque inicial común compila todo en un solo módulo WASM (no componente). Funcionaba pero perdía todos los beneficios de sandboxing y hacía imposible actualizar sin redeployar todo. La composición del Component Model es el punto, te deja actualizar un componente sin tocar los otros.
Error 3: Ignorar la versión del adapter. Es posible perder tres días debuggeando un segfault que resultó ser un mismatch entre el adapter WASI preview 1 y un componente buildeado para preview 2. Fijar las versiones de tu adapter. En serio.
Cuándo Usar Esto (y Cuándo No)
Utilizar el WASM Component Model cuando:
- Se necesitan componer código de múltiples lenguajes en un solo proceso
- El aislamiento de seguridad entre componentes es importante
- Querés alta densidad de deployment (muchos servicios, memoria limitada)
- Tus servicios son compute-heavy con intercambios de datos chicos
No lo uses cuando:
- Todo tu código es un solo lenguaje (utilizar el tooling nativo de ese lenguaje)
- Se necesitan intercambiar datasets grandes entre componentes (el overhead de copia se acumula)
- Tu equipo no se siente cómodo con la complejidad del build toolchain
- Se necesitan herramientas maduras de debugging/profiling (están mejorando, pero no llegaron todavía)
El Component Model no está reemplazando Kubernetes ni Docker. Es una herramienta nueva para una clase específica de problemas, y para esos problemas, es notablemente efectivo.