Распределённые системы часто характеризуют как палку о двух концах. В Интернете найдётся множество отличных материалов как об их неприглядных, так и об отличных сторонах. Но этот пост — немного иного характера. Вообще обычно я за распределённые системы в тех случаях, когда они действительно нужны, но в этом посте я расскажу, как одна моя ошибка при работе с распределённой системе привела к далеко идущим последствиям.
Ошибка, которую я допустил, сейчас случается во многих компаниях и может приводить к лавинообразным отказам. Назовём её глубокая проверка работоспособности в Kubernetes.
Kubernetes — это платформа для оркестрации контейнеров. Kubernetes не случайно популярен в среде специалистов, выстраивающих распределённые системы, так как позволяет формализовать инфраструктуру в виде разумной исходно облачной абстракции. В рамках такой абстракции разработчики могут конфигурировать и запускать приложения, не будучи экспертами в сетевых технологиях.
В Kubernetes допускается и рекомендуется конфигурировать пробы нескольких типов: для определения жизнеспособности (liveness), готовности (readiness) и возможности запуска (startup). Концептуально эти пробы просты, и их можно описать следующим образом:
При помощи проб жизнеспособности мы сообщаем Kubernetes,нужно ли перезапустить контейнер. Если проба жизнеспособности даст отрицательный результат, то приложение перезапустится. Таким методом можно отлавливать определённые проблемы — например, взаимные блокировки — и снова открывать доступ к приложению. Например, коллеги из Cloudflare описали здесь, как таким способом перезапускать «застрявших» потребителей Kafka.
Пробы готовности используются только с приложениями, работающими на основе http, и такие пробы позволяют просигнализировать, что контейнер готов получать трафик. Под считается готовым к получению трафика, когда готовы все его контейнеры. Если какой‑либо контейнер в поде провалил пробу готовности, то он удаляется из балансировщика нагрузки сервисов и не будет получать никаких HTTP‑запросов. Но, если провалена проба готовности, то под не перезапускается, а если провалена проба жизнеспособности, то перезапускается.
Пробы запуска обычно рекомендуется применять с унаследованными приложениями, на запуск которых требуется немало времени. Как только приложение пройдёт пробы запуска, последующие пробы жизнеспособности и готовности не учитываются.
В оставшейся части поста мы сосредоточимся на пробах готовности применительно к приложениям, работающим на основе HTTP.
❯ Когда моё приложение будет готово?
На первый взгляд, очень простой вопрос, правда? «Приложение готово к работе, как только сможет отвечать на запросы пользователя», — могли бы сказать вы. Рассмотрим приложение платёжной компании, в котором вы можете проверять ваш баланс. Когда пользователь открывает мобильное приложение, оно отправляет запрос на один из многих имеющихся у вас бекенд-сервисов. Сервис, получивший запрос, далее должен:
Валидировать пользовательский токен, обратившись для этого к сервису аутентификации.
Вызвать тот сервис, на котором ведётся баланс.
Отправить в Kafka событие
balance_viewed
.(Через другую конечную точку) позволитьпользователю закрыть свой аккаунт, в результате чего обновляется строка в собственной базе данных этого сервиса.
Следовательно, можно утверждать, что успешная работа приложения, обслуживающего пользователей, зависит от:
Доступности сервиса аутентификации
Доступности сервиса для проверки баланса
Доступности Kafka
Доступности нашей базы данных
Граф этих зависимостей будет выглядеть примерно так:
Следовательно, можно написать такую конечную точку проверки готовности, которая возвращала бы JSON и код 200
при доступности всего вышеперечисленного:
{
"available":{
"auth":true,
"balance":true,
"kafka":true,
"database":true
}
}
В данном случае «доступность» для разных элементов может пониматься немного по-разному:
Работая с сервисами аутентификации и баланса необходимо убедиться, что мы получаем код
200
от их конечных точек готовности.Работая с Kafka, убеждаемся, что можем выдать событие в топик, именуемый healthcheck.
Работая с базой данных, выполняем
SELECT 1
;
Если любая из этих операций закончится неуспешно, то мы вернём false
для ключа JSON, а также ошибку HTTP 500
. Такая ситуация расценивается как провал пробы готовности, в результате Kubernetes выведет этот под из балансировщика нагрузки сервисов. На первый взгляд может показаться, что это разумно, но на самом деле такая практика иногда приводит к лавинообразным отказам. Тем самым мы, пожалуй, обнуляем одно из самых значительных достоинств микросервисов (изоляция отказов).
Представьте себе следующий сценарий: сервис аутентификации отказал, а все остальные сервисы, используемые в нашей компании, опираются на него при глубокой проверке готовности:
В таком случае из-за отказа сервиса аутентификации все наши поды будут удалены из сервиса балансировки нагрузки. Отказ станет глобальным:
Хуже того, у нас почти не будет метрик, по которым можно было бы определить причину этого отказа. Поскольку запросы не доходят до наших подов, мы не знаем, насколько нарастить все те метрики Prometheus, которые мы аккуратно расставили в нашем коде. Вместо этого придётся внимательно рассмотреть все те поды, которые помечены в нашем кластере как неготовые.
После этого нам придётся достучаться до конечной точки, отвечающей за их готовность, чтобы выявить, какая зависимость стала причиной отказа и далее проследить всё дерево. Сервис аутентификации мог лечь из-за того, что отказала одна из его собственных зависимостей.
Примерно так:
Тем временем, наши пользователи видят следующее:
upstream connect error or disconnect/reset before headers. reset reason: connection failure
Не самое качественное сообщение об ошибке, верно? Мы можем сделать гораздо лучше и сделаем.
❯ Итак, как же судить о готовности приложения?
Приложение готово, если может выдать отклик. Это может быть отклик, свидетельствующий об отказе, но в таком случае он всё равно выполняет часть бизнес-логики. Например, если отказал сервис аутентификации, то мы можем (и должны) сначала повторить попытку, запрограммировав при этом экспоненциальную выдержку, тем временем увеличивая значение счётчика отказов. Если любой из этих счётчиков достигнет порогового значения, которое вы сочтёте неприемлемым (в соответствии с SLO, определёнными в вашей организации), то вы сможете объявить инцидент с чётко очерченной зоной поражения.
Остаётся надеяться, что в это время отдельные сегменты вашей бизнес-системы смогут продолжить работу, так как не вся система зависела от этого отказавшего сервиса.
Как только инцидент исправлен, следует обдумать, нужна ли сервису данная зависимость, и что можно сделать, чтобы от неё избавиться. Например, можно ли перейти на такую модель аутентификации, в которой меньше сохраняется состояние? Можно ли использовать кэш? Можно ли при некоторых последовательностях пользовательских действий автоматически их прерывать? Следует ли вынести в другой сервис некоторые рабочие потоки, для которых не требуется столько зависимостей — чтобы в будущем успешнее изолировать отказы?
Исходя из разговоров с коллегами, могу предположить, что этот пост получится довольно холиварным. Кто-то может счесть меня идиотом уже потому, что я вообще реализую глубокие проверки работоспособности, так как естественно они могут приводить к лавинообразным отказам. Другие поделятся этим постом в своих каналах и спросят: «мы что, неправильно проверяем готовность?» — и тут в дискуссию вмешается сеньор и станет доказывать, что их случай особый, поэтому у них такие проверки целесообразны. Может быть, и так, в таком случае мне хотелось бы подробнее почитать о вашей ситуации.
Создавая распределённую систему, мы дополнительно её усложняем. Работая с распределёнными системами, всегда стоит проявлять здоровый пессимизм и сразу задумываться о возможных отказах. Я имею в виду, не ждать отказа постоянно, а просто быть готовым к нему. Нужно понимать взаимосвязанную природу наших систем и знать, что от единой точки отказа проблемы расходятся как рябь на воде.
Основной вывод из моей истории о Kubernetes — не отказываться от глубоких проверок работоспособности полностью, а пользоваться ими аккуратно. Здесь очень важен баланс: взвесить все достоинства подробных проверок работоспособности и соотнести их с вероятностью обширного отказа системы. Мы совершенствуемся как разработчики, когда учимся на своих ошибках и помогаем в этом другим. Так нам удаётся сохранять устойчивость, работая со всё более сложными системами.
— Мэтт
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩
? Читайте также:
Комментарии (2)
Grand_piano
23.09.2024 14:43https://habr.com/ru/companies/vk/articles/530752/ на мой взгляд эта статья более полно отражает существующие в k8s проверки работоспособности сервисов. Ну про выводы... да эта целая проблема "правильное" применения проверок работоспособности.
saboteur_kiev
Какой же ужасный слог и перевод. Содержит критичные неточности.
Что значит не учитываются?
Это пока приложение НЕ пройдет startup probe, остальные не учитываются. Специально для запуска долгозапускающихся приложений, чтобы они не были прибиты до полного старта.
В принципе для readiness/lieveness есть отдельная опция initialDelay. Но иногда удобно просто выяснить, что под запустился и неудобно пользоваться initialDelay с другими пробами. Или их вообще нет, поэтому добавили еще и startup пробу. После того как она один раз отработала, контейнер считается успешно запустившимся. Но она отрабатывает один раз и никак не отменяет другие пробы после этого.
Если нет никакой пробы, то как только контейнер создался и началось запускаться приложение, он считается успешно запустившийся, и на него уже может пойти трафик, хотя на самом деле приложение может крешнуться, недозагрузиться, или еще некоторое время быть не готовым для обработки запросов.
Если есть liveness или readiness, то контейнер считается удачно запустившимся, если хотя бы одна из них отработала успешно.