SSD Nodes Learn 🎉 VPS от $5.50/мес
Руководства Matt ConnorАвтор: Matt Connor

Запуск headless-браузера на VPS для AI-агентов

Настройка Chromium на сервере часто вызывает ошибки из-за нехватки /dev/shm, проблем с песочницей и отсутствия шрифтов. Узнайте, как правильно сконфигурировать среду для агентов.

Что вы запускаете

Headless-браузер на VPS — это Chromium без графического интерфейса, управляемый вашим кодом, а не пользователем. На сервере это долгоживущее дерево процессов, с которым ваш агент взаимодействует через локальный сокет. Установка выполняется одной командой. Основная работа начинается после этого. Вы ограничиваете ресурсы, которые браузер может потреблять на машине, и закрываете его управляющую точку доступа от публичного интернета.

Это руководство предполагает, что выбор инструмента уже сделан и теперь вам нужно его эксплуатировать. Если вы всё ещё сравниваете краулеры и экстракторы, начните с альтернатив Firecrawl для self-hosted размещения и вернитесь сюда. Всё, что описано ниже, использует 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 как в команде установки, так и в юните сервиса, чтобы использовать одну общую копию. Сервис, который не может обнаружить браузер, завершается с ошибкой при запуске, указывая путь, по которому производился поиск.

Зафиксируйте версию Playwright. Каждый релиз привязан к конкретной сборке браузера, поэтому незафиксированный npm update может привести к замене браузера во время работы сервиса. Playwright 1.62 является актуальной версией на август 2026 года.

Существуют две сборки Chromium, и это не одна и та же программа. По умолчанию загружается headless shell — компактный бинарный файл, который работает только в headless-режиме, и npx playwright install --with-deps --only-shell устанавливает только его. Полноценный браузер доступен через канал chromium, который в документации Playwright по браузерам описывается как «настоящий браузер Chrome, который является более аутентичным, надежным и предлагает больше функций». Используйте shell для массового сбора данных. Используйте полноценный браузер, если сайт ведет себя иначе и вам нужно выяснить причину.

Почему headless-браузер аварийно завершается в контейнере

Docker выделяет каждому контейнеру /dev/shm объемом 64 МБ. В документации Docker прямо сказано: «Если размер не указан, система использует 64m». Chromium передает отрендеренный контент между своими процессами через эту область разделяемой памяти, поэтому одна тяжелая страница может заполнить её целиком. После этого процесс рендеринга завершается, а ваш клиент сообщает об ошибке «crashed target» на странице, которая корректно работает на локальном компьютере. Перед внесением изменений проверьте размер изнутри контейнера.

df -h /dev/shm

Существует два способа решения проблемы; они являются альтернативами, а не дополнениями. Флаг --ipc=host переводит контейнер в пространство имен IPC хоста, благодаря чему он использует /dev/shm хоста, объем которого обычно составляет половину оперативной памяти. Руководство по Docker от Playwright рекомендует этот вариант, так как без него «Chromium может исчерпать память и завершиться с ошибкой». Платой за это является отказ от изоляции IPC между контейнером и хостом. Флаг --shm-size=1g сохраняет приватное пространство имен и просто увеличивает размер точки монтирования.

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, данные снова попадают в оперативную память без каких-либо ограничений по размеру, что является одним из способов, которыми браузер может исчерпать ресурсы небольшого VPS. Вместо этого правильно настройте размер /dev/shm.

Чем на самом деле грозит --no-sandbox

Chromium изолирует каждый процесс отрисовки в «песочнице», построенной на базе пространств имен пользователей (user namespaces) в Linux. Эта «песочница» является границей между потенциально опасной веб-страницей и вашим сервером. Если она не может запуститься, Chromium отказывается работать, а в логах появляется следующая строка:

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

Обычно советуют использовать --no-sandbox. Документация по безопасности самого Chromium прямо говорит о цене такого решения: этот флаг «отключает критически важные функции безопасности Chromium и никогда не должен использоваться при просмотре открытого интернета». Агент, переходящий по ссылкам, по определению занимается просмотром открытого интернета. Найдите истинную причину.

Две причины охватывают почти все случаи. Запуск браузера от имени root отключает «песочницу», так как процесс не может сбросить привилегии, которыми уже обладает; именно поэтому в образе Playwright используется обычный пользователь pwuser. В Ubuntu 24.04 и более поздних версиях AppArmor ограничивает использование пространств имен непривилегированными пользователями, и бинарный файл 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. Путь содержит версию браузера, поэтому он меняется при каждом обновлении Playwright. Использование шаблонов (globs), приведенных выше, позволяет избежать проблем при обновлении. Профиль, написанный для одного конкретного пути, перестанет работать, и браузер снова начнет выдавать ошибки после обновления, которое на первый взгляд никак с этим не связано.

Почему скриншоты получаются пустыми или заполненными квадратами

Пустой скриншот или изображение, заполненное пустыми прямоугольниками, обычно указывает на проблему со шрифтами, а не на ошибку отрисовки. 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',
});

Почему утечки процессов браузера приводят к своппингу системы

Две разные проблемы носят название «зомби». Настоящий зомби — это завершившийся процесс, чей родитель не вызвал wait(). Он занимает только запись в таблице PID и не потребляет память. Такие процессы возникают, когда браузер запускается как PID 1 в контейнере, так как PID 1 не имеет встроенного механизма сбора завершившихся процессов (reaper). Флаг --init в Docker решает именно эту задачу, запуская небольшой init-процесс, который «пересылает сигналы и собирает процессы». В Compose за это отвечает init: true.

Утечка, которая действительно вызывает своппинг системы, имеет другую природу: это активные процессы Chromium, которые никто не закрыл. Это происходит, когда задача прерывается между newContext() и close() или когда управляющий скрипт завершается принудительно, оставляя дерево процессов браузера «сиротами». Худший вариант — код, который запускает новый экземпляр браузера для каждого запроса. Проверьте их количество:

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

Это число должно возвращаться к значению в режиме ожидания после завершения задач. Если оно растёт в течение дня, проблему нужно искать в коде, а не в флагах запуска: закрывайте контекст в блоке finally, закрывайте браузер по SIGTERM и перезапускайте браузер после выполнения фиксированного количества задач, вместо того чтобы держать один экземпляр запущенным месяцами. В systemd остановка или перезапуск юнита завершает все процессы в его cgroup, поэтому sudo systemctl restart browser.service является надежным способом сброса. Браузер, запущенный вручную внутри терминального мультиплексора, таких гарантий не имеет, и его «сироты» продолжают работать после завершения сессии.

Сколько оперативной памяти нужно одному контексту браузера

Формулируйте вопрос точно, так как «один браузер» — это не один процесс. Chromium запускает процесс браузера, процесс GPU, вспомогательные процессы и по одному процессу рендеринга на каждый сайт, а изоляция сайтов (site isolation) выделяет для iframe с других доменов отдельные процессы рендеринга. BrowserContext — это отдельное хранилище cookie и данных внутри того же дерева процессов, поэтому второй контекст потребляет мало ресурсов. Вторая страница потребляет много, так как она запускает процессы рендеринга, а страница с большим количеством рекламы запускает их несколько.

Поэтому измерять нужно пиковое потребление памяти для всего дерева процессов при вашей рабочей нагрузке. Цифры из чужих блогов здесь бесполезны, так как ответ зависит от страниц, которые открывает ваш агент. Проводите измерения на той машине, которую будете использовать, и на тех сайтах, которые планируете посещать:

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: в выводе этой команды показывает текущее и пиковое потребление памяти для юнита. Запустите воркер с одной страницей, зафиксируйте пик, затем повторите с двумя открытыми страницами, чтобы понять реальную стоимость второй страницы. Расчет параллелизма выполняется арифметически: возьмите общий объем RAM, вычтите память, необходимую для работы остальной системы, оставьте запас в несколько сотен МБ и разделите на измеренный пик на один воркер. Информацию о подборе характеристик сервера для этих задач см. в сколько RAM и CPU нужно VPS для агента.

Ограничьте это число в двух местах. В коде используйте фиксированный пул воркеров или семафор, чтобы при всплеске запросов к агенту они ставились в очередь, а не запускали новые экземпляры браузера. В ОС используйте лимит cgroup, чтобы ошибка в очереди не привела к падению всей машины:

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

MemorySwapMax=0 важнее, чем кажется. Без него cgroup вытесняет страницы в swap при достижении лимита: сервер продолжает работать, но каждый запрос начинает выполняться медленно, что диагностировать сложнее, чем явный отказ. С этим параметром ядро завершает дерево процессов браузера внутри cgroup, systemd перезапускает юнит, и sshd продолжает работу. Аналогичные настройки в Compose — это mem_limit, shm_size и init, они описаны в установка лимитов памяти в Docker Compose.

Ограничьте доступ к конечной точке браузера из публичной сети

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

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

У этой конечной точки нет авторизации. Документация API Playwright прямо указывает: «Любой процесс или веб-страница (включая те, что запущены внутри Playwright), знающие wsPath, могут получить контроль над пользователем ОС». По умолчанию используется хост localhost, «принимающий соединения только через интерфейс loopback», а документация предупреждает, что указание явного адреса, например 0.0.0.0, «открывает RPC браузера для любого устройства, имеющего доступ к прослушиваемому порту». Собственный --remote-debugging-port в Chrome ещё опаснее. Протокол DevTools не имеет никакой аутентификации и полностью полагается на привязку к loopback.

Проверьте, что именно вы опубликовали, причем сделайте это как с другого компьютера, так и с самого VPS:

ss -ltnp

Любой браузерный порт, привязанный к 0.0.0.0, является уязвимостью. Помните, что большинство провайдеров используют отдельный сетевой файрвол в панели управления, о котором ваши правила ufw ничего не знают. Вместо этого подключайтесь к конечной точке с другого компьютера через SSH-туннель или частную VPN:

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

Риск здесь гораздо выше, чем просто кража ресурсов браузера. Браузер, которым можно управлять извне, — это инструмент для подделки запросов (request forgery), находящийся внутри вашей сети. Любой, кто получит доступ к этому сокету, может заставить его обратиться к http://127.0.0.1:8080, странице администратора вашей базы данных или адресу метаданных облака 169.254.169.254, а затем прочитать ответ со страницы. Ваш файрвол будет видеть запрос, исходящий от самого VPS, что разрешено. Относитесь к управляющей конечной точке как к полноценному доступу к оболочке (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, чтобы сессия на одном сайте была недоступна для следующей страницы.
  • Поддерживайте список разрешенных источников (allowlist), если задача это позволяет. Playwright MCP принимает --allowed-origins и --blocked-origins в виде списков, разделенных точкой с запятой.
  • Требуйте подтверждения человеком перед любым действием, которое изменяет состояние, например, перед отправкой почты или расходованием средств.

Еще лучше держать весь браузер на машине, которую можно удалить и развернуть заново; это тот же аргумент, что и в запуске агентов для написания кода в одноразовой VM. Если основная задача агента — поиск, а не произвольный просмотр веб-страниц, более узкоспециализированный инструмент будет безопаснее, чем полноценный браузер: инструмент поиска на базе вашего собственного SearXNG возвращает результаты, не загружая вредоносную страницу целиком.

FAQ

Почему Chromium падает в Docker, хотя на том же VPS работает нормально?

Потому что контейнеру по умолчанию выделяется /dev/shm объемом 64 МБ, тогда как на хосте этот лимит значительно выше. Chromium передает отрисованный контент через эту область разделяемой памяти, поэтому тяжелая страница переполняет её, и процесс рендеринга завершается. Запустите df -h /dev/shm внутри контейнера для проверки, а затем запускайте его либо с флагом --ipc=host, который использует разделяемую память хоста, либо с --shm-size=1g, который увеличивает объем памяти самого контейнера. Флаг --disable-dev-shm-usage лишь переносит проблему в /tmp.

Безопасно ли использовать --no-sandbox, если на VPS больше ничего нет?

Нет. Песочница предотвращает выход вредоносного кода со страницы в систему. Документация Chromium гласит, что этот флаг «отключает критические функции безопасности Chromium и никогда не должен использоваться при просмотре веб-страниц». Агент, переходящий по ссылкам, занимается именно этим. Устраните причину: не запускайте браузер от имени root, а в Ubuntu 24.04 добавьте профиль AppArmor с параметром userns, для пути к бинарному файлу браузера, чтобы разрешить использование пространств имен непривилегированных пользователей только для этой программы.

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

Проведите замеры, не ориентируйтесь на абстрактные числа. Chromium запускает по одному процессу рендеринга на каждую вкладку, поэтому ответ зависит от открываемых страниц. Запустите один рабочий процесс под systemd-run с установленным MemoryMax, отследите пиковое потребление по строке Memory: в systemctl status, затем разделите объем свободной оперативной памяти на это значение, оставив запас. Ограничьте количество процессов, используя очередь в коде и MemoryMax в юнит-файле, чтобы при всплеске запросов они вставали в очередь, а не вызывали своппинг системы.

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

Да, но никогда не привязывайте порт к 0.0.0.0. Конечная точка сервера Playwright и порт Chrome DevTools принимают любого клиента без пароля. Оставьте слушающий сокет на 127.0.0.1 и пробрасывайте соединение через SSH-туннель или частную VPN. Проверьте состояние с помощью ss -ltnp на сервере и выполните проверку порта снаружи; также не забудьте про настройки сетевого файрвола вашего хостинг-провайдера.

Почему мои скриншоты пустые, хотя страница явно загрузилась?

Отсутствуют шрифты. Если нет шрифта, поддерживающего символы на странице, текст отображается как пустые прямоугольники или не отображается вовсе, поэтому страница с малым количеством графики выглядит пустой. Выполните fc-match "sans-serif:lang=ko" для каждого языка, который вы парсите, установите fonts-noto-core и fonts-noto-cjk в качестве универсальных шрифтов-заглушек, а затем перезапустите браузер, чтобы fontconfig обновил кэш. Контейнер, в котором вообще нет шрифтов, при запуске выводит в лог Fontconfig error: Cannot load default config file.

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