для чего нужен балансировщик нагрузки
Балансировка нагрузки
Время чтения : 7 минут
Что такое балансировка нагрузки?
Балансировка нагрузки (БН) — это метод, который гарантирует, что сервер организации не будет перегружен трафиком. Под этим определением понимается эффективное распределение входящего сетевого трафика между группой внутренних серверов, также известной как ферма серверов или пул серверов.
Операция выполняется физическим или виртуальным устройством, определяющим, какой сервер в пуле удовлетворит клиентский запрос наилучшим образом. При этом интенсивный сетевой трафик не перегружает систему. Кроме того, метод обеспечивает аварийное переключение между устройствами. Поэтому если один из серверов выходит из строя, балансировщик немедленно перенаправляет рабочие нагрузки на резервный сервер, тем самым снижая негативное воздействие на конечных пользователей.
Для чего нужен балансировщик нагрузки?
Балансировщик нагрузки (Load Balancer) — сервис, помогающий серверам эффективно перемещать данные, оптимизирующий использование ресурсов доставки приложений и предотвращающий перегрузки. Он управляет потоком информации между локальным или облачным хранилищем и конечным устройством — ПК, ноутбук, планшет или смартфон. Этот сервис проводит непрерывные проверки работоспособности серверов, чтобы убедиться в их работоспособности. При необходимости подсистема балансировки удаляет неисправные серверы из пула. Входящие в состав балансировщика контроллеры доставки приложений (ADC) предлагают множество дополнительных функций — шифрование, аутентификацию и межсетевой экран веб-приложений, создавая тем самым единую точку контроля для защиты, управления и мониторинга веб-сервисов.
К таким дополнительным возможностям относятся:
Балансировщики могут быть как отдельными физическими аппаратными устройствами, так и поставляться в виде программы. Аппаратные устройства работают на основе программного обеспечения, оптимизированного под специализированные процессоры. По мере увеличения трафика, поставщик просто добавляет дополнительные устройства балансировки нагрузки для обработки необходимого объема.
Преимущества такого подхода:
Программные балансировщики обычно работают на менее дорогом стандартном оборудовании или в облаке и имеют ряд собственных преимуществ:
Уровни балансировки
Прежде всего, давайте разберемся с моделью взаимодействия открытых систем OSI (Open System Interconnection). Разработанная еще в 1984 году, она описывает способ передачи информации между приложениями (программами), работающими на сетевых устройствах, компьютерах, маршрутизаторах (открытых системах). На изображении ниже показаны семь уровней взаимодействия этой модели:
Балансировка нагрузки происходит непосредственно на уровнях 4 и 7.
На уровне 4 балансировщик нагрузки отслеживает сетевую информацию о портах приложений и протоколах (TCP / UDP). Он доставляет трафик, комбинируя эти ограниченные данные с алгоритмом балансировки нагрузки, таким как циклический перебор, вычисляя лучший целевой сервер, на основе наименьшего количества подключений или времени ответа. Одним из наиболее известных балансировщиков нагрузки уровня 4 является Microsoft Network Load Balancer или NLB, это программное обеспечение для балансировки нагрузки ядра сети, доступное пользователям критически важных приложений Microsoft.
На уровне 7 балансировщик уже осведомлен о приложении и может использовать эту дополнительную информацию для принятия более сложных и обоснованных решений по балансировке нагрузки. С помощью протокола HTTP, он может однозначно идентифицировать клиентские сеансы, на основе файлов cookie и использовать эту информацию для доставки всех запросов клиента на один и тот же сервер. Благодаря своему логическому положению в сети, балансировщик нагрузки уровня 7 проверяет весь трафик уровней 4 и 7, проходящий к веб-сайтам и серверам приложений. Вся эта деятельность записывается в журналы, чтобы облегчить мониторинг и отслеживание сетевой информации.
Алгоритмы и методы балансировки
Существуют определенные алгоритмы балансировки нагрузки, определяющие оптимальные серверы для входящих клиентских запросов. Рассмотрим стандартные решения:
Преимущества облачного балансировщика
Все больше предприятий стремятся развертывать свои приложения в облаках. Это стало необходимостью поскольку, программы стали более сложными, потребности пользователей выросли, а объем трафика увеличился. Как и другие формы балансировки, балансировка нагрузки в облаке позволяет максимально повысить производительность и надежность приложений. Простота и скорость масштабирования в облаке означает, что компании могут справляться с пиками трафика без снижения производительности.
Аппаратные балансировщики при увеличении объема трафика, зачастую просто не справляются и поставщику приходится добавлять в пул дополнительные устройства. Это не продуктивно и поэтому на смену традиционным БН приходят облачные.
Отслеживание нагрузки здесь происходит с помощью «слушателей», использующих настроенные протоколы сети и порты для проверки клиентских запросов. ELB может обрабатывать миллионы параллельных подключений и обеспечивает высокую доступность серверов.
Резюме
В современном цифровом высоконагруженном мире без использования метода балансировки нагрузки обойтись практически невозможно. Грамотное распределение нагрузок обеспечивает ИТ-отделам масштабируемость и доступность услуг. Готовые инструменты управления трафиком помогают бизнесу более эффективно направлять запросы к нужным ресурсам, для каждого конечного пользователя, а реализованный принцип отказоустойчивости обеспечит бесперебойную работу вашей системы.
Адаптивная балансировка нагрузки или как повысить надёжность микросервиса
Привет, меня зовут Геннадий, я работаю в Ozon, занимаюсь разработкой backend-сервисов.
Избыточностью компонентов, кластеризацией или балансировкой уже никого не удивишь в наши дни. Это очень важные и нужные механизмы. Но так ли они хороши? На сколько они защищают нас от возможных отказов?
В Ozon все перечисленное используется, но мы сталкивается с проблемами, которые выходят за рамки возможностей стандартных решений и нужны иные подходы и инструменты. Я уверен, что и у вас есть кластеризация и она также не спасает на все сто.
В статье я хочу затронуть некоторые из этих вопросов и показать, как мы повышаем надежность сервисов с помощью адаптивной балансировки.
Изначально решалась проблема клиентской балансировки запросов к PostgreSQL и Redis, но описываемое ниже решение этим не ограничивается – можно применить и для других случаев.
Алгоритм достаточно простой, не привязан к какой-то технологии или языку программирования и может быть адаптирован под разные платформы.
Но обо всем по порядку.
Начнём с проблематики.
Узкие места масштабирования
Есть некий сервис, который должен отвечать на запросы. Он выглядит вот так:
Данные извлекаются, к примеру, из PostgreSQL и передаются потребителям.
В данной схеме узких мест несколько. Если не вдаваться в окружение (сетевую инфраструктуру, вычислительные узлы и т.д.), то их два. Сам сервис и хранилище.
Сервис можно масштабировать горизонтально и тем самым добиться высокой доступности и отказоустойчивости (примем это за истину). Плюс, мы же не в вакууме живем – под рукой обычно есть средства балансировки трафика. А какой-нибудь Kubernetes ещё и отслеживает состояние нашего сервиса (или сам балансировщик – с помощью проб).
Получаем такую картину:
Хранилище тоже можно масштабировать горизонтально. Например, сделать несколько реплик и организовать кластер, как в случае с PostgreSQL или тем же Redis.
Если бы мы жили в идеальном мире, то на этом можно было завершить статью — проблема решена и бизнес может спать спокойно.
Но в реальном мире остаётся ещё куча других проблем, которые не решаются только лишь кластеризацией.
Больше кластеров
Есть человеческий фактор. Кто-то случайно удалил логин, поменял пароль, удалил таблицу — такое редко, но бывает. В этой ситуации сервис будет полностью недоступен, хотя кластер продолжит работать.
Или, например, в системах, где «много» данных, кто-то запустил долгий запрос и это повлияло на всё остальное. В микросервисах с eventual consistency не так редки случаи запуска различных синхронизаций (пересчётов), индексаций, которые также грузят систему.
Долгие ответы, недоступность по таймауту. Кластер этого не заметит.
Как долго вы будете искать причину? Сколько времени займет исправление? Клиент не ждет, бизнес теряет деньги.
И тут мы приходим к выводу: нам нужны независимые системы, чтобы отказ одной, не влиял на другую!
Хорошо, давайте сделаем два кластера.
В подробности сходимости вдаваться не будем, решений очень много: репликация на уровне железа (СХД), репликация на уровне софта или, к примеру, клиент, мутирующий данные, может писать сразу в несколько мест.
Получили подходящую по согласованности систему из двух «независимых» источников.
И тут нужно решить откуда читать – копий-то две (а то и больше).
Есть несколько вариантов:
1) читаем из первой и, в случае недоступности, обращаемся ко второй;
2) балансируем запросы между системами и, в случае недоступности одной, делаем обращение к другой;
3) ходим сразу в оба кластера, берём первый (или лучший) полученный результат, второй отбрасываем.
Допустим, мы выбрали какую-то из схем доступа. Решили ли мы проблемы? Какие-то да. Если «сломается» одна система, то мы будем получать данные от другой.
Но часть проблем осталась.
В первом и втором варианте остаются все те же долгие ответы и таймауты. Если система быстро «падает», то хорошо – мы без потерь можем переключиться на другую и получить ответ от неё (на самом деле это тоже плохо, но об этом чуть позже). А вот если первый узел отвечает долго, то общее время ответа может быть неприемлемым.
Во втором варианте проблема будет сглажена балансировкой. Если запросы балансируются 1-к-1, то мы теряем всего 50% трафика. Стало лучше, но устраивает ли нас это?
Третий вариант (одновременные запросы к обоим узлам) – очень хороший и мы долгое время его использовали. Он даёт очень достойный с точки зрения отклика результат. Даже на предельной нагрузке ответы были константами, без деградации; узлы компенсировали «тормоза» друг друга. Плюс, решаются проблемы с прогреванием и мониторингом способности держать нагрузку. Если используются ответы только от одного узла, значит, скорее всего второй не справляется.
Но есть проблема: так потребляется больше ресурсов. Одно дело вы обращается к одной базе данных, другое дело сразу два I/O-запроса.
Приведу пример. На проде работает релиз, за данными он ходит одновременно в PostgreSQL и Redis. Начинаем выкатывать новый релиз, канарейкой. Переключаем трафик: 5%, 10%, 15% и т. д. Видим, что новый релиз генерирует исключения о нехватке соединений к БД: «FATAL: connection limit exceeded for non-superusers». Ничего страшно, Redis же отвечает!
Переключаем трафик дальше. И в какой-то момент, условно на 80% трафика – поды начинают перезагружаться.
Что случилось? Проблема в исключениях (Exceptions).
Я думаю, не секрет, что обработка исключения в тысячи раз «медленнее», чем обработка кода возврата. И чем дальше по стеку происходит «захват», тем затратнее такая обработка. Это всё stack trace.
Во всех вариантах балансировки (и без балансировки) исключения могут сыграть роковую роль. Сервис тратит на это много вычислительных ресурсов и сам деградирует, в конечном итоге. В контейнерной среде это выглядит как повышенная утилизация CPU, throttling, «подвисание» контейнера и его последующий перезапуск. А пока происходит перезапуск, запросы будут перенаправлены на другие узлы, где произойдет то же самое. Сервис будет полностью недоступен.
Из описанного выше случая с нехваткой соединений, контейнеру «хватило» всего 200 Exceptions в секунду, чтобы произошёл лавинообразный отказ всего приложения.
Чтобы решить эту проблему, можно перестать кидать исключения и перейти на soft-exception – коды возврата. Оставить исключения только для исключительных случаев.
Хорошо, в своём коде и библиотеках мы можем поменять подход. А как быть со сторонними? Они то продолжают использовать исключения. Будем переписывать библиотеку, скажем, NpgSql, которая по-другому не умеет сообщать о проблемах?
Подведём промежуточные итоги. У нас есть следующие проблемы:
И есть требования к отказоустойчивости и высокой доступности.
Нам нужен какой-то балансировщик, который бы мог распределять запросы между узлами, в соответствии с временем ответа, учитывая таймауты, и при этом как-то отключать «плохие» узлы. А когда система вернётся к штатному функционированию, автоматически перераспределить нагрузку.
Сделаем такой балансировщик.
Балансировщик
Распределять запросы в соответствии с response time узла – не сложно. Мы должны считать время ответа каждого и распределять запросы в соответствии с этим показателем.
Как считать время? Можно вычислять среднее время всех ответов, начиная со старта приложения. Ок, но с таким подсчётом текущие «проблемы» очень нескоро повлияют на балансировку.
Можно считать средний показатель за период – скользящую. Например, среднее время ответа за 100 последних запросов. Уже лучше.
У простой скользящей есть две проблемы. Первая – все значения имеют равный вес, а это значит, что старые значения будут одинаково влиять на результат наряду с новыми (хотя старые уже не вызывают интереса, как правило).
Здесь видно, что десятый запрос с response time в 20ms будет влиять на все последующие десять запросов.
Вторая проблема – значения, которые «приходят» в скользящую, влияют на неё 2 раза (когда входят и когда выходят).
Решить эти проблемы позволяют взвешенные скользящие – они сглаживают этот эффект.
Например, экспоненциальная скользящая средняя. Вот её формула:
F – фактор сглаживания; это значение можно подобрать, обычно используется «стандартное»: 2/(n+1), где n – период скользящей;
EMA(i-1) – значение EMA за предыдущий период.
Если простым языком, то предыдущее значение RT (за период) уменьшается на некоторый коэффициент и к нему прибавляется текущее значение с большим весом. Получаем, что текущие значения имеют более высокий приоритет, а старые и те, что выходят из «окна» – влияют в меньшей степени.
Вот так выглядит предыдущий всплеск. Он в большей степени влияет на среднюю при появлении и постепенно уменьшает своё влияние на более новые значения.
А вот как выглядит значение средней с периодом 30, если, скажем, сервис начал постоянно отвечать «плохо»:
Средний ответ был 2ms, а потом резко упал до 10ms и остановился на этом уровне. Как видим, «поведение» обычной скользящей средней (SMA) линейное и грубое. Хотя при этом стало быстрее соответствовать реальной картине. EMA наоборот, более сглаженное, но немного отстаёт. Чтобы для EMA получить отклик лучше, необходимо «поиграться» с периодом или фактором сглаживания.
Есть другие взвешенные: AMA, Double EMA, Triple EMA, Fractal AMA. Суть балансировки не меняется, в статье их рассматривать не будем.
Отлично, мы научились считать среднее и теперь можем балансировать трафик между узлами.
Давайте разбираться с таймаутами и устойчивыми исключениями (permanent exceptions). Их можно обрабатывать отдельно. Но тогда механизм балансировки станет сложнее – необходимо будет отслеживает ошибки за период и принимать решение об ограничении трафика уже на основе двух показателей. Сложно, не подходит.
А что, если учитывать их как response time? Здесь также есть несколько подходов.
Можно назначить разным показателям разные значения RT. К примеру, договориться что timeout – это 100ms, ошибка – 200ms и т. д. Но в этом случае долгие ответы будут мало отличимыми от этих обособленных.
Или можно задать им коэффициенты от нормального отклика – чтобы в случае замедления ответов нормального узла, мы не получили распределение трафика близкого 1-к-1. Тогда опять придётся отдельно считать среднюю RT для нормальных ответов и вводить ещё какой-то общий счетчик.
А что, если ввести коэффициенты от среднего response time? То есть применить геометрическую прогрессию. Timeout к примеру, будет с коэффициентом 1.5, ошибки – 2 и т. д.
Тогда единичные ошибки будут умножать нормальное время на этот коэффициент и не сильно влиять на среднюю, а постоянные ошибки будут увеличивать это значение и вносить «существенный» вклад в общую картину. То, что нужно!
Есть еще некоторые нюансы, которые мы должны учесть, но хватит теории – пора писать алгоритм.
Адаптивный балансировщик: алгоритм
Алгоритм представлен на некотором псевдоязыке.
Вводить соглашения не будем. Уверен, у вас не возникнет трудностей понять, что происходит. К тому же я старался вставлять полезные комментарии и пояснения, что также должно облегчить понимание.
Использовать его нужно следующим образом:
То есть для запроса вы должны спросить у балансировщика, «кто следующий», и после запроса обновить информацию о нём. Для второго ответа, если он случился, также обновляем статистику.
Что ещё забыли? Конечно, про синхронизацию потоков. Этот вопрос нельзя обойти стороной, поскольку он очень важен в реальном мире.
Структуры балансировки (счётчики, веса) сосредоточены в одном месте, и в высоконагруженном приложении в любом случае произойдёт конкурентный доступ.
Я использую синхронизацию, когда обновляю счётчик запросов (функция GetNext, инструкция RequestTotal++). Конкретно, в С# используется конструкция синхронизации пользовательского режима – Interlocked.
Нужно ли использовать синхронизацию при обновлении счётчиков средних ответов (firstNodeResponseTime и secondNodeResponseTime)? Мой ответ – нет. И вот почему.
Как правило, на протяжении некоторого продолжительного времени, сервис отвечает с константной задержкой или не отвечает вовсе. То есть ведёт себя ожидаемо, стабильно.
Даже если случайные всплески в порыве конкуренции перезатрут «хорошие» (или «плохие») данные, вряд ли это существенно повлияет на общую картину. Поэтому здесь их можно не использовать.
А вот для обновления весов (nodeRatio) синхронизация нужна. Мы должны обеспечить балансировку 1-к-X, поэтому структура должна быть целостной. Значит, за это должен отвечать один поток. Используем Interlocked.
А есть ли смысл обновлять веса для каждого запроса? В highload-системах можно этой точностью пренебречь. Одного раза на каждые 50 запросов будет вполне достаточно.
На этом обсуждение самого алгоритма можно завершить. На мой взгляд все ключевые вопросы мы разобрали.
Практика
Первым делом запускаем нагрузочные тесты.
У нас есть приложение, которое ходит за данными в PostgreSQL и Redis. Даём на него нагрузку и через какое-то время производим отключение одного из хранилищ.
На графике стрелкой 1 отмечен момент отключения базы данных (переименовываем таблицу, из которой читает сервис). Отключение было примерно в 16:44 (балансировщик отдает метрики, но с небольшим отставанием, чтобы уменьшить накладные расходы). Видно, что примерно за две минуты база данных была полностью отсечена от запросов, на неё падали эти 0.5% трафика с fallback на Redis. Cтрелка 2 – обратно вернул таблицу на место.
Давайте посмотрим, как это повлияло на характеристики сервиса.
Error rate остался на прежнем уровне и немного увеличился response time – поскольку всё-таки часть трафика попала на отказавший postgres, и только потом происходил fallback на Redis. PostgreSQL очень быстро кидал exception (таблицы же нет, чего ждать), поэтому response time не сильно увеличился. Но, что самое главное, приложение «выжило» и не упало!
И обратная картина. Убиваем Redis (командой DEBUG SEGFAULT).
К сожалению, данных балансировки по этому тесту нет. Но есть данные по работе приложения.
На скрине плохо видно, но error rate увеличился до 2.51% (в нормальном режиме работы – 0.009%). Подрос response time в 90 и 99 квантилях. Но в целом, катастрофы не случилось.
Это были стресс-тесты.
Там вот так убивать базу или Redis страшно (хотя очень бы хотелось).
Но есть момент выкатки релиза, когда не хватает соединений к PostgreSQL и постоянно возникает exception.
Вот этот «холмик» – как раз и есть момент отсечения PostgreSQL.
Нехватка соединений в целом никак не повлияла на сервис, лишь немного деградировал ответ в 99 квантиле. На графике это период с 11:30 до 12:30.
А вот несколькими неделями ранее, до релиза балансировки – был бы полный отказ приложения.
Не серебряная, но пуля
Мы написали адаптивный балансировщик, который приспосабливается к изменяющимся условиям, умеет отсекать нагрузку и автоматически восстанавливаться. При этом весьма простой и с небольшими накладными расходами.
Понятно, что это не серебряная пуля, и всех проблем мы не решили. Но в какой-то степени мы добились улучшения показателей высокой доступности и отказоустойчивости сервиса.
На этом у меня всё.
Буду рад комментариям, замечаниям, пожеланиям!
Тонкая настройка балансировки нагрузки
В этой статье речь пойдет о балансировке нагрузки в веб-проектах. Многие считают, что решение этой задачи в распределении нагрузки между серверами — чем точнее, тем лучше. Но мы же знаем, что это не совсем так. Стабильность работы системы куда важнее с точки зрения бизнеса.
Маленький минутрый пик в 84 RPS «пятисоток» — это пять тысяч ошибок, которые получили реальные пользователи. Это много и это очень важно. Необходимо искать причины, проводить работу над ошибками и стараться впредь не допускать подобных ситуаций.
Николай Сивко (NikolaySivko) в своем докладе на RootConf 2018 рассказал о тонких и пока не очень популярных аспектах балансировки нагрузки:
О спикере: Николай Сивко сооснователь okmeter.io. Работал системным администратором и руководителем группы администраторов. Руководил эксплуатацией в hh.ru. Основал сервис мониторинга okmeter.io. В рамках этого доклада опыт разработки мониторинга является основным источником кейсов.
Про что будем говорить?
В этой статье речь пойдет про веб-проекты. Ниже пример живого продакшена: на графике показаны запросы в секунду на некий веб-сервис.
Когда я рассказываю про балансировку, многие воспринимают это, как «нам надо распределить нагрузку между серверами — чем точнее, тем лучше».
На самом деле это не совсем так. Такая проблема актуальна для очень небольшого числа компаний. Чаще бизнес волнуют ошибки и стабильность работы системы.
Маленький пик на графике — это «пятисотки», которые сервер возвращал в течение минуты, а потом перестал. С точки зрения бизнеса, например интернет-магазина, этот маленький пик в 84 RPS «пятисоток» — это 5040 ошибок реальным пользователям. Одни что-то не нашли в вашем каталоге, другие не смогли положить товар в корзину. И это очень важно. Пусть на графике этот пик выглядит не очень масштабным, но в реальных пользователях это много.
Как правило такие пики есть у всех, и админы не всегда на них реагируют. Очень часто, когда бизнес спрашивает, что это было, ему отвечают:
«Тонкая» настройка
Я назвал доклад «Тонкаянастройка» (англ. Fine tuning), потому что я подумал, что не все добираются до этой задачи, а стоило бы. Почему не добираются?
Тестовый стенд
Начнем с простых очевидных кейсов. Для наглядности я буду использовать тестовый стенд. Это Golang-приложение, которое отдает http-200, или его можно переключить в режим «отдавай http-503».
Запускаем 3 инстанса:
Примитивный сценарий
В какой-то момент включаем один из бэкендов в режим отдавать 503, и получаем ровно треть ошибок.
Понятно, что из коробки ничего не работает: nginx из коробки не делает retry, если получили с сервера любой ответ.
На самом деле это довольно логично со стороны разработчиков nginx: nginx не вправе за вас решать, что вы хотите ретраить, а что нет.
Соответственно, нам нужны retries — повторные попытки, и мы начинаем о них говорить.
Retries
Нужно найти компромисс между:
Я разделил неудачные попытки на 3 категории:
1. Transport error
Для HTTP транспортом являются TCP, и, как правило, здесь мы говорим об ошибках установки соединения и о таймаутах установки соединения. В своем докладе я буду упоминать 3 распространенных балансировщика (про Envoy поговорим чуть дальше):
2. Request timeout:
Допустим, что мы отправили запрос на сервер, успешно с ним соединились, но ответ нам не приходит, мы его подождали и понимаем, что дальше ждать уже нет никакого смысла. Это называется request timeout:
Таймауты
Поговорим теперь подробно про таймауты, мне кажется, что стоит уделить этому внимание. Дальше не будет rocket science — это просто структурированная информация про то, что вообще бывает, и как к этому относится.
Connect timeout
Connect timeout — это время на установку соединения. Это характеристика вашей сети и вашего конкретного сервера, и не зависит от запроса. Обычно, значение по умолчанию для сonnect timeout устанавливает небольшим. Во всех прокси дефолтовое значение достаточно велико, и это неправильно — должно быть единицы, иногда десятки миллисекунд (если мы говорим о сети в пределах одного ДЦ).
Если вы хотите проблемные сервера определять чуть быстрее, чем эти единицы-десятки миллисекунд, вы можете регулировать нагрузку на бэкенд путем установки небольшого backlog на прием TCP-соединений. В таком случае вы можете, когда backlog приложения заполнится, сказать Linux, чтобы он сделал reset на переполнение backlog. Тогда вы будете иметь возможность отстрелить «плохой» перегруженный бэкенд чуть раньше, чем connect timeout:
Request timeout
Request timeout — это характеристика не сети, а характеристика группы запросов (handler). Есть разные запросы — они разные по тяжести, у них внутри совершенно разная логика, им нужно обращаться к совершенно разным хранилищам.
У nginx, как такового, нет таймаута на весь запрос. У него есть:
У Envoy все есть: timeout || per_try_timeout.
Выбираем request timeout
Теперь самое важное, на мой взгляд — какой поставить request_timeout. Мы исходим из того, сколько допустимо ждать пользователю — это некий максимум. Понятно, что пользователь не будет ждать дольше 10 с, поэтому надо ответить ему быстрее.
С этим 1% нужно что-то сделать, потому что вся группа запросов должна, например, соответствовать SLA и укладываться в 100 мс. Очень часто в эти моменты перерабатывается приложение:
После этого упрощается процесс мониторинга вашего сервиса с точки зрения пользователя:
Speculative retries #нифигасечобывает
Мы убедились, что выбрать значение таймаута достаточно сложно. Как известно, чтобы что-то упростить, нужно что-то усложнить:)
Спекулятивный ретрай — повторный запрос на другой сервер, который запускается по какому-то условию, но первый запрос при этом не прерывается. Ответ мы берем от того сервера, который ответил быстрее.
Я не видел этой фичи в известных мне балансерах, но есть отличный пример с Cassandra (rapid read protection):
speculative_retry = N ms | M th percentile
Таким образом вам не надо зажимать таймаут. Вы можете оставить его на приемлемом уровне и в любом случае имеете вторую попытку получить ответ на запрос.
В Cassandra есть интересная возможность, задать статический speculative_retry или динамический, тогда вторая попытка будет сделана через перцентиль времени ответа. Cassandra накапливает статистику по временам ответов предыдущих запросов и адаптирует конкретное значение таймаута. Это достаточно хорошо работает.
В этом подходе все держится на балансе между надежностью и паразитной нагрузкой не серверы Вы обеспечиваете надежность, но иногда получаете лишние запросы на сервер. Если вы где-то поторопились и отправили второй запрос, а первый все-таки ответил — сервер получил чуть больше нагрузки. В единичном случае это небольшая проблема.
Согласованность таймаутов — еще один важный аспект. Про request cancellation мы еще поговорим, но в целом, если таймаут на весь пользовательский запрос 100 мс, то нет смысла ставить таймаут на запрос в базу 1 с. Есть системы, которые позволяют это делать динамически: сервис к сервису передает остаток времени, который вы будете ждать ответа на этот запрос. Это сложновато, но, если вам вдруг это понадобится, вы легко найдете, как в том же Envoy это сделать.
Что еще надо знать про retry?
Точка невозврата (V1)
Здесь V1 — это не версия 1. В авиации есть такое понятие — скорость V1. Это скорость, после достижении которой на разгоне по взлетной полосе тормозить нельзя. Надо обязательно взлетать, и потом уже принимать решение о том, что делать дальше.
Такая же точка невозврата есть в балансировщиках нагрузки: когда вы 1 байт ответа передали своему клиенту, никакие ошибки исправить уже нельзя. Если в этот момент бэкенд умирает, никакие retries не помогут. Можно только уменьшить вероятность срабатывания такого сценария, сделать graceful shutdown, то есть сказать своему приложению: «Ты сейчас новые запросы не принимаешь, но старые дорабатывай!», и только потом его гасить.
Если вы контролируете клиент, это какой-нибудь хитрый Ajax или мобильное приложение, оно может попробовать повторить запрос, и тогда вы можете выйти из этой ситуации.
Точка невозврата [Envoy]
В Envoy была такая странная фишка. Есть per_try_timeout — он ограничивает сколько может занимать каждая попытка получить ответ на запрос. Если этот таймаут срабатывал, но бэкенд уже начал отвечать клиенту, то все прерывалось, клиент получал ошибку.
Мой коллега Павел Труханов (tru_pablo) сделал патч, который уже в master Envoy и будет в 1.7. Теперь это работает так, как надо: если ответ начали передавать, сработает только global timeout.
Retries: нужно ограничивать
Retries — это хорошо, но бывают так называемые запросы-убийцы: тяжелые запросы, которые выполняют очень сложную логику, много обращается к базе данных и часто не укладывается в per_try_timeout. Если мы снова и снова посылаем retry, то этим мы убиваем нашу базу. Потому, что в большинстве (99.9%) сервисов баз данных нет request cancellation.
Request cancellation означает, что клиент отцепился, нужно прекратить всю работу прямо сейчас. В Golang сейчас активно пропагандируется этот подход, но, к сожалению, он заканчивается бэкендом, и многие хранилища баз данных это не поддерживают.
Соответственно, retries нужно ограничивать, что позволяют практически все балансировщики (HAProxy мы с данного момента рассматривать перестаем).
Nginx:
Retries: применяем [nginx]
Рассмотрим пример: задаем в nginx 2 попытки retry — соответственно, получив HTTP 503, пробуем послать запрос на сервер еще раз. Потом выключаем два бэкенда.
Ниже графики нашего тестового стенда. На верхнем графике ошибок не видно, потому что их очень мало. Если оставить только ошибки, видно, что они есть.
Что произошло?
Что с этим делать?
Мы можем либо увеличить количество повторных попыток (но тогда возвращаемся к проблеме «запросов-убийц»), либо мы можем уменьшить вероятность попадания запроса на «мертвые» бэкенды. Это можно делать с помощью health checks.
Health checks
Я предлагаю рассматривать health checks как оптимизацию процесса выбора «живого» сервера. Это ни в коем случае не дает никаких гарантий. Соответственно, в ходе выполнения пользовательского запроса мы с большей вероятностью будем попадать только на «живые» серверы. Балансировщик регулярно обращается по определенному URL, сервер ему отвечает: «Я жив и готов».
Health checks: с точки зрения бэкенда
С точки зрения бэкенда можно сделать интересные вещи:
Health checks: имплементации
Как правило, здесь все у всех всё примерно одинаково:
Отмечу особенность Envoy, у него есть Health check panic mode. Когда мы забанили, как «нездоровые», больше, чем N% хостов (допустим, 70%), он считает, что все наши Health checks врут, и все хосты на самом деле живы. В совсем плохом случае это поможет вам не нарваться на ситуацию, когда вы сами себе прострелили ногу, и забанили все серверы. Это способ еще раз подстраховаться.
Собираем все воедино
Обычно для Health checks ставят:
У нас nginx + Envoy, но, если заморочиться, можно ограничиться только Envoy.
Какой-такой Envoy?
Envoy — это модный молодежный балансировщик нагрузки, изначально разрабатывался в Lyft, написан на С++. Из коробки умеет кучу плюшек по нашей сегодняшней теме. Вы, наверное, видели его, как Service Mesh для Kubernetes. Как правило, Envoy выступает в роли data plane, то есть непосредственно балансирует трафик, а еще есть control plane, который предоставляет информацию о том, между чем надо распределять нагрузку (service discovery и пр.).
Расскажу пару слов о его плюшках.
Чтобы увеличить вероятность успешного ответа при retry при следующей попытке, можно немного поспать и подождать, пока бэкенды придут в себя. Таким образом мы обработаем короткие проблемы на базе данных. У Envoy есть backoff for retries — паузы между повторными попытками. Причем интервал задержки между попытками экспоненциально возрастает. Первый retry происходит через 0-24 ms, второй — через 0-74 ms, и далее для каждой следующей попытки интервал увеличивается, а конкретная задержка выбирается рандомно из этого интервала.
Второй подход — не специфичная для Envoy штука, а паттерн, который называется Circuit breaking (букв. разрыв цепи или предохранитель). Когда у нас бэкенд притупливает, на самом деле мы его каждый раз пытаемся добить. Это происходит потому, что пользователи в любой непонятной ситуации нажимают refresh-ат страницу, посылая вам все новые и новые запросы. Ваши балансировщики нервничают, отправляют retries, увеличивается количество запросов — нагрузка все растет, и в этой ситуации хорошо бы запросы не посылать.
Circuit breaker как раз позволяет определить, что мы в таком состоянии, быстро отстрелить ошибку и дать бэкендам «отдышаться».
Circuit breaker (hystrix like libs), оригинал в блоге ebay.
Выше схема Circuit breaker от Hystrix. Hystrix — это Java-библиотека от Netflix, которая призвана реализовывать паттерны отказоустойчивости.
У вас не было retries, что-то взорвалось — пошли retries. Envoy понимает, что больше, чем N — это ненормально, и все запросы надо отстреливать ошибкой.
Circuit breaking [Envoy]
Circuit breaker: наш опыт
Раньше у нас был HTTP-коллектор метрик, то есть агенты, установленные на серверах наших клиентов, отправляли метрики в наше облако по HTTP. Если у нас случаются какие-то проблемы в инфраструктуре, агент записывает метрики на свой диск и потом пытается их нам дослать.
А агенты постоянно делают попытки отправить нам данные, они не расстраиваются от того, что мы как-то не так отвечаем, и не уходят.
Если после восстановления мы пустим полный поток запросов (к тому же нагрузка будет даже больше обычной, так как нам доливают накопившиеся метрики) на серверы, скорее всего все снова ляжет, так как некоторые компоненты окажутся с холодным кэшем или типа того.
Чтобы справиться с такой проблемой мы зажимали входящий поток записи через nginx limit req. То есть мы говорим, что сейчас мы обрабатываем, допустим, 200 RPS. Когда это все приходило в нормальный режим, мы убирали ограничение, потому что в нормальной ситуации коллектор способен записывать гораздо больше, чем кризисный limit req.
Потом по некоторой причине мы перешли на свой протокол поверх TCP и потеряли плюшки проксирования HTTP (возможность использовать nginx limit req). Да и в любом случае надо было этот участок привести в порядок. Нам больше не хотелось менять limit req руками.
У нас достаточно удобный случай, потому что мы контролируем и клиента, и сервер. Поэтому мы в агенте закодии Circuit breaker, который понимал, что если он получил N ошибок подряд, ему надо поспать, и через какое-то время, причем экспоненциально возрастающее, пытаться заново. Когда все нормализуется, он все метрики доливает, так как у него есть spool на диске.
На сервере мы добавили Circuit breaker код всех обращений во все подсистемы + request cancellation (где возможно). Тем самым, если мы получили N ошибок от Cassandra, N ошибок от Elastic, от базы, от соседнего сервиса — от чего угодно, мы отдаем быстро ошибку и не выполняем дальнейшую логику. Просто отстреливаем ошибку и все — ждем, пока это нормализуется.
На графиках выше видно, что мы не получаем всплеск ошибок при проблемах (условно: серое — это «двухсотки», красное — «пятисотки»). Видно, что в момент проблем из 800 RPS на бэкенд долетело 20-30. Это позволило нашему бэкенду «отдышаться», подняться, и дальше хорошо работать.
Самые сложные ошибки
Самые сложные ошибки — это те, которые неоднозначны.
Если у вас сервер просто выключился и не включается, или вы поняли, что он мертвый и сами его добили — это на самом деле подарок судьбы. Такой вариант хорошо формализуется.
Намного хуже, когда один серверов начинает вести себя непредсказуемо, например, сервер на все запросы отвечает ошибками, а на Health checks — HTTP 200.
Приведу пример из жизни.
У нас есть некий Load Balancer, 3 узла, на каждом из которых приложение и под ним Cassandra. Приложения обращаются ко всем экземплярам Cassandra, и все Cassandra взаимодействуют с соседними, потому что у Cassandra двухуровневая модель координатор и data noda.
Сервер Шредингера — один из них целиком: kernel: NETDEV WATCHDOG: eth0 (ixgbe): transmit queue 3 timed out.
Там произошло следующее: в сетевом драйвере баг (в интеловских драйверах они случаются), и одна из 64 очередей передачи просто перестала отправляться. Соответственно, 1/64 всего трафика теряется. Это может происходить до reboot, это никак не лечится.
Меня, как админа, волнует в этой ситуации, не почему в драйвере такие баги. Меня волнует, почему, когда вы строите систему без единой точки отказа, проблемы на одном сервере в итоге таки приводят к отказу всего продакшена. Мне было интересно, как это можно разрулить, причем на автомате. Я не хочу ночью просыпаться, чтобы выключить сервер.
У Cassandra, есть те самые спекулятивные ретраи (speculative retries), и эта ситуация отрабатывается очень легко. Есть небольшое повышение latency на 99 перцентиле, но это не фатально и в целом все работает.
Из коробки ничего не работает. Когда приложение стучится в Cassandra и попадает на координатор на «мертвом» сервере, никак это не отрабатывает, получаются ошибки, рост latency и т.д.
На самом деле мы используем gocql — достаточно навороченный cassandra client. Мы просто не использовали все его возможности. Там есть HostSelectionPolicy, в который мы можем подсунуть библиотеку bitly/go-hostpool. Она использует алгоритмы Epsilon greedy для того, чтобы найти и удалить выбросы из списка.
Попытаюсь в двух словах объяснить, как работает алгоритм Epsilon-greedy.
Есть задача о многоруком бандите (multi-armed bandit): вы находитесь в комнате с игровыми автоматами, у вас есть несколько монет, и вы должны за N попыток выиграть как можно больше.
Алгоритм включает две стадии:
Host-pool каждый сервер оценивает по количеству успешных ответов и времени ответа. То есть он берет самый быстрый сервер в итоге. Время ответа он вычисляет как взвешенное среднее (свежие замеры более значимые — то, что было прямо сейчас, гораздо весомее). Поэтому мы периодически сбрасываем старые замеры. Так с неплохой вероятностью наш сервер, который возвращает ошибки или тупит, уходит, и на него практически перестают поступать запросы.
Промежуточный итог
Мы добавили «бронебойности» (отказоустойчивости) на уровне приложение—Cassandra и Cassandra coordinator-data. Но если наш балансировщик (nginx, Envoy — какой угодно) шлет запросы на «плохой» Application, который обращаясь к любой Cassandra будет тупить, потому что у него самого сетка нерабочая, мы в любом случае получим проблемы.
В Envoy из коробки есть Outlier detection по:
С другой стороны, чтобы защититься от неконтролируемого взрыва этой «умности», мы имеем max_ejection_percent. Это максимальное количество хостов, которое мы можем посчитать за outlier, в процентах от всех доступных. То есть, если мы забанили 70% хостов —это не считается, всех разбаниваем — короче, амнистия!
Это очень крутая штука отлично работает, при этом она простая и понятная — советую!
ИТОГО
Надеюсь, я убедил вас в том, что надо бороться с подобными кейсами. Я считаю, что бороться с выбросами ошибок и latency определенно стоит для того, чтобы:
Не нужно гнаться за самым навороченным балансировщиком. Для 99% проблем хватает стандартных возможностей nginx/HAProxy/Envoy. Более навороченный proxy понадобится, если вы захотите сделать все абсолютно идеально и убрать микроспайки «пятисоток».
Дело не в конкретном proxy (если это не HAProxy:)), а в том, как вы его настроили.
На DevOpsConf Russia Николай расскажет об опыте внедрения Kubernetes с учетом сохранения отказоустойчивости и необходимостью экономить человеческие ресурсы. Что еще нас ждет на конференции можно посмотреть в Программе.
Хотите получать обновления программы, новые расшифровки и важные новости конференций — подпишитесь на тематическую рассылку Онтико по DevOps.
Больше любите смотреть доклады, чем читать статьи, заходите на YouTube-канале — там собраны все видео по эксплуатации за последние годы и список все время пополняется.