Headless Chrome es fácil de lanzar, pero difícil de operar en producción. Localmente todo funciona, pero en un contenedor el navegador de repente se cae, en CI las pruebas a veces pasan y a veces no, y el servicio que renderiza enlaces ajenos se convierte en un riesgo de seguridad. En este artículo se recopilan problemas típicos y sus soluciones: desde /dev/shm en Docker hasta la protección contra SSRF y el rastreo de caídas en Playwright. Cómo funciona todo por dentro, en el artículo «Cómo funciona Headless Chrome».
⚡ En resumen: dónde falla Headless Chrome con más frecuencia
| Problema |
Síntoma |
Solución |
| Poca memoria compartida en el contenedor |
El navegador se cae o está «inaccesible» solo en Docker |
--shm-size, --ipc=host o emptyDir basado en memoria en Kubernetes |
| Sandbox |
Chrome no se inicia como root o en un contenedor restringido |
Usuario no root y perfil seccomp, en lugar de --no-sandbox |
| Procesos zombis y fugas de navegadores |
Aumenta el número de procesos y la memoria |
--init, cierre obligatorio del navegador, reinicio después de N tareas |
| Pruebas inestables (flaky) |
Pasan localmente, fallan en CI |
Esperas explícitas en lugar de sleep, rastreo de caídas |
| Discrepancias de renderizado |
Fuentes y diseño diferentes en el servidor |
Instalar fuentes, fijar el tamaño de la ventana y la imagen |
| Renderizado de URLs ajenas |
El navegador accede a direcciones internas |
Protección contra SSRF: lista blanca, aislamiento de red, metadatos de la nube |
Al trabajar con Headless Chrome, es importante tener en cuenta no solo la configuración del navegador, sino también las particularidades del entorno en el que se ejecuta. Yo primero comprobaría el uso de memoria, el ciclo de vida de los procesos y los parámetros del contenedor. Son estos aspectos los que se deben diagnosticar primero si el navegador funciona de manera inestable o se comporta de manera diferente en producción que localmente.
📚 Índice del artículo
🧠 ¿Qué limita exactamente el modo headless?
Hoy en día, el nuevo modo headless es el mismo Chrome sin ventanas visibles, por lo que la mayoría de las «limitaciones» no están relacionadas con el navegador, sino con el entorno en el que se ejecuta: contenedor, memoria, permisos de usuario, red. Una historia aparte es chrome-headless-shell: es una versión aligerada con menos dependencias, y según la descripción de los desarrolladores, es menos «auténtica» que un Chrome completo. La diferencia entre los modos se detalla en el artículo «Cómo funciona Headless Chrome». A continuación, analizamos lo que se rompe en la práctica. En cada sección, el esquema es el mismo: síntoma, causa, opciones de solución.
💾 Memoria, procesos y recursos
Síntoma: con el tiempo, el servicio consume cada vez más memoria, se acumulan procesos de Chrome en el sistema.
Causas:
Qué hacer:
- Cierra el navegador en el bloque
finally, incluso si la tarea falló.
- Mantén un navegador de larga duración y crea un contexto separado para cada tarea en lugar de lanzar un nuevo navegador cada vez.
- Limita el paralelismo y reinicia el navegador periódicamente (por ejemplo, después de un cierto número de tareas). Selecciona los límites específicos mediante mediciones con tu carga de trabajo.
- Establece límites de memoria para el contenedor y síguelos en el monitoreo.
⏱️ Tiempos de espera y pruebas inestables
Síntoma: las pruebas pasan localmente y fallan en CI, la captura de pantalla se realiza antes de que aparezca el contenido.
Causa: el código depende del tiempo en lugar del estado de la página. En CI, los recursos son menores y la página tarda más en cargarse.
| Error típico |
Enfoque correcto |
sleep fijo |
Esperar a un elemento o evento específico. Playwright realiza algunas esperas automáticamente antes de las acciones |
| Captura de pantalla «inmediatamente después de cargar» |
Esperar a que aparezca el contenido deseado. En CLI, --timeout y --virtual-time-budget ayudan |
| Un único tiempo de espera global |
Tiempos de espera separados para navegación, acciones y toda la tarea |
| La prueba falla sin explicaciones |
Grabar rastreo de caídas (ver sección sobre Playwright Trace Viewer) |
🐳 Docker e CI: /dev/shm e sandbox
Il problema con /dev/shm
Sintomo: Chrome funziona stabilmente sul tuo computer, ma nel container cade casualmente o segnala che non è disponibile. In una delle analisi vengono descritti proprio tali crash "casuali" solo in Docker.
Causa: secondo la documentazione di Puppeteer (versione archiviata), Docker per impostazione predefinita alloca al container una memoria condivisa /dev/shm di soli 64 MB, e Chrome la utilizza attivamente.
Come verificare:
docker exec <container> df -h /dev/shm
Se la dimensione è 64 MB e durante il crash è quasi tutta occupata, la diagnosi è confermata.
Opzioni di soluzione
| Opzione |
Come |
Vantaggi |
Svantaggi |
| A. Aumentare la dimensione |
docker run --shm-size=2g ..., in Compose shm_size: "2gb" |
Soluzione più diretta, il browser funziona velocemente, l'isolamento non cambia |
La memoria viene conteggiata nel limite del container, quindi la dimensione deve essere concordata con il limite |
| B. IPC condiviso con l'host |
docker run --ipc=host ... |
Questa è la raccomandazione ufficiale di Playwright per Chromium: senza di essa Chromium può esaurire la memoria e cadere |
Il container condivide lo spazio IPC con l'host, quindi per pagine non attendibili preferirei l'opzione A |
| C. Disabilitare l'uso di /dev/shm |
Flag di Chrome --disable-dev-shm-usage |
Aiuta quando non si ha accesso alle impostazioni del container |
I file vengono scritti in /tmp, e questo è un normale filesystem, non RAM, quindi è possibile un rallentamento. In alcune recensioni questo viene consigliato solo come ultima risorsa |
| D. Kubernetes |
Montare un emptyDir basato su memoria in /dev/shm |
Non esiste un analogo diretto di --shm-size in un pod, questo è un workaround standard |
La dimensione viene conteggiata nella memoria del pod, quindi è necessario specificare sizeLimit |
Esempio per Docker Compose:
services:
renderer:
image: my-renderer
init: true
shm_size: "2gb"
mem_limit: 4g
Esempio per Kubernetes:
spec:
containers:
- name: renderer
image: my-renderer
resources:
limits:
memory: "4Gi"
volumeMounts:
- name: dshm
mountPath: /dev/shm
volumes:
- name: dshm
emptyDir:
medium: Memory
sizeLimit: 2Gi
Kubernetes supporta tali volumi basati su memoria, e la loro dimensione predefinita dipende dalla memoria del nodo, quindi è consigliabile specificare sizeLimit esplicitamente. I dati in tali volumi utilizzano la memoria del pod, quindi la dimensione deve rientrare nel limite del container.
Il valore di 2 GB negli esempi è indicativo. Scegli il tuo in base alle misurazioni: dipende dal numero di schede e dalla complessità delle pagine.
Sandbox e utente
Il consiglio frequente "aggiungi --no-sandbox" risolve il problema di avvio, ma rimuove una protezione importante. Viene descritto che l'immagine Playwright per impostazione predefinita funziona come root, il che disabilita la sandbox di Chromium, che non è disponibile con root. Per siti non attendibili si raccomanda un utente separato insieme al profilo seccomp. Esempio di avvio ufficiale per Java:
docker run -it --rm --init --ipc=host --user pwuser \
--security-opt seccomp=seccomp_profile.json \
mcr.microsoft.com/playwright/java:<versione>-noble /bin/bash
Nella documentazione viene anche indicato che il profilo seccomp_profile.json è necessario per avviare Chromium con la sandbox, e questa immagine non è raccomandata per visitare siti non attendibili. Sostituisci la versione attuale dell'immagine dalla documentazione.
🔤 Font e discrepanze nel rendering
Sintomo: sul server il testo appare diverso rispetto a localmente, nel PDF compaiono "quadratini" al posto dei caratteri.
Causa: nell'immagine minima mancano i font necessari. Nel Dockerfile dalla documentazione di Puppeteer i font per i set di caratteri principali (cinese, giapponese, arabo, ebraico, tailandese) vengono installati separatamente.
Cosa fare:
- Installa i font per le lingue di cui hai bisogno (per un blog multilingue questo include almeno il cirillico, il latino e, se necessario, CJK).
- Specifica esplicitamente la dimensione della finestra (ad esempio,
--window-size nella CLI), in modo che il risultato non dipenda dai valori predefiniti.
- Fissa l'immagine e la versione di Chrome. Versioni diverse possono dare risultati diversi.
- Per i PDF, controlla gli stili di stampa (
@media print): potrebbero differire dal layout dello schermo.
🧩 Pagine complesse, autenticazione e sessioni
- iframe, Shadow DOM, finestre popup, caricamento file. Diverse librerie li gestiscono in modi differenti. Prima di scegliere uno strumento, verifica sulle tue pagine quanto sia comodo lavorare con tali elementi nella documentazione di Puppeteer, Playwright o Selenium.
- Autenticazione. Non effettuare il login nuovamente in ogni task. Playwright consente di salvare lo stato della sessione e riutilizzarlo. Salva tali file come segreti: contengono i cookie.
- Isolamento. Un contesto separato per ogni task impedisce che i cookie e lo storage di un task finiscano in un altro.
- Segreti nei log e negli screenshot. Non stampare token nella console e non salvare screenshot di pagine con dati privati senza necessità.
🚧 Quando non ti fanno entrare: opzioni legali
I siti hanno il diritto di limitare l'accesso automatico. Se il tuo client headless riceve rifiuti, è un motivo per accordarsi, non per mascherarsi. Cosa funziona legalmente:
- Utilizza l'API ufficiale, se disponibile.
- Chiedi al proprietario del sito di aggiungere il tuo client alla lista consentita (allowlist).
- Identifica onestamente il client: un User-Agent comprensibile con contatti.
- Rispetta i limiti di frequenza delle richieste e i termini di utilizzo del sito.
- Testa sui tuoi ambienti (staging), non sulla produzione altrui.
Il file robots.txt non è un meccanismo di autorizzazione, ma rispettalo: maggiori dettagli su robots.txt. E come appare tale traffico per il proprietario del sito, vedi nell'articolo "Traffico sospetto in GA4".
🛡️ Sicurezza: SSRF nel rendering di URL altrui
Se il tuo servizio apre in Chrome headless indirizzi inseriti dagli utenti (screenshot, PDF, anteprime di link), crei un classico vettore SSRF (Server-Side Request Forgery). OWASP descrive SSRF come l'abuso di un'applicazione per effettuare richieste alla rete interna o esterna o alla macchina stessa. Nel nostro caso, il ruolo di "applicazione" è svolto dal tuo browser, e le richieste provengono dalla tua rete.
Perché un browser è più rischioso di un normale client HTTP. Questa è la mia conclusione su come funziona Chrome (OWASP non parla di browser): il browser segue da solo i redirect, scarica sottorisorse ed esegue script della pagina altrui, che a loro volta possono accedere ad indirizzi all'interno della tua rete. Pertanto, la verifica del solo URL inserito non è sufficiente.
OWASP distingue due casi. Nel primo, l'applicazione deve accedere solo ad indirizzi fidati noti, e qui si adatta l'allowlist. Nel secondo, deve accedere a qualsiasi indirizzo esterno, e l'allowlist non è disponibile. Un servizio di rendering di URL altrui appartiene più spesso al secondo caso.
Opzioni di protezione
| Opzione |
Quando è adatta |
Cosa fare |
| A. Allowlist |
Renderizzi solo siti noti |
OWASP raccomanda di confrontare l'host con un elenco esplicito di indirizzi consentiti, costruire la richiesta autonomamente e non accettare un URL completo dall'utente |
| B. Verifica degli indirizzi (denylist) |
L'utente può specificare qualsiasi sito pubblico |
Bloccare gli intervalli interni secondo la tabella sottostante, verificare l'IP dopo la risoluzione DNS, consentire solo http e https. OWASP definisce la denylist un'ultima risorsa, soggetta a bypass |
| C. Isolamento di rete |
Sempre, come secondo livello |
Avviare il browser in un container o rete separata, da cui l'accesso ai servizi interni è bloccato. OWASP raccomanda vivamente la segmentazione della rete |
| D. Protezione dei metadati cloud |
Il servizio funziona su AWS, GCP o Azure |
In AWS passare a IMDSv2 e disabilitare IMDSv1. Non dare alla macchina ruoli con permessi eccessivi |
Elenco minimo bloccato nell'opzione B
Secondo la tabella OWASP:
| Cosa |
Intervalli |
| Metadati cloud (AWS, GCP, Azure) |
169.254.169.254, metadata.google.internal, metadata.amazonaws.com |
| Localhost |
127.0.0.0/8, 0.0.0.0/8, ::1/128 |
| Reti private (RFC 1918) |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
| IPv6 unique local e link-local |
fc00::/7, fe80::/10 |
| Multicast |
224.0.0.0/4, ff00::/8 |
Esempio di verifica dell'indirizzo in Java (scheletro)
static boolean isPublic(InetAddress a) {
if (a.isAnyLocalAddress() || a.isLoopbackAddress() || a.isLinkLocalAddress()
|| a.isSiteLocalAddress() || a.isMulticastAddress()) {
return false;
}
byte[] b = a.getAddress();
if (b.length == 16 && (b[0] & 0xFE) == 0xFC) return false; // fc00::/7
if (b.length == 4 && (b[0] & 0xFF) == 100 && (b[1] & 0xC0) == 64) return false; // 100.64.0.0/10
return true;
}
// Risolvere tutti gli indirizzi dell'host e rifiutare se almeno uno non è pubblico
for (InetAddress a : InetAddress.getAllByName(host)) {
if (!isPublic(a)) throw new SecurityException("Indirizzo proibito: " + a);
}
Questo è solo un inizio, non una protezione completa. OWASP avverte che la verifica delle risposte DNS separatamente non protegge dal DNS rebinding: tra la verifica e la connessione l'indirizzo potrebbe cambiare. Il client deve connettersi solo all'indirizzo verificato, e una nuova richiesta DNS non completata tra la verifica e la connessione può aggirare la protezione. Proprio per questo l'isolamento di rete (opzione C) è un secondo livello obbligatorio: funziona indipendentemente da ciò che ha restituito il DNS.
Misure aggiuntive per browser headless
- Consentire solo
http e https. OWASP ricorda che SSRF non si limita a HTTP, e cita schemi come file:// e gopher://.
- Intercettare le richieste nel browser stesso. In Playwright per questo ci sono
page.route() e context.route(): si possono rifiutare le richieste verso host proibiti, inclusi sottorisorse e redirect. Ma l'intercettazione vede l'URL, non l'indirizzo IP effettivo, quindi integra l'isolamento di rete, non lo sostituisce. (Verifica l'API nella documentazione della tua versione.)
- Non aprire la porta di debug all'esterno (
--remote-debugging-port): dà il controllo completo sul browser.
- Isolare il processo. Un container separato senza segreti e senza accesso ai database interni, un utente non privilegiato, tempo e dimensione del task limitati.
- Non disabilitare la sandbox per le pagine che non controlli.
🔧 Трассировка и отладка: Playwright Trace Viewer
Самое сложное в нестабильных сбоях — невозможность их воспроизвести. Trace Viewer от Playwright решает эту проблему, записывая происходящее. Playwright Trace Viewer — это графический инструмент, позволяющий изучать записанные трассировки после завершения скрипта. Он показывает временную шкалу действий со скриншотами, снимки DOM для каждого действия, исходный код, журнал действий, ошибки, консоль и сетевые запросы.
Варианты записи трассировки
| Вариант |
Как |
Когда |
| A. Playwright Test, локально |
npx playwright test --trace on |
Во время разработки и отладки |
| B. Playwright Test, CI |
В конфигурации trace: 'on-first-retry' |
Для CI документация рекомендует записывать трассировку при первой повторной попытке упавшего теста. Также доступны значения 'on' (каждый тест) и 'off'. По описаниям практиков, есть и retain-on-failure, проверьте в своей версии. Запись каждого запуска увеличивает нагрузку, поэтому в CI её не включают постоянно |
| C. Собственный сервис или скрипт |
API context.tracing |
Когда вы не используете Playwright Test, например, в сервисе рендеринга |
Вариант C: трассировка в собственном сервисе (Node.js)
const context = await browser.newContext();
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
const page = await context.newPage();
try {
await page.goto(url);
// ...работа со страницей
await context.tracing.stop(); // успех: файл не сохраняем
} catch (e) {
await context.tracing.stop({ path: `trace-${jobId}.zip` }); // сбой: сохраняем
throw e;
} finally {
await context.close();
}
То же на Java
BrowserContext context = browser.newContext();
context.tracing().start(new Tracing.StartOptions()
.setScreenshots(true).setSnapshots(true).setSources(true));
Page page = context.newPage();
try {
page.navigate(url);
// ...работа со страницей
context.tracing().stop();
} catch (RuntimeException e) {
context.tracing().stop(new Tracing.StopOptions()
.setPath(Paths.get("trace-" + jobId + ".zip")));
throw e;
} finally {
context.close();
}
Примеры упрощены и не компилировались. Поведение stop() без пути (запись отбрасывается) и точные названия классов проверьте по документации вашей версии Playwright.
Как просмотреть трассировку
- Локально:
npx playwright show-trace trace.zip.
- В браузере: trace.playwright.dev — статически размещённая версия просмотрщика. Туда можно передать и URL трассировки, загруженной в доступное хранилище.
- Типичный порядок анализа: открыть трассировку сбоя, найти действие, на котором упало, сравнить снимки DOM до и после, затем просмотреть консоль и сеть.
Безопасность трассировок
Трассировки содержат снимки страниц и сетевую активность, поэтому могут содержать персональные данные, токены и cookies (это моё предупреждение, в документации оно прямо не приведено). Не публикуйте их в открытом доступе, ограничьте срок хранения и доступ, а для страниц с приватными данными замаскируйте чувствительные поля или не записывайте трассировку вовсе.
Если вы не используете Playwright
- Для Chrome есть отладка в headless-режиме: запустите браузер с удалённым портом отладки и подключитесь DevTools. Порт держите закрытым для внешней сети.
- Минимум для любого инструмента: скриншот и журнал консоли и сети при ошибке, идентификатор задачи в каждой записи логов.
- В Puppeteer есть собственная трассировка Chrome, но она предназначена для профилирования производительности, а не для пошагового воспроизведения действий, как в Playwright. Детали проверьте в документации.
✅ Чек-лист для продакшена
- ☐ Контейнер запускается с
--init (или init: true в Compose).
- ☐
/dev/shm увеличен (--shm-size, --ipc=host или memory-backed emptyDir) и согласован с лимитом памяти.
- ☐ Браузер работает от не-root пользователя, песочница не отключена.
- ☐ Браузер закрывается в
finally, параллелизм ограничен, есть перезапуск после N задач.
- ☐ Установлены шрифты для нужных языков, размер окна задан явно.
- ☐ Версии Chrome и библиотеки зафиксированы.
- ☐ Ожидания работают по состоянию страницы, а не по
sleep.
- ☐ Для чужих URL: allowlist или denylist, проверка IP после DNS, только http/https, запрещены редиректы на внутренние адреса.
- ☐ Браузер работает в сети без доступа к внутренним сервисам, метаданные облака защищены (IMDSv2).
- ☐ Порт отладки не открыт наружу.
- ☐ Записывается трассировка или скриншот при сбое, сроком хранения управляет политика приватности.
- ☐ Есть метрики: время рендеринга, ошибки, перезапуски.
❓ Частые вопросы (FAQ)
Почему Headless Chrome падает в Docker?
Самые частые причины: малый размер /dev/shm (64 МБ по умолчанию), запуск от root, что отключает песочницу, нехватка памяти и зомби-процессы. Начните с df -h /dev/shm в контейнере и флагов --init и --shm-size.
Почему тест проходит локально, а в CI падает?
Обычно из-за зависимости от времени и различий среды: меньше ресурсов, другие шрифты, медленнее сеть. Замените sleep на ожидание состояния и включите trace: 'on-first-retry', чтобы видеть, что именно произошло.
Сколько памяти нужно?
Универсальной цифры нет: зависит от страниц и количества вкладок. Измерьте потребление на своих задачах, добавьте запас и задайте лимит контейнера. Память в /dev/shm входит в этот лимит.
Безопасно ли рендерить URL, которые вводят пользователи?
Только при условии защиты от SSRF: проверка адресов, сетевая изоляция, защита метаданных облака и ограничение схем. Без этого браузер в вашей сети становится точкой входа для злоумышленника.
Можно ли использовать --no-sandbox?
Технически да, но это снижает безопасность, поэтому лучше запускать браузер от не-root пользователя с соответствующим профилем seccomp. Для страниц, которые вы не контролируете, песочницу отключать не стоит.
🏁 Выводы
- 🔹 Большинство проблем Headless Chrome связаны со средой, а не с самим браузером: контейнер, память, права, сеть.
- 🔹 Для
/dev/shm есть несколько вариантов: --shm-size, --ipc=host, memory-backed emptyDir, а --disable-dev-shm-usage оставляйте последним средством.
- 🔹 Песочницу лучше настроить (не-root и seccomp), чем отключать.
- 🔹 Если вы рендерите чужие URL, SSRF становится главным риском, и одна проверка введённого адреса его не закрывает: нужна сетевая изоляция.
- 🔹 Playwright Trace Viewer позволяет разобрать сбой постфактум: записывайте трассировку при падении и храните их как чувствительные данные.
- 🔹 Фиксируйте версии, измеряйте потребление и автоматизируйте чек-лист, тогда продакшен с headless-браузером становится предсказуемым.
📎 Источники
Важно: версии Chrome, образы Playwright и API библиотек меняются. Перед использованием проверяйте актуальную документацию. Материал актуален на октябрь 2026 года.