Пишем сервер, который не падает под нагрузкой

https://hacks.mozilla.org/2013/01/building-a-node-js-server-that-wont-melt-a-node-js-holiday-season-part-5/
  • Перевод
  • Tutorial
От переводчика: Это пятая статья из цикла о Node.js от команды Mozilla Identity, которая занимается проектом Persona.





Как написать приложение Node.js, которое будет продолжать работать даже под невозможной нагрузкой? В этой статье показана методика и библиотека node-toobusy, её воплощающая, суть которой наиболее кратко может быть передана этим фрагментом кода:

var toobusy = require('toobusy');
 
app.use(function(req, res, next) {
  if (toobusy()) res.send(503, "I'm busy right now, sorry.");
  else next();
});

В чём заключается проблема?


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

Это может быть и злонамеренный всплеск трафика, например от DoS-атаки. Первый шаг к борьбе с такими атаками — написание сервера, который не падает.

Ваш сервер под нагрузкой


Чтобы показать, как ведёт себя обычный, не подготовленный к всплеску посещаемости, сервер, я написал демонстрационное приложение, которое на каждый запрос делает пять асинхронных вызовов, в сумме затрачивая пять миллисекунд процессорного времени.

Это примерно соответствует обычному приложению, которое может при каждом запросе писать что-то в логи, обращаться к БД, рендерить шаблон и отправлять клиенту ответ. Ниже приведён график зависимости задержек и ошибок TCP от количества соединений.

image

Анализ этих данных вполне очевиден:

  • Сервер нельзя назвать отзывчивым. Под нагрузкой, в шесть раз превышающей штатную (1200 запросов в секунду) он со скрипом выдаёт ответ в среднем через 40 секунд.
  • Отказы выглядят ужасно. В 80% случаев пользователь получает сообщение об ошибке после почти минуты томительного ожидания.

Учимся отказывать вежливо


Для сравнения, я применил к демонстрационному приложению подход, описанный в начале статьи. Он позволяет определять, когда нагрузка превышает допустимую и сразу отвечать сообщением об ошибке. Вот аналогичный первому примеру график для него:

image

На графике не показано количество ошибок с кодом 503 (service unavailable) — оно плавно возрастает по мере роста нагрузки. Какие выводы можно сделать, глядя на этот график?

  • Упреждающие сообщения об ошибках добавляют надёжности. Под нагрузкой, в десять раз превышающей штатную, приложение ведёт себя вполне отзывчиво.
  • Успешный ответ или отказ происходит быстро. Среднее время ответа почти всегда меньше 10 секунд.
  • Отказы происходят вежливо. Заблаговременно отклоняя запрос при перегрузке, мы заменяем неуклюжее отваливание по таймауту на мгновенный ответ с 503-й ошибкой.

Для того, чтобы заставить сервер аккуратно сообщать о 503-й ошибке, надо лишь создать небольшой шаблон с сообщением для пользователя. Пример такого ответа можно увидеть на множестве популярных сайтов.

Как использовать node-toobusy


Модуль node-toobusy доступен в виде пакета npm и на Гитхабе. После установки (npm install toobusy), он обычным образом включается в приложение:

var toobusy = require('toobusy');

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

// Регистрируем в самом начале стека middleware, чтобы
// отклонить запрос до того, как мы потратим на него хоть какие-то ресурсы
app.use(function(req, res, next) {
  // проверяем состояние занятости - вызов toobusy() очень быстр,
  // так как состояние кэшируется на фиксированный интервал времени
  if (toobusy()) res.send(503, "I'm busy right now, sorry.");
  else next();
});

Уже в таком виде модуль node-toobusy значительно повышает стойкость приложения под нагрузкой. Остаётся подобрать значение чувствительности, наиболее подходящее именно для вашего приложения.

Как это работает?


Как можно надёжно определять, что приложение Node слишком занято?

Это более интересный вопрос, чем можно было бы ожидать, особенно учитывая, что node-toobusy работает в любом приложении «из коробки». Рассмотрим некоторые подходы к решению этой задачи:

Отслеживание использования процессора для текущего процесса. Мы могли бы использовать цифру, которую показывает команда top — процент времени, которое тратит процессор на работу приложения. Если, к примеру, эта цифра превышает 90%, мы можем сделать вывод, что приложение перегружено. Но если на машине работают несколько процессов, уже нельзя быть уверенным, что именно процесс с приложением Node имеет возможность использовать процессор на 100%. При таком сценарии приложение может никогда не достигнуть этих 90% и при этом практически лежать.

Отслеживание общей загрузки системы. Мы могли бы вычислять общую системную загрузку, чтобы учитывать её при определении перегрузки приложения. Нам бы пришлось учитывать так же число доступных ядер процессора и т.д. Очень быстро такой подход оказывается слишком сложным, требует зависимых от платформы расширений, а ведь ещё надо бы учитывать приоритет процессов!

Нам нужно решение, которое просто работает. Всё что нам нужно — это определять, что наше приложение неспособно отвечать на запросы с приемлемой скоростью. Этот критерий не зависит ни от платформы, ни от других процессов в системе.

В node-toobusy используется замер задержки основного цикла обработки событий. Этот цикл лежит в основе любого приложения Node.js. Вся работа ставится в очередь, и в основном цикле задачи из этой очереди выполняются последовательно. Когда процесс перегружен, очередь начинает разрастаться — работы становится больше, чем можно сделать. Чтобы узнать степень перегрузки, достаточно замерить время, которое требуется крошечной задаче, чтобы отстоять всю очередь. Для этого node-toobusy использует колбэк, который должен вызываться каждые 500 миллисекунд. Отнимая 500 мс от реально измеренного значения интервала, можно получить время, в течение которого задача стояла в очереди, то есть искомую задержку.

Таким образом node-toobusy для определения перегрузки процесса постоянно замеряет задержку основного цикла событий — это простой и надёжный способ, работающий в любом окружении на любом сервере.



Нордавинд 44,45
Компания
Поделиться публикацией
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 26
    +16
    Секунду, 503 — это разве не падает?
    Так не честно.
      +5
      Чудес ведь не бывает. Сервер, рассчитанный на 200 запросов в секунду не потянет 2000. А вот если он нормально обслужит 200 из них, а остальных не заставит минуту пялиться в браузер, ожидая загрузки — вполне можно сказать, что он не упал.
        0
        (комментарий удален)
          +6
          блин, я почти поверил, что на хабре стало можно удалять комментарии :))

          UPD. понятно почему: вы удачно мимикрировали под надпись (комментарий был изменен)
            +1
            Удачно было бы, если бы была буква «ё». В интерфейсе Хабра она есть.
      +4
      Я с уважением отношусь к node.js, но опять какое-то шаманство… как это не падает? Ожидал от прочтения, что наконец-то кто-то изобрел серебряную пулю.
        +1
        Я не совсем понял, как все-таки работает toobusy(). Все ясно с callstack — замеряется раз в 500 мс. На основе этих данных можно при всех вызовах toobusy возвращать true. Но тогда получается, что в этом случае очередь может за 500мс рассосаться и при этом продолжить всем отвечать ошибкой. Далее предположим нагрузка есть, сделан новый замер длины очереди. Очередь пустая (но нагрузка до сих пор большая, просто всем отвечаем 503). Функция toobusy начинает всем отвечать false. И отвечает так в течение 500мс. В течение этого времени запросы начинают обрабатываться и все равно сервер ложится…

        Я к тому, что по идее надо делать какое-то распределение ответов между замерами. Например, под нагрузкой отвечать toobusy() = false каждому десятому. Но как это сделать?
          +1
          Видимо от этого зубы на графике и появляются. По хорошему нужна количественная оценка загруженности и отбивать определенный процент запросов, плавно повышая нагрузку, а не скачкообразно.
            +1
            Я тут просто мимо проходил, но вроде, судя по коду, там так и делается:

            Handle<Value> TooBusy(const Arguments& args) {
                // No HandleScope required, because this function allocates no
                // v8 classes that reside on the heap.
                bool block = false;
                if (s_currentLag > HIGH_WATER_MARK_MS) {
                    // probabilistically block requests proportional to how
                    // far behind we are.
                    double pctToBlock = ((s_currentLag - HIGH_WATER_MARK_MS) /
                                         (double) HIGH_WATER_MARK_MS) * 100.0;
                    double r = (rand() / (double) RAND_MAX) * 100.0;
                    if (r < pctToBlock) block = true;
                }
                return block ? True() : False();
            }
            
          +12
          /humour mode on/ Требуем опубликования второй части статьи: «Пишем сервер, который не взламывается под атаками».
          Краткое содержание: отключаем от сети, делаем только оффлайн доступ… профит. /humour mode off/

          Но на самом деле, серьезно. Это вообще не та задача которую node.js должен решать, в принципе.
          Конечно это не «антиддос на пхп», но все же, несмотря на то что node.js сам себе сервер — задачу отброса клиента по превышению нагрузки должен решать специальный лоад-балансер, отдельная софтина.
            +1
            Вы наверное никогда не были в ситуации, когда нагрузка резко возрастает. Понятно, что можно подготовиться, поставить железо, балансеры, расширить каналы и т.д. Но это подготовка. Иногда бывает так, что есть приложение и на него именно сейчас идет в 10 раз больше пользователей, чем обычно. В этом случае могут «помочь» подобные решения — это легко прикрутить уже под нагрузкой. Понятно, что большинство пользователей все равно увидит ошибку, но не все. Обслуживать 20% запросов гораздо лучше, чем обслуживать 0%.
              +5
              Речь о том, что когда нагрузка на node.js, php, mysql, apache, что-то еще резко возрастает следует воспользоваться сторонней тулзой для решения вопроса с нагрузкой.
              Кроме того, что это будет инструмент специально заточенный под лоад-балансинг со всеми его плюсами, имея сторонний инструмент — Вы не будете смешивать «нагрузку от фильтрации нагрузки» и «нагрузку от обработки клиентских запросов» — что тоже достаточно важно.
              А займет это не больше времени, чем поставить либу для node.js. Благо балансирующего софта навалом.
              Обслуживать 20% запросов гораздо лучше, чем обслуживать 0%.
              А обслуживать 40% запросов и четко понимать на что уходят ресурсы и иметь данные в привычном для сисадминов виде еще лучше, разве нет?
                +2
                Есть ссылки на эти самые инструменты, которые позволяют «фильтровать» часть запросов в зависимости от нагрузки? Как эти инструменты вообще понимают насколько нагружен бэкэнд? Было бы интересно почитать.
              +4
              ну то есть обычное правило — наперед выставляем nginx, сзади нода — и получаем сразу не только это, но и еще 100500 плюшек :)
                +2
                Нет, это — именно та самая задача, которую должна решать нода. В противном случае даже самый хороший балансировщик нагрузки не спасет от «эффекта домино».

                Кроме того, ответы 503 — это самый красивый способ получения обратной связи балансировщиком.
                0
                Я правильно понимаю, что когда node-toobusy обнаружит что до начала обработки запроса более 70 мс, то пользователь получит 503?
                  0
                  Нет. Никакие 70мс нигде не указаны — в коде вообще нет никаких констант времени, кроме 1сек (интервал проверки).
                    0
                    Во-первых, в посте упоминается «значение чувствительности» со ссылкой на maxLag, maximum amount of time in milliseconds that the event queue is behind.

                    Во-вторых, в исходном коде есть временной параметр:

                    static unsigned int HIGH_WATER_MARK_MS = 70;
                    


                    Вы тот код смотрели?
                      0
                      А, точно, почему-то именно эти константы я и проглядел…

                      Но это — порог лага для тестового таймера, а не для запроса. То есть время начала запроса и 503 никак напрямую не связаны. Кроме того, чем меньше превышение лага над 70мс, тем меньше вероятность отказа отвечать на конкретный запрос.
                        0
                        Во-первых, я и написал время до «начала обработки запроса», то есть задержка, лаг.
                        Во-вторых, таймауты традиционно меряют секундами, десятками секунд.

                        Меня удивляет, что кто-то решил отшвыривать клиента в случае некой задержки в 70 мс.
                          0
                          Это — не тайм-аут в обычном смысле этого слова. Тайм-аут — это ограничение времени на полное формирование страницы. А здесь измеряется время, необходимое для полного оборота очереди событий (и ограничение на этот оборот — 500+70=570мс, кстати говоря). В процессе формирования страницы очередь событий может «провернуться» несколько раз (как минимум — по разу на каждый запрос к БД, если не распараллеливать их).
                            0
                            UPD: кажется, я не проснулся. Конечно же, 500мс не надо добавлять к 70мс…
                  0
                  Злые вы…
                  По моему, хороший способ подстраховки от внезапного наплыва пользователей, особенно в стадии развития проекта, когда нет времени и сил на настройку и мониторинг сторонних тулов.
                    +1
                    По мне, так было бы лучше сразу не пятисотить. Лучше что-нибудь такое, что честно мониторит бекенды, а если они заняты — просто переселяет пользователя в медленный пул, честно его предупредив перед этим. А тем временем поднять еще бекендов в автоматическом или полуавтоматическом режимах. Например, с помощью модулей openresty легко можно так сделать на lua. Сразу же активировать несколько ступеней защиты.
                    В общем как-то это все неправильно — кидать пачку пятисотых, если мы сильно заняты, и больше ничего не делать.
                      +1
                      А всякие nginx'ы, lighthttpd'ы и аналоги не спасут? Мне казалось, они как раз для решения таких проблем.
                        +1
                        Из коробки не очень получается.
                        Проблема в том, что для балансировки нам нужно точно знать состояние бекендов. Мы сейчас у себя исследуем такую штуку. Правда там похапэ, это несколько сложнее, чем нода )) Авторизация у нас по ключам.

                        1. Сделано. Используем openresty. В LUA вынесли все коды ошибок и сделали firewall. Запрос прежде всего должен пробиться через lua, где ему скажут, можно ли вообще идти дальше. Если можно, то с какими лимитами и на какой бекенд.
                        2. Еще не сделано. По приложениям ползает паук, который проводит самодиагностику и оценку производительности бекенда со всеми его связями и зависимостями — по факту и в прогнозе на минуту вперед. Теоритически выставляет некое количество баллов для бекенда. Самый быстрый и незанятый получает, скажем 100 баллов. Если есть затупы, баллов будет меньше. Информация складывается опять в LUA, через, скажем redis. Тут все работает напрямую, очень быстро.
                        3. Еще не сделано. Есть еще несколько гипервизоров, которым можно сказать поднять дополнительные мощности — виртуалки с бекендами.
                        Если мы везде тупим, то пора поднимать еще виртуалку в большом геокластере.

                        На выходе получаем nginx-as-web-application, который точно знает, куда и как ему зарутить запрос.
                        Тема вообще интересная, рекомендую ))

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое