- PVSM.RU - https://www.pvsm.ru -

Опыт 2 миллионов headless-сессий

Опубликовано 4 июня 2018 года в корпоративном блоге browserless

Рады сообщить, что недавно мы преодолели рубеж в два миллиона обслуженных сессий! Это миллионы сгенерированных скриншотов, напечатанных PDF и протестированных сайтов. Мы сделали почти всё, что вы можете придумать делать с headless-браузером.

Хотя приятно достичь такой вехи, но на пути оказалось явно много накладок и проблем. В связи с огромным объёмом полученного трафика хотелось бы сделать шаг назад и изложить общие рекомендации для запуска headless-браузеров (и puppeteer [1]) в продакшне.

Вот некоторые советы.

1. Не используйте headless-браузер вообще

Опыт 2 миллионов headless-сессий - 1

Изменчивое потребление ресурсов 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. Но если вы не используете наш сервис [2] или резервный образ Docker [3], то обязательно убедитесь в каком-нибудь автоматическом закрытии браузера, потому что будет неприятно, когда всё упадёт посреди ночи.

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. Конечно, всегда рекомендуется использовать отладчик [4] для сокращения времени разработки.

Простое эмпирическое правило состоит в том, чтобы подсчитать количество await или then в коде. Если их больше одного, то вероятно лучше запускать код внутри вызова page.evaluate. Причина в том, что все действия async ходят туда-сюда между средой выполнения Node и браузером, а это означает постоянные сериализации и десериализации JSON. Хотя здесь не такой огромный объём парсинга (потому что всё поддерживается WebSockets), он всё равно отнимает время, которое лучше потратить на что-то другое.

4. Распараллеливайте браузеры, а не веб-страницы

Итак, мы поняли, что браузер запускать нехорошо и нужно делать это только в случае крайней необходимости. Следующий совет — запускать только одну сессию на каждый браузер. Хотя в реальности можно и сэкономить ресурсы, распараллелив работу через pages, но если упадёт одна страница, она может повалить весь браузер. К тому же не гарантируется, что каждая страница идеально чистая (куки и хранение могут стать головной болью, как видим [5]).

Вместо этого:

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 (если не указано иное [6]). То есть он полностью обрабатывается как свежая новая сессия. Если 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 не срабатывает в одном месте (см. демо [7]):

await page.goto('https://example.com');
await page.click('a');
const title = await page.title();
console.log(title);

Но срабатывает в другом (см. демо [8]).

await page.goto('https://example.com');
page.click('a');
await page.waitForNavigation();
const title = await page.title();
console.log(title);

Больше о waitForNavigation можно прочитать здесь [9]. У этой функции примерно такие же параметры интерфейса, как у 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 [10]:

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 [11].

8. Помните о двух разных средах выполнения

Полезно помнить, что здесь две среды выполнения JavaScript (Node и браузер). Это отлично для разделения задач, но неизбежно происходит путаница, потому что некоторые методы потребуют явной передачи ссылок вместо замыканий или подъёмов (hoistings).

Для примера возьмём page.evaluate. Глубоко в недрах протокола происходит буквальная стрингификация функции и передача её в Chrome [12]. Поэтому вещи вроде замыканий и подъёмов вообще не будут работать. Если вам нужно передать какие-то ссылки или значения в вызов 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 для аккаунтов [13] и функций [14], которые помогут лучше справляться с вашей headless-работой!

Автор: m1rko

Источник [15]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/chrome/282273

Ссылки в тексте:

[1] puppeteer: https://github.com/GoogleChrome/puppeteer

[2] сервис: https://www.browserless.io/

[3] резервный образ Docker: https://hub.docker.com/r/browserless/chrome/

[4] использовать отладчик: https://chrome.browserless.io/

[5] как видим: https://bugs.chromium.org/p/chromium/issues/detail?id=754576

[6] если не указано иное: https://chromium.googlesource.com/chromium/src.git/+/HEAD/docs/user_data_dir.md

[7] см. демо: https://chrome.browserless.io/?script=await%20page.goto(%27https%3A%2F%2Fexample.com%27)%3B%0Aawait%20page.click(%27a%27)%3B%0Aconst%20title%20%3D%20await%20page.title()%3B%0Aconsole.log(title)%3B

[8] см. демо: https://chrome.browserless.io/?script=await%20page.goto(%27https%3A%2F%2Fexample.com%27)%3B%0Apage.click(%27a%27)%3B%0Aawait%20page.waitForNavigation()%3B%0Aconst%20title%20%3D%20await%20page.title()%3B%0Aconsole.log(title)%3B

[9] здесь: https://github.com/GoogleChrome/puppeteer/blob/v1.4.0/docs/api.md#pagewaitfornavigationoptions

[10] dumb-init: https://github.com/Yelp/dumb-init

[11] наш Dockerfile: https://github.com/joelgriffith/browserless/blob/master/Dockerfile

[12] буквальная стрингификация функции и передача её в Chrome: https://github.com/GoogleChrome/puppeteer/blob/master/lib/ExecutionContext.js#L66

[13] тарификацию pay-as-you-go для аккаунтов: https://docs.browserless.io/blog/2018/05/26/metered-functions.html#new-usage-based-accounts

[14] функций: https://docs.browserless.io/blog/2018/05/26/metered-functions.html#browserless-functions

[15] Источник: https://habr.com/post/413547/?utm_campaign=413547