Обмеження Headless Chrome: типові проблеми в продакшені

Оновлено:
Обмеження Headless Chrome: типові проблеми в продакшені

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 року.