Принципы построения микросервисов
Для поддержания качественной кодовой базы необходимо следовать некоторым принципам при построении очередного сервиса и внедрения его в эксплуатацию. Принципы, которые описаны ниже позволяют достигнуть вполне конкретных свойств сситемы:
- Отказоустойчивость. Вся сситема, состоящая из набора сервисов может справляться с кратковременными проблемами, такими как отказ некоторых частей или подсистем.
- Масштабируемость. Система способна адаптироваться (в ручном или автоматическом режиме) к изменению характера нагрузки (при повышении нагрузки - использоваться больше ресурсов для обработки запросов, при понижении нагрузки - освобождать бездействующие ресурсы).
- Модульность. Каждый сервис в системе должен выполнять строго определенный набор задач с доменом, которым он управляет.
Экосистема#
Для решения части проблем, связанных с деплоем и управлением сервисами необходимо использовать kubernetes. Если решение использовать сервисы и тем более с сильными дроблением (то есть большое количество сервисов), то без окрестратора данных сервисов не обойтись.
Помимо этого в инфраструктуру входит набор сервисов, которые необходимы для поддержки:
- Prometheus. Сбор и хранение метрик приложений, запущенных на платформе.
- Grafana. Интерфейс для просмотра метрик из Prometheus.
- Jaeger. Трейсинг, карта связей сервисов.
- ELK. Хранение логов.
- Системы алертинга. Создание алертов с оповещениями на основе метрик.
Определение необходимости в новом сервисе#
При разработке системы необходимо руководствоваться следующими принципами для определения необходимости в создании нового сервиса:
- Несколько подсистем имеют дублирующую логику внутри себя. Так, сервис отправки смс, почты, пуш уведомлений в конечном счете станут проверять частоту отправки (не раздражать пользователя, не тратить много денег), расписание уведомлений ( не разбудить пользователя), использовать шаблоны уведомлений (не загружать внутреннюю сеть). Это означает, что данная логика может быть обобщена, инкапсулирована в отдельном сервисе (сервис уведомлений).
- Требования законов. Так, законы связанные с хранением и обработкой ПД, PCI-DSS и других сенситив данных регулируют разделение хранилищ и ПО, которое с ним взаимодействует в отдельные подсети на спец оборудовании. Работая с чувствительными данными необходимо консультироваться с юристами и безопасниками.
- Сложные или повторяющиеся флоу работы с сущностями. Если сервис обработки заказов начинает пополняться функционалом слежения за статусом заказа, обрастать функционалом, не связанным напрямую с сущностью заказа - необходимо выделить данные процессы в отдельный сервис и проинтегрироваться с ним.
- Рост команды разработки. Если для разработки и поддержки очередного сервиса необходимо наращивать команду (допустимый размер команды определяется ситуативно), как следствие учащать процесс релизов, дописывать корнер-кейсы для поведения - скорее всего это означает, что сервис берет на себя много ответственности за сущности, которыми он не должен владеть. Необходимо проанализировать ответственность сервиса, ограничить его зону ответственности и выделить отдельный сервис, отвечающий за часть работы текущего.
- Недопонимание функционала сервиса. В случае, если становится понятно, что один человек не способен разобраться в сервисе и взять его на поддержку с пониманием всех особенностей его работы - этот сервис довольно большой и возможно нуждается в разбиении на несколько.
Особенности построения сервисов#
- Каждый сервис должен иметь документированное API. Только через данный API с сервисом допускается коммуницировать, отправляя запросы и получая ответы согласно контрактам
- Каждый API сервиса должен иметь версионирование на уровне endpoint. Это означает, что каждый endpoint имеет свою версию. ручки выглядят в формате /api/action/v1. Таким образом повышается версия только конкретных ручек при изменении конкретных ручек, но не всей системы.
- Каждый сервис обязан масштабироваться горизонтально. Это означает, что кэширование состояния в памяти чревато проблемами неконсистентности состояния. Необходимо контролировать, что кэшируемые (в памяти) объекты допускают неконсистентное чтение устаревших данных.
- Каждый метод на любое изменение данных обязан быть идемпотентным (по возможности), что означает, что повторный вызов данного метода не приведет к повторному исполнению запроса. При необходимости - каждый подобный запрос должен сопровождаться correlationID. Это необходимо для того, что бы вызывающая сторона могла без проблем повторить запрос, ответ на который она не смогла получить по любым причинам.
- Сервис обязан предоставлять общие инструменты:
- Логирование. Все логи в json формате на стандартный вывод. Оттуда логи должны забираться инструментами инфраструктуры.
- Метрики. Информация для prometheus о количественных изменениях в системе. В метрики входят информация о внешних запросах (кол-во, время, ошибки.), внутренних операциях(по усмотрению команды разработки), бизнес объектах и операциях (рост кол-ва пользователей, объектов, статусов)
- Трейсинг. В условиях роста количества сервисов необходимо иметь понимание пути исполнения запроса, а так же того, какие подсистемы/сервисы были затронуты в процессе выполнения операции. Jaeger может помочь в определении этого.
- Инциденты. Sentry как система для слежения за инцидентами.
- Профайлер/дебаггер. Некоторые проблемы можно разобрать только в условиях работающего под определенной нагрузкой сервисе. Для этого необходимо иметь возможность подключиться к нему удаленным дебаггером или получить снимок состояния процесса для отложенного анализа.
- Liveness/readiness пробы. Строго необходимы для корректной работы системы.
- Информация о сборке. Для определения проблемы обязательна информация о версии исходного кода, номера сборки и любой другой информации, строго указывающей на то, кто и когда создал данную сборку.
- В особенных случаях необходимо расширять набор инструментов или набор данных, которые они возвращают для улучшения уровня поддержки.
- Graceful Shutdown. Сервис обязан корректно обрабатывать сигнал останова. При получении сигнала необходимо запретить прием запросов (установить ready=false), дождаться завершения обработки всех запросов, корректно закрыть все используемые ресурсы и соединения и завершиться.
- Каждый сервис обязан реализовывать политики повторных запросов. Circuit breaker с растущей задержкой и лимитом на кол-во повторов. Таким образом контролируется количество запросов к внешним, деградированным системам, что предоставляет им время на восстановление работоспособности. Переотправка запроса в общем случае помогает избежать проблем с работой сервиса, который в данный момент обновляется или недоступен по другим причинам.
- Каждый сервис при получении запроса обязан устанавливать лимит на время обработки запроса. При превышении данного лимита - обработку запроса необходмио форсированно завершать. Таким образом контролируется отсечка бесполезной работы в условиях перегрузки.
- Миграции, необходимые для запуска определенной версии приложения должны быть включены в процесс запуска сервиса. То есть выполняться они будут каждый раз при запуске очередного инстанса. необходимо убедиться, что при запуске двух инстансов не произойдет проблемы с двойным выполнением миграций. Для этого можно захватить эксклюзивную блокировку на таблицу миграций.
- Миграции строятся таким образом, что бы после их выполнения, предыдущая версия приложения могла корректно работать. Таким образом, при запуске новой версии инстанса, старая может корректно обрабатывать запросы. При необходимости создания миграции, затрагивающей старую версию - необходимо создать новую версию ПО, которая может работать с новой и старой схемой, раздеплоить ее, далее создать миграции и раздеплоить их.
- Каждый сервис обязан работать с токенами в каждом запросе (кроме запросов инфраструктуры). Токен может быть выдан пользователю, внешней системе или другому сервису. В зависимости от политики, необходимо выполнять авторизацию данного запроса с данным токеном на каждом методе.
Внутреннее устройство сервиса#
Каждый сервис должен быть построен по определенному шаблону. При необходимости опредедиться с инсрументом шаблонизации ФС и бутстрапить проект из основного шаблона.
Пример набора слоев для типовых сервисов:
- Storage. Реализует подсистему хранения. Отдельный пакет для манипуляции данными в хранилище. Каждая операция минимальна, состоит из одного запроса, имеет минимальный интерфейс, использует определенные в данном пакете ошибки для сообщения о конкретных проблемах. Возвращает свои структуры - модели сущностей бд или более специфичные, кастомные для данного метода структуры данных.
- Cache. Подсистема для работы с кэшем. Может быть как локальным, так и распределенным. Может иметь простой или сложный интерфейс, но как компонент - должен быть выделен в отдельный пакет и может использоваться различными слоями приложения.
- SDK. При разработке любого проекта неизбежно появляются инструменты, которые необходимо реализовать, но которые можно переиспользовать в других проектах. Например алгоритм балансировки, сортировки, логгеры, метрики и прочее. Такие компоненты необходимо отправлять в подпакет sdk. Пакеты из набора sdk в дальнейшем можно выделять в отдельные библиотеки в инфраструктурных репозиториях. Клиенты для доступа к внешним ресурсам (клиент для доступа к соседнему сервису) первоначально должен располагаться в директории sdk. Далее, при необходимости использовать его в другом сервисе - он переедет в общий репозиторий.
- Domain. Слой, который отвечает за логику работы данного сервиса. Обращается к Storage, Cache, 3rd-party сервисам. Предоставляет наружу публичный апи (внутренний апи - набор методов/привязанных функций к объекту Domain). Этот набор методов должен покрывать все возможности данного сервиса. Для выполнения любой операции запрос неизбежно должен попасть в один из методов домена.
- Facade. Реализует интерфейс коммуникации с внешним миром. Если наружу сервис хочет предоставить json api - фасад его реализует и интегрирует с доменом. Если необходимо еще и grpc - фасад реализует и интегрирует и его так же.
Подписки#
Для реализации механизмов подписки (например, на обновление информации о состоянии заказа) необходимо использовать очереди. При использовании очередей необходимо избегать создания своих велосипедов и при возможности пользоваться готовыми решениями типа kafka. Так же по возможности необходимо избегать случаев, когда требуется отправка большого (по количеству полей) объекта в кафку, так как это накладывает неудобства на интеграцию с определенным топиком другим клиентам. Идеальный кейс - отправка нотификаций о том, что объект типа A с идентификатором X был подтвержен операции O ( DELETE/CREATE/UPDATE).
Общие советы при работе с сервисами#
- Необходимо учитывать, что взаимодействие по сети - очень медленное, а ее пропускная способность ограничена. Необходимо минимизировать работу с сетью путем выбора более эффективных протоколов (grpc/msgpack), сжатия (для текстовых протоколов типа json), не передавать данные, которые не являются обязательными (разбивать большие ручки на несколько маленьких).
- Даже на тестовых площадках необходимо запускать каждый сервис в количестве реплик более 1.
- Каждый сервис обязан иметь описанные requests/limits для его корректного планирования на физические ресурсы.
- Сервис, который работает с предсказуемо большой таблицей(коллекцией) должен уметь в партиционирование/шардирование с самого начала.
- Если для большой таблицы частые обращения будут только за последними данными (последний час/день/месяц) - необходимо предусмотреть индексы только за этот период или механизм архивирования/переноса в другую таблицу данных из рабочей таблицы.
- Prefork модели работы (в частности uwsgi, gunicorn) могут иметь проблемы в момент останов сервиса. Так, при старте приложения, gunicorn создает обработчики с приложением в отдельных процессах, которые имеют отдельные идентификаторы, о связи которых с основным процессом (pid=1) не знает оркестратор (k8s) и подсистема контейниризации (Docker). Это может привести к тому, что либо дочерние процессы не получат сигнал останова, либо к тому, что докер посчитает приложение остановленным, как только основной процесс будет завершен. Необходимо внимательно выбирать и настраивать application server. с Celery такая же проблема.
- Падение сервиса - “это нормально”. В случае непредвиденных проблем, можно завершать процесс и перезапускаться. С таким поведением в случае серьезных проблем система мониторинга дополнительно укажет на большое количество перезапусков сервиса, а оркестратор перезапустит сервис в любом случае. Так же в случае наличия бага с утечкой памяти или других накапливаемых проблем - есть шанс, что приложение сможет продержаться в таком режиме перезапуска до момента, пока ошибка не будет устранена в новой версии.