Рады сообщить, что недавно мы преодолели рубеж в два миллиона обслуженных сессий! Это миллионы сгенерированных скриншотов, напечатанных PDF и протестированных сайтов. Мы сделали почти всё, что вы можете придумать делать с headless-браузером.
Хотя приятно достичь такой вехи, но на пути оказалось явно много накладок и проблем. В связи с огромным объёмом полученного трафика хотелось бы сделать шаг назад и изложить общие рекомендации для запуска headless-браузеров (и puppeteer) в продакшне.
Вот некоторые советы.
1. Не используйте headless-браузер вообще
Изменчивое потребление ресурсов 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-браузер. Поэтому приступим.
2. Не запускайте 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, то обязательно убедитесь в каком-нибудь автоматическом закрытии браузера, потому что будет неприятно, когда всё упадёт посреди ночи.
3. Ваш друг page.evaluate
Будьте осторожны с транспилерами вроде 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), он всё равно отнимает время, которое лучше потратить на что-то другое.4. Распараллеливайте браузеры, а не веб-страницы
Итак, мы поняли, что браузер запускать нехорошо и нужно делать это только в случае крайней необходимости. Следующий совет — запускать только одну сессию на каждый браузер. Хотя в реальности можно и сэкономить ресурсы, распараллелив работу через
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 по какой-то причине упадёт, то не потянет с собой также и другие сессии.5. Очередь и ограничение параллельной работы
Одна из главных фич 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 может изменяться для разных задач, но в основном вам понадобится много и много оперативной памяти.6. Не забывайте про page.waitForNavigation
Одна из самых распространённых проблем, которая нам встречалась, — это действия, запускающие загрузку страниц с последующим внезапным прекращением работы скриптов. Так происходит потому что действия, которые запускают
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”.7. Используйте Docker для всего необходимого
Для корректной работы 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.
8. Помните о двух разных средах выполнения
Полезно помнить, что здесь две среды выполнения 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-работой!
Комментарии (10)
Kicker
08.06.2018 00:40Даже не смотря на то, что это перевод, можно добавить не много от себя и обьяснить что ж это за зверь такой и чем он лучше?)
VolCh
08.06.2018 10:38А кто-то сталкивался с задачей обработки загружаемых по ссылке файлов? Крайне желательно с получением в nodejs файла в виде строки, без сохранения его на диск. Пока видится перехват внутри evulate onRequest, создание и исполнение XHR и возврат полученного тела ну или сереилизованного ответа в nodejs.
Myateznik
08.06.2018 11:02+1Могу посоветовать обратить внимание на событие `response`:
page.on('response', response => { // Skip http bad statuses. if (!response.ok()) return; const headers = response.headers(); // Skip any content that type not application/octet-stream if (headers['Content-Type'] !== 'application/octet-stream') return; const buffer = await response.buffer(); // Do what you want }); await page.goto('https://example.com/some.file');
Вместо вызова `response.buffer()` можно ещё использовать `response.text()`, если работа идёт с текстовыми файлами.
P.S. этот код полностью исполняется в контексте NodejsVolCh
08.06.2018 11:45Как я понимаю здесь не получится предотвратить запись файла на диск?
mobi
08.06.2018 12:46В последних версиях puppeteer есть Page.setCacheEnabled(false); [соответствует Network.setCacheDisabled({cacheDisabled: true}); в chrome-remote-interface], а раньше просто размер кэша устанавливали в один байт (--disk-cache-size=1 --media-cache-size=1).
mobi
08.06.2018 12:59Сейчас возникло подозрение, что setCacheEnabled просто запрещает использование кэша, но не мешает сохранять его на диск. Нужно будет проверить.
mobi
08.06.2018 11:11+1Если работать с хромом напрямую, то скорее всего можно через
иNetwork.enable();
Network.loadingFinished(async (params) => { let content = await Network.getResponseBody({requestId: params.requestId}); /* ....... */ });
Честно говоря, не знаю, есть ли у puppeteer прямой доступ к используемому chrome-remote-interface, но у нас в одном проекте (правда, на основе chrome-pool) доступ к загружаемым стилям и скриптам происходит именно таким образом (там логика чуть сложнее, с использованием requestIntercepted, requestWillBeSent и requestServedFromCache, но суть именно в loadingFinished и getResponseBody).
naneri
08.06.2018 11:11+1«Почти всё, что может сделать браузер (кроме интерполирования и запуска JavaScript), можно сделать с помощью простых инструментов Linux»
Так 99% случаев именно изза js его и используют
Sirian
08.06.2018 13:54+1Ощущение что статья написана каким-то джуном
1. Не используйте headless-браузер вообще
Никакой аргументации не приведено.
Почти всё, что может сделать браузер (кроме интерполирования и запуска JavaScript),
Так headless браузер как раз в основном и используют для сценариев с js
3. Ваш друг page.evaluate
Два приведенных кода сработают по разному. click() внтури evalute и click() через хендлер отличаются как минимум достоверностью события (event.isTrusted)
const puppeteer = require("puppeteer"); (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); page.on("console", (m) => console.log(m.text())); await page.evaluate(function () { const elem = this.document.documentElement; elem.addEventListener("click", e => console.log(e.isTrusted)); elem.click(); }); const elem = await page.$('html'); await elem.click(); await browser.close(); })()
4. Распараллеливайте браузеры, а не веб-страницы
Вообще в апи puppeteer есть возможность создавать чистые контексты в
рамках одного запущенного браузера с помощью createIncognitoBrowserContext()
Это существенно дешевле, чем каждый раз запускать новый инстанс браузера
Ipauler
Есть ли способ записи видео со страницы в хэдлэсс режиме на сервере?