SSD Nodes Learn 🎉 VPS від $5.50/міс
Посібники Matt ConnorВід Matt Connor

Як запустити headless Chromium для AI-агента на VPS

Headless Chromium на VPS ламається через малий /dev/shm, sandbox, відсутні шрифти та процеси, що не завершуються. Налаштуйте ліміти заздалегідь.

Що ви запускаєте

Headless-браузер на VPS — це Chromium без вікна, яким керує ваш код, а не користувач. На сервері це довготривале дерево процесів, до якого ваш агент звертається через локальний сокет. Для встановлення достатньо однієї команди. Уся складність починається після цього. Ви обмежуєте ресурси, які браузер може використовувати на машині, і не відкриваєте його кінцеву точку керування для публічного інтернету.

У цьому посібнику передбачається, що інструмент уже вибрано і тепер його потрібно експлуатувати. Якщо ви ще порівнюєте краулери та засоби видобування даних, почніть із self-hosted альтернатив Firecrawl і поверніться до цього посібника. Нижче використовується Chromium із Playwright, оскільки Playwright постачається з власною збіркою браузера та власним інсталятором залежностей. Тому ті самі команди працюють на чистому Ubuntu VPS і всередині контейнера. Версії актуальні станом на серпень 2026 року.

Встановлюйте Chromium без припущень щодо залежностей

npm i -D playwright@1.62.0
npx playwright install --with-deps chromium

--with-deps запускає apt для встановлення спільних бібліотек і шрифтів, потрібних Chromium, а на цьому етапі запитує права root. Сам браузер завантажується в ~/.cache/ms-playwright для користувача, який виконав команду. На сервері це важливо, оскільки користувач сервісу зазвичай відрізняється від користувача, під яким ви входите в систему. Один раз встановіть системні пакети від імені адміністратора за допомогою sudo npx playwright install-deps chromium, а потім укажіть PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers і в команді встановлення, і в unit сервісу, щоб спільно використовувати одну копію. Якщо сервіс не бачить браузер, під час запуску він завершується з повідомленням, у якому зазначено шлях, де виконувалася перевірка.

Зафіксуйте версію Playwright. Кожен реліз пов’язаний з певною збіркою браузера, тому версія npm update без фіксації може замінити браузер у вже запущеному сервісі. Playwright 1.62 є актуальним станом на August 2026.

Існує дві збірки Chromium, і це не одна й та сама програма. Типове завантаження — це headless shell, менший бінарний файл, який працює лише в headless-режимі; npx playwright install --with-deps --only-shell встановлює тільки його. Повну версію браузера ви отримуєте з каналом chromium, який документація Playwright щодо браузерів називає "справжнім браузером Chrome, тому він автентичніший, надійніший і має більше функцій". Використовуйте shell для масового отримання даних. Використовуйте повний браузер, коли сайт працює інакше й потрібно з’ясувати причину.

Чому headless-браузер завершує роботу з помилкою в контейнері

Docker виділяє кожному контейнеру 64 MB /dev/shm. У документації Docker це чітко зазначено: «Якщо розмір не вказано взагалі, система використовує 64m». Chromium передає відрендерений вміст між своїми процесами через цю область спільної пам’яті, тому одна важка сторінка може її повністю заповнити. Після цього процес renderer завершується, а клієнт повідомляє про аварійне завершення target, хоча сторінка нормально працює на вашому ноутбуці. Перш ніж щось змінювати, перевірте розмір зсередини контейнера.

df -h /dev/shm

Є два коректні способи виправлення. Це альтернативи, а не налаштування, які потрібно застосовувати разом. --ipc=host розміщує контейнер у IPC namespace хоста, тому контейнер використовує /dev/shm хоста, розмір якого зазвичай становить половину обсягу RAM. Посібник Playwright для Docker рекомендує цей спосіб, оскільки без нього «Chromium може вичерпати пам’ять і аварійно завершити роботу». Недолік полягає у втраті IPC-ізоляції між контейнером і хостом. --shm-size=1g зберігає приватний namespace і просто збільшує розмір mount.

docker run --rm -it --init --ipc=host --user pwuser mcr.microsoft.com/playwright:v1.62.0-noble /bin/bash

Прапорець --disable-dev-shm-usage — це відповідь, яку ви знайдете в більшості результатів пошуку. Але він робить інше: переміщує ці файли з /dev/shm до тимчасового каталогу. Якщо /tmp розташований на диску, ви замінюєте аварійне завершення роботи повільнішим рендерингом і записуванням на диск. Якщо /tmp — це tmpfs, дані знову зберігаються в RAM, але вже без обмеження розміру. Через це браузер може швидко використати пам’ять невеликого VPS. Натомість правильно задайте розмір /dev/shm.

Що насправді означає використання --no-sandbox

Chromium ізолює кожен renderer у sandbox, побудованому на Linux user namespaces. Цей sandbox є межею між шкідливою вебсторінкою та вашим сервером. Якщо sandbox не запускається, Chromium відмовляється працювати, а в журналі з’являється такий рядок:

Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted

Зазвичай радять --no-sandbox. У документації Chromium з безпеки прямо зазначено ціну цього рішення: прапорець «вимикає критично важливі функції безпеки Chromium, і його ніколи не слід використовувати під час перегляду відкритого вебу». Агент, який переходить за посиланнями, за визначенням переглядає відкритий веб. Знайдіть справжню причину.

Майже всі випадки пояснюються двома причинами. Запуск браузера від імені root вимикає sandbox, оскільки процес не може відмовитися від привілеїв, які вже має. Саме тому образ Playwright містить звичайного користувача з іменем pwuser. В Ubuntu 24.04 і новіших версіях AppArmor обмежує непривілейовані user namespaces, а двійковий файл Chromium у каталозі, на який не поширюється жоден профіль із системи, отримує відмову в доступі. Завантаження Playwright у ~/.cache/ms-playwright використовує саме такий шлях. Перевірте обидві причини:

id -u
sysctl kernel.apparmor_restrict_unprivileged_userns
sudo dmesg | grep -i userns_create

Значення 1 у sysctl разом із рядком ядра, що містить apparmor="DENIED" operation="userns_create", підтверджує другу причину. Дозвольте цей один двійковий файл у /etc/apparmor.d/pw-chromium. Обмеження для всіх інших об’єктів на сервері залишаться чинними:

abi <abi/4.0>,
include <tunables/global>

profile pw-chromium /home/*/.cache/ms-playwright/*/chrome-linux/{chrome,headless_shell} flags=(unconfined) {
  userns,
}

Завантажте профіль командою sudo apparmor_parser -r /etc/apparmor.d/pw-chromium. Шлях містить revision браузера, тому він змінюється після кожного оновлення Playwright. Наведені вище glob-шаблони продовжують працювати. Профіль, створений для одного точного шляху, непомітно перестає відповідати, і після оновлення, яке здавалося не пов’язаним із проблемою, браузер знову починає завершуватися з помилкою.

Чому скриншоти повертаються порожніми або заповненими прямокутниками

Порожній скриншот або скриншот, заповнений порожніми прямокутниками, зазвичай вказує на проблему зі шрифтами, а не на помилку рендерингу. install-deps підтягує базовий набір: fonts-liberation, fonts-freefont-ttf, fonts-noto-color-emoji, fonts-unifont, fonts-ipafont-gothic для японської мови, fonts-wqy-zenhei для китайської, fonts-tlwg-loma-otf для тайської. Шрифтів Noto CJK у цьому наборі немає, тому для корейської та кількох інших писемностей використовується резервний шрифт, який може знайти fontconfig. Замість припущень запитайте fontconfig:

fc-match "sans-serif:lang=ko"
fc-match "sans-serif:lang=ar"
fc-list | wc -l

Якщо потрібна вам мова визначається як unifont або переходить до резервного шрифту без потрібних гліфів, установіть fonts-noto-core і fonts-noto-cjk, а потім повторіть перевірку. fontconfig кешує результати, тому після встановлення шрифтів перезапустіть браузер. У мінімальному образі без шрифтів узагалі під час запуску записується Fontconfig error: Cannot load default config file, і кожна сторінка відображається порожньою.

Локаль і часовий пояс не залежать від шрифтів. Вони змінюють вміст сторінки, а не лише її вигляд. У контейнері зазвичай LANG не встановлено, а TZ має значення UTC. Тому сайти обслуговують запити англійською та виводять часові мітки в UTC, а ваш агент повідомляє час, який не відповідає часу, що бачить людина в цій країні. Встановлюйте ці параметри для кожного контексту браузера, а не для всієї машини. Так один браузер зможе виконувати завдання для різних регіонів.

const context = await browser.newContext({
  locale: 'en-GB',
  timezoneId: 'Europe/Paris',
});

Як витоки процесів браузера змушують сервер використовувати swap

Дві різні проблеми називають словом «zombie». Справжній zombie — це завершений процес, батьківський процес якого не викликав wait(). Він утримує лише запис PID і більше нічого, тому не споживає пам’ять. Такі процеси накопичуються, коли браузер працює як PID 1 у контейнері, оскільки PID 1 за замовчуванням не виконує роль reaper. Прапорець --init у Docker виправляє саме це: він запускає невеликий init, який «пересилає сигнали та збирає завершені процеси». У Compose для цього використовується init: true.

Витік, через який сервер фактично починає використовувати swap, має іншу причину: це активні процеси Chromium, які ніхто не завершив. Таке трапляється, коли завдання завершується з помилкою між newContext() і close() або коли керівний скрипт примусово завершується й залишає своє дерево процесів браузера без батьківського процесу. Найгірший варіант — код, який запускає новий браузер для кожного запиту. Порахуйте їх:

pgrep -c -f 'headless_shell|chrome'
ps -eo pid,ppid,rss,etime,comm --sort=-rss | head -20

Між завданнями це число має повертатися до значення в режимі простою. Якщо протягом дня воно зростає, проблему потрібно виправляти в коді, а не в параметрах запуску: закривайте context у блоці finally, закривайте браузер у SIGTERM і перезапускайте браузер після фіксованої кількості завдань, а не залишайте один процес працювати місяць. У systemd зупинка або перезапуск завершує всі процеси в cgroup цього unit, тому sudo systemctl restart browser.service є надійним способом скидання стану. Браузер, запущений вручну в terminal multiplexer, не має такої гарантії, і його дочірні процеси можуть працювати після завершення сесії.

Скільки оперативної пам’яті потребує один browser context

Сформулюйте запитання точно, оскільки «один браузер» — це не один процес. Chromium запускає процес браузера, процес GPU, службові процеси та по одному renderer process для кожного сайту. Site isolation також створює окремий renderer для iframe з іншого сайту. BrowserContext — це окреме сховище cookie та даних усередині цього самого дерева процесів, тому другий context споживає небагато ресурсів. Друге page — ні, оскільки воно запускає renderer processes, а сторінка з великою кількістю реклами запускає кілька таких процесів.

Тому вимірювати потрібно пікове споживання пам’яті всім деревом процесів під час вашого робочого навантаження. Дані з чужого блогу тут не мають практичної цінності, оскільки відповідь визначають сторінки, які відкриває ваш агент. Виконуйте вимірювання на машині, яку використовуватимете, і на сайтах, які відвідуватимете:

sudo systemd-run --unit=browser-probe -p MemoryMax=2G -p MemorySwapMax=0 -p WorkingDirectory=/srv/agent /usr/bin/node worker.js
systemctl status browser-probe

В Ubuntu 24.04 рядок Memory: у цьому виведенні показує поточне та пікове споживання пам’яті unit. Запустіть worker з однією сторінкою, зафіксуйте пікове значення, а потім повторіть вимірювання з двома відкритими сторінками, щоб побачити фактичну вартість другої сторінки. Далі кількість одночасних worker визначається арифметично: від загального обсягу RAM відніміть пам’ять, потрібну решті системи, залиште запас у кілька сотень MB і поділіть результат на виміряне пікове споживання одного worker. Щоб визначити розмір машини для цього навантаження, див. скільки RAM і CPU потрібно VPS для агента.

Обмежте це число у двох місцях. У коді використовуйте фіксований пул worker або semaphore, щоб під час сплеску запитів вони ставали в чергу, а не запускали нові браузери. В ОС використовуйте ліміт cgroup, щоб помилка в черзі не вивела з ладу всю машину:

[Service]
MemoryMax=2G
MemorySwapMax=0
TasksMax=512
Restart=always

MemorySwapMax=0 важливіший, ніж може здаватися. Без нього cgroup переміщує сторінки у swap після досягнення ліміту. Машина продовжує працювати, але кожен запит сповільнюється, і таку проблему важче діагностувати, ніж однозначний збій. З ним kernel завершує дерево процесів браузера всередині цієї cgroup, systemd перезапускає unit, а sshd продовжує працювати. Відповідні параметри в Compose — mem_limit, shm_size і init; їх описано в розділі налаштування лімітів пам’яті в Docker Compose.

Не виставляйте браузерний endpoint у публічний інтернет

Playwright може запускати браузер як сервер і передавати агенту WebSocket URL:

const { chromium } = require('playwright');
const server = await chromium.launchServer({ port: 3000 });
console.log(server.wsEndpoint());

Цей endpoint не має автентифікації. В API-документації Playwright це зазначено безпосередньо: «Будь-який процес або вебсторінка (зокрема ті, що працюють у Playwright), яким відомий wsPath, можуть отримати контроль над користувачем ОС». Типове значення host — localhost, що «приймає з’єднання лише з loopback-інтерфейсу», а документація попереджає, що передавання явної адреси, наприклад 0.0.0.0, «відкриває browser RPC для всіх, хто може досягти порту, на якому він прослуховується». Власний --remote-debugging-port у Chrome ще небезпечніший. Протокол DevTools не має жодної автентифікації та повністю залежить від прив’язки до loopback.

Перевірте, що саме ви опублікували, а також виконайте перевірку з іншої машини, не лише з VPS:

ss -ltnp

Будь-який браузерний порт, прив’язаний до 0.0.0.0, є проблемою безпеки. Пам’ятайте, що більшість провайдерів мають окремий мережевий firewall у панелі керування. Ваші правила ufw про нього нічого не знають. Отримуйте доступ до endpoint з іншої машини через SSH-тунель або приватний VPN:

ssh -N -L 3000:127.0.0.1:3000 you@your-vps

Ризик тут не обмежується викраденням часу роботи браузера. Браузер, яким можна керувати, — це механізм підроблення запитів усередині вашої мережі. Той, хто отримає доступ до цього сокета, зможе змусити браузер запитати http://127.0.0.1:8080, сторінку адміністрування бази даних або адресу хмарних метаданих 169.254.169.254, а потім прочитати відповідь зі сторінки. Ваш firewall бачить запит, що надходить із самого VPS, а такий запит дозволений. Вважайте endpoint керування еквівалентним доступу до shell на цьому сервері.

MCP-сервери мають таку саму модель. npx @playwright/mcp@latest --headless --port 8931 обслуговує HTTP на localhost, а --host 0.0.0.0 — це прапорець, який перетворює локальний інструмент на публічний. У README проєкту прямо сказано, що Playwright MCP «не є межею безпеки». Залиште порт на loopback і надайте агенту доступ до нього через той самий тунель.

Сторінки, які читає ваш агент, є ненадійними вхідними даними

Агент, який переглядає відкритий веб, передає моделі текст, написаний незнайомими людьми, а модель також отримує ваші інструкції. Сторінка може містити текст, адресований цій моделі, із вказівкою припинити виконання завдання, викликати інструмент або передати дані на URL-адресу. Модель отримує все це як текст, тому не має надійного способу відрізнити слова зі сторінки від ваших інструкцій. Налаштуйте систему так, щоб шкідлива сторінка мала мінімальні можливості для впливу.

  • Запускайте браузер під окремим користувачем ОС. Не зберігайте в його середовищі SSH-ключі та облікові дані хмарних сервісів.
  • Використовуйте новий контекст для кожного завдання та --isolated з Playwright MCP, щоб сеанс на одному сайті був недоступний для наступної сторінки.
  • Використовуйте список дозволених origin, якщо завдання це передбачає. Playwright MCP приймає --allowed-origins і --blocked-origins як списки, розділені крапками з комою.
  • Вимагайте підтвердження людиною перед будь-якою дією, що змінює стан, наприклад надсиланням пошти або витрачанням коштів.

Ще краще розмістити весь браузер на машині, яку можна видалити та заново розгорнути. Це той самий принцип, що й запуск coding agents у disposable VM. Якщо фактичне завдання агента — пошук, а не довільний перегляд вебу, безпечнішим буде спеціалізований інструмент замість повноцінного браузера: пошуковий skill на базі власного SearXNG повертає результати, не завантажуючи шкідливу сторінку.

FAQ

Чому Chromium аварійно завершує роботу в Docker, але без проблем працює безпосередньо на тому самому VPS?

Тому що контейнер за замовчуванням отримує 64 MB /dev/shm, а на хості це значення набагато більше. Chromium передає відрендерений вміст через цю область спільної пам’яті, тому велика сторінка заповнює її, і процес рендерингу завершується. Виконайте df -h /dev/shm усередині контейнера, щоб це підтвердити. Потім запустіть браузер із --ipc=host, який використовує shared memory хоста, або з --shm-size=1g, який збільшує власну shared memory контейнера. --disable-dev-shm-usage лише переносить проблему до /tmp.

Чи безпечно використовувати --no-sandbox, якщо на VPS більше нічого не працює?

Ні. Sandbox не дає шкідливій сторінці отримати доступ до решти системи. У документації Chromium зазначено, що цей прапорець «вимикає критично важливі функції безпеки Chromium і його ніколи не слід використовувати під час перегляду відкритого вебу». Агент, який переходить за посиланнями, переглядає відкритий веб. Усуньте причину: не запускайте браузер від імені root. В Ubuntu 24.04 додайте профіль AppArmor із userns, для шляху до бінарного файлу браузера, щоб дозволити unprivileged user namespaces лише для цієї програми.

Скільки браузерів можна запустити на невеликому VPS?

Виміряйте це значення, а не використовуйте готове число. Chromium запускає окремий процес рендерингу для кожного сайту, тому результат залежить від відкритих сторінок. Запустіть один worker із systemd-run, задавши MemoryMax, зчитайте пікове значення з рядка Memory: у systemctl status, потім поділіть доступний обсяг RAM на цей пік і залиште запас. Обмежте результат на двох рівнях: додайте queue у коді та MemoryMax у unit file. Тоді сплеск запитів очікуватиме в черзі, а не спричинятиме swapping на машині.

Чи може мій агент підключатися до браузера з іншої машини?

Так, але ніколи не прив’язуйте порт до 0.0.0.0. Endpoint Playwright server і порт Chrome DevTools приймають підключення від будь-якого клієнта, який може до них дістатися, без пароля. Залиште listener на 127.0.0.1, а з’єднання передавайте через SSH tunnel або приватну VPN. Перевірте це за допомогою ss -ltnp на сервері та перевіркою порту ззовні. Також перевірте окремий network firewall у вашого провайдера.

Чому мої screenshots порожні, хоча сторінка явно завантажилася?

Відсутні fonts. Якщо немає font, який підтримує script сторінки, текст відображається як порожні квадрати або взагалі не відображається. Через це сторінка без зображень у результаті виглядає порожньою. Виконайте fc-match "sans-serif:lang=ko" для кожної мови, яку ви обробляєте. Якщо результатом є generic fallback, встановіть fonts-noto-core і fonts-noto-cjk. Потім перезапустіть браузер, щоб fontconfig повторно завантажив свій cache. Контейнер без fonts взагалі записує Fontconfig error: Cannot load default config file під час запуску.

#headless-browser#playwright#chromium#ai-agents#automation