Заболел – не значит умер

Привет! Это Сергей Калинец из Parimatch Tech, и мы будем говорить про устойчивость наших с вами сервисов.

4918c8c3b60c68f37172191aa6b68b73.jpg

Long time ago

Когда-то давно, на старте моей карьеры, была занятная история. Тогда мы писали десктопные приложения на Delphi для Windows, и у нас был мем «что-то не так». Как обычно, мем смешной, но ситуация страшная. Одна разработчица открыла для себя исключения, а именно возможность их перехвата. И для того, чтобы в приложении не было ошибок, позаворачивала все, что можно, в try / catch, в котором просто отображалось модальное окно с надписью «что-то не так». Что именно было не так, оставалось тайной, и для расследования нужно было подключать тяжелую артиллерию — звать разработчиков. Ошибка происходила на компе пользователя, логов конечно же никаких не было и поиск причин напоминал гадание на кофейной гуще. В общем это было весьма наглядное пособие, почему важно иметь информацию о сбоях.

Потом основной фокус моей работы перешел на бекенд сервисы. Там уже появился .NET, IIS, Windows сервисы. Передача ошибок через диалоговые окна уже не работала (хотя были веселые случаи, когда на удаленном хосте сервис вываливал окно с сообщением и отказывался продолжать работу, пока кто-то не нажмет кнопку OK. Пикантности добавлял тот факт, что делать это можно было только физически находясь около сервера.) Нужно было писать логи, потом в них смотреть и разбираться что же шло не так. Но в эпоху монолитов это тоже было относительно просто. У вас было одно приложение, которое могло разрабатываться годами. Оно работало на сервере с каким-то крутым именем типа PETROVICH, с которым команда разработки была хорошо знакома (а иногда можно было и посмотреть на него в серверной). Ошибки фиксились рестартами сервиса, для анализа подключались к удаленном рабочему столу, деплоили с помощью копирования файлов. В общем была ламповая, домашняя атмосфера. А потом наступили микросервисы. 

Микросервисы

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

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

  • избежание потери данных во время сбоя;

  • быстрое и надежное восстановление работы после сбоя;

Для обеспечения устойчивости придумали ряд паттернов и подходов (для детального изучения вопроса я настоятельно рекомендую книгу Release It!). Их можно применять в самом коде сервисов и в инфраструктуре, а можно комбинировать.

Проверка работоспособности

Чтобы начать решать проблему, нужно про нее, как минимум, узнать. Как понять, все ли хорошо с сервисом? Самый простой вариант — пока процесс запущен, все ОК. Но это подходит не для всех случаев. Процесс может выполняться, но ничего полезного не делать. Пример такого поведения — deadlock внутри главного цикла. Хорошо бы дать сервису возможность как-то анонсировать свое состояние, чтобы внешние агенты (человеки и другие сервисы) могли соответствующим образом реагировать на его изменение. Такая возможность реализуется через так называемые проверки работоспособности (по английски это health checks и можно было бы перевести как медосмотры :)), и существуют ѓотовые решения для основных стеков, на которых пишут микросервисы. Для дотнета, например, можно посмотреть детали в официальной документации. Часто в такие проверки добавляют статусы зависимостей, исходя из того, что если база недоступна, то и сервис не может быть работоспособным и должен отдавать статус «нездоров». 

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

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

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

Оба этих действия можно делать вручную, а можно и автоматизировать. Оркестраторы, такие как Kubernetes (как будто бывают еще какие-то :)), как раз этим и занимаются. Рассмотрим же, как это делает Kubernetes.

Kubernetes

При описании манифеста сервиса можно указать так называемые пробы (probes). В двух словах, это проверки, которые выполняются по заданному расписанию, могут возвращать один из двух статусов (работает / не работает) и имеют определенную семантику. Они бывают трех типов:

  • liveness (жив ли братишка?). Если такая проба не проходит, оркестратор перезапускает экземпляр сервиса;

  • readiness (готов ли к работе?). В случае отрицательного результата оркестратор убирает экземпляр из списка балансировщика нагрузки (load balancer);

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

Есть интересное заблуждение по поводу последних двух проверок. Может показаться, что это одно и то же, но это, конечно же, не так. Readiness запускается не только на старте, а периодически в течение жизни экземпляра. Этот факт является сюрпризом для многих разработчиков, которые ожидали, что оно работает только при старте. И такая проверка может помочь, когда экземпляр «занят» работой настолько, что не может принимать новых запросов. В этом, кстати, readiness очень похожа на liveness — обе запускаются по определенным интервалам во время жизни приложения. Startup же запускается именно на старте, и как только вернет зеленый свет, больше не используется. Служит она для решения проблемы долгого старта, которой подвержены некоторые фреймворки (та же Java или .NET). Там сервисам требуется определенное время на инициализацию, в течение которого они не очень дружелюбно относятся к входящим запросам. При плотном трафике, если сразу после старта открыть новый экземпляр для входящих запросов, можно отгрести большое количество ошибок.

Кстати, еще одно типичное недопонимание состоит в том, что в случае kubernetes и readiness и startup пробы работают только с трафиком, который заходит на экземпляр через балансировщик, например, входящий http (тут я все слегка упрощаю, но сути это не меняет). Для так называемых рабочих процессов (workers), которые обрабатывают сообщения Kafka или RabbitMQ, обе эти пробы никак не ограничивают поток этих сообщений. Да, поды, которые эти проверки не прошли, будут отображаться, как «не готов», но при этом могут вполне себе нормально работать. 

Реальность

Итак, мы узнали (или освежили память — кому как) про проверки работоспособности сервисов и возможности оркестраторов реагировать на изменение состояния сервисов. И тут возникает вопрос –, а что если это все объединить и получить на выходе полностью автоматизированное обеспечение устойчивости сервиса? Звучит отлично, но не все так просто (иначе не было бы этой статьи).

Представим, что у нас есть веб сервис, который может масштабироваться и работает с базой данных. Как мы можем повысить его устойчивость? Для упрощения рассмотрим сценарий, когда соединение с базой нестабильно. Классический путь это добавить проверки работоспособности и натравить на них пробы кубера (liveness и readiness). Повысит ли это устойчивость сервиса? Как ни странно — нет.

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

Liveness. Если база недоступна, сервис будет перезапущен. Спасет ли это ситуацию? Скорее всего, нет. Обычно соединение с базой устанавливается для каждого запроса. Если бы мы ничего не делали, то получали бы ошибку в случае недоступной базы и ожидаемый результат, когда база отвечает. С liveness пробой мы по сути будем получать то же самое + рестарты сервисов. А рестарты сами по себе приносят потенциальные проблемы — пока сервис стартует, он может возвращать ошибки, даже если с базой все ок (мы обсуждали это выше, для startup проб). В добавок, кубернетес добавляет интервал между перезапусками и этот интервал может увеличиваться в случае нескольких неуспешных запусков. А это приведет к еще большим задержкам. Получается, что пользы особой нет, а вот потенциальный вред есть.

Хорошо, тогда может readiness нам поможет? Действительно, алгоритм простой: база недоступна — трафик на экземпляр не пускаем; база появилась — открываем ворота. Что может пойти не так? Ну, во-первых, пробы выполняются с заданным интервалом, а значит мы можем получить ситуацию, когда трафик открыт, а база лежит, или же наоборот — трафик закрыт, а база живет. Оба варианта генерируют ошибки, и не добавляют устойчивости. Во-вторых, пробы запускаются на всех экземплярах и проверяют один и тот же ресурс. Это может привести к тому, что мигание базы будет делать недоступными все экземпляры. А в этом случае клиенты будут получать 503 ошибки от балансировщика, который не сможет найти ни одного доступного сервиса. И диагностировать такие ошибки будет сложнее (нужно пройти цепочку 503 от шлюза → сервисы недоступны → проблемы с доступом к базе). Если бы никаких проб не было, ошибки тоже были бы, но там было бы явно видно, что проблемы с базой. Опять получаем отсутствие пользы и наличие вреда.

Кстати, последний аргумент справедлив и для liveness проб. Вообще есть рекомендация не использовать статусы внешних сервисов для проб. Пробы должны проверять только локальный статус. Например, liveness может просто слушать эндпоинт без логики, типа /ping или /info. Если они перестанут отвечать, это значит, что что-то сломалось в сервисе и этот конкретный экземпляр не может обрабатывать входящие запросы. Или же такая проба может проверять локальных файл, в который сервис периодически сохраняет текущее время. Если при проверке мы получим устаревшее время, это может означать, что внутри сервиса произошел какой-то deadlock, который сломал как минимум один периодический процесс, а значит, вполне мог поломать что-то еще. Обе проблемы отлично лечатся рестартом проблемного сервиса. 

Может возникнуть вопрос –, а что, если мы внутри сервиса поместим логику, которая будет отвечать на вопрос «сломалось ли что-то, что можно починить рестартом»? Тогда можно подцепить к ней liveness пробу и вуаля. Но на самом деле, если у нас есть такая логика, то помощь оркестратора ей абсолютно не нужна — она вполне может просто завершить процесс, вызвав какой-нибудь exit (). В результате количество запущенных экземпляров станет меньше, чем нужно, и kubernetes поднимет новый. Произойдет ровно то же самое, что и с liveness пробой, но это потребует меньше движений и рестарт случится быстрее (не нужно будет ждать, пока сработает проба). Такой подход улучшает общую устойчивость сервиса, потому что минимизирует время его пребывания в поломанном состоянии. У RedHat есть отличная инструкция, какие пробы и когда нужны, там, правда, по английски, но с картинками и очень доходчиво все рассказывается.

Что дальше?

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

Ответ — паттерны стабильности, о которых мы упоминали в начале статьи. Эти паттерны позволяют уменьшить влияние проблем с внешними зависимостями на устойчивость наших сервисов. 

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

А пока пишите в коменты, что мы упустили и где не правы :) Спасибо за внимание и всем стабильности!

© Habrahabr.ru