Рано или поздно кто-то задаёт инфраструктурный вопрос: что нужно, чтобы выдержать 10 000 одновременных сессий браузера? Звучит как вопрос о мощности с ответом про железо. На деле это вопрос о единицах измерения, и единицы выбраны неверно.
Десять тысяч одновременных сессий это не нагрузка. Это решение о выделении ресурсов. Нагрузка это страницы в сутки, и связывает их одна строка арифметики, которая решает весь спор "строить или покупать" ещё до того, как посчитана хоть одна единица железа.
Этот пост строит то, что управляет парком браузеров, на TypeScript и Playwright: пул сессий с жёстким потолком параллелизма, с арендой и переработкой. Затем он считает стоимость парка, которого действительно требуют 10 000 сессий, переводит это число в нагрузку, которую оно обслуживает, и сравнивает с управляемым рендерингом при равной пропускной способности, а не при равном заголовке.
- Параллелизм это пропускная способность, умноженная на время обслуживания. При пяти секундах на отрендеренную страницу 10 000 сессий это 2 000 страниц в секунду, или 172,8 миллиона в сутки.
- Большинству команд, которые просят 10 000 сессий, нужно около 1% от этого. Миллион страниц в сутки требует параллелизма примерно 58.
- Парк, который держит 10 000 сессий, это около 100 узлов и 3,2 ТБ оперативной памяти, рассчитанных на пик и простаивающих всё остальное время.
- Заблокированная страница обходится собственному парку в те же РАМ-секунды, что и успешная. В Crawling API неудачные запросы не тарифицируются вовсе.
- Каждый контекст на узле живёт внутри одного процесса браузера. Один сбой уносит их все.
10 000 сессий это число выделения ресурсов, а не нагрузка
Закон Литтла и есть перевод. Число операций в полёте равно скорости завершения, умноженной на длительность каждой. Для парка браузеров это читается так: параллелизм это страницы в секунду, умноженные на секунды на страницу.
Тяжёлая по JavaScript страница, отрендеренная до domcontentloaded занимает несколько секунд. Пусть пять. Тогда 10 000 одновременных сессий это вообще не число спроса, а вот это:
| Нагрузка | Страниц в секунду | Нужный параллелизм при 5 с на страницу |
|---|---|---|
| 1 миллион страниц в сутки | 11,6 | 58 |
| 10 миллионов страниц в сутки | 115,7 | 579 |
| 172,8 миллиона страниц в сутки | 2 000 | 10 000 |
Десять тысяч одновременных сессий это выделение ресурсов под 172,8 миллиона страниц в сутки. Если ваша реальная потребность миллион страниц в сутки, её обслуживает параллелизм 58, а запрос на 10 000 сессий примерно в 173 раза больше стоящей за ним нагрузки.
Это стоит выяснить до того, как что-либо считать в деньгах, потому что всё сравнение "строить или покупать" меняется в зависимости от того, какое из чисел настоящее. Честная версия вопроса звучит не "потянем ли мы 10 000 сессий", а "сколько у нас страниц в сутки, какая задержка на страницу и какой параллелизм следует из этой пары".
Эталонная реализация
Сопровождающий проект в ScraperHub/scaling-a-headless-browser-fleet-to-10000-concurrent-sessions разбит на три запускаемые части, каждая отвечает за свою сторону вопроса.
session.ts one browser process, many isolated contexts pool.ts the control plane: ceiling, leasing, recycling capacity.ts per-session assumptions to node count and RAM crawlbase.ts the managed path, one HTTP request index.ts the runner for all three modes
Нужен Node.js 18 или новее и Chromium из Playwright. Токен нужен только управляемому пути.
git clone https://github.com/ScraperHub/scaling-a-headless-browser-fleet-to-10000-concurrent-sessions.git cd scaling-a-headless-browser-fleet-to-10000-concurrent-sessions/final npm install npx playwright install chromium cp .env.example .env
Сессия это контекст, а не браузер
Первое архитектурное решение это единица параллелизма. Здесь сессия это контекст браузера Playwright, а не процесс браузера. Контекст несёт собственные cookies, собственное хранилище и собственное состояние выполнения и стоит долю от нового процесса Chromium. Именно это вообще делает высокую плотность возможной.
Источник: final/src/session.ts
async open(url: string, timeoutMs: number): Promise<OpenResult> { const page = await this.context.newPage(); try { const response = await page.goto(url, { timeout: timeoutMs, waitUntil: 'domcontentloaded' }); const title = await page.title(); return { status: response ? response.status() : 0, title }; } finally { await page.close(); } }
Страница намеренно недолговечна: открыть, перейти, прочитать один сигнал, закрыть. Короткая жизнь страницы не даёт состоянию накапливаться, поэтому один контекст обслуживает много последовательных задач, а жизненный цикл браузера остаётся в фабрике.
Фабрика запускает один Chromium и создаёт каждый контекст внутри него. Именно это делает контекст дешёвым, и это же означает, что сбой браузера уносит все сессии на этом узле. При рабочем потолке в 100 сессий на узел один сбой это 100 потерянных сессий, а проверка healthy() в пуле обнаруживает это по одной сессии, задним числом, при следующем acquire или release. Плотность и радиус поражения это один и тот же регулятор.
Гарантия живёт в управляющем слое
Пул решает, получит ли вызывающий существующую сессию, новую или ожидание. Эти три исхода и есть вся устойчивость парка.
Источник: final/src/pool.ts
if (this.live < this.maxConcurrency) { // Reserve the slot synchronously, before the await. this.live += 1; this.created += 1; this.inUse += 1; this.peakInUse = Math.max(this.peakInUse, this.inUse); try { return await this.factory.create(); } catch (error) { this.live -= 1; this.inUse -= 1; throw error; } } // Fleet is full. Queue and wait for a release. return new Promise<Session>((resolve) => { this.waiters.push(resolve); });
Одна деталь несёт всю гарантию: слот резервируется до ожидания factory.create(). Поскольку await отдаёт управление, иначе несколько вызывающих прочитали бы live < maxConcurrency пока первый контекст ещё создаётся. Потолок в пять мог бы ненадолго стать двадцатью. Синхронное резервирование с откатом при ошибке превращает предел в настоящий, а не в рекомендацию.
Путь release замыкает цикл. Сессия, которая состарилась или не прошла проверку здоровья, перерабатывается, а если вызывающие уже ждут, пул немедленно создаёт замену, чтобы ёмкость не сжималась тихо при каждом выводе сессии из строя.
Как это выглядит при конкуренции
Запустите больше задач, чем допускает потолок:
npm run pool
tasks=20 ok=20 elapsed=541ms throughput=37.0/s pool: maxConcurrency=5 peakInUse=5 created=5 recycled=0 peak utilization=100%
Важна вторая строка. Двадцать задач завершились, существовало пять контекстов, и peakInUse ни разу не превысил потолок. Остальные пятнадцать задач обслужили, выдавая и возвращая те же пять сессий. Поставьте MAX_SESSION_AGE_MS=0 и вместо этого будет расти счётчик recycled , что прогоняет путь вывода из строя, который не даёт долго живущим паркам накапливать устаревшее состояние.
Обратите внимание, чего этот запуск не показывает. Это пять контекстов против example.com на одной машине, то есть он проверяет логику выделения и ничего не говорит о масштабе. Цифра пропускной способности, 37 запросов в секунду, это свойство тривиальной страницы и локального браузера, а не прогноз.
Считаем стоимость парка
Модель ёмкости превращает предположения на сессию в число узлов и счёт за память. Она намеренно маленькая, потому что важна арифметика, а не инструмент.
Источник: final/src/capacity.ts
const usableRamMb = (input.nodeRamGb - input.nodeReserveGb) * 1024; const ramBoundSessionsPerNode = Math.max( 1, Math.floor(usableRamMb / input.ramPerSessionMb) ); const effectiveSessionsPerNode = Math.min( input.configuredSessionsPerNode, ramBoundSessionsPerNode ); const nodes = Math.ceil(input.targetSessions / effectiveSessionsPerNode);
При 250 МБ на сессию, узлах по 32 ГБ, из которых по 4 ГБ удерживаются, и рабочем потолке в 100 сессий на узел:
target sessions: 10000 RAM-bound sessions/node: 114 effective sessions/node: 100 nodes required: 100 total fleet RAM: ~3200 GB
Одна только память позволила бы 114 сессий на узел. Модель берёт 100, потому что именно столько вы бы реально запустили, и разрыв между ними это разница между спецификацией и продакшен-парком. Итог примерно 100 узлов и 3,2 ТБ оперативной памяти, ещё до автомасштабирования, поддержки образов браузера, восстановления после сбоев, мониторинга, выкатов и дежурств, которые всё это держат живым.
Две статьи расходов, которых число узлов не показывает
Цифра в 100 узлов это цена по прайсу. Настоящую двигают две вещи, и обе играют на стороне с лучшей утилизацией.
Парк рассчитан на пик и оплачивается непрерывно
Ёмкость выделяется под самый загруженный час и арендуется на все остальные. Парк, чей пик втрое выше среднего, работает примерно на трети утилизации, поэтому каждая полезная страница несёт втрое больше стоимости железа, чем предполагает спецификация. Автомасштабирование сужает этот разрыв, но не закрывает его: браузерам нужен прогрев, а масштабироваться по метрике, которая меняется за секунды, значит получить либо отставание, либо запас.
Неудачные страницы стоят столько же, сколько успешные
Страница, вернувшая challenge, таймаут или мягкую блокировку, израсходовала ровно тот же контекст, ту же память и то же реальное время, что и страница с данными. На собственном парке вы платите за неё одинаково. В Crawling API нет: неудачные запросы не тарифицируются, поэтому повтор к нестабильной цели меняет вашу задержку, но не ваш счёт.
Эта разница растёт вместе с враждебностью целей. На спокойном корпусе это ошибка округления. На сайтах с настоящей антибот-защитой, где заметная доля попыток заканчивается challenge, это большая часть счёта, и выставляет её только одна из двух моделей.
Управляемый путь
Путь "покупать" удаляет управляющий слой. Нет пула, нет прогрева, нет переработки и нечего планировать по ёмкости, потому что браузер работает по ту сторону вызова API.
Источник: final/src/crawlbase.ts
export async function crawlbaseRender( url: string, token: string, timeoutMs: number ): Promise<RenderResult> { const endpoint = `https://api.crawlbase.com/?token=${token}&url=${encodeURIComponent(url)}`; const response = await fetch(endpoint, { signal: controller.signal }); const body = await response.text(); const cbStatus = Number(response.headers.get('cb_status') ?? response.status); return { cbStatus, bytes: body.length, ms: Date.now() - started }; }
Токен JavaScript и делает это браузером, а не fetch: он приводит в движение настоящий движок рендеринга на той стороне. Читайте cb_status вместо HTTP-статуса, потому что именно это поле описывает, что случилось с целью, в отличие от того, что случилось с вашим вызовом API.
Параллелизм здесь это настройка тарифа, а не парк, и его повышают по запросу. Именно поэтому сравнивать его с числом сессий неверный ход: полезное сравнение идёт при равной пропускной способности. Возьмите свои страницы в сутки, разделите на 86 400, умножьте на свои секунды на страницу и сравните получившийся параллелизм с парком, которого это же число потребовало бы.
Параллелизм рендеринга без парка: настоящий браузер работает на нашей стороне, за ротацией резидентных IP, и возвращает один чистый ответ. Неудачные запросы не тарифицируются, поэтому враждебные цели стоят вам задержки, а не счёта. Начните бесплатно с 1 000 запросов, без карты.
Принимаем решение
Как только нагрузка выражена в страницах в сутки, выбор перестаёт быть идеологическим.
| Строить парк, когда | Покупать рендеринг, когда |
|---|---|
| выполнение браузера само по себе и есть продукт | рендеринг питает продукт, который является чем-то другим |
| нужен контроль над всем жизненным циклом браузера | нужны страницы, а не браузеры |
| платформенные инженеры уже в штате и на дежурстве | эти люди принесут больше пользы дальше по конвейеру |
| утилизация высокая и предсказуемая | спрос рваный, и рассчитанная на пик ёмкость простаивает |
| цели дружелюбны, а доля отказов низкая | цели сопротивляются, и отказы это заметная доля попыток |
Последние две строки команды недооценивают чаще всего. Это же те две, которых модель ёмкости не видит, потому что обе являются свойствами нагрузки, а не железа.
Заключение
Десять тысяч одновременных сессий это ответ на вопрос, который большинство команд не задали точно. В переводе на нагрузку это 172,8 миллиона страниц в сутки; обратно, миллион страниц в сутки требует параллелизма 58. Прояснить эти два числа решает спор надёжнее любого бенчмарка.
Сам управляющий слой не трудная часть, и эталонная реализация показывает почему: ограниченный потолок, аренда, путь переработки и одно аккуратно поставленное увеличение счётчика перед await. Это пара сотен строк, и они готовы.
Не заканчивается эксплуатация: 100 узлов, рассчитанных на пик, ниже которого они проводят большую часть суток, один процесс браузера на узел, отдающий сотню сессий во власть одного сбоя, и счёт, который берёт за заблокированную страницу столько же, сколько за доставленную. Постройка это выходные. Парк это дежурство.
Часто задаваемые вопросы
Сессия это браузер или контекст браузера?
Контекст браузера. Он несёт изолированные cookies, хранилище и состояние выполнения за долю стоимости отдельного процесса Chromium, и именно это делает сотню сессий на узел вообще выполнимой. Плата за это в том, что все контексты на узле делят один процесс браузера, поэтому сбой уносит их вместе.
Почему так важно зарезервировать слот параллелизма до await?
Потому что await отдаёт управление. Если счётчик увеличивается уже после создания контекста, каждый вызывающий, попавший в этот промежуток, читает старое значение и проходит проверку потолка, поэтому настроенный предел в пять может ненадолго создать двадцать контекстов. Зарезервировать слот синхронно и откатить при ошибке это разница между пределом и пожеланием.
Зачем различать сессии, ограниченные памятью, и эффективные сессии на узел?
Одно это то, что позволяет память, другое то, что вы бы реально запустили. При 250 МБ на сессию на узле в 32 ГБ, из которых 4 удержаны, память позволяет 114; рабочий потолок в 100 это число, которое задаёт размер парка. Работа на пределе памяти не оставляет запаса ни на всплеск трафика, ни на утекающий контекст.
Какие предположения дают оценку в 100 узлов?
250 МБ на сессию, 32 ГБ на узел, 4 ГБ удержаны на узле и рабочий потолок в 100 сессий на узел при цели в 10 000 сессий. Все четыре настраиваются в capacity.ts, и все четыре стоит заменить собственными измерениями, прежде чем цитировать результат, потому что память на сессию особенно сильно зависит от того, что страницы на самом деле грузят.
Как сравнить управляемый параллелизм с числом сессий?
Никак, это разные единицы. Сначала переведите обе стороны в пропускную способность: разделите страницы в сутки на 86 400, чтобы получить страницы в секунду, затем умножьте на измеренные секунды на страницу, чтобы получить нужный каждой стороне параллелизм. Сравнивайте при равной пропускной способности и подбирайте тариф под это число, а не под заголовочное число сессий.
Предсказывают ли 37 запросов в секунду из демо пропускную способность парка?
Нет. Это число получено пятью контекстами, бьющими по example.com на одной машине, то есть оно измеряет логику выделения на тривиальной странице, а не рендеринг в реальных условиях. Тяжёлая по JavaScript страница занимает секунды, а не миллисекунды, и именно эта задержка решает, сколько параллелизма требует целевая пропускная способность.
Обходите любой сайт в масштабе, без борьбы с инфраструктурой.
Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда выпускала конвейеры данных вместо поддержки обвязки краулинга. 1 000 запросов бесплатно, без карты.
