Browser headless per agenti AI su un VPS
Chromium headless su un VPS può fallire per /dev/shm ridotto, flag sandbox, font mancanti e processi residui. Imposta i limiti prima dell'agente.
Cosa stai eseguendo
Un browser headless su un VPS è Chromium senza interfaccia grafica, controllato dal codice invece che da una persona. Su un server è un albero di processi a lunga esecuzione con cui il tuo agente comunica tramite un socket locale. Per installarlo basta un comando. Il lavoro consiste in tutto ciò che viene dopo. Devi limitare le risorse che il browser può consumare sulla macchina e mantenere il suo endpoint di controllo non accessibile da Internet.
Questa guida presuppone che la scelta dello strumento sia già stata effettuata e che ora tu debba gestirlo. Se stai ancora confrontando crawler ed estrattori, inizia dalle alternative self-hosted a Firecrawl e torna qui in seguito. Tutto ciò che segue usa Chromium di Playwright, perché Playwright distribuisce una propria build del browser e un proprio programma di installazione delle dipendenze; gli stessi comandi funzionano quindi su un VPS Ubuntu senza configurazioni aggiuntive e all'interno di un container. Le versioni sono aggiornate ad agosto 2026.
Installare Chromium senza indovinare le dipendenze
npm i -D playwright@1.62.0
npx playwright install --with-deps chromium--with-deps esegue apt per le librerie condivise e i font necessari a Chromium e richiede l'accesso root quando necessario. La build del browser viene scaricata in ~/.cache/ms-playwright per l'utente che ha eseguito il comando. Questo è importante su un server, perché l'utente del servizio di solito non coincide con l'utente con cui si accede al sistema. Installare una volta i pacchetti di sistema come amministratore con sudo npx playwright install-deps chromium, quindi impostare PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers sia nel comando di installazione sia nell'unità del servizio, in modo da condividere una sola copia. Se il servizio non riesce a vedere il browser, l'avvio fallisce con un messaggio che indica il percorso cercato.
Bloccare la versione di Playwright. Ogni release è associata a una build specifica del browser, quindi un npm update senza versione fissata può sostituire il browser mentre un servizio è in esecuzione. Playwright 1.62 è la versione corrente ad agosto 2026.
Esistono 2 build di Chromium, che non corrispondono allo stesso programma. Il download predefinito è la shell headless, un binario più piccolo che funziona solo in modalità headless, e npx playwright install --with-deps --only-shell installa soltanto questa. Il browser completo si ottiene con il canale chromium, che la documentazione dei browser di Playwright definisce "il browser Chrome reale, quindi più autentico, affidabile e dotato di più funzionalità". Usare la shell per i download massivi. Usare il browser completo quando un sito si comporta in modo diverso e occorre determinare il motivo.
Perché un browser headless si arresta in un container
Docker assegna a ogni container un /dev/shm da 64 MB. La documentazione di Docker è esplicita: «Se si omette completamente la dimensione, il sistema usa 64m». Chromium trasferisce i contenuti renderizzati tra i propri processi tramite quest'area di memoria condivisa, quindi una singola pagina pesante può riempirla. Il processo renderer si arresta e il client segnala una destinazione arrestata, anche se la pagina funziona correttamente sul laptop. Verificare la dimensione dall'interno del container prima di modificare la configurazione.
df -h /dev/shmEsistono due correzioni effettive, che sono alternative e non vanno utilizzate insieme. --ipc=host inserisce il container nello spazio dei nomi IPC dell'host, quindi il container usa il /dev/shm dell'host, che normalmente corrisponde alla metà della RAM. La guida Docker di Playwright lo consiglia perché, senza questa impostazione, «Chromium può esaurire la memoria e arrestarsi». Il compromesso consiste nella perdita dell'isolamento IPC tra container e host. --shm-size=1g mantiene lo spazio dei nomi privato e aumenta semplicemente la dimensione del mount.
docker run --rm -it --init --ipc=host --user pwuser mcr.microsoft.com/playwright:v1.62.0-noble /bin/bashIl flag --disable-dev-shm-usage è la soluzione che compare nella maggior parte dei risultati di ricerca, ma svolge una funzione diversa: sposta quei file da /dev/shm a una directory temporanea. Se /tmp risiede su disco, si sostituisce l'arresto con un rendering più lento e ulteriori scritture su disco. Se /tmp è un tmpfs, i dati tornano in RAM senza alcun limite di dimensione, consentendo al browser di consumare tutta la memoria di un piccolo VPS. Dimensionare correttamente /dev/shm.
Il costo effettivo di --no-sandbox
Chromium isola ogni renderer in una sandbox basata sui namespace utente di Linux. Questa sandbox costituisce il confine tra una pagina ostile e il server. Quando non può essere avviata, Chromium rifiuta di eseguire e nel log compare una riga come questa:
Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permittedIl suggerimento abituale è --no-sandbox. La documentazione di sicurezza di Chromium è chiara sul costo: il flag «disabilita funzionalità di sicurezza critiche di Chromium e non deve mai essere usato durante la navigazione sul web aperto». Un agente che segue link naviga per definizione sul web aperto. Occorre individuare la causa reale.
Due cause coprono quasi tutti i casi. L'esecuzione del browser come root disabilita la sandbox, perché il processo non può abbandonare privilegi che possiede già. Per questo l'immagine di Playwright include un utente ordinario chiamato pwuser. In Ubuntu 24.04 e versioni successive, AppArmor limita i namespace utente non privilegiati e nega l'accesso a un binario Chromium in un percorso non coperto da alcun profilo distribuito. Il download di Playwright in ~/.cache/ms-playwright si trova esattamente in un percorso di questo tipo. Verifica entrambe le condizioni:
id -u
sysctl kernel.apparmor_restrict_unprivileged_userns
sudo dmesg | grep -i userns_createUn 1 dall'impostazione sysctl, insieme a una riga del kernel che contiene apparmor="DENIED" operation="userns_create", conferma la seconda causa. Consenti quel singolo binario in /etc/apparmor.d/pw-chromium, mantenendo la restrizione per tutto il 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,
}Carica il profilo con sudo apparmor_parser -r /etc/apparmor.d/pw-chromium. Il percorso contiene la revisione del browser, quindi cambia a ogni aggiornamento di Playwright. I glob riportati sopra continuano a funzionare. Un profilo scritto per un percorso esatto smette silenziosamente di corrispondere e il browser ricomincia a non funzionare dopo un aggiornamento apparentemente non correlato.
Perché gli screenshot risultano vuoti o pieni di riquadri
Uno screenshot vuoto, o pieno di rettangoli vuoti, indica di solito un problema di font e non un errore di rendering. install-deps installa una base funzionante: fonts-liberation, fonts-freefont-ttf, fonts-noto-color-emoji, fonts-unifont, fonts-ipafont-gothic per il giapponese, fonts-wqy-zenhei per il cinese, fonts-tlwg-loma-otf per il thai. In questo insieme non è presente Noto CJK, quindi il coreano e diversi altri script usano il fallback che fontconfig riesce a trovare. Interroga fontconfig invece di procedere per tentativi:
fc-match "sans-serif:lang=ko"
fc-match "sans-serif:lang=ar"
fc-list | wc -lSe una lingua di cui hai bisogno viene associata a unifont o a un fallback privo dei glifi effettivi, installa fonts-noto-core e fonts-noto-cjk, quindi esegui nuovamente il controllo. Fontconfig memorizza nella cache i risultati, quindi riavvia il browser dopo aver installato i font. Un'immagine ridotta priva di font registra Fontconfig error: Cannot load default config file all'avvio e visualizza ogni pagina vuota.
La locale e il fuso orario sono separati dai font e modificano il contenuto della pagina, non soltanto il suo aspetto. Un container normalmente ha LANG non impostata e TZ impostato su UTC, quindi i siti pubblicano contenuti in inglese e mostrano timestamp in UTC; inoltre, il tuo agent comunica orari diversi da quelli visualizzati da una persona in quel paese. Impostali per ogni contesto del browser, non per l'intera macchina, in modo che un singolo browser possa eseguire attività per regioni diverse.
const context = await browser.newContext({
locale: 'en-GB',
timezoneId: 'Europe/Paris',
});Perché i processi del browser lasciati aperti fanno usare la swap al server
Due problemi diversi condividono il nome "zombie". Uno zombie vero è un processo terminato il cui processo padre non ha mai chiamato wait(). Mantiene una voce PID e nient'altro, quindi non consuma memoria. Questi processi si accumulano quando il browser viene eseguito come PID 1 in un container, perché PID 1 non dispone di un reaper predefinito. Il flag --init di Docker risolve esattamente questo problema, avviando un init minimale che "inoltra i segnali e raccoglie i processi". In Compose l'equivalente è init: true.
Il leak che fa davvero usare la swap al server è diverso: si tratta di processi Chromium ancora attivi che nessuno ha chiuso. Si verifica quando un task genera un'eccezione tra newContext() e close(), oppure quando lo script di controllo viene terminato e lascia orfano il relativo albero di processi del browser. Il caso peggiore è il codice che avvia un browser nuovo per ogni richiesta. Contali:
pgrep -c -f 'headless_shell|chrome'
ps -eo pid,ppid,rss,etime,comm --sort=-rss | head -20Questo conteggio dovrebbe tornare al valore di inattività tra un task e l'altro. Se aumenta nell'arco di una giornata, la correzione va applicata al codice, non ai flag di avvio: chiudi il context in un blocco finally, chiudi il browser su SIGTERM e ricrea il browser dopo un numero fisso di task, invece di lasciarlo in esecuzione per un mese. Con systemd, un arresto o un riavvio termina tutti i processi nel cgroup dell'unità, quindi sudo systemctl restart browser.service è un reset affidabile. Un browser avviato manualmente all'interno di un multiplexer di terminale non offre la stessa garanzia e i relativi processi orfani sopravvivono alla sessione.
Quanta RAM richiede un contesto del browser
Formulate la domanda in modo preciso, perché «un browser» non corrisponde a un solo processo. Chromium esegue un processo browser, un processo GPU, processi di utilità e un processo renderer per ogni sito; inoltre, l'isolamento dei siti assegna un renderer separato anche agli iframe tra siti diversi. Un BrowserContext è un contenitore separato per cookie e storage all'interno dello stesso albero, quindi un secondo contesto incide poco. Una seconda pagina è diversa, perché avvia processi renderer; una pagina con molti annunci ne avvia diversi.
Il valore da misurare è quindi il picco di memoria dell'intero albero di processi con il vostro carico di lavoro. Un valore riportato nel blog di altri non è utile, perché sono le pagine aperte dal vostro agent a determinare il risultato. Misurate sulla macchina che userete, visitando i siti che dovrete aprire:
sudo systemd-run --unit=browser-probe -p MemoryMax=2G -p MemorySwapMax=0 -p WorkingDirectory=/srv/agent /usr/bin/node worker.js
systemctl status browser-probeSu Ubuntu 24.04, la riga Memory: di quell'output riporta l'utilizzo corrente e il picco dell'unità. Eseguite il worker con una pagina alla volta, annotate il picco, quindi ripetete con due pagine aperte per verificare il costo effettivo di una seconda pagina. Il numero di worker concorrenti si calcola quindi in modo diretto: prendete la RAM totale, sottraete quella necessaria al resto del sistema, mantenete alcune centinaia di MB di margine e dividete il risultato per il picco misurato per worker. Per dimensionare la macchina sottostante, consultate quanta RAM e CPU richiede una VPS per un agent.
Imponete questo limite in due punti. Nel codice, usate un pool di worker fisso o un semaforo, in modo che un picco di richieste dell'agent venga accodato invece di avviare nuovi browser. Nel sistema operativo, usate un limite cgroup, così un bug nella coda non può abbattere l'intera macchina:
[Service]
MemoryMax=2G
MemorySwapMax=0
TasksMax=512
Restart=alwaysMemorySwapMax=0 è più importante di quanto sembri. Senza questa impostazione, quando raggiunge il limite il cgroup sposta le pagine nella swap: la macchina rimane attiva, ma tutte le richieste diventano lente. Questo è più difficile da diagnosticare di un errore immediato. Con questa impostazione, il kernel termina l'albero del browser all'interno del cgroup, systemd riavvia l'unità e sshd rimane operativo. In Compose, gli stessi controlli sono mem_limit, shm_size e init, descritti in impostare i limiti di memoria in Docker Compose.
Mantieni l'endpoint del browser fuori da Internet pubblico
Playwright può eseguire il browser come server e fornire all'agente un URL WebSocket:
const { chromium } = require('playwright');
const server = await chromium.launchServer({ port: 3000 });
console.log(server.wsEndpoint());Questo endpoint non richiede autenticazione. La documentazione dell'API di Playwright lo dichiara esplicitamente: "Qualsiasi processo o pagina Web, inclusi quelli in esecuzione in Playwright, che conosca wsPath può assumere il controllo dell'utente del sistema operativo." L'host predefinito è localhost, "che accetta connessioni soltanto dall'interfaccia di loopback", e la documentazione avverte che specificare un indirizzo esplicito come 0.0.0.0 "espone l'RPC del browser a qualsiasi sistema in grado di raggiungere la porta in ascolto". Il --remote-debugging-port di Chrome è ancora più rischioso. Il protocollo DevTools non implementa alcun tipo di autenticazione e dipende interamente dal binding sull'interfaccia di loopback.
Verifica ciò che hai effettivamente pubblicato. Esegui il controllo anche da una seconda macchina, oltre che dal VPS:
ss -ltnpQualsiasi servizio del browser associato a 0.0.0.0 costituisce un problema di sicurezza. Ricorda che la maggior parte dei provider utilizza un firewall di rete separato nel pannello di controllo. Le regole ufw non ne sono a conoscenza. Raggiungi l'endpoint da un'altra macchina tramite un tunnel SSH o una VPN privata:
ssh -N -L 3000:127.0.0.1:3000 you@your-vpsIl rischio non consiste soltanto nel furto del tempo di esecuzione del browser. Un browser che puoi controllare è uno strumento per falsificare richieste, posizionato all'interno della tua rete. Chiunque raggiunga quel socket può fare in modo che il browser richieda http://127.0.0.1:8080, la pagina di amministrazione del database o l'indirizzo dei metadati cloud 169.254.169.254, quindi leggere la risposta dalla pagina. Il firewall vede una richiesta proveniente dal VPS stesso e quindi autorizzata. Considera l'endpoint di controllo equivalente all'accesso shell a quel sistema.
I server MCP hanno la stessa struttura. npx @playwright/mcp@latest --headless --port 8931 espone un servizio HTTP su localhost e --host 0.0.0.0 è il flag che trasforma uno strumento locale in uno pubblico. Il README del progetto dichiara chiaramente che Playwright MCP "non è un confine di sicurezza". Mantieni la porta sull'interfaccia di loopback e consenti all'agente di raggiungerla attraverso lo stesso tunnel.
Le pagine consultate dall'agente sono input non attendibile
Un agente che naviga sul Web aperto immette nel modello testo scritto da sconosciuti, mentre il modello contiene anche le tue istruzioni. Una pagina può contenere testo rivolto al modello, con l'ordine di abbandonare l'attività, chiamare uno strumento o inviare dati a un URL. Il modello riceve entrambi come testo, quindi non può distinguere in modo affidabile le parole della pagina dalle tue. Configura l'ambiente in modo che una pagina ostile abbia poche possibilità di interferire.
- Esegui il browser con un utente OS dedicato, senza chiavi SSH e senza credenziali cloud nell'ambiente.
- Usa un contesto nuovo per ogni attività e
--isolatedcon Playwright MCP, in modo che una sessione su un sito non sia disponibile alla pagina successiva. - Mantieni una allowlist delle origini quando l'attività lo consente. Playwright MCP accetta
--allowed-originse--blocked-originscome liste separate da punto e virgola. - Richiedi un passaggio umano prima di qualsiasi azione che modifichi lo stato, ad esempio l'invio di e-mail o una spesa.
Meglio ancora, esegui l'intero browser su una macchina che puoi eliminare e ricreare. È lo stesso principio di eseguire gli agenti di coding in una VM usa e getta. Se il compito effettivo dell'agente è la ricerca, anziché la navigazione aperta, uno strumento più circoscritto è più sicuro di un browser completo: una skill di ricerca basata sul tuo SearXNG restituisce i risultati senza caricare mai la pagina ostile.
FAQ
Perché Chromium si arresta in modo anomalo in Docker, ma funziona correttamente direttamente sullo stesso VPS?
Perché il container riceve per impostazione predefinita una /dev/shm di 64 MB, mentre l'host ne ha una molto più grande. Chromium passa il contenuto renderizzato attraverso quest'area di memoria condivisa, quindi una pagina pesante la riempie e il processo renderer termina. Esegui df -h /dev/shm all'interno del container per confermare la causa, quindi avvialo con --ipc=host, che usa la memoria condivisa dell'host, oppure con --shm-size=1g, che aumenta quella propria del container. --disable-dev-shm-usage sposta soltanto il problema in /tmp.
È sicuro usare --no-sandbox se sul VPS non eseguo altro?
No. La sandbox impedisce a una pagina malevola di raggiungere il resto della macchina e la documentazione di Chromium specifica che il flag "disables critical security features of Chromium and should never be used when browsing the open web". Un agent che segue i link sta navigando sul web pubblico. Correggi invece la causa: non eseguire il browser come root e, su Ubuntu 24.04, aggiungi un profilo AppArmor che includa userns, per il percorso del binario del browser, in modo da consentire gli user namespace senza privilegi soltanto a quel programma.
Quanti browser posso eseguire su un VPS di piccole dimensioni?
Misura il consumo, non copiare un numero. Chromium avvia un processo renderer per ogni sito, quindi la risposta dipende dalle pagine che apri. Esegui un worker sotto systemd-run con MemoryMax impostato, leggi il picco dalla riga Memory: in systemctl status, quindi dividi la RAM libera per quel picco e lascia un margine. Applica il limite due volte: con una coda nel codice e con un MemoryMax nel file dell'unità, così una raffica di richieste resta in attesa invece di causare lo swapping della macchina.
Il mio agent può connettersi al browser da un'altra macchina?
Sì, ma non associare mai la porta a 0.0.0.0. L'endpoint del server Playwright e la porta Chrome DevTools accettano qualsiasi client che possa raggiungerli, senza password. Mantieni il listener su 127.0.0.1 e trasporta la connessione tramite un tunnel SSH o una VPN privata. Verifica con ss -ltnp sul server ed esegui un controllo della porta dall'esterno. Controlla anche il firewall di rete separato del provider.
Perché i miei screenshot sono vuoti se la pagina è stata chiaramente caricata?
Mancano i font. Se non è disponibile alcun font che copra lo script usato dalla pagina, il testo viene visualizzato come riquadri vuoti oppure non viene visualizzato affatto. Di conseguenza, una pagina con poche immagini può produrre un'immagine apparentemente vuota. Esegui fc-match "sans-serif:lang=ko" per ogni lingua sottoposta a scraping, installa fonts-noto-core e fonts-noto-cjk quando il risultato è un fallback generico, quindi riavvia il browser affinché fontconfig ricarichi la cache. Un container senza alcun font registra Fontconfig error: Cannot load default config file all'avvio.