В соавторстве Дмитрий Плиска.

Это история использования и поддержки Airtable Importer, надстройки электронных таблиц Google, которая импортирует данные из общего представления таблицы Airtable в электронную таблицу, включая ежечасный автоматический повторный импорт.

Вкратце, это небольшое и простое приложение JS, использующее прокси-сервер, который делает запросы к Airtable с помощью частного API, а затем отправляет данные в надстройку, которая вставляет данные в электронную таблицу.

В свою очередь, прокси-сервер (наш главный герой) представляет собой приложение NodeJS, установленное на одном динамометрическом стенде Hobby Heroku (512 МБ ОЗУ). Он использует PhantomJS в качестве браузера для получения данных из Airtable. Но принцип работы сервера и использование приватного API (почему не публичного) заслуживает отдельного рассказа.

Эта экосистема просуществовала там более года в относительной тишине и покое. Надстройка никогда не подвергалась активному продвижению и, следовательно, никогда не пользовалась большой популярностью. Однако встроенный поиск Google Web Store, уникальность надстройки (пока что это единственная надстройка для таблиц Google) и несколько сообщений на форумах кое-где сделали свое дело: согласно статистике Google Web Store, надстройка выросла на 20–30 пользователей в месяц и сейчас составляет около 600–700 установок и ~ 100 пользователей в день. Кроме того, ежечасный автоматический импорт обрабатывается без какого-либо взаимодействия с пользователем, поэтому реальная нагрузка может быть больше, чем количество пользователей.

Что случилось

При этом мы оказались в ситуации, когда ежечасные пики активности сервера периодически вызывали значительное превышение лимита памяти, сбои приложений, простои сервера и, как следствие, сбои в работе надстроек. К счастью, настройка Drift позволяла пользователям сообщать нам об ошибках приложения в течение нескольких минут, поэтому ручной перезапуск дино время от времени решал проблему.

Ожидается, что при медленном, но стабильном притоке пользователей ситуация станет еще хуже. Кроме того, добрая половина наших пользователей из США, поэтому нам пришлось пожертвовать своим сном, чтобы иметь возможность быстро перезапустить для них дино в случае сбоя.

Итак, после регулярных перезапусков вручную мы задумались о расширении ресурсов хостинга, чтобы избавиться (или хотя бы уменьшить) необходимость ручного вмешательства. Готово, дино переключили, и мы ожидали вздохнуть с облегчением. Но, как говорится, если хочешь рассмешить богов, расскажи им о своих планах.

Сразу после перезапуска приложение начало вылетать, и нам не удалось его восстановить. Многочисленные перезапуски вручную не помогли, и приложение продолжало давать сбой по непонятной причине.

За исключением оккультного объяснения, что мы каким-то образом пробудили древнее зло и теперь весь ад вырвался на свободу (а мы не были готовы к этому: ни IDKFA, ни мегаздоровье ...), у нас не было четкого понимания того, что произошло. Сначала мы попытались вернуть вещи в их старое состояние (если это работает, не трогайте его ©), но это не дало результата. Сбои в приложении происходили еще чаще.

Как мы уже говорили, у нас была довольно хорошая связь с пользователями, поэтому их реакция была незамедлительной: десятки уведомлений, электронных писем и неактивных сообщений: «Привет, я получаю сообщение об ошибке. Ошибка: запрос не выполнен…». Вы, вероятно, спросите: «Но использование небольшое, это бесплатное дополнение, поэтому вы можете просто оставить его и вернуться к нему, когда будет время». Возможно, но из предыдущего опыта мы знали, что все пользователи, которые у нас были, на самом деле были активными пользователями. Мы получили приличное количество отзывов: «Этим вы значительно облегчили жизнь моей компании!». Итак, мы знали, что работа людей зависит от ежедневного использования надстройки, и нам пришлось вернуть ее обратно, чтобы не подвести наше уютное сообщество пользователей.

Процесс решения

Первое, в чем мы виноваты, - это утечка памяти. Как показано на рисунке 2, приложение потребляло столько памяти, сколько было предоставлено, независимо от числа. Даже когда мы обновили дино и перезапустили его, он по-прежнему потреблял всю доступную память на следующем пике активности, а затем возвращался в неактивное состояние, сопровождаемое ошибками R14 (превышена квота памяти) и H10 (сбой приложения / ошибка развертывания Heroku) .

Следующим шагом было обрисовать, что именно потребляло память. Итак, мы установили New Relic, чтобы лучше понять, как используется память. Мы хотели выяснить соотношение потребления памяти самим сервером NodeJS и PhantomJS соответственно. Оказалось, что процессы браузера потребляли большую часть памяти и, таким образом, приводили к утечке памяти.

Замените PhantomJS автономным браузером (Google Chrome)

Первое, что мы здесь попытались сделать, это обновить NodeJS и зависимости приложений, надеясь, что новые версии решат проблему. Это не сработало, а только усугубило ситуацию. Итак, нашим единственным подозреваемым на данный момент была эмуляция браузера PhantomJS. К тому времени мы узнали, что PhantomJS хорошо известен утечкой памяти и что его также больше не обслуживают, основной участник советует перейти на безголовый Chrome. Вот что мы сделали. Мы перешли с PhantomJS на puppeteer - официальную библиотеку NodeJS, которая предоставляет высокоуровневый API для управления Chrome или Chromium по протоколу DevTools. Это усугубило ситуацию, поскольку требовалось больше памяти ... и требовалось запустить дополнительный пакет сборки на Heroku. Мы успешно использовали Puppeteer Heroku Buildpack, который был создан для поддержки кукловода. Есть еще одна для Google Chrome, сделанная Heroku, но она вызвала некоторые другие ошибки, с которыми мы не хотели разбираться. Если вы никогда раньше не использовали пакеты сборки, они используются для улучшения дино через код, поэтому их можно использовать для установки собственных библиотек, запуска скриптов и т. Д. (Официальная документация)

Увеличить лимит размера запросов в DevTools

Следующим шагом стал размер запроса. Мы заметили, что некоторые запросы были слишком большими, чтобы их можно было обработать. По умолчанию DevTools может обрабатывать запросы размером до 10 МБ, но некоторые запросы сервера превышают этот предел, вызывая еще больше сбоев приложений. После некоторого чтения здесь и там мы узнали, что его можно увеличить с помощью экспериментальной функции. Это сработало, но ... ну, как и следовало ожидать, использование памяти увеличилось еще больше.

Обработка запроса вне браузера

Это заставило нас задуматься ... что, если бы мы могли сделать большой запрос за пределами браузера без головы? Мы достигли этого, установив перехватчик запросов для перехвата XMLHttpRequest. Его URL-адрес и заголовки передаются браузером и передаются на сервер NodeJS для выполнения запроса и предоставления ответа прямо надстройке.

// enable interception
page.setRequestInterception(true)
const closePage = () => {
  page.removeAllListeners();
  return page.close();
};
page.on('request', (interceptedRequest) => {
  // we are interested in one specific request
  if (REQUEST_URL_PATTERN.test(interceptedRequest.url)) {
    interceptedRequest.abort();
    return closePage()
      .then(() => (
        resolve({
          url: interceptedRequest.url,
          headers: interceptedRequest.headers,
        })
      )
      .catch(reject);
  }
  interceptedRequest.continue();
});

Наконец, мы начали улучшать ситуацию:

  • Играть с ограничением размера запроса DevTools больше не нужно
  • Устранены потенциальные запросы на скачивание изображений, исходящие из данных.
  • Ресурсы браузера были потрачены лучше, поскольку он мог обрабатывать больше параллельных запросов, используя тот же объем памяти.

Поделиться экземпляром браузера

Но мы еще не закончили. Следующая идея заключалась в том, чтобы предоставить общий доступ к одному экземпляру браузера, а не запускать отдельный экземпляр для каждого запроса. Мы использовали механизм подключения к существующему браузеру и создания в нем новой страницы. Это можно представить как открытие еще одной вкладки в браузере, которая закрывается сразу после обработки запроса. Это очень помогло, но также привело к возникновению некоторых других проблем. Оказалось, что браузер, являющийся EventEmitter, из коробки может обрабатывать до 10 подписчиков, поэтому нам пришлось увеличить и это число. При текущей настройке он поддерживает до 1000 параллельных запросов и при необходимости может быть увеличен.

// ignore potential problems with SSL certificates and algorithms
const ignoreHTTPSErrors = true;
// Store single instance to be able to reconnect to Chromium
// using `.wsEndpoint()`
const _sharedBrowser = puppeteer
  .launch({
    ignoreHTTPSErrors,
    headless: true,
    // required by heroku buildpack
    args: ['--no-sandbox', '--disable-setuid-sandbox'], 
  })
  // increase parallel connections limit (default is 10)
  .then(browser => browser.setMaxListeners(1000));
// Connect to a shared browser upon request
let _browser = null;
const tearDown = () => {
  // release the memory
  if (_browser) {
    _browser.disconnect();
    _browser = null;
  }
};
_sharedBrowser
  .then((browser) => {
    const browserWSEndpoint = browser.wsEndpoint();
    return puppeteer.connect({
      browserWSEndpoint,
      ignoreHTTPSErrors,
    });
  })
  .then((browser) => {
    _browser = browser;
    // do whatever you need to do
  })
  .then((data) => {
    tearDown();
    return data;
  })
  .catch((err) => {
    tearDown();
    throw err;
  });

Наличие общего экземпляра создает другой поток: что, если процесс узла умрет? Heroku перезапустит его, но не браузер, поскольку он не указан в Procfile. Браузер тоже должен выйти. Чтобы справиться с этим, мы добавили обработку сигналов прерывания для закрытия браузера, чтобы он мог запускаться с нуля после перезапуска.

Освободи память, как сумасшедшая

Ситуация улучшалась, но мы должны были убедиться, что освободили память должным образом, всегда закрывая страницу и отключаясь от общего браузера, что бы ни происходило. Мы добавили простой таймаут, по истечении которого страница закрывается, если запрос XHR не выполнен. Кроме того, мы включили механизм быстрого сбоя, если Airtable в первую очередь не отвечает успешным ответом, так как большинство ошибок возникает из-за того, что общий вид на самом деле не используется совместно.

И это наконец привело нас к некоему стабильному состоянию.

Heroku dynos на велосипеде

Будучи относительно уверены в том, что пользователи смогут выполнить импорт, мы дали ему поработать несколько дней, чтобы отслеживать использование памяти, и казалось, что все проблемы, связанные с памятью, остались позади, но ... мы заметили некоторые странности в показателях. Heroku перезапускает каждый дино каждые 24 часа или около того, чтобы поддерживать наши приложения и их собственную инфраструктуру в исправном состоянии. Разорванные соединения накапливались во время пиков максимальной загрузки и вызывали сбои приложений при загрузке. Решение заключалось в использовании двух дино (Standard 1X), которые автоматически перезапускались без перекрытия в течение 216 минут. Следовательно, трафик может быть перенаправлен на динамометрический стенд, который не перезапускается. Это позволило нам уменьшить количество разорванных подключений с сотен до почти полного отсутствия.

Уроки выучены

  • Надстройки, которые кажутся «маленькими и простыми», могут иметь под капотом относительно серьезный бизнес.
  • Запрос табличных данных может быть больше 20 МБ.
  • Headless Chrome предпочтительнее PhantomJS
  • По возможности не используйте браузер для запросов к серверу или предпочитайте общий его экземпляр.
  • Используйте 2 средних динамометрических зала Heroku вместо одного большого, чтобы использовать динамометрическое управление для бесперебойной работы.
  • Контролируйте потребление памяти в течение более длительных периодов времени, чтобы выявлять утечки и своевременно их устранять.

Первоначально опубликовано на сайте railsware.com 26 января 2018 г.