Chromium headless para agentes de IA en un VPS
Evite fallos de Chromium headless en un VPS: /dev/shm pequeno, flags de sandbox, fuentes ausentes y procesos filtrados. Ajuste los limites antes del agente.
Lo que está ejecutando
Un navegador headless en un VPS es Chromium sin ventana, controlado por su código en lugar de una persona. En un servidor, es un árbol de procesos de larga duración con el que su agente se comunica mediante un socket local. Instalarlo requiere un comando. El trabajo está en todo lo que sigue. Debe limitar los recursos que el navegador puede consumir del equipo y mantener su endpoint de control fuera de Internet pública.
Esta guía presupone que ya ha elegido la herramienta y que ahora debe operarla. Si todavía está comparando crawlers y extractores, empiece por las alternativas autoalojadas a Firecrawl y vuelva después. Todo lo que sigue usa el Chromium de Playwright, porque Playwright incluye su propia compilación del navegador y su propio instalador de dependencias. Por eso, los mismos comandos funcionan en un VPS Ubuntu sin modificaciones y dentro de un contenedor. Las versiones están actualizadas a agosto de 2026.
Instalar Chromium sin adivinar las dependencias
npm i -D playwright@1.62.0
npx playwright install --with-deps chromium--with-deps ejecuta apt para obtener las bibliotecas compartidas y las fuentes que necesita Chromium, y solicita acceso root cuando llega a ese paso. La compilación del navegador se descarga en ~/.cache/ms-playwright para el usuario que ejecutó el comando. Esto es importante en un servidor, porque el usuario del servicio normalmente no es el usuario con el que inicia sesión. Instale una vez los paquetes del sistema como administrador con sudo npx playwright install-deps chromium y, después, establezca PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers tanto en el comando de instalación como en la unidad del servicio para compartir una sola copia. Si un servicio no puede acceder a su navegador, falla al iniciarse y muestra un mensaje con la ruta que buscó.
Fije la versión de Playwright. Cada versión está vinculada a una compilación concreta del navegador, por lo que un npm update sin versión fijada puede sustituir el navegador mientras un servicio está en ejecución. Playwright 1.62 es la versión actual en agosto de 2026.
Existen dos compilaciones de Chromium y no son el mismo programa. La descarga predeterminada es el shell headless, un binario más pequeño que sólo se ejecuta sin interfaz gráfica, y npx playwright install --with-deps --only-shell instala únicamente ese componente. El navegador completo se obtiene con el canal chromium, que la documentación de navegadores de Playwright describe como "el navegador Chrome real y, por tanto, más auténtico, fiable y con más funciones". Use el shell para realizar descargas masivas. Use el navegador completo cuando un sitio se comporte de forma diferente y necesite determinar la causa.
Por qué un navegador sin interfaz gráfica se bloquea en un contenedor
Docker asigna a cada contenedor un /dev/shm de 64 MB. La documentación de Docker es explícita: "Si omite el tamaño por completo, el sistema usa 64m". Chromium pasa el contenido renderizado entre sus procesos mediante esa área de memoria compartida, por lo que una página pesada puede llenarla. El proceso de renderizado termina y el cliente informa de que el destino se bloqueó, aunque la página funcione correctamente en el equipo local. Confirme el tamaño desde dentro del contenedor antes de cambiar nada.
df -h /dev/shmHay dos soluciones reales y son alternativas, no opciones que deban combinarse. --ipc=host coloca el contenedor en el espacio de nombres IPC del host, por lo que usa el /dev/shm del host, que normalmente equivale a la mitad de la RAM. La guía de Docker de Playwright lo recomienda porque, sin esta opción, "Chromium puede quedarse sin memoria y bloquearse". El coste es que se pierde el aislamiento IPC entre el contenedor y el host. --shm-size=1g mantiene el espacio de nombres privado y simplemente aumenta el tamaño del montaje.
docker run --rm -it --init --ipc=host --user pwuser mcr.microsoft.com/playwright:v1.62.0-noble /bin/bashLa opción --disable-dev-shm-usage es la respuesta que encontrará en la mayoría de los resultados de búsqueda, pero hace algo diferente: mueve esos archivos de /dev/shm a un directorio temporal. Si /tmp está en disco, cambia el bloqueo por un renderizado más lento y escrituras en disco. Si /tmp es un tmpfs, los datos vuelven a estar en RAM sin ningún límite de tamaño, que es una forma de que un navegador consuma un VPS pequeño. En su lugar, configure correctamente el tamaño de /dev/shm.
Qué coste tiene realmente --no-sandbox
Chromium aísla cada proceso renderer en un sandbox basado en los user namespaces de Linux. Ese sandbox es la frontera entre una página hostil y el servidor. Cuando no puede iniciarse, Chromium se niega a ejecutarse y el registro contiene una línea como esta:
Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permittedLa recomendación habitual es --no-sandbox. La documentación de seguridad de Chromium es clara sobre el coste: la opción «desactiva funciones de seguridad críticas de Chromium y nunca debe usarse al navegar por la web abierta». Un agente que sigue enlaces navega por la web abierta por definición. Busque la causa real.
Dos causas cubren casi todos los casos. Ejecutar el navegador como root desactiva el sandbox porque no puede eliminar privilegios que ya posee. Por eso la imagen de Playwright incluye un usuario normal llamado pwuser. En Ubuntu 24.04 y versiones posteriores, AppArmor restringe los user namespaces sin privilegios y deniega un binario de Chromium ubicado en una ruta que ningún perfil incluido contempla. La descarga de Playwright en ~/.cache/ms-playwright usa exactamente una ruta de ese tipo. Compruebe ambas causas:
id -u
sysctl kernel.apparmor_restrict_unprivileged_userns
sudo dmesg | grep -i userns_createUn 1 del sysctl junto con una línea del kernel que contenga apparmor="DENIED" operation="userns_create" confirma la segunda causa. Permita ese binario concreto en /etc/apparmor.d/pw-chromium. La restricción seguirá aplicándose al resto del sistema:
abi <abi/4.0>,
include <tunables/global>
profile pw-chromium /home/*/.cache/ms-playwright/*/chrome-linux/{chrome,headless_shell} flags=(unconfined) {
userns,
}Cárguelo con sudo apparmor_parser -r /etc/apparmor.d/pw-chromium. La ruta contiene la revisión del navegador, por lo que cambia con cada actualización de Playwright. Los patrones anteriores siguen funcionando. Un perfil escrito para una ruta exacta deja de coincidir silenciosamente y el navegador vuelve a fallar después de una actualización que parecía no estar relacionada.
Por qué las capturas de pantalla aparecen en blanco o llenas de recuadros
Una captura de pantalla en blanco, o llena de rectángulos vacíos, suele indicar un problema de fuentes y no un error de renderizado. install-deps instala una base funcional: fonts-liberation, fonts-freefont-ttf, fonts-noto-color-emoji, fonts-unifont, fonts-ipafont-gothic para japonés, fonts-wqy-zenhei para chino y fonts-tlwg-loma-otf para tailandés. Ese conjunto no incluye Noto CJK, por lo que el coreano y varios otros sistemas de escritura recurren a la fuente que fontconfig pueda encontrar. Consulte fontconfig en lugar de hacer suposiciones:
fc-match "sans-serif:lang=ko"
fc-match "sans-serif:lang=ar"
fc-list | wc -lSi el idioma que necesita se resuelve en unifont o en una fuente alternativa sin glifos reales, instale fonts-noto-core y fonts-noto-cjk y vuelva a ejecutar la comprobación. Fontconfig almacena sus resultados en caché, por lo que debe reiniciar el navegador después de instalar las fuentes. Una imagen reducida sin ninguna fuente registra Fontconfig error: Cannot load default config file durante el arranque y muestra todas las páginas vacías.
La configuración regional y la zona horaria son independientes de las fuentes. Cambian el contenido de la página, no sólo su aspecto. Un contenedor normalmente tiene LANG sin definir y TZ en UTC, por lo que los sitios se sirven en inglés y muestran marcas de tiempo en UTC. Además, el agente informa de horas que no coinciden con las que ve una persona de ese país. Configure estos valores por contexto del navegador y no por máquina. Así, un mismo navegador puede ejecutar tareas para distintas regiones.
const context = await browser.newContext({
locale: 'en-GB',
timezoneId: 'Europe/Paris',
});Por qué los procesos del navegador que quedan abiertos hacen que el servidor use swap
Dos problemas distintos reciben el nombre de «zombi». Un zombi real es un proceso finalizado cuyo proceso padre nunca llamó a wait(). Conserva una entrada de PID y nada más, por lo que no consume memoria. Estos procesos se acumulan cuando el navegador se ejecuta como PID 1 en un contenedor, porque PID 1 no tiene un recolector de procesos predeterminado. La opción --init de Docker corrige exactamente esto al ejecutar un proceso init pequeño que «reenvía señales y recolecta procesos». En Compose, el equivalente es init: true.
La fuga que realmente hace que el servidor use swap es distinta: son procesos de Chromium activos que ningún proceso cerró. Ocurre cuando una tarea genera una excepción entre newContext() y close(), o cuando se termina el script de control y deja huérfano su árbol de procesos del navegador. La peor variante es el código que inicia un navegador nuevo para cada petición. Cuéntelos:
pgrep -c -f 'headless_shell|chrome'
ps -eo pid,ppid,rss,etime,comm --sort=-rss | head -20Ese recuento debe volver a su valor inactivo entre tareas. Si aumenta durante un día, la corrección está en el código, no en las opciones de inicio: cierre el contexto en un bloque finally, cierre el navegador en SIGTERM y recicle el navegador después de un número fijo de tareas en lugar de mantenerlo en ejecución durante un mes. Con systemd, una detención o un reinicio termina todos los procesos del cgroup de la unidad, por lo que sudo systemctl restart browser.service es un restablecimiento fiable. Un navegador iniciado manualmente dentro de un multiplexor de terminal no ofrece esa garantía, y sus procesos huérfanos sobreviven a la sesión.
Cuánta RAM necesita un contexto de navegador
Formule la pregunta con precisión, porque «un navegador» no es un solo proceso. Chromium ejecuta un proceso del navegador, un proceso de GPU, procesos de utilidades y un proceso de renderizado por sitio. Además, el aislamiento de sitios asigna su propio proceso de renderizado a los iframes de sitios distintos. Un BrowserContext es un almacén de cookies y un área de almacenamiento independientes dentro de ese mismo árbol, por lo que un segundo contexto tiene un coste bajo. Una segunda página no, porque inicia procesos de renderizado, y una página con mucha publicidad inicia varios.
Por tanto, debe medir la memoria máxima de todo el árbol con su propia carga de trabajo. Una cifra de un blog ajeno no sirve en este caso, porque las páginas que abre su agente determinan el resultado. Mida en la máquina que usará y con los sitios que visitará:
sudo systemd-run --unit=browser-probe -p MemoryMax=2G -p MemorySwapMax=0 -p WorkingDirectory=/srv/agent /usr/bin/node worker.js
systemctl status browser-probeEn Ubuntu 24.04, la línea Memory: de esa salida muestra el uso actual y máximo de la unidad. Ejecute el worker con una página cada vez y anote el máximo. Después, repita la prueba con dos páginas abiertas para saber cuánto cuesta realmente una segunda página. La concurrencia se calcula entonces con una operación aritmética: reste de la RAM total lo que necesita el resto del equipo, reserve unos cientos de MB de margen y divida el resultado entre el máximo medido por worker. Para dimensionar la máquina subyacente, consulte cuánta RAM y CPU necesita un VPS para un agente.
Aplique ese límite en dos lugares. En el código, use un grupo fijo de workers o un semáforo, para que las ráfagas de solicitudes del agente se pongan en cola en lugar de iniciar navegadores. En el sistema operativo, use un límite de cgroup, para que un error en la cola no termine afectando a toda la máquina:
[Service]
MemoryMax=2G
MemorySwapMax=0
TasksMax=512
Restart=alwaysMemorySwapMax=0 es más importante de lo que parece. Sin esta opción, el cgroup envía páginas a swap cuando alcanza el límite. La máquina sigue activa, pero todas las solicitudes se vuelven lentas. Esto es más difícil de diagnosticar que un fallo limpio. Con esta opción, el kernel termina el árbol del navegador dentro de ese cgroup, systemd reinicia la unidad y sshd permanece disponible. Los mismos controles en Compose son mem_limit, shm_size y init. Se explican en configurar límites de memoria en Docker Compose.
Mantenga el endpoint del navegador fuera de Internet pública
Playwright puede ejecutar el navegador como servidor y proporcionar a su agente una URL de WebSocket:
const { chromium } = require('playwright');
const server = await chromium.launchServer({ port: 3000 });
console.log(server.wsEndpoint());Ese endpoint no tiene autenticación. La documentación de la API de Playwright lo indica directamente: «Cualquier proceso o página web (incluidos los que se ejecutan en Playwright) que conozca wsPath puede tomar el control del usuario del sistema operativo». El host predeterminado es localhost, «que sólo acepta conexiones desde la interfaz de loopback», y la documentación advierte que pasar una dirección explícita, como 0.0.0.0, «expone el RPC del navegador a cualquier elemento que pueda alcanzar el puerto en escucha». El --remote-debugging-port de Chrome es todavía más problemático. El protocolo DevTools no tiene ningún tipo de autenticación y depende por completo de estar enlazado a loopback.
Compruebe qué ha publicado realmente. Hágalo también desde una segunda máquina, además de hacerlo desde el VPS:
ss -ltnpCualquier servicio de navegador enlazado a 0.0.0.0 constituye un hallazgo. Recuerde que la mayoría de los proveedores ejecutan un firewall de red independiente en su panel de control. Sus reglas de ufw no tienen información sobre ese firewall. Acceda al endpoint desde otra máquina mediante un túnel SSH o una VPN privada:
ssh -N -L 3000:127.0.0.1:3000 you@your-vpsEl riesgo es mayor que el simple robo de tiempo de navegador. Un navegador que se puede controlar es una máquina de falsificación de solicitudes dentro de su red. Quien alcance ese socket puede hacer que consulte http://127.0.0.1:8080, la página de administración de su base de datos o la dirección de metadatos de la nube en 169.254.169.254, y después leer la respuesta desde la página. El firewall ve una solicitud procedente del propio VPS, por lo que la permite. Trate el endpoint de control como si equivaliera al acceso shell a ese equipo.
Los servidores MCP tienen el mismo diseño. npx @playwright/mcp@latest --headless --port 8931 sirve contenido mediante HTTP en localhost, y --host 0.0.0.0 es la opción que convierte una herramienta local en una herramienta pública. El README del proyecto indica claramente que Playwright MCP «no es un límite de seguridad». Mantenga el puerto en loopback y permita que el agente acceda a él mediante el mismo túnel.
Las páginas que lee el agente contienen entradas no confiables
Un agente que navega por la web abierta introduce en el modelo texto escrito por terceros, mientras el modelo también recibe sus instrucciones. Una página puede incluir texto dirigido al modelo para indicarle que abandone la tarea, llame a una herramienta o publique datos en una URL. El modelo recibe ambos contenidos como texto, por lo que no puede distinguir de forma fiable las palabras de la página de sus instrucciones. Diseñe la configuración para que una página hostil tenga pocas posibilidades de actuar.
- Ejecute el navegador con su propio usuario del sistema operativo, sin claves SSH ni credenciales de servicios cloud en su entorno.
- Use un contexto nuevo para cada tarea y
--isolatedcon Playwright MCP, de modo que una sesión en un sitio no esté disponible para la página siguiente. - Mantenga una lista de permitidos de orígenes cuando el trabajo lo permita. Playwright MCP acepta
--allowed-originsy--blocked-originscomo listas separadas por punto y coma. - Exija una intervención humana antes de cualquier acción que cambie el estado, como enviar correo o gastar dinero.
Es preferible mantener todo el navegador en una máquina que pueda eliminar y reconstruir. El razonamiento es el mismo que ejecutar agentes de programación en una VM desechable. Si la tarea real del agente es buscar información y no navegar sin restricciones, una herramienta más limitada es más segura que un navegador completo: una skill de búsqueda respaldada por su propio SearXNG devuelve resultados sin cargar nunca la página hostil.
FAQ
¿Por qué Chromium se bloquea en Docker, pero funciona correctamente directamente en el mismo VPS?
Porque el contenedor obtiene 64 MB de /dev/shm de forma predeterminada, mientras que el host tiene uno mucho mayor. Chromium procesa el contenido renderizado mediante esa área de memoria compartida. Por eso, una página pesada puede llenarla y provocar que muera el proceso de renderizado. Ejecute df -h /dev/shm dentro del contenedor para confirmarlo. Después, inícielo con --ipc=host, que usa la memoria compartida del host, o con --shm-size=1g, que aumenta la memoria propia del contenedor. --disable-dev-shm-usage sólo traslada el problema a /tmp.
¿Es seguro usar --no-sandbox si el VPS no ejecuta nada más?
No. El sandbox impide que una página maliciosa acceda al resto de la máquina. La documentación de Chromium indica que la opción «deshabilita funciones de seguridad críticas de Chromium y nunca debe usarse al navegar por la web abierta». Un agente que sigue enlaces está navegando por la web abierta. Corrija la causa en su lugar: no ejecute el navegador como root y, en Ubuntu 24.04, añada un perfil de AppArmor que incluya userns, para la ruta del binario del navegador, de modo que los espacios de nombres de usuario sin privilegios estén permitidos sólo para ese programa.
¿Cuántos navegadores puedo ejecutar en un VPS pequeño?
Mídalo. No copie una cifra. Chromium inicia un proceso de renderizado por sitio, por lo que la respuesta depende de las páginas que abra. Ejecute un worker con systemd-run y establezca MemoryMax. Lea el valor máximo de la línea Memory: en systemctl status. Después, divida la RAM libre por ese valor máximo y reserve margen. Aplique el límite dos veces: con una cola en su código y con un MemoryMax en el archivo de unidad. Así, una ráfaga de solicitudes espera en lugar de provocar swapping en la máquina.
¿Puede mi agente conectarse al navegador desde otra máquina?
Sí, pero nunca vincule el puerto a 0.0.0.0. El endpoint del servidor de Playwright y el puerto de Chrome DevTools aceptan cualquier cliente que pueda acceder a ellos, sin contraseña. Mantenga el listener en 127.0.0.1 y transfiera la conexión mediante un túnel SSH o una VPN privada. Verifique el servicio con ss -ltnp en el servidor y compruebe el puerto desde el exterior. Revise también el firewall de red independiente de su proveedor.
¿Por qué mis capturas de pantalla aparecen en blanco si la página se cargó correctamente?
Faltan fuentes. Si no hay ninguna fuente que cubra el script de la página, el texto se muestra como cuadros vacíos o no se muestra. Por eso, una página con pocas imágenes puede parecer en blanco. Ejecute fc-match "sans-serif:lang=ko" para cada idioma que procese. Instale fonts-noto-core y fonts-noto-cjk cuando el resultado sea una fuente genérica de respaldo. Después, reinicie el navegador para que fontconfig vuelva a cargar su caché. Un contenedor sin ninguna fuente registra Fontconfig error: Cannot load default config file durante el arranque.