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

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

Для нас компонент означает что угодно: микросервис, база данных (БД), балансировщик нагрузки (LB) и т. Д. Я не буду описывать механизмы отказоустойчивости DB / LB, потому что они зависят от поставщика и включение их приводит к настройке некоторых свойств или изменению политики развертывания.

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

  • Таймауты
  • Повторные попытки
  • Автоматический выключатель
  • Сроки
  • Ограничители скорости

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

Таймауты

Тайм-аут - это определенный период времени, в течение которого можно дождаться наступления какого-либо события. Проблема возникает, если вы используете SO_TIMEOUT (также известный как тайм-аут сокета или тайм-аут чтения) - он представляет собой тайм-аут между любыми двумя последовательными пакетами данных, а не для всего ответа, поэтому труднее обеспечить соблюдение SLA, особенно когда полезная нагрузка ответа велика. Обычно вам нужен тайм-аут, который охватывает все взаимодействие от установления соединения до самого последнего байта ответа. SLA обычно описывается такими тайм-аутами, потому что они гуманны и естественны для нас. К сожалению, они не соответствуют философии SO_TIMEOUT. Чтобы преодолеть это в мире JVM, вы можете использовать клиент JDK11 или OkHttp. Go также имеет механизмы в библиотеке std.

Если хотите покопаться - посмотрите мою предыдущую статью.

Повторные попытки

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

Что произойдет, если мы установим общее количество попыток равным 3 для каждой службы, и служба D внезапно начнет обрабатывать 100% ошибок? Это приведет к шторму повторов - ситуации, когда каждая служба в цепочке начинает повторять свои запросы, что резко увеличивает общую нагрузку, так что B столкнется с трехкратной нагрузкой, C - 9x и D - 27x! Избыточность - один из ключевых принципов достижения высокой доступности, но я сомневаюсь, что в этом случае у вас будет достаточно свободной емкости в кластерах C и D. Установка для общего числа попыток значения 2 тоже не очень помогает, к тому же ухудшает взаимодействие с пользователем при небольших всплесках.

Решение:

  • Отличите повторяющиеся ошибки от неповторяемых. Бессмысленно повторять запрос, если у пользователя нет разрешений или полезная нагрузка не структурирована должным образом. Напротив, можно использовать повторные попытки тайм-аутов запроса или 5xx.
  • Принять ошибочный бюджет - метод, когда вы прекращаете повторные попытки, если количество повторных ошибок превышает пороговое значение, например если 20% взаимодействий со службой D приводят к ошибке, прекратите повторные попытки и попытайтесь постепенно ухудшиться. Количество ошибок можно отслеживать с помощью скользящего окна за N последних секунд.

Автоматический выключатель

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

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

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

Hystrix долгое время являлся популярной реализацией выключателя в JVM. На данный момент он перешел в режим обслуживания, посоветовав вместо него использовать resilience4j.

Сроки / распределенные таймауты

Мы обсуждали тайм-ауты в первой части этой статьи, теперь давайте посмотрим, как мы можем сделать их «распределенными». Во-первых, вернитесь к одной и той же цепочке вызовов друг друга:

Служба Готовность ждать не более 400 мсек и запрос требует выполнения некоторой работы со стороны всех трех нижестоящих служб. Предположим, что служба B заняла 400 мс и теперь готова вызвать службу C. Разве это вообще разумно? Нет! Служба Тайм-аут истек и больше не ждет результата. Дальнейшее продвижение приведет к потере ресурсов и повысит вероятность повторных попыток шторма.

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

На практике это одни из следующих метаданных:

  • Отметка времени: время, по истечении которого служба перестает ждать ответа. Во-первых, служба шлюза / внешнего интерфейса устанавливает крайний срок на текущая отметка времени + тайм-аут. Затем любая последующая служба должна проверить, не превышает ли текущая отметка времени крайний срок. Если ответ положительный, то можно безопасно выключить его, в противном случае - начать обработку. К сожалению, существует проблема смещения часов, когда машины могут иметь разное время. Если это произойдет, запросы будут зависать или / и будут немедленно отклонены, что приведет к отключению.
  • Тайм-аут: время ожидания службы. Это немного сложнее реализовать. То же, что и раньше, вы как можно скорее установите крайний срок. Затем любая нижестоящая служба должна рассчитать, сколько времени она тратит, вычесть это время из тайм-аута входящего трафика и передать следующему участнику. Крайне важно не забывать о времени, затраченном на ожидание в очереди! Таким образом, если службе A разрешено ждать 400 мс, а службе B - 150 мс, она должна добавить таймаут крайнего срока 250 мс при вызове службы C. Хотя это не так. не считайте время, потраченное на провод, крайний срок может быть запущен только позже, а не раньше, таким образом потенциально потребляя немного больше ресурсов, но не портя результат. Так реализованы дедлайны в GRPC.

Последнее, что нужно обсудить, - есть ли смысл не прерывать цепочку вызовов, когда крайний срок превышен? Ответ - да, если у вашей службы достаточно свободной емкости и выполнение запроса сделает ее более горячей (кеш / JIT), можно продолжить обработку.

Ограничители скорости

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

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

Что происходит, когда нагрузка превышает допустимую? Обычно возникает этот порочный круг:

  1. Увеличивается время отклика, увеличивается площадь сборки мусора
  2. Клиенты получают больше таймаутов, приходит еще больше нагрузки
  3. goto 1, но более серьезный

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

Ограничители спешат на помощь! Их идея - изящно сбросить входящую нагрузку. Вот как в идеале следует справляться с чрезмерной нагрузкой:

  1. Ограничитель сбрасывает дополнительную нагрузку, превышающую емкость, тем самым позволяя приложению обслуживать запросы в соответствии с SLA
  2. Чрезмерная нагрузка перераспределяется на другие экземпляры / автоматическое масштабирование кластера / масштабирование кластера выполняется человеком

Есть 2 типа ограничителей - скорость и параллелизм, первый ограничивает входящий RPS, второй ограничивает количество запросов, обрабатываемых в любой момент времени.

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

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

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

Эта служба может обрабатывать N запросов в секунду в любой момент времени.

Но на самом деле мы заявляем следующее:

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

Почему это замечание важно? Я докажу это интуицией. Для желающих получить математическое доказательство - см. Закон Литтла. Предполагая, что ограничение скорости составляет 1000 запросов в секунду, время ответа составляет 1000 мс, а SLA - 1200 мс, мы легко обслуживаем ровно 1000 запросов в секунду в рамках данного SLA.

Теперь время отклика увеличилось на 50 мс (служба зависимостей начала выполнять дополнительную работу). С этого момента каждую секунду сервис будет сталкиваться с все большим и большим количеством запросов, обрабатываемых одновременно, потому что скорость поступления больше, чем скорость обслуживания. Неограниченное количество воркеров означает, что у вас закончатся ресурсы и произойдет сбой, особенно в средах, где воркеры сопоставляют потоки ОС 1: 1. Как с этим справится ограничение параллелизма с 1000 рабочими? Он будет надежно обслуживать 1000 / 1,05 = ~ 950 запросов в секунду без нарушения SLA и отбрасывать остальные. Кроме того, не требуется реконфигурация, чтобы наверстать упущенное!

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

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

Статический

В этом случае лимит настраивается вручную. Ценность можно оценить с помощью регулярных тестов производительности. Хотя это не будет 100% точным, его можно пессимизировать в целях безопасности. Этот тип ограничения требует работы с конвейерами CI / CD и требует более низкого использования ресурсов. Статический ограничитель можно реализовать, ограничив размер пула рабочих потоков (только для параллелизма), добавив входящий фильтр, который учитывает запросы, ограничивающей функциональностью NGINX или прокси-сервером envoy sidecar.

Динамический

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

Затем определите предикат, который ответит, исправна ли метрика. Например, если p99 ≥ 500 мс считается нездоровой, предел следует уменьшить. Способ увеличения и уменьшения лимита должен решаться с помощью применяемого алгоритма управления с обратной связью, такого как AIMD (который используется в протоколе TCP). Вот его псевдокод:

if healthy {
    limit = limit + increase;
} else {
    limit = limit * decreaseRatio; // 0 < decreaseRatio < 1.0
}

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

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

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

использованная литература