Headless Chrome легко запустити, але важко експлуатувати в продакшені. Локально все працює, а в контейнері браузер раптом падає, у CI тести то проходять, то ні, а сервіс, що рендерить чужі посилання, стає ризиком для безпеки. У цій статті зібрано типові проблеми й варіанти їх вирішення: від /dev/shm у Docker до захисту від SSRF і трасування падінь у Playwright. Як усе влаштоване всередині, у статті «Як працює Headless Chrome».
⚡ Коротко: де Headless Chrome ламається найчастіше
| Проблема |
Симптом |
Рішення |
| Мало спільної пам'яті в контейнері |
Браузер падає або «недоступний» лише в Docker |
--shm-size, --ipc=host або memory-backed emptyDir у Kubernetes |
| Пісочниця (sandbox) |
Chrome не запускається під root або в обмеженому контейнері |
Не-root користувач і профіль seccomp, а не --no-sandbox |
| Зомбі-процеси та витоки браузерів |
Росте кількість процесів і пам'яті |
--init, обов'язкове закриття браузера, перезапуск після N завдань |
| Нестабільні тести (flaky) |
Проходять локально, падають у CI |
Явні очікування замість sleep, трасування падінь |
| Розбіжності рендерингу |
Інші шрифти й верстка на сервері |
Встановити шрифти, зафіксувати розмір вікна й образ |
| Рендеринг чужих URL |
Браузер звертається до внутрішніх адрес |
Захист від SSRF: allowlist, мережева ізоляція, метадані хмари |
Під час роботи з Headless Chrome важливо враховувати не лише налаштування браузера, а й особливості середовища, у якому він запускається. Я б насамперед перевіряв використання пам’яті, життєвий цикл процесів і параметри контейнера. Саме ці аспекти варто діагностувати першими, якщо браузер працює нестабільно або поводиться інакше в production, ніж локально.
📚 Зміст статті
🧠 Що саме обмежує headless-режим
Сьогодні новий headless-режим є тим самим Chrome без видимих вікон, тож більшість «обмежень» пов'язані не з браузером, а з середовищем, у якому його запускають: контейнер, пам'ять, права користувача, мережа. Окрема історія з chrome-headless-shell: це полегшена версія з меншою кількістю залежностей, і за описом розробників вона менш «автентична», ніж повноцінний Chrome. Про різницю між режимами докладно в статті «Як працює Headless Chrome». Нижче розглядаємо те, що ламається на практиці. У кожному розділі схема однакова: симптом, причина, варіанти рішення.
💾 Пам'ять, процеси та ресурси
Симптом: з часом сервіс споживає дедалі більше пам'яті, у системі накопичуються процеси Chrome.
Причини:
- Зомбі-процеси. Документація Playwright радить запускати контейнер із прапорцем
--init, щоб уникнути особливої поведінки процесів із PID=1, які є частою причиною зомбі-процесів.
- Незакриті браузери. Серед найчастіших причин збоїв у Docker називають витоки браузерів: кожен запуск
launch() створює повноцінний Chrome, який треба закрити.
Що робити:
- Закривайте браузер у блоці
finally, навіть якщо завдання впало.
- Тримайте один довгоживучий браузер і створюйте для кожного завдання окремий контекст замість запуску нового браузера щоразу.
- Обмежте паралелізм і періодично перезапускайте браузер (наприклад, після певної кількості завдань). Конкретні межі підберіть вимірюваннями на своєму навантаженні.
- Встановіть ліміти пам'яті контейнера й слідкуйте за ними в моніторингу.
⏱️ Таймаути та нестабільні тести
Симптом: тести проходять локально й падають у CI, скриншот робиться до появи контенту.
Причина: код покладається на час, а не на стан сторінки. В CI ресурсів менше, і сторінка завантажується повільніше.
| Типова помилка |
Правильний підхід |
Фіксований sleep |
Чекати на конкретний елемент або подію. Playwright робить частину очікувань автоматично перед діями |
| Знімок «одразу після завантаження» |
Чекати, поки з'явиться потрібний контент. У CLI допомагають --timeout і --virtual-time-budget |
| Один глобальний таймаут |
Окремі таймаути на навігацію, дії й усе завдання |
| Тест падає без пояснень |
Записувати трасування падінь (див. розділ про Playwright Trace Viewer) |
🐳 Docker і CI: /dev/shm та пісочниця
Проблема з /dev/shm
Симптом: Chrome стабільно працює на вашому комп'ютері, а в контейнері випадково падає або повідомляє, що недоступний. У одному з розборів описано саме такі «випадкові» збої лише в Docker.
Причина: за документацією Puppeteer (архівна версія), Docker за замовчуванням виділяє контейнеру спільну пам'ять /dev/shm лише 64 МБ, а Chrome активно користується нею.
Як перевірити:
docker exec <container> df -h /dev/shm
Якщо розмір 64 МБ, а під час падіння зайнято майже все, діагноз підтверджується.
Варіанти рішення
| Варіант |
Як |
Плюси |
Мінуси |
| A. Збільшити розмір |
docker run --shm-size=2g ..., у Compose shm_size: "2gb" |
Найпряміше рішення, браузер працює швидко, ізоляція не змінюється |
Пам'ять рахується в ліміт контейнера, тож розмір треба узгодити з лімітом |
| B. Спільний IPC з хостом |
docker run --ipc=host ... |
Це офіційна рекомендація Playwright для Chromium: без неї Chromium може вичерпати пам'ять і впасти |
Контейнер ділить IPC-простір з хостом, тож для недовірених сторінок я б віддав перевагу варіанту A |
| C. Вимкнути використання /dev/shm |
Прапорець Chrome --disable-dev-shm-usage |
Допомагає, коли немає доступу до налаштувань контейнера |
Файли пишуться в /tmp, а це звичайна файлова система, не RAM, тому можливе уповільнення. У деяких оглядах це радять лише як крайній захід |
| D. Kubernetes |
Змонтувати memory-backed emptyDir у /dev/shm |
Прямого аналога --shm-size у pod немає, це стандартний обхід |
Розмір рахується у пам'ять pod, тож треба задати sizeLimit |
Приклад для Docker Compose:
services:
renderer:
image: my-renderer
init: true
shm_size: "2gb"
mem_limit: 4g
Приклад для 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 підтримує такі томи з пам'яттю, а їхній розмір за замовчуванням залежить від пам'яті вузла, тому sizeLimit варто задавати явно. Дані в таких томах використовують пам'ять pod, тож розмір має вміщатися в ліміт контейнера.
Значення 2 ГБ у прикладах орієнтовні. Підберіть своє за вимірюваннями: залежить від кількості вкладок і складності сторінок.
Пісочниця (sandbox) і користувач
Часта порада «додайте --no-sandbox» вирішує проблему запуску, але знімає важливий захист. Описано, що образ Playwright за замовчуванням працює від root, а це вимикає пісочницю Chromium, яка з root недоступна. Для недовірених сайтів рекомендують окремого користувача разом із профілем seccomp. Офіційний приклад запуску для Java:
docker run -it --rm --init --ipc=host --user pwuser \
--security-opt seccomp=seccomp_profile.json \
mcr.microsoft.com/playwright/java:<версія>-noble /bin/bash
У документації також зазначено, що профіль seccomp_profile.json потрібен для запуску Chromium із пісочницею, а цей образ не рекомендують використовувати для відвідування недовірених сайтів. Підставте актуальну версію образу з документації.
🔤 Шрифти та розбіжності рендерингу
Симптом: на сервері текст виглядає інакше, ніж локально, у PDF з'являються «квадратики» замість символів.
Причина: у мінімальному образі немає потрібних шрифтів. У Dockerfile з документації Puppeteer шрифти для основних наборів символів (китайські, японські, арабські, івритські, тайські) встановлюють окремо.
Що робити:
- Встановіть шрифти для мов, які вам потрібні (для багатомовного блогу це як мінімум кирилиця, латиниця та, за потреби, CJK).
- Задавайте розмір вікна явно (наприклад,
--window-size у CLI), щоб результат не залежав від значень за замовчуванням.
- Фіксуйте образ і версію Chrome. Різні версії можуть давати різний результат.
- Для PDF перевірте стилі друку (
@media print): вони можуть відрізнятися від екранної верстки.
🧩 Складні сторінки, авторизація й сесії
- iframe, Shadow DOM, спливаючі вікна, завантаження файлів. У різних бібліотеках їх обробляють по-різному. Перш ніж обирати інструмент, перевірте на своїх сторінках, чи зручно працювати з такими елементами в документації Puppeteer, Playwright або Selenium.
- Авторизація. Не логіньтеся наново в кожному завданні. Playwright дозволяє зберігати стан сесії і використовувати його повторно. Зберігайте такі файли як секрети: у них є cookies.
- Ізоляція. Окремий контекст на завдання не дає cookies і сховищу одного завдання потрапити в інше.
- Секрети в логах і скриншотах. Не друкуйте токени в консоль і не зберігайте скриншоти сторінок із приватними даними без потреби.
🚧 Коли вас не пускають: легальні варіанти
Сайти мають право обмежувати автоматичний доступ. Якщо ваш headless-клієнт отримує відмови, це привід домовитися, а не маскуватися. Що працює легально:
- Користуйтеся офіційним API, якщо він є.
- Попросіть власника сайту додати ваш клієнт до дозволеного списку (allowlist).
- Чесно ідентифікуйте клієнт: зрозумілий User-Agent із контактами.
- Дотримуйтеся обмежень частоти запитів і умов використання сайту.
- Тестуйте на своїх середовищах (staging), а не на чужому продакшені.
Файл robots.txt не є механізмом авторизації, але шануйте його: докладніше про robots.txt. А як такий трафік виглядає для власника сайту, дивіться в статті «Підозрілий трафік у GA4».
🛡️ Безпека: SSRF при рендерингу чужих URL
Якщо ваш сервіс відкриває в headless Chrome адреси, які вводять користувачі (скриншоти, PDF, прев'ю посилань), ви створюєте класичний вектор SSRF (Server-Side Request Forgery). OWASP описує SSRF як зловживання застосунком для звернень до внутрішньої чи зовнішньої мережі або до самої машини. У нашому випадку роль «застосунку» виконує ваш браузер, а запити йдуть з вашої мережі.
Чому браузер ризикованіший за звичайний HTTP-клієнт. Це мій висновок із того, як працює Chrome (OWASP про браузери не говорить): браузер сам слідує за редіректами, завантажує підресурси й виконує скрипти чужої сторінки, які теж можуть звертатися до адрес усередині вашої мережі. Тому перевірки одного лише введеного URL недостатньо.
OWASP розрізняє два випадки. У першому застосунок має звертатися лише до відомих довірених адрес, і тут підходить allowlist. У другому він має ходити на будь-які зовнішні адреси, і allowlist недоступний. Сервіс рендерингу чужих URL найчастіше належить до другого випадку.
Варіанти захисту
| Варіант |
Коли підходить |
Що робити |
| A. Allowlist |
Ви рендерите лише відомі сайти |
OWASP радить порівнювати хост із явним списком дозволених адрес, будувати запит самостійно і не приймати від користувача повний URL |
| B. Перевірка адрес (denylist) |
Користувач може вказати будь-який публічний сайт |
Блокувати внутрішні діапазони за таблицею нижче, перевіряти IP після DNS-розв'язання, дозволити лише http і https. OWASP називає denylist крайнім засобом, схильним до обходів |
| C. Мережева ізоляція |
Завжди, як другий рівень |
Запускати браузер в окремому контейнері або мережі, з якої закритий доступ до внутрішніх сервісів. OWASP наполегливо рекомендує сегментацію мережі |
| D. Захист метаданих хмари |
Сервіс працює в AWS, GCP або Azure |
В AWS перейти на IMDSv2 і вимкнути IMDSv1. Не давати ролі машини зайвих прав |
Мінімальний список, який блокують у варіанті B
За таблицею OWASP:
| Що |
Діапазони |
| Метадані хмари (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 |
| Приватні мережі (RFC 1918) |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
| IPv6 unique local та link-local |
fc00::/7, fe80::/10 |
| Multicast |
224.0.0.0/4, ff00::/8 |
Приклад перевірки адреси на Java (скелет)
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;
}
// Розв'язати всі адреси хоста і відхилити, якщо хоч одна не публічна
for (InetAddress a : InetAddress.getAllByName(host)) {
if (!isPublic(a)) throw new SecurityException("Заборонена адреса: " + a);
}
Це лише початок, а не повний захист. OWASP попереджає, що перевірка DNS-відповідей окремо не захищає від DNS rebinding: між перевіркою і підключенням адреса може змінитися. Клієнт має підключатися лише до перевіреної адреси, а повторний незавершений запит DNS між перевіркою і з'єднанням може обійти захист. Саме тому мережева ізоляція (варіант C) є обов'язковим другим рівнем: вона працює незалежно від того, що повернув DNS.
Додаткові заходи для headless-браузера
- Дозволяйте лише
http і https. OWASP нагадує, що SSRF не обмежується HTTP, і називає схеми на кшталт file:// і gopher://.
- Перехоплюйте запити в самому браузері. У Playwright для цього є
page.route() і context.route(): можна відхиляти запити до заборонених хостів, зокрема підресурси й редіректи. Але перехоплення бачить URL, а не фактичну IP-адресу, тому воно доповнює мережеву ізоляцію, а не замінює її. (Перевірте API у документації вашої версії.)
- Не відкривайте порт налагодження назовні (
--remote-debugging-port): він дає повний контроль над браузером.
- Ізолюйте процес. Окремий контейнер без секретів і без доступу до внутрішніх баз, непривілейований користувач, обмежений час і розмір завдання.
- Не відключайте пісочницю для сторінок, які ви не контролюєте.
🔧 Трасування та налагодження: 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 року.