Монолог про отказоустойчивость микросервисных приложений, или Что может пойти не так?
Привет, меня зовут Антон Гращенков, я занимаюсь разработкой достаточно давно — больше 15 лет. Писал на С++, на Java, даже на ActionScript немножко. Успел позаниматься и мультимедиа, и восстановлением данных, а сейчас работаю в финтехе — лидом в Альфа-Банке.
Наши команды занимаются разработкой приложений для внутреннего пользования, которые помогают сотрудникам выдавать кредитные продукты: карты, кредиты наличными и всё, что с этим связано. Если приложение не работает, то сотрудники просто не могут выполнять свою работу — они теряют инструменты, которые используют.
Естественно это неприемлемо и мы тратим много сил, чтобы сделать их отказоустойчивыми. Мы работаем в Альфа-Банке с микросервисной архитектурой, поэтому и отказоустойчивость мы будем рассматривать для микросервисных систем.
Давайте сначала определимся с терминологией.
Отказоустойчивость — это свойство системы выполнять свои функции, даже если какие-то её компоненты перестали работать.
Работоспособность — способность системы выполнять свою функцию.
Отказ — потеря работоспособности.
Объединив всё вместе, получим, что отказоустойчивая система та, что продолжает выполнять свою работу, даже если отказали какие-то её компоненты или внешние системы, от которых она зависит. Пусть и с некоторой потерей эффективности или не в полном объеме.
Теперь давайте посмотрим, а что вообще может пойти не так.
Почему приложения вдруг перестают работать?
Упавший микросервис. Например из-за утечек памяти, ошибок в коде, исчерпания ресурсов или чего угодно ещё.
Неработающие БД. Практически любое приложение содержит какой-то слой хранения данных и он может отказать в самый неподходящий момент, например, из-за проблем на application server’e или из-за слишком высокой нагрузки.
Отказавшие каналы связи. Обычно все компоненты системы общаются друг с другом при помощи некоторых каналов связи. И это следующая точка отказа.
Вышедшие из строя внешние системы. Помимо того, что в приложении есть внутренние компоненты, мы часто зависим от внешних систем, на которые никак не можем повлиять. Например, это интеграция с социальными сетями или ПО, на которое вдруг закончилась лицензия.
Отказы в работе оборудования. Софт сам по себе не работает — ему нужно железо, и это тоже элемент неожиданности, как в моём случае с лодкой, звездами и восстановлением.
Если посмотреть на список того, что может пойти не так, становится немного грустно — ведь не на всё мы можем повлиять напрямую. Даже если мы будем писать идеальный код без ошибок, нет никаких гарантий, что лицензия на суперэффективный датагрид не закончится в самый неподходящий момент.
Следует признать, что одним кодом не обойтись. Проблема отказоустойчивости — комплексная. Раз она комплексная, то и решать её нужно на разных уровнях, используя комплексный подход. Таких уровней мы рассмотрим четыре.
Разработка — как писать код.
Архитектура — как строить системы из компонентов.
Инфраструктура — то, без чего мы не можем запускать наш код, потому что нам нужно железо.
Мониторинг — как понять, работает ли то что мы разработали, а если нет, то что вообще пошло не так.
Разработка
Начнём с того, что сделать отказоустойчивое решение не потратив на это кучу сил и времени невозможно.
Если мы хотим разработать действительно стабильное решение, которое сможет самостоятельно восстанавливаться после сбоев, то следует приготовиться планировать свои действия заранее, много думать о том, что именно мы пишем, какие проблемы могут возникнуть в каждом отдельном вызове, и что с ними можно сделать.
Благодаря современным языкам и IDE, нам достаточно просто понять где код может упасть. Гораздо сложнее понять как это обработать, можно ли что-то сделать чтобы продолжить работу? Критичен ли этот сбой?
Серебряной пули или универсального стартера, который можно подключить и дальше он все сделает сам, не существует.
Если мы решили сделать конкретный код отказоустойчивым, то помимо бизнес-логики, придется написать ещё примерно столько же кода для обработки различных ошибок и нештатных ситуаций.
Какого-то исчерпывающего списка правил как сделать всё правильно, нет. Но список с типичными точками отказа есть. Давайте посмотри, что мы с ними можем сделать.
Что в микросервисе может пойти не так?
Большая часть современных микросервисных приложений использует REST API для коммуникации между своими компонентами. Когда мы работали в монолитах (и сейчас некоторые приложения отлично в них вписываются), у нас весь код находился в одном адресном пространстве. Мы всегда были уверены что вызов метода некоторого сервиса точно произойдет, достаточно просто написать CardService.giveMeTheCard()
. Мы вызываем метод и он выполнится: может быть неправильно, может вернет ошибку, но выполнится. С распределёнными системами не всё так просто, у нас есть куча точек отказа
Удалённый сервис не запущен или не готов к работе. Когда система распределена, мы не можем быть уверены, что вызов, в принципе, дойдёт до адресата: сервис может быть просто потушен или не успел инициализироваться. Причина может быть любая, главное, что сервис не готов принять принять запросы и дать на него ответ.
Проблема с балансировщиком. Также в микросервисной архитектуре у одного микросервиса есть множество инстансов и оркестратор делает балансировку. Обычно таких балансировщиков несколько и каждый из них — это точка отказа, которая может ввести некоторую сумятицу в работу приложения.
Сервис перегружен и не может обработать запрос. Сервис может быть запущен, проинициализирован, но перегружен: слишком много трафика, запросов, у него просто нет ресурсов, чтобы взять ещё одну задачу.
Нет сетевой связности. Бывают печальные истории, когда экскаваторщик, который пытался найти прорыв в трубе, внезапно нашёл кабель к вашему дата-центру. В такой неприятной ситуации у нас просто не будет сетевой связности — мы физически не сможем сделать запрос.
Все остальные проблемы. Например, в Альфе был кейс, когда из-за сильного дождя затопило дата-центр и пришлось обесточивать оборудование. Мы остались без кучи серверов — неприятная ситуация.
Можем ли мы как-то это обработать? Конечно можем, причем вариантов у нас, как водится, больше одного.
Что с этим делать?
Повторить запрос. Первое очевидное и понятное решение. При этом вопрос «Как повторять?» не важен — у нас есть много вариантов вроде circuit breaker решения, resilence или spring retry. Можно настроить количество попыток, таймуты между ними и всё это малыми затратами.
Важнее «Когда?». Очевидно что повторять 4xx запросы нет особого смысла, скорее всего, они вернут ровно то же самое.
А вот вернувшие 502, 503, 504 и даже 500 запросы можно перезапросить. Если удаленный сервис использует авторизацию, то нужно быть готовым что он вернет 401 или 403 ответ и подумать что с ним делать. В случае OAuth, например, нужно получить новый токен, а при базовой аутентификации повторять запрос с теми же логином и паролем не имеет смысла.
Использовать ответ по умолчанию. Множество микросервисов используются для каких-то вспомогательных функций. Например, мы можем получать текущий город, в котором находится пользователь. Если мы ответ не получили — это не критическая ошибка, продолжим дальше, используя город по умолчанию.
Игнорировать. Но только в некоторых случаях. Чаще всего это сбор каких-то статистических данных, отправка метрик, запись логов в журналы. Не прошел вызов и не прошел, просто идём дальше по процессу.
Обработать авторизацию. Хочу отдельно обратить ваше внимание на ошибки, связанные с авторизацией. Мы практически повсеместно используем OAuth 2.0 и частый кейс, когда у нас просто заэкспарился токен. Естественно, нужно уметь обрабатывать такие ситуации и генерировать новые токены, повторять запрос.
Сообщать об ошибке так, чтобы вызывающая сторона сама могла их обработать. Если в случае любой ошибки мы просто будет возвращать 500, как потребитель будет их обрабатывать? Сможет ли он сохранить работоспособность и выбрать правильную стратегию по обработке ошибки? Как бы вы сами обрабатывали ошибки, если бы были потребителем вашего сервиса?
Например если сервис получает больше запросов чем может обработать, то кажется что 503 ошибка подходит куда лучше чем 500, ведь она явно сообщает потребителю что операцию можно попробовать повторить позже.
Но одними повторами не обойтись.
На что ещё обратить внимание?
Кэши, агрегирующие БД и прочее вспомогательное ПО. В больших системах обрабатывается огромное количество данных и, чтобы ускорить эту обработку, мы любим использовать кеши. В распределенных системах это обычно внешние распределенные кеши, типа Redis или Hazelcast. Это позволяет нам не забивать голову синхронизацией локальных кешей и прочими сложным штуками.
Но платой за это удобство становится ещё одна точка отказа. Что если Redis вдруг стал недоступным на 10 минут? Упадет ли сервис, который его использует, потому что не сможет проверить есть ли в кеше нужное значение? Вполне себе может. А должен?
Таких, повышающих эффективность, но не обязательных для работы компонентов, может быть много: кеши, логи, статистика, метрики, промежуточные БД для агрегации. Без них система может стать медленной или потеряется часть какой-то служебной информации, но она сможет выполнить основную свою функцию. Соответственно наша задача, как разработчиков, писать код так, чтобы он корректно обрабатывал такие отказы.
Не доступен кеш? Идём в БД.
Не доступна агрегирующая БД? Рассмотрим вариант хранить результаты в памяти.
Не можем отправить статистику или метрики? Рассмотрим вариант с накоплением её в очереди или обойдемся записью в логи об ошибке.
Сделаем запись в лог асинхронной, чтобы она поменьше влияла на основной функционал
Некорректный ввод, бесконечные циклы и мёртвые письма. Данным, которые приходят извне мы никогда не можем доверять, потому что не можем ими управлять.
Доверие ко входящим данным обычно приводит к ошибкам в конкретных запросах, но при неудачном стечении обстоятельств такая ошибка может загнать микросервис в бесконечный цикл.
Например, при чтении сообщений из Kafka, можно реализовать listener так, чтобы он коммитил оффсет после того, как прочитанное сообщение распарсено, обработано и сохранено в какую-то БД. На любом их этих этапов мы можем получить ошибку. Например:
сообщение не является валидным JSON’ом, который мы ожидаем;
или это JSON, но не той структуры что нам нужна;
или БД отвечает ошибкой на попытку вставки.
В любом их этих случаев коммит оффсета не произойдет, и в следующий цикл мы снова попытаемся прочитать сообщение из топика.
В некоторых случаях такое поведение нас полностью устраивает. Например, в случае кратковременной недоступности БД мы просто приостановим обработку топика до тех пор, пока она не поднимется. За сохранность сообщений при этом отвечает Kafka, что очень даже неплохо. Но вот если кто-то запишет в Kafka невалидный JSON, мы попадем в бесконечный цикл чтения битого сообщения, выбраться из которого самостоятельно сервис не сможет.
Чтобы избежать таких циклов смерти, нужно особенно внимательно относиться к входящим данным и обрабатывать ситуации, когда эти данные не корректны. Например можно использовать dead letter topic, в который будут отправлять битые сообщения для их дальнейшего анализа. Другим решением может быть отдельный журнал для таких сообщений или просто файл логов.
Реконнекты и ожидания. Если приложение использует сокеты (TCP, UDP или WEB), то ситуация закрытия сокета вполне вероятна. Даже больше — она частая, особенно, если приложение используется из мобильной сети. После закрытия сокета приложение может закрыться, а может продолжить работу, при этом не принимая и не отдавая трафик — перестанет делать свою работу! И если в первом случае мы просто получаем лишние перезапуски, то во втором — полноценный отказ системы, и для восстановления работоспособности придется вмешаться человеку.
Соответственно всегда нужно писать код, поддерживающий переподключение. Второй момент с сокетами — из-за особенностей протокола мы не узнаем о том, что соединение разорвалось, до тех пор пока не попытаемся что-то отправить. Соответственно, если требуется удерживать соединение (например для всяческих чатов на веб-сокетах), то нужно сразу продумать некоторый heartbeat механизм для контроля наличие соединения.
И ещё немного напоследок
Разумное количество защитного программирования. В написании кода нам помогает так называемое защитное программирование. Я выступаю за то, чтобы использовать этот метод в разумных количествах.
Это значит, что мы не параноим и не проверяем все подряд на null, но считаем, что данные, которые к нам придут, могут быть некорректными, а также сами не возвращаем заведомо некорректные данные, например пустые или null’ы. В общем, с уважением относимся к принципу устойчивости.
Так же каждый раз когда вы видите IOException — стоит подумать, можем ли сделать что-то кроме log.error («I can do nothing», e).
Статический анализ. Часто о потенциально опасных местах нас может предупредить компилятор или статический анализатор, такой, как SonarQube. Это определённо наши лучшие друзья, которые порой находят то, что мы сами почему-то пропустили. Пока мы печатаем код, он нам подсвечивает места, в которых может произойти ошибка, на которые мы можем обратить внимание, и понять, почему появляется предупреждение, и соответствующим образом отреагировать.
Тесты. Куда без них, очень люблю тесты, у нас их очень много. Например, нагрузочное тестирование (проводим для всей системы) позволит посмотреть, что будет с приложением при пиках нагрузки, а Unit-тесты помогают проверить, как работает наш код с ошибками и при этом не сломать стенды, с которыми работают другие люди.
Итого.
Когда мы пишем код, нам всегда нужно думать «Что будет, если какой-то из вызовов не пройдет?», «Можем ли мы как-то смягчить ситуацию?», «Можем ли мы перезапросить данные и соответствующим образом реагировать в своем коде?»
Теперь давайте поднимемся на уровень повыше.
Архитектура
Как спроектировать систему так, чтобы она старалась выживать как можно дольше, любой ценой пытаясь делать свою работу?
Разделяй и не падай целиком
Начнем мы, конечно же, с Domain Driven Design и выделения доменов. Наверное, все давно и уверенно пользуются стандартной трехзвенной архитектурой: controller —> service —> repository. Микросервисы, код которых организован в эти три слоя, проверены временем, их понятно как писать, читать и поддерживать. В микросервисах мы обычно используем vertical slice, т.е. выделение некоторого функционала в отдельные, независимые сервисы, отвечающие за определенную функцию.
Наша задача на этапе проектирования — разбить систему на отдельные изолированные домены, обменивающиеся стандартными сообщениями. И каждый такой домен в итоге станет микросервисом с понятной трехзвенной архитектурой. В итоге у нас будет свой сервис под каждую функцию, и падение одного из них, приведет лишь к отказу какой-то одной функции.
Например, мы перестанем выдавать кредитные карты, но будем продолжать их обслуживать, будем проводить переводы между счетами и оплачивать покупки. Конечно, это неприятно для тех людей, которые лишились возможности получить кредитку, но всех остальных пользователей это не затронет и принесет гораздо меньше ущерба.
Паттерны для работы с БД
Если система уже поделена на микросервисы, и это не просто распределенный монолит, а действительно набор слабосвязанных компонентов, то следующей, по популярности, точкой отказа будет БД. Если мне дадут незнакомый проект и спросят «А где здесь тонкое горлышко?», то я скажу «База данных» и, скорее всего, попаду.
А раз это потенциальная точка отказа, то наша задача смягчить последствия её выхода из строя. Именно поэтому в наших приложениях мы стараемся использовать паттерн Database per service, когда у каждого микросервиса/компонента есть своя база.
Это позволяет микросервисам хранить в своих базах исключительно те данные, с которыми они работают. Связи при таком подходе строятся при помощи внешних идентификаторов и строго установленных интерфейсов и контрактов. Такие системы проще масштабировать и обслуживать.
При этом, если вдруг БД по каким-то причинам станет недоступна, это повлияет лишь на работоспособность связанного с ней функционала, не затронув остальную часть системы. Побочным эффектом станет устранение бутылочного горлышка, ведь нагрузка на базу очевидно будет сильно меньше, чем если бы использовали одну БД на все микросервисы. К тому же можно использовать разные типы БД для разных сервисов, в зависимости от потребностей. Там, где важная изоляция и надежность можно использовать SQL решения, там, где требуется горизонтальное масштабирование — NoSQL базы данных.
Конечно могут (и будут) возникать проблемы распределённых транзакций, или агрегации данных. Но их тоже можно решить, например, воспользовавшись зарекомендовавшими себя паттернами типа CQRS, 2FC или распределенными блокировками. Паттерны хорошо описаны, проверены в деле и, в принципе, позволяют нам пользоваться Database per service и нивелировать проблемы.
Сюда же можно отнести и использование распределенных внешних кешей, таких как Redis или Hazelcast. Конечно, в первую очередь, они позволяют повысить эффективность работы с БД путем кеширования результата выполнения тяжелых запросов. Но в некоторых случаях кеш может нас спасти, если соединение с БД пропадает, а в кеше есть нужные данные. Особенно это полезно, если сами данные меняются относительно редко.
Конечно, сам кеш это также возможная точка отказа, но при правильном подходе мы ведь можем игнорировать его недоступность, а вероятность того что одновременно выйдут из строя и кеш и база гораздо ниже, чем каждого из компонентов по отдельности.
Каналы общения
Третье, что есть в архитектуре, это каналы связи.
Естественно, когда все со всем обменивается данными, главное — выбрать правильный канал связи и технологию. Когда мы говорим про микросервисы, то обычно в голове появляется REST. REST — это понятно, это удобно, мы все его знаем и любим. Сервисы с REST API очень просто писать и использовать, однако они не подходят для асинхронного взаимодействия или каких-то задач требующих обновления данных в реальном времени.
Если зашла речь про асинхронные механизмы, то вспоминаем про очереди RabbitMQ или Kafka. В целом, эти каналы коммуникации by design более отказоустойчивые, чем REST, использующий HTTP. Например если нам нужно отправить задание какому-то исполнителю и результат его выполнения не требуется здесь и сейчас, можно переложить работу по обеспечению сохранности и гарантированной доставки на Kafka, а не изобретать свой собственный протокол подтверждений. Создатели Kafka приложили много сил чтобы сделать её отказоустойчивой и масштабируемой, так что грех этим не воспользоваться.
Когда же мы говорим о синхронном обмене, например, когда хотим передавать данные с бэкенда на фронт, сразу приходят на ум websocket’ы: они модные, классные, мощные, с их помощью можно делать очень много всего.
Но если наши задачи простые, нам не нужен полный дуплекс, и мы просто хотим получить данные сервера, можно посмотреть на что-нибудь попроще, например, SSE. Да, он не позволяет гонять данные в обе стороны, но зато берёт на себя всю работу по переподключению. SSE — стандарт HTTP, и люди, которые продумали стандарт, уже сделали всю работу за нас.
Если пойти дальше, то можно реализовать event-driven архитектуру. При использовании событийного подхода вся интеграция построена при помощи маршрутизатора, когда нет прямых интеграций между сервисами, благодаря чему достигается слабая связность между ними. Если один из сервисов не доступен, вероятность того, что остальные выйдут из строя вслед за ним, снижается. Опять же используется хорошо проработанный и отлаженный транспорт. Конечно за это придется заплатить дополнительными сложностями как в разработке, так и в отладке и мониторинге.
Диаграмма компонентов
Когда мы смотрим на наше приложение с точки зрения архитектуры, всегда полезно иметь диаграмму компонентов. С её помощью мы можем посмотреть:
как компоненты взаимодействуют друг с другом;
как идет поток данных;
что будет, если один из кубиков выпадет из общей мозаики, развалится приложение или нет;
как починить;
как смягчить последствия.
Здесь появляются варианты стратегий, например, мы можем просто задублировать наше приложение в другом дата-центре и переключаться на него, либо можем использовать различные источники данных. Главное подумать об этих стратегиях.
Но начинать стоит с проработки вопроса »Где самые уязвимые места и как повысить их надежность? »
Если диаграмма выглядит как пирамида и все сходится в одной точке, очевидно эту точку следует как-то укрепить, как минимум, добавить больше инстансов.
Инфраструктура
Мы вдумчиво проработали архитектуру, потратили много сил и времени на код и готовы наслаждаться результатами своих трудов. К сожалению, пока что рано. Код в отрыве от железа работать просто не будет, ведь это лишь буквы в файлах, а чтобы системой кто-то пользовался, она должна быть запущена. А если вдруг что-то пошло не так — перезапущена. Давайте посмотрим что можно сделать на уровне инфраструктуры чтобы повысить время доступности приложения.
Наш друг оркестратор
Говоря об инфраструктуре, в первую очередь, будем говорить об оркестраторе.
Оркестратор — наш самый лучший друг, который берёт на себя большую часть работы.
Что он только не делает: и поднимает контейнеры, и следит, чтобы они не упали, и распределяет между ними трафик, и даёт нам доменные имена, по которым общаются сервисы. Много всего.
Но несмотря на всю свою мощь, даже ему требуется от нас помощь.
Readyness vs Liveness
Конечно, как разработчики, мы должны подготовить образы, чтобы они всегда были доступны оркестратору, но что ещё от нас требуется? Наверное, самое главное — это обеспечить механизм проб, чтобы условный K8S всегда мог понять, что происходит с нашим микросервисом: работает ли он вообще и способен ли принимать входящие запросы. Отвечают за это Readyness и Liveness пробы, и их настройка крайне важна для правильной работы кластера, в котором находится приложение.
Liveness пробы говорят оркестратору о том, жив ли контейнер в принципе, запущено ли в нем приложение. Если проба не проходит, то контейнер будет перезапущен. Если мы ничего не укажем, то в качестве пробы будет использован статус процесса с PID 1 — родительский процесс контейнера. Возможно это именно то что нужно, однако далеко не всегда, например первым запущенным процессом может быть некий инициализатор, тогда PID основного процесса будет отличным от 1. Или же процесс может быть запущен, но находиться в состоянии дедлока или бесконечного цикла, что делает его бесполезным с точки зрения выполнения возложенной на него функции.
Readyness пробы говорят оркестратору готов ли контейнер принимать входящие запросы. Например:
Контейнер может быть жив, но не готов работать, например, конкретно сейчас микросервис занят собственной инициализацией или прогревом кешей. Если на него перенаправить запрос, то он его гарантированно не обработает.
Другой вариант — контейнер полностью инициализирован, но достиг предела своих возможностей и пока не может обрабатывать входящий трафик. А когда закончит текущие задания — вновь сможет.
Readyness позволяет исключить такие контейнеры из балансировки, что, конечно же поможет сократить количество необработанных запросов. В общем случае, если сервис обрабатывает входящих трафик, то в нём всегда должна быть Readyness проба.
Это то, что вы всегда должны настраивать для контейнеров, иначе эффективность работы оркестраторы будет далека от оптимальной.
Примечание. В Spring приложениях есть отличный вариант — spring boot actuator, который позволяет нам предоставлять healthcheck endpoint’ы и кастомизировать логику их ответов.
Время старта контейнера и native решения на GraalVM
В микросервисной среде контейнеры могут создаваться и пересоздаваться достаточно часто. Хорошо бы, чтобы они это делали максимально быстро. Мало приятного если после запуска пода он несколько минут греет кеш и не способен принимать трафик.
Представим ситуацию: конец года, семьи отправились в магазин за подарками, происходит множество транзакций и растет нагрузка на нашу систему.
А теперь представьте, что система не справляется, не может обработать входящий трафик и оркестратор принимает решение, что нужно заскейлить приложение — добавить инстансов в поддержку. Оркестратор запускает новые контейнеры, но чтобы стартануть им нужно две минуты. Что произойдет?
Скорее всего за это время уже запущенные контейнеры не смогут обработать запросы, начнут отказывать и выдавать ошибки. Это может привести к тому, что вызывающие их микросервисы тоже начнут отдавать ошибки и все сложится как карточный домик.
Что можно сделать?
Самое простое — держать микросервисы компактными, и всю логику по инициализации выносить куда-то в асинхронщину.
Но можно пойти дальше и использовать такие технологии как spring native, GraalVM или Quarkus. Быстро стартующие поды дают нам возможность гибко реагировать на текущую нагрузку, добавляя инстансы, а значит, уменьшать количество необработанных запросов.
Разделение ресурсов и лимиты
Какая-то часть контейнеров в микросервисной архитектуре работает на одном хосте. Это значит, что хоть процессы в них и изолированы друг от друга, но они всё же разделяют общие ресурсы.
Самое неприятное, что может случиться, — не очень хорошо написанный и очень жадный сервис потребует к себе слишком много внимания в плане CPU и памяти. Что при этом произойдет с остальными подами? Да, они начнут страдать от нехватки ресурсов.
Чтобы такого не произошло мы всегда ограничиваем ресурс контейнера.
Это правило номер один. Если микросервис начнет потреблять больше положенного, оркестратор просто перезапустит микросервис и он не повлияет на все остальные. Пусть один микросервис упадет, но остальные продолжат свою работу.
Примечание. В первую очередь мы ограждаем остальные микросервисы от излишнего потребления памяти одним. Но в долгосрочной перспективе, естественно, мы переписываем микросервис так, чтобы он сам потреблял меньше ресурсов.
Откаты, бэкапы, релизы и восстановление
Последнее, о чём хочется сказать в разделе инфраструктуры — процедура обновления и план отката.
Здесь, наверно, самое главное — это инфраструктура как код. Для чего она нужна?
То, что мы задеплоили приложение в кластер, ещё не значит что мы его зарелизили. Могут всплыть ошибки конфигурации, не выловленные баги и что угодно ещё. Чтобы признать релиз успешны, за ним нужно некоторое время понаблюдать.
А если что-то всё же пошло не так, у нас всегда должен быть под рукой план Б — план отката. И лучше, если он будет в виде кода, а не записан на стикере перед монитором (хотя такой вариант все жё лучше, чем ничего). Иметь возможность откатить неудачные релиз так же важно, как и возможность его установить в автоматическом режиме.
Соответственно, инфраструктура как код наш главный помощник, потому что она нам помогает это сделать буквально в пару кликов. Кроме того, инфраструктура как код помогает нам переехать из одного кластера в другой, а также позволяет хранить историю изменений.
Для этого у нас есть множество инструментов: это и Ansible, и Helm, и Terraform и даже Puppet. Есть из чего выбрать и этим нужно пользоваться.
Заканчивая блок об инфраструктуре, подытожу, что здесь главное — подумать, как мы можем помочь нашему оркестратору, чтобы он хорошо выполнял свою работу.
Мониторинг
Итак, мы все подготовили, написали, задеплоили, теперь можно уже и расслабиться? Не совсем. Когда (не если) что-то сломается, это что-то нужно починить. А чтобы что-то починить нужно узнать, что оно сломано. А значит нам нужен мониторинг.
Просто посмотреть в дашборд K8S часто недостаточно — все контейнеры могут гореть зелененьким, всё хорошо, но бизнес-задачи не выполняются.
Типы мониторинга: что у нас с ресурсами, ошибками, метриками
Для такого мониторинга используются различные метрики, как технические так и продуктовые.
Технический мониторинг: потребление памяти и CPU, и количество успешных (с кодом 2xx) и неуспешных (с кодом 5xx) запросов.
Продуктовые метрики. Бизнес метрики специфичны для проекта и могут включать, например, количество уникальных пользователей или оформленных продаж.
Для каждой собираемой метрики заранее определяются пороговые значения, по достижению которых считаем, что что-то пошло не так. Например потребление ресурсов на уровне в 90%, количество запросов с ошибками на уровне 15% от общего количества запросов или не менее 100 оформленных кредитных карт в день. От приложения к приложению эти значения могут сильно отличаться.
Конечно же, чтобы эти метрики анализировать, предварительно нужно настроить их сборку, например при помощи Micrometer и Prometeus.
Графики
У нас есть ELK и Kibana в ней, Prometheus и Grafana, и, используя любимые средства мониторинга, мы можем рисовать графики. Графики позволяют нам просматривать изменения во времени, находить аномалии, которые мы иначе просто не сможем найти, и реагировать на них.
Допустим, у нас все работает, однако медленно, но верно, увеличивается количество потребляемых ресурсов. Это может говорить об утечке памяти, с которой нужно что-то делать. Может не прямо сейчас, но задуматься стоит.
Резкий скачок потребления ресурсов после релиза может информировать нас о проблеме, и лучше вернуть всё как было и спокойно разобраться, что пошло не так, чем дожидаться отказа в обслуживании и разбираться уже с ним.
Рост количества ошибок в запросах может указать на проблему с конфигурацией.
Иногда технические метрики находятся в пределах нормы, однако приложение начинает работать хуже, например количество продаж резко падает. В этом случае тоже может быть инициирован откат, а логи и любая другая информация, собранная во время аварии, позволит в дальнейшем расследовании и исправлении её причин.
Мониторинг работы приложения позволяет предсказать возможные аварийные ситуации, которые иначе заметить невозможно. Например, график среднего времени обработки запросов может очень медленно, но очень верно расти, указывая на накопление ошибок, приводящих к деградации сервиса, что в перспективе может привести к отказу в обслуживании.
Итого
Что касается мониторинга, всегда нужно думать и представлять как мы можем наблюдать за нашим приложением, как сделать его прозрачным, как понимать, что в нём происходит.
План действий
Ну и поговорив о всех этих четырех уровнях отказоустойчивости, вы, наверное, думаете «Складно стелишь, а делать-то что?»
А вот что.
Решить, стоит ли оно того?
Если у вас приложение, например, предназначено, чтобы трём вашим друзьям по утрам показывать мемы, то тратить силы, время и деньги отказоустойчивость не стоит. Мемы подождут.
А если приложением пользуются тысячи или миллионы людей каждый день, и от этого зависят их финансовые операции, тогда стоит.
Написать план
Если вы решили, что да, стоит, составим план.
На чем мы сконцентрируемся в первую очередь?
Что доставляет больше всего проблем?
Как оценивать результат?
А что делать следующим шагом?
Не обязательно расписывать работы на годы вперед, можно делить слона по частям.
Допустим, мы хотим начать писать правильный код, поэтому внедряем анализаторы, добавляем их наш пайплайн, чтобы плохой код не проходил Quality Gates. Например, мы не можем оформить пулреквест, пока код не прокатится по пайплайну, а один из этапов — это Quality Gate. Он прогоняет всё через SonarQube и считает, что всё подозрительное нужно исправлять. Я SonarQube подключаю даже в оффлайне, когда пишу код, чтобы лишний раз не ждать пока у меня пайплайн упадет.
Дальше добавляем метрики, чтобы оценить результат и понять — стало лучше или хуже после изменений? А в конце, естественно, запланируем следующий шаг — что будет, когда мы со всем закончим.
Совершенствоваться
Так мы плавно подошли к мысли об итеративном подходе (или цикле Шухарта-Деминга) в обеспечении отказоустойчивости для постепенного улучшения системы. Он, вероятнее всего, самый правильный, самый простой и понятный.
Мы сначала думаем, что хотим сделать.
Планируем.
Делаем.
Смотрим на результат.
Повторяем, пока приложение не достигнет н