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

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

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

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

Звучит просто. Какая-то реплика вернула нам 500? Прекрасно, кидаем ее в черный список и через пару минут проверяем — как она там, не ожила? Если ожила, то мы убираем реплику из черного списка и продолжаем слать в нее запросы. 

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

Нужно было придумать что-нибудь похитрее.

Решение первое: веса реплик

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

Любая реплика начинает с максимальным весом, единицей. Как только мы получаем плохой ответ (пятисотую ошибку или таймаут, например) — вес провинившейся реплики умножается на некоторое число меньше единицы. А как только от реплики получен хороший ответ, ее вес умножается на число больше единицы и таким образом вес реплики постепенно восстанавливается. 

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

А дальше запросы распределяются вероятностно, согласно выставленным весам. Например, если у нас есть две реплики с весами 1 и одна реплика с весом 0.5 — вероятность послать запрос в первую или вторую реплику будет вдвое выше, чем вероятность послать запрос в третью.

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

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

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

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

Итого, на каждом слое у нас треть реплик совсем плохих и две трети реплик умеренно плохих. И если бы мы умели на каждом слое выбирать только умеренно плохие реплики — все было бы неплохо, и падение одного дата-центра не убило бы доступность верхнего слоя. Однако механика с весами посчитала все реплики одинаково плохими и опустила им веса ниже плинтуса. Как следствие, мы получили на каждом из слоев вероятность зафейлиться в трети случаев. И эта вероятность увеличивается с каждым слоем — значит, ничего толком не доступно.

Разумеется, в этой ситуации есть достаточно простые и понятные решения. Например, мы можем забанить отказавший дата-центр. Однако похожие неполадки могут возникать в любых масштабах — отказать может конкретная машина, стойка, какая-то сетевая железка и что угодно еще. И такая механика балансировки не слишком-то хорошо умеет изолировать проблемные группы сервисов.

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

Вывод — нам нужно следить за состоянием всего кластера и соответственно балансировать нагрузку.

Трассировки и метрики

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

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

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

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

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

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

А теперь давайте разберемся, как мы эту идею реализуем.

Решение второе: Snitch и latency

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

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

Для начала мы берем весь наш поток данных и делаем с ним достаточно стандартную штуку — бьем окнами по десять секунд, каждое окно отдельно обрабатываем и соответственно обновляем веса реплик. 

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

Итак, теперь мы знаем параметры распределения для отдельных реплик и для всего кластера. С этими параметрами мы можем посчитать вес конкретной реплики - это значение функции распределения latency для реплики в точке, равной матожиданию для всего кластера. 

Для тех, кто запутался в математике

Для начала разберемся, что такое функция распределения. Как можно догадаться из названия, эта функция описывает, как распределены наши значения. А именно, значение функции распределения F(x) описывает вероятность того, что случайная величина (в нашем случае — latency) будет меньше или равна x. Иначе говоря, передавая в эту функцию значение latency мы получаем на выходе вероятность того, что latency будет меньше указанной величины. 

Теперь нам нужно подобрать функцию распределения, которая хорошо опишет наше распределение latency. Как сказано выше, мы выбрали распределение Гаусса (или нормальное распределение). Эта функция является аппроксимирующей, то есть приближенной.

Дальше мы считаем матожидание, дисперсию и среднеквадратичное отклонение, параметры распределения. 

Математическое ожидание — это среднее вероятное значение случайной величины. Иначе говоря, если мы бесконечное количество раз будем бросать шестигранный кубик, средний результат будет 3,5. В данном случае матожидание — это просто среднее значение, однако если распределение описано некоторой нетривиальной функцией, то значение будет зависеть от функции распределения.

Дисперсия — это разброс случайной величины относительно матожидания. Упрощенно говоря, эта величина показывает, насколько сильно распределены значения.

Корень же из дисперсии называется «среднеквадратичное отклонение». Квадратичное отклонение используется как наиболее распространенный показатель отклонения значения от матожидания. 

Итак, вес конкретной реплики — это значение функции распределения latency для реплики в точке, равной матожиданию для всего кластера. Что можно примерно перевести как «вес конкретной реплики — это вероятность того, что latency данной реплики будет не выше, чем среднее значение (точнее, матожидание) по кластеру».

Вот так, с помощью нехитрых приспособлений мы превратили собранные Snitch данные в веса реплик. Возникает логичный вопрос — и как, это действительно работает?

На самом деле, не совсем. Поэтому поверх этой логики мы реализовали несколько трюков.

Снова вспоминаем про ошибки

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

И все-таки нет, нельзя. Глядя исключительно на latency сложно отловить реплику, которая периодически пятисотит (а очень нужно). Поэтому мы будем учитывать и ошибки. А чтобы не уходить от единой численной величины, ошибки мы будем рассматривать как штрафы к latency. 

Здесь вы можете увидеть, как падает вес выдающей ошибки реплики

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

Кстати, мы можем конфигурировать, за какие ошибки идут штрафы к latency. Например, если для вашего сервиса 409 ошибка недопустима — ее также можно указать как критическую.

Применяем экспоненциальное сглаживание

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

А что такое экспоненциальное сглаживание?

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

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

Стандартное состояние весов для реплик

Интегрируемся с деплоем

Последний трюк позволяет нам использовать эту механику для деплоя новых версий сервисов. 

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

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

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

Заключение

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

Но это только одна из проблем, которые решала наша команда инфраструктуры (огромное спасибо лиду команды @Diafilm за помощь со статьей). Оставайтесь на связи, мы еще расскажем о многих других инфраструктурных инструментах, которые делают в Контуре.