[Перевод] Опыт 2 миллионов headless-сессий
Опубликовано 4 июня 2018 года в корпоративном блоге browserless
Рады сообщить, что недавно мы преодолели рубеж в два миллиона обслуженных сессий! Это миллионы сгенерированных скриншотов, напечатанных PDF и протестированных сайтов. Мы сделали почти всё, что вы можете придумать делать с headless-браузером.
Хотя приятно достичь такой вехи, но на пути оказалось явно много накладок и проблем. В связи с огромным объёмом полученного трафика хотелось бы сделать шаг назад и изложить общие рекомендации для запуска headless-браузеров (и puppeteer) в продакшне.
Вот некоторые советы.
Изменчивое потребление ресурсов Headless Chrome
Никоим образом, если это вообще возможно, вообще не запускайте браузер в режиме headless. Особенно на той же инфраструктуре, что и ваше приложение (см. выше). Headless-браузер непредсказуем, прожорлив и размножается как мистер Мисикс из «Рика и Морти». Почти всё, что может сделать браузер (кроме интерполирования и запуска JavaScript), можно сделать с помощью простых инструментов Linux. Библиотеки Cheerio и другие предлагают элегантный Node API для извлечения данных HTTP-запросами и скрапинга, если такова ваша цель.
Например, вы можете забрать страницу (предполагая, что это некий HTML) и произвести скрапинг простыми командами вроде таких:
import cheerio from 'cheerio';
import fetch from 'node-fetch';
async function getPrice(url) {
const res = await fetch(url);
const html = await res.test();
const $ = cheerio.load(html);
return $('buy-now.price').text();
}
getPrice('https://my-cool-website.com/');
Очевидно, скрипт не охватывает все случаи использования, и если вы читаете данную статью, то скорее всего вам придётся использовать headless-браузер. Поэтому приступим.
Мы столкнулись с многочисленными пользователями, которые пытаются держать браузер запущенным, даже если он не используется (с открытыми соединениями). Хотя это может быть хорошей стратегией, чтобы ускорить запуск сеанса, но приведёт к краху через несколько часов. Во многом потому что браузеры любят кэшировать всё подряд и постепенно выедают память. Как только вы прекратили интенсивно использовать браузер — сразу закройте его!
import puppeteer from 'puppeteer';
async function run() {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://www.example.com/');
// More stuff ...page.click() page.type()
browser.close(); // <- Always do this!
}
В browserless мы обычно сами исправляем эту ошибку за пользователей, всегда устанавливая какой-то таймер на сессию и закрывая браузер при отключении WebSocket. Но если вы не используете наш сервис или резервный образ Docker, то обязательно убедитесь в каком-нибудь автоматическом закрытии браузера, потому что будет неприятно, когда всё упадёт посреди ночи.
Будьте осторожны с транспилерами вроде babel или typescript, поскольку они любят создавать функции хелперов и предполагать, что те доступны с замыканиями. То есть обратный вызов .evaluate может работать неправильно.
В Puppeteer есть много приятных методов вроде сохранения DOM-селекторов и прочего в окружении Node. Хотя это очень удобно, но вы легко можете выстрелить себе в ногу, если что-то на странице заставит мутировать этот узел DOM. Пусть это не так круто, но в реальности лучше всю работу на стороне браузера выполнять в контексте браузера. Обычно это означает загрузку page.evaulate
для всей работы, которую надо сделать.
Например, вместо чего-то подобного (три действия async):
const $anchor = await page.$('a.buy-now');
const link = await $anchor.getProperty('href');
await $anchor.click();
return link;
Лучше сделать так (одно действие async):
await page.evaluate(() => {
const $anchor = document.querySelector('a.buy-now');
const text = $anchor.href;
$anchor.click();
});
Другое преимущество обернуть действия в вызов evaluate
— это переносимость: этот код можно для проверки запустить в браузере вместо того, чтобы пытаться переписать код Node. Конечно, всегда рекомендуется использовать отладчик для сокращения времени разработки.
Простое эмпирическое правило состоит в том, чтобы подсчитать количество await
или then
в коде. Если их больше одного, то вероятно лучше запускать код внутри вызова page.evaluate
. Причина в том, что все действия async ходят туда-сюда между средой выполнения Node и браузером, а это означает постоянные сериализации и десериализации JSON. Хотя здесь не такой огромный объём парсинга (потому что всё поддерживается WebSockets), он всё равно отнимает время, которое лучше потратить на что-то другое.
Итак, мы поняли, что браузер запускать нехорошо и нужно делать это только в случае крайней необходимости. Следующий совет — запускать только одну сессию на каждый браузер. Хотя в реальности можно и сэкономить ресурсы, распараллелив работу через pages
, но если упадёт одна страница, она может повалить весь браузер. К тому же не гарантируется, что каждая страница идеально чистая (куки и хранение могут стать головной болью, как видим).
Вместо этого:
import puppeteer from 'puppeteer';
// Launch one browser and capture the promise
const launch = puppeteer.launch();
const runJob = async (url) {
// Re-use the browser here
const browser = await launch;
const page = await browser.newPage();
await page.goto(url);
const title = await page.title();
browser.close();
return title;
};
Лучше сделайте так:
import puppeteer from 'puppeteer';
const runJob = async (url) {
// Launch a clean browser for every "job"
const browser = puppeteer.launch();
const page = await browser.newPage();
await page.goto(url);
const title = await page.title();
browser.close();
return title;
};
Каждый новый инстанс браузера получает чистый --user-data-dir
(если не указано иное). То есть он полностью обрабатывается как свежая новая сессия. Если Chrome по какой-то причине упадёт, то не потянет с собой также и другие сессии.
Одна из главных фич browserless — способность аккуратно ограничивать распараллеливание и очередь. Так клиентские приложения просто запускают puppeteer.connect
, а сами не думают о реализации очереди. Это предотвращает огромное количество проблем, в основном, с параллельными инстансами Chrome, которые пожирают все доступные ресурсы вашего приложения.
Лучший и самый простой способ — взять наш образ Docker и запустить его с необходимыми параметрами:
# Pull in Puppeteer@1.4.0 support
$ docker pull browserless/chrome:release-puppeteer-1.4.0
$ docker run -e "MAX_CONCURRENT_SESSIONS=10" browserless/chrome:release-puppeteer-1.4.0
Это ограничивает количество параллельных запросов десятью (включая сессии отладки и многое другое). Очередь настраивается переменной MAX_QUEUE_LENGTH
. Как правило, можно выполнять примерно 10 параллельных запросов на каждый гигабайт памяти. Процент загрузки CPU может изменяться для разных задач, но в основном вам понадобится много и много оперативной памяти.
Одна из самых распространённых проблем, которая нам встречалась, — это действия, запускающие загрузку страниц с последующим внезапным прекращением работы скриптов. Так происходит потому что действия, которые запускают pageload
, часто вызывают «проглатывание» последующей работы. Чтобы обойти проблему обычно нужно вызвать действие загрузки страницы — и сразу за ним ожидание загрузки.
Например, такой console.log
не срабатывает в одном месте (см. демо):
await page.goto('https://example.com');
await page.click('a');
const title = await page.title();
console.log(title);
Но срабатывает в другом (см. демо).
await page.goto('https://example.com');
page.click('a');
await page.waitForNavigation();
const title = await page.title();
console.log(title);
Больше о waitForNavigation можно прочитать здесь. У этой функции примерно такие же параметры интерфейса, как у page.goto
, но только с частью «wait».
Для корректной работы Chrome нужно много зависимостей. Реально много. Даже после установки всего необходимого придётся беспокоиться о таких вещах как шрифты и фантомные процессы. Поэтому идеально использовать какой-то контейнер, чтобы поместить всё туда. Docker почти специально создан для этой задачи, поскольку вы можете ограничить количество доступных ресурсов и изолировать его. Если хотите создать собственный Dockerfile
, проверьте ниже все необходимые зависимости:
# Dependencies needed for packages downstream
RUN apt-get update && apt-get install -y \
unzip \
fontconfig \
locales \
gconf-service \
libasound2 \
libatk1.0-0 \
libc6 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libexpat1 \
libfontconfig1 \
libgcc1 \
libgconf-2-4 \
libgdk-pixbuf2.0-0 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libstdc++6 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxcursor1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxi6 \
libxrandr2 \
libxrender1 \
libxss1 \
libxtst6 \
ca-certificates \
fonts-liberation \
libappindicator1 \
libnss3 \
lsb-release \
xdg-utils \
wget
А чтобы избежать процессов-зомби (обычное дело в Chrome), то лучше для правильного запуска использовать что-то вроде dumb-init:
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.0/dumb-init_1.2.0_amd64 /usr/local/bin/dumb-init
RUN chmod +x /usr/local/bin/dumb-init
Если хотите узнать больше, взгляните на наш Dockerfile.
Полезно помнить, что здесь две среды выполнения JavaScript (Node и браузер). Это отлично для разделения задач, но неизбежно происходит путаница, потому что некоторые методы потребуют явной передачи ссылок вместо замыканий или подъёмов (hoistings).
Для примера возьмём page.evaluate
. Глубоко в недрах протокола происходит буквальная стрингификация функции и передача её в Chrome. Поэтому вещи вроде замыканий и подъёмов вообще не будут работать. Если вам нужно передать какие-то ссылки или значения в вызов evaluate, просто добавьте их в качестве аргументов, которые будут правильно обработаны.
Таким образом, вместо ссылки на selector
через замыкания:
const anchor = 'a';
await page.goto('https://example.com/');
// `selector` here is `undefined` since we're in the browser context
const clicked = await page.evaluate(() => document.querySelector(anchor).click());
Лучше передайте параметр:
const anchor = 'a';
await page.goto('https://example.com/');
// Here we add a `selector` arg and pass in the reference in `evaluate`
const clicked = await page.evaluate((selector) => document.querySelector(selector).click(), anchor);
К функции page.evaluate
можно добавить один или несколько аргументов, поскольку здесь она вариативна. Обязательно используйте это в своих интересах!
Мы с невероятным оптимизмом смотрим на будущее headless-браузеров и всей автоматизации, которую они позволяют достичь. С помощью мощных инструментов вроде puppeteer и browserless мы надеемся, что отладка и запуск headless-автоматизации в продакшне станет проще и быстрее. Скоро мы запустим тарификацию pay-as-you-go для аккаунтов и функций, которые помогут лучше справляться с вашей headless-работой!