Большинство крупномасштабных сервисов, которые мы разрабатываем в настоящее время, в большей или меньшей степени зависят от внешних API. Вы слышали это несколько раз: как только ваша кодовая база начинает выглядеть как монолит, пора начинать разбивать ее на более мелкие сервисы, которые могут развиваться независимо и не сильно связаны с монолитом.

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

Что происходит, когда эти службы работают медленно или недоступны? Что ж, вы не можете обрабатывать поисковые запросы или платежи, но ваше приложение все равно будет работать нормально, верно?

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

Наш случай

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

Наши клиенты будут подключаться к нашему основному интерфейсу server1.js, который затем отправит HTTP-запрос другой службе server2.js, которая ответит. Получив ответ от server2.js, мы можем вернуть тело ответа нашему клиенту.

Несколько замечаний:

  • Серверы работают на порте 3000 (основное приложение) и 3001 («внутренний» сервер). Таким образом, как только клиент сделает запрос localhost:3000, новый HTTP-запрос будет отправлен на localhost:3001
  • Бэкэнд-служба будет ждать 100 мс (это для имитации реальных вариантов использования), прежде чем вернуть ответ.
  • Я использую unirest HTTP-клиент. Мне это очень нравится, и хотя мы могли бы просто использовать встроенный http модуль, я уверен, что это даст нам лучшее представление о реальных приложениях.
  • Unirest достаточно хорош, чтобы сообщить нам, была ли ошибка в нашем запросе, поэтому мы можем просто проверить response.error и обработать драму оттуда.
  • Я собираюсь использовать докер для запуска этих тестов, а код доступен на GitHub.

Давайте проведем наши первые тесты

Давайте запустим наши серверы и начнем бомбардировать server1.js запросами. Мы будем использовать siege (я слишком хипстер для AB), который дает некоторую полезную информацию при выполнении нагрузочного теста:

siege -c 5 www.google.com
** SIEGE 3.0.5
** Preparing 5 concurrent users for battle.
The server is now under siege...^C
Lifting the server siege...      done.
Transactions:                26 hits
Availability:            100.00 %
Elapsed time:              6.78 secs
Data transferred:          0.20 MB
Response time:             0.52 secs
Transaction rate:          3.83 trans/sec
Throughput:                0.03 MB/sec
Concurrency:               2.01
Successful transactions:          27
Failed transactions:              0
Longest transaction:           1.28
Shortest transaction:          0.36

Параметр -c в осаде определяет, сколько одновременных запросов мы должны отправить на сервер, и вы даже можете указать, сколько повторений (-r) вы хотите выполнить. Например, -c 10 -r 5 будет означать, что мы отправляем на сервер всего 50 запросов в пакетах по 10 одновременных запросов. Однако для целей нашего теста я решил оставить тесты запущенными в течение трех минут, а затем проанализировать результаты, не устанавливая максимальное количество повторений.

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

  • Доступность: сколько наших запросов сервер смог обработать.
  • Скорость транзакции: сколько запросов в секунду мы смогли сделать?
  • Успешные / неудачные транзакции: сколько запросов завершилось успешными / неудачными кодами статуса (например, 2xx против 5xx)?

Начнем с отправки 500 одновременных запросов, чтобы понаблюдать за поведением служб.

docker run --net host -v $(pwd):/src -d mhart/alpine-node:7.1 node /src/server1.js
docker run --net host -v $(pwd):/src -d mhart/alpine-node:7.1 node /src/server2.js
siege -c 500 127.0.0.1:3000

Примерно через три минуты пора прекратить осаду (ctrl+c) и посмотреть, как выглядят результаты:

Availability:             100.00 %
Transaction rate:       1156.89 trans/sec
Successful transactions:      205382
Failed transactions:              0

Неплохо, поскольку мы смогли обработать 1156 транзакций в секунду. Более того, похоже, что у нас нет никаких ошибок, а это означает, что наш показатель успеха составляет 100%. Что, если мы запустим нашу игру и перейдем к 1К одновременных транзакций?

siege -c 1000 127.0.0.1:3000
...
Availability:            100.00 %
Transaction rate:       1283.61 trans/sec
Successful transactions:      232141
Failed transactions:              0

Отличная работа! Мы немного увеличили пропускную способность, так как теперь наше приложение может обрабатывать 1283 запроса в секунду. Поскольку приложения делают очень мало (выводят строку и все), вероятно, чем больше одновременных запросов мы отправим, тем выше будет пропускная способность.

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

Представляем неудачу

Реальные веб-службы ведут себя иначе: вы должны принимать сбои и создавать устойчивые приложения, способные их преодолевать.

Например, предположим, что наша серверная служба переживает тяжелую фазу и время от времени начинает отставать:

В этом примере 1 из 10 запросов будет обслуживаться после истечения тайм-аута в 10 секунд, тогда как остальные будут обрабатываться со «стандартной» задержкой в ​​100 мс. Этот тип моделирует сценарий, в котором у нас есть несколько серверов за балансировщиком нагрузки, и один из них начинает выдавать случайные ошибки или становится медленнее из-за чрезмерной нагрузки.

Вернемся к нашему тесту и посмотрим, как работает наш server1.js теперь, когда его зависимость начнет замедляться:

siege -c 1000 127.0.0.1:3000
Availability:            100.00 %
Transaction rate:        853.93 trans/sec
Successful transactions:      154374
Failed transactions:              0

Какой облом! Это означает, что server1.js нужно продержаться дольше, чтобы получить ответы от server2.js, таким образом используя больше ресурсов и имея возможность обслуживать меньше запросов, чем это теоретически может.

Ошибка сейчас лучше, чем ответ завтра

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

Через 1-2 секунды их внимание исчезнет, ​​и шансы, что они все еще могут быть привязаны к вашему контенту, исчезнут, как только вы пересечете порог 4/5. Это означает, что обычно лучше дать им немедленный отзыв, даже если он отрицательныйПроизошла ошибка, попробуйте еще раз»), чем позволять им расстраиваться из-за того, как медленная ваша служба.

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

...
require('unirest').get('http://localhost:3001').timeout(3000).end(function(r) {
...

Посмотрим, как выглядят числа с включенным таймаутом:

Availability:              90.14 %
Transaction rate:       1125.26 trans/sec
Successful transactions:      209861
Failed transactions:          22964

О боже, мы снова в игре. Скорость транзакций снова превышает 1 тыс. В секунду, и мы можем обслуживать почти столько же запросов, сколько в идеальных условиях (когда в серверной службе нет задержек).

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

Теперь мы убедились, что в идеале тайм-ауты помогают сохранить почти идеальное число запросов в секунду (запросов в секунду), но как насчет потребления ресурсов? Смогут ли они лучше гарантировать, что нашим серверам не потребуются дополнительные ресурсы, если одна из их зависимостей станет менее отзывчивой?

Давайте вникнем в это.

Фактор RAM

Чтобы выяснить, сколько памяти потребляет наш server1.js, нам нужно периодически измерять объем памяти, который использует сервер. В производстве мы будем использовать такие инструменты, как NewRelic или KeyMetrics, но для наших простых скриптов мы будем прибегать к версии таких инструментов для бедняков. Мы напечатаем объем памяти из server1.js и воспользуемся другим скриптом, чтобы записать результат и распечатать некоторую простую статистику.

Убедитесь, что server1.js печатает объем используемой памяти каждые 100 мс:

...
setInterval(_ => {
  console.log(process.memoryUsage().heapUsed / 1000000)
}, 100)
...

Если мы запустим сервер, мы должны увидеть что-то вроде:

3.990176
4.066752
4.076024
4.077784
4.079544
4.081304
4.083064
4.084824

который представляет собой объем памяти в МБ, который использует сервер. Чтобы подсчитать числа, я написал простой скрипт, который считывает ввод из stdin и вычисляет статистику:

Модуль является общедоступным и доступным на NPM, поэтому мы можем просто установить его глобально и перенаправить на него вывод сервера:

docker run --net host -v $(pwd):/src -ti mhart/alpine-node:7.1 sh
npm install -g number-aggregator-stats
node /src/server1.js | number-aggregator-stats
Meas: 18 Min: 3 Max: 4 Avg: 4 Cur: 4

Давайте снова запустим наш тест - 3 минуты, 1k одновременных запросов, без тайм-аутов:

node /src/server1.js | number-aggregator-stats
Meas: 1745 Min: 3 Max: 349 Avg: 194 Cur: 232

А теперь давайте включим таймаут 3 секунды:

node /src/server1.js | number-aggregator-stats
Meas: 1429 Min: 3 Max: 411 Avg: 205 Cur: 172

На первый взгляд кажется, что тайм-ауты в конце концов не помогают: наше использование памяти достигает максимума при включенных тайм-аутах и ​​в среднем на 5% выше. Есть ли этому разумное объяснение?

Есть, конечно, так как нам просто нужно вернуться к осаде и посмотреть на rps:

853.60 trans/sec --> without timeouts
1134.48 trans/sec --> with timeouts

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

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

Введите Vegeta

Вегета очень проста в использовании, просто начните «атаковать» сервер и дайте ему сообщить о результатах:

echo "GET http://google.com" | vegeta attack --duration 1h -rate 1000 | tee results.bin | vegeta report

Здесь два очень интересных варианта:

  • --duration, чтобы вегета прекратилась через определенное время
  • --rate, как в rps

Похоже, что Vegeta - правильный инструмент для нас - затем мы можем ввести команду, адаптированную к нашему серверу, и увидеть результаты:

echo "GET http://localhost:3000" | vegeta attack --duration 3m --insecure -rate 1000 | tee results.bin | vegeta report

Вот что выдает Vegeta без тайм-аутов:

Requests      [total, rate]            180000, 1000.01
Duration      [total, attack, wait]    3m10.062132905s, 2m59.998999675s, 10.06313323s
Latencies     [mean, 50, 95, 99, max]  1.172619756s, 170.947889ms, 10.062145485s, 10.134037994s, 10.766903205s
Bytes In      [total, mean]            1080000, 6.00
Bytes Out     [total, mean]            0, 0.00
Success       [ratio]                  100.00%
Status Codes  [code:count]             200:180000
Error Set:

и вот что мы получаем, когда server1.js имеет тайм-аут 3 секунды:

Requests      [total, rate]            180000, 1000.01
Duration      [total, attack, wait]    3m3.028009507s, 2m59.998999479s, 3.029010028s
Latencies     [mean, 50, 95, 99, max]  455.780741ms, 162.876833ms, 3.047947339s, 3.070030628s, 3.669993753s
Bytes In      [total, mean]            1142472, 6.35
Bytes Out     [total, mean]            0, 0.00
Success       [ratio]                  90.00%
Status Codes  [code:count]             500:18000  200:162000
Error Set:
500 Internal Server Error

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

Без таймаутов:

node /src/server1.js | number-aggregator-stats
Meas: 1818 Min: 3 Max: 372 Avg: 212 Cur: 274

и с таймаутами:

node /src/server1.js | number-aggregator-stats
Meas: 1886 Min: 3 Max: 299 Avg: 149 Cur: 292

Скорее всего, это так: тайм-ауты помогли нам сохранить использование памяти в среднем на 30% ниже.

Все это благодаря простому .timeout(3000). Какая победа!

Как избежать эффекта домино

Цитирую себя:

Что происходит, если эти службы работают медленно или недоступны? Что ж, вы не можете обрабатывать поисковые запросы или платежи, но ваше приложение все равно будет работать нормально, верно?

Интересный факт: отсутствие тайм-аута может вывести из строя всю вашу инфраструктуру!

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

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

Вы только что создали ловушку для своих интерфейсов. Они начнут требовать больше памяти, обрабатывать меньше запросов и ... это верный путь к катастрофе.

Вот как выглядит эффект домино: система замедляется (или возникает простой), и это влияет на другие элементы архитектуры, что подчеркивает проект, в котором отказ не рассматривался как вариант, и он не является ни достаточно надежным, ни устойчивым.

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

Примечание о тайм-аутах

Если вы думали, что ждать опасно, давайте добавим в огонь:

  • Мы не говорим только о HTTP - каждый раз, когда мы полагаемся на внешнюю систему, мы должны использовать тайм-ауты
  • Сервер может иметь открытый порт и отбрасывать каждый отправляемый вами пакет - это приведет к тайм-ауту TCP-соединения. Попробуйте это в своем терминале: time curl example.com:81. Удачи!
  • Сервер мог отвечать мгновенно, но очень медленно отправлять каждый пакет (например, секунды между пакетами). Затем вам нужно будет защитить себя от тайм-аута чтения.

… И еще много крайних случаев, которые стоит перечислить. Я знаю, что распределенные системы отвратительны.

К счастью, высокоуровневые API-интерфейсы (например, тот, который выставлен unirest) обычно полезны, поскольку они устраняют все ошибки, которые могут случиться в пути.

Заключение: я нарушил все правила сравнительного анализа.

Если у вас есть какие-либо «агрессивные» отзывы о моих ржавых навыках тестирования производительности ... что ж, я с вами согласен. Я специально сделал несколько сокращений, чтобы упростить свою работу и дать вам возможность легко воспроизвести эти тесты.

Что вам следует делать, если вы серьезно относитесь к тестам:

  • Не запускайте код, который вы тестируете, и инструмент, который вы используете для тестирования, на одном компьютере. Здесь я запускал все на своем XPS, который достаточно мощный, чтобы позволить мне запускать эти тесты. Но выполнение siege / Vegeta на той же машине, на которой работают серверы, определенно влияет на результаты (я говорю ulimit, а вы сами разберетесь с остальным). Мой совет - попробовать получить какое-то оборудование на AWS и протестировать его - больше изоляции, меньше сомнений.
  • Не измеряйте память, выйдя из системы с помощью console.log, вместо этого используйте такой инструмент, как NewRelic, который, на мой взгляд, менее инвазивен.
  • Измерьте больше данных: трехминутный сравнительный анализ подходит для этой публикации, но если мы хотим взглянуть на реальные данные, чтобы лучше оценить, насколько полезны тайм-ауты, вам следует оставить тесты запущенными на более длительный срок.
  • Держите Gmail закрытым, пока вы используете siege ..., жильцы, живущие в /proc/cpuinfo, будут вам благодарны.

И… Я закончил на день: надеюсь, вам понравился этот пост, а если нет, не стесняйтесь разглагольствовать в поле для комментариев ниже!

Первоначально опубликовано на odino.org ( 19 января 2017 ).