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

Рады сообщить, что недавно мы преодолели рубеж в два миллиона обслуженных сессий! Это миллионы сгенерированных скриншотов, напечатанных 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)


  1. Ipauler
    07.06.2018 21:40
    +1

    Есть ли способ записи видео со страницы в хэдлэсс режиме на сервере?


  1. Kicker
    08.06.2018 00:40

    Даже не смотря на то, что это перевод, можно добавить не много от себя и обьяснить что ж это за зверь такой и чем он лучше?)


  1. VolCh
    08.06.2018 10:38

    А кто-то сталкивался с задачей обработки загружаемых по ссылке файлов? Крайне желательно с получением в nodejs файла в виде строки, без сохранения его на диск. Пока видится перехват внутри evulate onRequest, создание и исполнение XHR и возврат полученного тела ну или сереилизованного ответа в nodejs.


    1. 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. этот код полностью исполняется в контексте Nodejs


      1. VolCh
        08.06.2018 11:45

        Как я понимаю здесь не получится предотвратить запись файла на диск?


        1. 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).


          1. mobi
            08.06.2018 12:59

            Сейчас возникло подозрение, что setCacheEnabled просто запрещает использование кэша, но не мешает сохранять его на диск. Нужно будет проверить.


    1. 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).


  1. naneri
    08.06.2018 11:11
    +1

    «Почти всё, что может сделать браузер (кроме интерполирования и запуска JavaScript), можно сделать с помощью простых инструментов Linux»

    Так 99% случаев именно изза js его и используют


  1. 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()
    Это существенно дешевле, чем каждый раз запускать новый инстанс браузера