Como rodar Chromium headless para agentes de IA em VPS
Evite falhas do Chromium headless no VPS por /dev/shm pequeno, sandbox, fontes ausentes e processos órfãos. Defina limites e proteja o socket local.
O que está a executar
Um browser headless num VPS é o Chromium sem janela, controlado pelo seu código em vez de uma pessoa. Num servidor, é uma árvore de processos de longa duração com a qual o seu agente comunica através de um socket local. A instalação requer um comando. O trabalho está em tudo o que vem depois. Deve limitar os recursos que o browser pode consumir na máquina e manter o endpoint de controlo fora da Internet pública.
Este guia parte do princípio de que a ferramenta já foi escolhida e que agora precisa de a operar. Se ainda estiver a comparar crawlers e extractors, comece por as alternativas self-hosted ao Firecrawl e volte depois. Tudo o que se segue usa o Chromium do Playwright, porque o Playwright inclui a sua própria compilação do browser e o seu próprio instalador de dependências. Por isso, os mesmos comandos funcionam num VPS Ubuntu sem preparação e dentro de um container. As versões estão atualizadas em agosto de 2026.
Instale o Chromium sem adivinhar dependências
npm i -D playwright@1.62.0
npx playwright install --with-deps chromium--with-deps executa apt para obter as bibliotecas partilhadas e os tipos de letra de que o Chromium necessita, solicitando acesso root quando chega a essa etapa. A compilação do browser é descarregada para ~/.cache/ms-playwright do utilizador que executou o comando. Isto é importante num servidor, porque o utilizador do serviço normalmente não é o utilizador com que inicia sessão. Instale uma vez os pacotes do sistema como administrador com sudo npx playwright install-deps chromium. Depois, defina PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers tanto no comando de instalação como na unidade do serviço, para que seja utilizada uma única cópia. Um serviço que não consiga encontrar o browser falha ao arrancar com uma mensagem que indica o caminho pesquisado.
Fixe a versão do Playwright. Cada release está associada a uma compilação específica do browser. Por isso, um npm update sem versão fixa pode substituir o browser enquanto o serviço está em execução. O Playwright 1.62 é a versão atual em agosto de 2026.
Existem duas compilações do Chromium, e não são o mesmo programa. A transferência predefinida é a shell headless, um binário mais pequeno que funciona apenas em modo headless. npx playwright install --with-deps --only-shell instala apenas essa versão. O browser completo é obtido com o canal chromium, que a documentação de browsers do Playwright descreve como "o browser Chrome real, sendo por isso mais autêntico, fiável e com mais funcionalidades". Use a shell para obtenção de grandes volumes. Use o browser completo quando um site se comportar de forma diferente e precisar de descobrir porquê.
Por que um navegador headless falha num contentor
O Docker atribui a cada contentor um /dev/shm de 64 MB. A documentação do Docker é explícita: "Se omitir completamente o tamanho, o sistema utiliza 64m". O Chromium transfere conteúdo renderizado entre os seus processos através dessa área de memória partilhada, pelo que uma página pesada pode preenchê-la. O processo de renderização termina e o cliente comunica que o alvo falhou, embora a página funcione normalmente no seu computador. Confirme o tamanho a partir do interior do contentor antes de alterar qualquer configuração.
df -h /dev/shmExistem duas correções reais, que são alternativas e não devem ser combinadas. --ipc=host coloca o contentor no namespace IPC do host, para que utilize o /dev/shm do host, que normalmente corresponde a metade da RAM. O guia do Docker do Playwright recomenda esta opção, porque, sem ela, "o Chromium pode ficar sem memória e falhar". O custo é perder o isolamento IPC entre o contentor e o host. --shm-size=1g mantém o namespace privado e aumenta simplesmente o tamanho do mount.
docker run --rm -it --init --ipc=host --user pwuser mcr.microsoft.com/playwright:v1.62.0-noble /bin/bashA flag --disable-dev-shm-usage é a resposta que encontrará na maioria dos resultados de pesquisa, mas faz algo diferente: move esses ficheiros de /dev/shm para um diretório temporário. Se /tmp estiver num disco, trocou uma falha por uma renderização mais lenta e escritas no disco. Se /tmp for um tmpfs, os dados continuam na RAM sem qualquer limite de tamanho, o que pode fazer um navegador consumir toda a memória de uma VPS pequena. Em vez disso, dimensione /dev/shm corretamente.
O custo real de usar --no-sandbox
O Chromium isola cada renderer numa sandbox baseada em user namespaces do Linux. Essa sandbox é a fronteira entre uma página hostil e o seu servidor. Quando não consegue iniciar, o Chromium recusa-se a executar, e o log contém uma linha como esta:
Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permittedA recomendação habitual é --no-sandbox. A própria documentação de segurança do Chromium é clara sobre o custo: a flag "desativa funcionalidades de segurança críticas do Chromium e nunca deve ser usada ao navegar na web pública". Um agente que segue ligações está, por definição, a navegar na web pública. Encontre a causa real.
Duas causas abrangem quase todos os casos. Executar o browser como root desativa a sandbox, porque o processo não pode abdicar de privilégios que já possui. É por isso que a imagem do Playwright inclui um utilizador normal chamado pwuser. No Ubuntu 24.04 e posteriores, o AppArmor restringe user namespaces não privilegiados, e um binário do Chromium num caminho que nenhum perfil fornecido abrange é bloqueado. O download do Playwright em ~/.cache/ms-playwright está exatamente nesse caso. Verifique ambos:
id -u
sysctl kernel.apparmor_restrict_unprivileged_userns
sudo dmesg | grep -i userns_createUm 1 proveniente do sysctl, juntamente com uma linha do kernel que contenha apparmor="DENIED" operation="userns_create", confirma a segunda causa. Permita esse único binário em /etc/apparmor.d/pw-chromium. Assim, a restrição mantém-se ativa para todo o resto do sistema:
abi <abi/4.0>,
include <tunables/global>
profile pw-chromium /home/*/.cache/ms-playwright/*/chrome-linux/{chrome,headless_shell} flags=(unconfined) {
userns,
}Carregue-o com sudo apparmor_parser -r /etc/apparmor.d/pw-chromium. O caminho contém a revisão do browser, por isso muda a cada atualização do Playwright. Os globs acima continuam a funcionar. Um perfil criado para um caminho exato deixa silenciosamente de corresponder, e o browser volta a falhar depois de uma atualização que parecia não ter relação.
Por que as capturas de ecrã aparecem vazias ou cheias de caixas
Uma captura de ecrã vazia, ou cheia de retângulos vazios, normalmente indica um problema de fontes e não um erro de renderização. install-deps instala uma base funcional: fonts-liberation, fonts-freefont-ttf, fonts-noto-color-emoji, fonts-unifont, fonts-ipafont-gothic para japonês, fonts-wqy-zenhei para chinês e fonts-tlwg-loma-otf para tailandês. Esse conjunto não inclui Noto CJK, por isso o coreano e vários outros sistemas de escrita usam como fallback a fonte que o fontconfig conseguir encontrar. Consulte o fontconfig em vez de tentar adivinhar:
fc-match "sans-serif:lang=ko"
fc-match "sans-serif:lang=ar"
fc-list | wc -lSe uma língua importante para si for resolvida para unifont ou para um fallback sem glifos reais, instale fonts-noto-core e fonts-noto-cjk e execute a verificação novamente. O fontconfig coloca os resultados em cache, por isso reinicie o browser depois de instalar as fontes. Uma imagem reduzida sem fontes regista Fontconfig error: Cannot load default config file no arranque e renderiza todas as páginas vazias.
A localidade e o fuso horário são independentes das fontes e alteram o conteúdo apresentado pela página, não apenas o seu aspeto. Um contentor normalmente tem LANG sem valor e TZ definido como UTC, por isso os sites são servidos em inglês e apresentam marcas temporais em UTC; o seu agente também comunica horas diferentes das que uma pessoa nesse país vê. Defina estas opções por contexto do browser e não por máquina, para que um único browser possa executar tarefas para diferentes regiões.
const context = await browser.newContext({
locale: 'en-GB',
timezoneId: 'Europe/Paris',
});Como processos do browser abandonados fazem o servidor usar swap
Dois problemas diferentes partilham o nome "zombie". Um zombie verdadeiro é um processo terminado cujo processo pai nunca chamou wait(). Mantém uma entrada de PID e nada mais, por isso não consome memória. Estes processos acumulam-se quando o browser é executado como PID 1 num contentor, porque o PID 1 não tem um reaper predefinido. A opção --init do Docker corrige exatamente isso, executando um init pequeno que "encaminha sinais e recolhe processos". No Compose, o equivalente é init: true.
A fuga que realmente faz o servidor usar swap é diferente: são processos Chromium ativos que ninguém encerrou. Isto acontece quando uma tarefa lança uma exceção entre newContext() e close(), ou quando o script de controlo é terminado e deixa a árvore de processos do browser órfã. A pior variante é o código que inicia um browser novo para cada pedido. Conte-os:
pgrep -c -f 'headless_shell|chrome'
ps -eo pid,ppid,rss,etime,comm --sort=-rss | head -20Essa contagem deve voltar ao valor em inatividade entre tarefas. Se aumentar ao longo de um dia, a correção está no código, não nas opções de arranque: feche o contexto num bloco finally, feche o browser em SIGTERM e recicle o browser depois de um número fixo de tarefas, em vez de o manter em execução durante um mês. No systemd, uma paragem ou reinício termina tudo no cgroup da unidade, por isso sudo systemctl restart browser.service é uma reposição fiável. Um browser iniciado manualmente dentro de um multiplexador de terminal não tem essa garantia, e os seus processos órfãos continuam ativos depois de a sessão terminar.
Quanta RAM é necessária para um contexto de browser
Faça a pergunta com precisão, porque "um browser" não corresponde a um único processo. O Chromium executa um processo de browser, um processo de GPU, processos de utilitários e um processo de renderização por site. O isolamento de sites também atribui um processo de renderização próprio aos iframes de sites diferentes. Um BrowserContext é um armazenamento separado de cookies e dados dentro dessa mesma árvore, por isso um segundo contexto tem um custo reduzido. Uma segunda página é diferente, porque inicia processos de renderização, e uma página com muita publicidade inicia vários.
Por isso, o valor a medir é o consumo de memória de pico de toda a árvore com a sua carga de trabalho. Um valor publicado no blogue de outra pessoa não é útil neste caso, porque as páginas abertas pelo seu agente determinam o resultado. Faça a medição na máquina que vai utilizar e com os sites que vai 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-probeNo Ubuntu 24.04, a linha Memory: dessa saída apresenta o consumo atual e o consumo de pico da unidade. Execute o worker com uma página de cada vez, registe o pico e repita o teste com duas páginas abertas para determinar o custo real de uma segunda página. A concorrência é depois uma questão de aritmética: subtraia à RAM total o que o restante sistema necessita, reserve algumas centenas de MB e divida o resultado pelo pico medido por worker. Para dimensionar a máquina subjacente, consulte quanta RAM e CPU um VPS de agente necessita.
Imponha esse número em dois locais. No código, utilize um conjunto fixo de workers ou um semáforo, para que uma rajada de pedidos do agente fique em fila em vez de iniciar browsers. No sistema operativo, utilize um limite de cgroup, para que um erro na fila não derrube também a máquina:
[Service]
MemoryMax=2G
MemorySwapMax=0
TasksMax=512
Restart=alwaysMemorySwapMax=0 é mais importante do que parece. Sem essa opção, o cgroup envia páginas para a swap quando atinge o limite. A máquina continua operacional, mas todos os pedidos ficam lentos, o que é mais difícil de diagnosticar do que uma falha imediata. Com essa opção, o kernel termina a árvore do browser dentro desse cgroup, o systemd reinicia a unidade e o sshd continua a funcionar. Os mesmos controlos no Compose são mem_limit, shm_size e init, descritos em definir limites de memória no Docker Compose.
Mantenha o endpoint do navegador fora da Internet pública
O Playwright pode executar o navegador como um servidor e fornecer ao seu agente uma URL WebSocket:
const { chromium } = require('playwright');
const server = await chromium.launchServer({ port: 3000 });
console.log(server.wsEndpoint());Esse endpoint não tem autenticação. A documentação da API do Playwright afirma isso diretamente: "Qualquer processo ou página Web (incluindo os que são executados no Playwright) que conheça o wsPath pode assumir o controlo do utilizador do sistema operativo." O host predefinido é localhost, "aceitando ligações apenas através da interface de loopback", e a documentação avisa que passar um endereço explícito, como 0.0.0.0, "expõe o RPC do navegador a qualquer entidade que consiga alcançar a porta em escuta". O próprio --remote-debugging-port do Chrome é ainda pior. O protocolo DevTools não tem qualquer tipo de autenticação e depende totalmente de estar associado à interface de loopback.
Verifique o que publicou efetivamente e faça também a verificação a partir de uma segunda máquina, além da VPS:
ss -ltnpQualquer serviço num browser port associado a 0.0.0.0 é um problema de segurança. Lembre-se de que a maioria dos fornecedores executa uma firewall de rede separada no respetivo painel de controlo. As regras de ufw não têm conhecimento dessa firewall. Aceda ao endpoint a partir de outra máquina através de um túnel SSH ou de uma VPN privada:
ssh -N -L 3000:127.0.0.1:3000 you@your-vpsO risco é maior do que alguém utilizar o navegador indevidamente. Um navegador que pode controlar é uma máquina de falsificação de pedidos alojada dentro da sua rede. Quem alcançar esse socket pode fazê-lo aceder a http://127.0.0.1:8080, à página de administração da sua base de dados ou ao endereço de metadados da cloud em 169.254.169.254 e, em seguida, ler a resposta a partir da página. A firewall vê um pedido proveniente da própria VPS, que é permitido. Trate o endpoint de controlo como equivalente ao acesso shell a essa máquina.
Os servidores MCP têm a mesma estrutura. npx @playwright/mcp@latest --headless --port 8931 disponibiliza serviço HTTP em localhost, e --host 0.0.0.0 é a flag que transforma uma ferramenta local numa ferramenta pública. O README do projeto afirma claramente que o Playwright MCP "não é um limite de segurança". Mantenha a porta na interface de loopback e permita que o agente a alcance através do mesmo túnel.
As páginas que o seu agente lê são entradas não confiáveis
Um agente que navega na Web aberta fornece ao modelo texto escrito por desconhecidos, juntamente com as suas instruções. Uma página pode conter texto dirigido a esse modelo, instruindo-o a abandonar a tarefa, chamar uma ferramenta ou publicar dados num URL. O modelo recebe ambos como texto, por isso não tem uma forma fiável de distinguir as palavras da página das suas instruções. Configure o ambiente para que uma página maliciosa tenha pouco com que trabalhar.
- Execute o navegador com um utilizador próprio do sistema operativo, sem chaves SSH nem credenciais de cloud no ambiente.
- Use um contexto novo por tarefa e
--isolatedcom Playwright MCP, para que uma sessão num site não fique disponível para a página seguinte. - Mantenha uma lista de permissões de origens quando o trabalho o permitir. O Playwright MCP aceita
--allowed-originse--blocked-originscomo listas separadas por ponto e vírgula. - Exija uma etapa de confirmação humana antes de qualquer ação que altere o estado, como enviar correio ou gastar dinheiro.
Melhor ainda, mantenha todo o navegador numa máquina que possa eliminar e reconstruir. O princípio é o mesmo de executar agentes de programação numa VM descartável. Se o trabalho real do agente for pesquisar, e não navegar de forma aberta, uma ferramenta mais restrita será mais segura do que um navegador completo: uma skill de pesquisa suportada pelo seu próprio SearXNG devolve resultados sem nunca carregar a página maliciosa.
FAQ
Por que o Chromium falha no Docker, mas funciona normalmente no mesmo VPS de forma direta?
Porque o container recebe um /dev/shm de 64 MB por padrão, enquanto o host tem um valor muito maior. O Chromium passa o conteúdo renderizado por essa área de memória partilhada. Uma página pesada enche-a e o processo de renderização termina. Execute df -h /dev/shm dentro do container para confirmar. Depois, inicie-o com --ipc=host, que usa a memória partilhada do host, ou com --shm-size=1g, que aumenta a memória própria do container. --disable-dev-shm-usage apenas transfere o problema para /tmp.
O uso de --no-sandbox é seguro se o VPS não executar mais nada?
Não. O sandbox impede que uma página maliciosa aceda ao resto da máquina. A documentação do Chromium afirma que essa flag "desativa funcionalidades críticas de segurança do Chromium e nunca deve ser usada ao navegar na Web pública". Um agente que segue links está a navegar na Web pública. Corrija a causa. Não execute o browser como root. No Ubuntu 24.04, adicione um perfil AppArmor que contenha userns, para o caminho do binário do browser. Assim, os user namespaces sem privilégios são permitidos apenas para esse programa.
Quantos browsers posso executar num VPS pequeno?
Meça, em vez de copiar um número. O Chromium inicia um processo de renderização por site. Portanto, a resposta depende das páginas que abrir. Execute um worker com systemd-run e MemoryMax definido. Leia o pico na linha Memory: de systemctl status. Depois, divida a RAM livre por esse pico e mantenha margem de segurança. Aplique o limite duas vezes: use uma fila no código e um MemoryMax no ficheiro da unidade. Assim, uma rajada de pedidos aguarda em vez de provocar swap na máquina.
O meu agente pode ligar-se ao browser a partir de outra máquina?
Sim, mas nunca associe a porta a 0.0.0.0. O endpoint do servidor Playwright e a porta do Chrome DevTools aceitam qualquer cliente que consiga aceder-lhes, sem palavra-passe. Mantenha o listener em 127.0.0.1 e transporte a ligação através de um túnel SSH ou de uma VPN privada. Confirme com ss -ltnp no servidor e verifique a porta a partir do exterior. Consulte também a firewall de rede separada do seu fornecedor.
Por que as minhas capturas de ecrã ficam vazias quando a página carregou claramente?
Faltam fontes. Sem uma fonte que cubra o sistema de escrita da página, o texto é apresentado como caixas vazias ou não é apresentado. Assim, uma página com poucas imagens parece vazia. Execute fc-match "sans-serif:lang=ko" para cada idioma que recolhe. Instale fonts-noto-core e fonts-noto-cjk quando a resposta for uma alternativa genérica. Reinicie o browser para que o fontconfig recarregue a cache. Um container sem fontes regista Fontconfig error: Cannot load default config file no arranque.