Por qué corremos Bun en el contenedor del agente (y Node en el host)

BunNodeArquitecturaContainersSQLite8 min de lectura
Por qué corremos Bun en el contenedor del agente (y Node en el host)

Por qué corremos Bun en el contenedor del agente (y Node en el host)

Cuando diseñas un sistema que orquesta contenedores por sesión, cada milisegundo de wake-up se nota. Si el usuario manda un mensaje y tarda un segundo en llegar al modelo, eso es UX. Si tarda medio segundo más porque hay un tsc corriendo antes de que el runtime arranque, ese medio segundo es puro peaje técnico que el usuario nunca debería pagar.

Este post documenta una decisión arquitectónica que tomamos en Ghosty v2: correr Bun dentro del contenedor del agente, mientras el host sigue en Node + pnpm. Un solo stack era más simple. Pero "simple" no siempre significa "correcto".

El setup: host orquestador, contenedor por sesión

El patrón es común en sistemas de agentes: un proceso host de larga vida que recibe mensajes de múltiples canales (WhatsApp, Slack, Telegram, etc.) y, por cada sesión activa, levanta un contenedor aislado donde corre el agente. Host y contenedor no comparten memoria — se comunican vía dos archivos SQLite por sesión:

text
session_id/
  inbound.db    — host escribe, contenedor lee
  outbound.db   — contenedor escribe, host lee

Exactamente un escritor por archivo. Cero contención de locks entre mounts. Ese aislamiento es lo que permite usar runtimes distintos en cada lado sin acoplarlos.

El stack original: Node + npm en todos lados

La primera versión era uniforme. Node 22 + npm en el host, Node 22 + npm en el contenedor. Mismo lockfile, mismas dependencias, mismo tsc compilando TypeScript a dist/ durante el build de imagen.

Los problemas aparecieron en producción:

  1. better-sqlite3 recompila en cada rebuild de imagen. Es una extensión nativa. Cada vez que invalidabas la capa, 30-60 segundos de node-gyp contra headers de Node, libc, V8. Frustrante en CI, peor localmente cuando iteras.
  2. El tsc del build sumaba 200-500ms al wake de sesión. Porque aunque compilábamos a dist/, el host mounteaba fresh source sobre /app/src para que los edits del operador se aplicaran sin rebuild. Si mounteas source, necesitas compilar o interpretar al arrancar. Compilar en cada wake no escala.
  3. npm install en el contenedor era lento. No es catastrófico, pero cuando iteras en deps del agente lo sientes.

Ninguno era un bug, pero sumados eran fricción constante.

La alternativa: Bun en el contenedor

Bun resuelve estos tres problemas de una sentada:

  • bun:sqlite viene built-in. No hay módulo nativo que compilar. La capa de Docker con deps es instantánea.
  • Corre TypeScript directo, sin tsc. bun run src/index.ts ejecuta el archivo sin paso de build. Borrar el tsc del wake le quitó ~300ms en promedio al arranque de sesión.
  • bun install es 5-10× más rápido que npm install. Cuando bumpeas el SDK del agente, lo notas.

La pregunta obvia: ¿y por qué no pasar el host a Bun también?

Por qué el host se queda en Node

Hay una librería en el host que decide esto: Baileys (WhatsApp). Baileys depende de libsignal-node vía bindings nativos, más un stack WebSocket/HTTP bastante probado sobre Node. La compat Node-API de Bun ha mejorado muchísimo, pero Baileys es justamente el tipo de dependencia donde un edge case te arruina el fin de semana: reconexiones, signal keys, binary frames de WebSocket.

El contenedor del agente, en cambio, no tiene deps nativas. Su vida es: leer de inbound.db, llamar al SDK del modelo, escribir a outbound.db. Bun puede hacer eso perfecto sin tocar bindings nativos.

Así que el criterio terminó siendo:

Usa Bun donde te beneficia (velocidad de install, sin native compile, sin tsc de wake) y deja Node donde ya funciona con deps que te importa que sigan funcionando.

Lo que se complica con el split

Dos runtimes no es gratis. Estas son las cosas que tuvimos que arreglar y documentar:

1. Dos lockfiles, dos package managers

text
/                                 pnpm 10 + Node 22
  pnpm-lock.yaml
  pnpm-workspace.yaml             ← minimumReleaseAge, onlyBuiltDependencies

/container/agent-runner/          Bun 1.3+
  bun.lock
  package.json

Ambos committeados. CI corre --frozen-lockfile en los dos, así que cualquier drift entre package.json y lockfile rompe el build. El mental model es: son dos paquetes, no un monorepo.

2. Supply chain asimétrica

En el host usamos minimumReleaseAge: 4320 (3 días de espera antes de resolver una versión nueva) y un allowlist de onlyBuiltDependencies para scripts de postinstall. Es la política pnpm contra ataques tipo chalk/shaiHulud/compromised-maintainer.

Bun no tiene hoy un equivalente a minimumReleaseAge. La defensa en el contenedor es:

  • bun.lock pinned
  • Versiones de Bun, Claude Code, Vercel y demás CLIs pinned vía ARG en el Dockerfile
  • Cuando bumpeas @anthropic-ai/claude-agent-sdk revisas fecha de release en npm y lo haces deliberadamente, nunca con bun update

Los CLIs globales que el agente invoca en runtime (claude-code, vercel, etc.) siguen instalándose con pnpm install -g en el Dockerfile para no saltarse la política. bun install -g rompería eso.

3. bun:sqlite no auto-strip de named params

Este fue mi favorito descubrir. En better-sqlite3 puedes hacer:

typescript
db.prepare("INSERT INTO msgs (id, body) VALUES ($id, $body)")
  .run({ id: "abc", body: "hola" });  // auto-strip del $

En bun:sqlite eso falla silenciosamente. Hay que mandar el prefijo en las keys del objeto:

typescript
db.prepare("INSERT INTO msgs (id, body) VALUES ($id, $body)")
  .run({ $id: "abc", $body: "hola" });  // ← el $ es literal

Los params posicionales con ? funcionan igual en ambos. La trampa es solo con named. Documentado con neón en el doc de build-and-runtime porque el síntoma es un undefined raro en la columna, no una excepción.

4. Tests distintos

Los tests del host corren con vitest. Los del contenedor con bun test. No son intercambiables: vitest corre sobre Node y no puede cargar bun:sqlite. vitest.config.ts excluye container/agent-runner/ explícitamente.

El CI corre ambos en secuencia:

yaml
- pnpm install --frozen-lockfile
- bun install --frozen-lockfile  # cwd: container/agent-runner
- pnpm exec tsc --noEmit
- pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
- pnpm exec vitest run
- bun test                       # cwd: container/agent-runner

Cualquiera que falle, falla el PR.

5. Una invariante crítica: journal_mode=DELETE

Las DBs de sesión no pueden usar WAL (journal_mode=WAL). Por qué: WAL usa un archivo -shm mapeado en memoria, y ese memory-map no cruza VirtioFS entre host y guest del contenedor. El host escribiría en WAL, el contenedor leería stale y viceversa.

Con journal_mode=DELETE cada commit hace fsync completo. Es más lento que WAL en absoluto, pero "lento" aquí son ~1ms por transacción y nuestras transacciones son pequeñas. No es el cuello de botella.

Resultado

MétricaNode+npm uniformeSplit Node/Bun
Wake de sesión~700-900ms~400-500ms
Rebuild de imagen (deps change)45-90s10-20s
Rebuild de imagen (source change)15-30s3-5s
pnpm install host~25s~25s (igual)
install del agent-runner~40s (npm)~5s (bun)

Los números son de nuestro hardware y varían, pero el orden de magnitud se mantiene. Lo importante es que el wake de sesión bajó a casi la mitad, y ese es el número que ve el usuario final.

Cuándo vale la pena un split así

No siempre. Si tu app es un solo proceso que habla con una base de datos compartida, split de runtimes es overkill — gestionar dos lockfiles, dos policies de supply chain, dos sistemas de test es costo real.

Vale la pena cuando:

  1. El IO entre procesos ya es explícito y aislado. En nuestro caso son los dos SQLite files por sesión. Si host y contenedor compartieran módulos JS, forget it.
  2. El lado "nuevo runtime" tiene una superficie de deps controlada. El contenedor del agente tiene ~15 deps runtime. El host tiene Baileys y compañía — cientos.
  3. El beneficio es medible. 300-400ms de wake es notable. 20ms no lo sería.

Si estás en ese perfil, el split te compra velocidad real sin sacrificar la confiabilidad en los lugares donde la necesitas.

Conclusiones

La lección general no es "Bun es más rápido" — es elegir el runtime por pieza, no por proyecto. Node en el host porque Baileys y el ecosistema maduro pesan más que la velocidad. Bun en el contenedor porque SQLite built-in + TS directo + install rápido pesan más que la uniformidad.

El precio es tener dos lockfiles, dos package managers, dos policies de supply chain y un par de gotchas como el $ literal de bun:sqlite. Bien documentado, es manejable. Invisible para el usuario, que solo nota que el agente responde más rápido.


Prueba Formmy

Si estás construyendo agentes, formularios inteligentes o cualquier cosa que hable con tus usuarios, Formmy te da la plataforma sin que tengas que pelearte con runtimes, containers y SQLite.

Prueba Formmy Gratis — Enfócate en tu producto, no en el plumbing.

¿Tienes preguntas sobre cómo escalamos agentes o sobre el stack técnico? Nuestro equipo responde.