Считается, что запуск микросервисов изначально затратнее по времени, чем монолит, и наш опыт это подтверждает. Однако, если следовать проверенным процессам, эти затраты можно минимизировать. Делюсь лучшими практиками и составляю чек-лист запуска.
В red_mad_robot мы делаем полный цикл разработки ПО, в том числе полноценные бэкенды. Оставим за скобками холиварную тему «Монолит vs Микросервисы», и представим, что выбрана микросервисная архитектура. Отмечу, что многие пункты не содержат прямого совета делать только так и никак иначе, но призывают задуматься над выбранным решением. Также важно иметь в виду, что они могут быть очевидны или нерелевантны уже запущенным системам с налаженными процессами. Здесь речь пойдет именно про запуск новой системы, и советы как быстрее принять решение и ничего не забыть.
1. Монорепозиторий vs мультирепозиторий
Обычно старт проекта подразумевает несколько понятных микросервисов, которые, скорее всего, будут написаны на одном языке, и, возможно, реализованы одной командой. Удобнее всего держать все контракты в одном месте, а если они находятся в одном репозитории с кодом, мы избегаем проблем с синхронизацией или подменой локальных зависимостей.
В случае монорепозитория у нас появляется возможность унифицировать стадию сборки проекта (например, для Docker) и вести общий файл зависимостей для всех частей системы. Это ускоряет добавление новых микросервисов. По умолчанию мы выбираем монорепозиторий, если изначально не видим выгоды от мультирепозитория. С ростом количества микросервисов вопрос деления становится все более актуальным, но при общем их числе в 20–30 монорепозиторий весьма удобен.
2. Подумайте про архитектуру взаимодействия частей системы между собой
Микросервисная архитектура в самом простом виде предполагает набор бизнесовых сервисов (например, «Каталог книг» и «Продажи»), а также сервис Gateway, который предоставляет API клиенту и знает, как получить нужные ему данные. Конечно, Gateway лишь распространенный паттерн, но можно обходиться и без него; речь пойдет про взаимоотношения микросервисов между собой в целом. Представим, что клиенту необходим список книг, с указанием количества продаж каждой книги за месяц. Здесь можно использовать два варианта работы — оркестрацию и хореографию.
Оркестрация похожа на обход графа «в ширину»: сначала мы в сервисе Gateway получаем список книг из «Каталога», а затем по ID книг извлекаем нужные данные из «Продаж», объединяем их и отдаем клиенту. Если нам потребуются еще данные по пользователям, оформившим покупку, мы сделаем дополнительный вызов из Gateway.
Хореография похожа на обход графа «в глубину». В таком случае вся информация для Gateway целиком подготавливается в «Каталоге», где она обогащается нужными данными из «Продаж». В свою очередь «Продажи» предварительно также могут запросить что-то из смежного микросервиса для подготовки ответа, и так далее по цепочке.
Подход оркестрации уменьшает связность микросервисов, но быстро приводит к разрастанию Gateway, необходимости добавлять в него бизнес-логику и соблазнам. Самый простой пример — появление условий для сбора данных, когда в зависимости от параметров или состояния объекта мы выбираем, откуда и какие данные хотим получить. В итоге рискуем получить God-object и точку отказа всего приложения. Хореография позволяет держать Gateway максимально простым, но повышает связность внутренних сервисов. В своей работе мы балансируем между этими двумя подходами, но точка равновесия смещена вправо к хореографии.
Удобство API важнее сложности внутренней реализации. Ещё одно важное правило: API микросервиса нужно проектировать на основании потребностей в данных других микросервисов, которые его вызывают. Так мы везде сохраняем принцип «обхода в глубину», снижаем риск циклов или повторного запроса одних и тех же данных.
Для организации асинхронного взаимодействия обычно используется шина сообщений. Формально, общая шина — первый признак сервисной архитектуры, а не микросервисной, но это не мешает нам пользоваться ее преимуществами. Она хорошо подходит для поэтапной обработки информации, периодических задач и формирования логики реакций на неуспешные операции. Подумайте о бизнес-процессах и внедрении очередей с самого начала, тогда вам не понадобится сразу обновлять в базе некритичные сущности, и вы ускорите запросы. Но также подумайте, насколько для вас будет критично время ответа на старте.
3. Унифицируйте подходы
Стоит сделать шаблонный микросервис и заложить в нем:
договоренности по именованию в коде;
правила логирования;
правила построения API;
архитектуру и слои: модели, схемы, сервис, транспорт;
формирование конфигурации;
правила кодогенерации, если они предполагаются;
артефакты развертывания, например, манифесты k8s, или пример контейнера в docker-compose;
тесты, в идеале отдельно для бизнес-логики и отдельно для сообщений на уровне транспорта;
некоторые базовые операции-утилиты, которые будут использоваться несколькими микросервисами, например, работа с пользователем и любимые таймзоны.
Всё вышеперечисленное полезно описать в документе вроде README, это всегда помогает при вгрузе новых людей в проект. На первом этапе важно выделять достаточное время на код-ревью и следить за соблюдением всех этих подходов. С развитием проекта они растиражируются, договоренности закрепятся и вы перестанете про них думать.
4. Подумайте над межсервисной авторизацией
Самое простое — сделать Gateway единой точкой входа, и делегировать ему все вопросы аутентификации и авторизации: доверять запросам от Gateway и сразу оттуда считывать информацию о юзере, по крайней мере его id.
Этого будет достаточно? По нашему опыту, лучше сразу делать проверку данных через JWT. Это вопрос больше не про безопасность, а про защиту от случайных ошибок в коде, когда один микросервис случайно заменяет user_id инициатора. Такое, к примеру, встречается в запросах для админок, когда надо выполнить операцию для какого-то юзера от имени другого юзера.
Также для избежания ошибок при наличии ролевой модели — явно объявляйте для каждого метода необходимые права, и прокидывайте роль в JWT. Например, есть схожие методы для пользователя и администратора, и в спешке разработчик может случайно использовать вызов привилегированного метода от имени обычного посетителя.
5. Настройте процессы CI/CD
Сборка приложения не должна требовать от разработчика каких-либо усилий. Но когда микросервисов много, встаёт вопрос ускорения всех стадий. Мы используем подход liquid software, когда код выкатывается мелкими проверенными порциями сразу в master, а оттуда автоматически на dev контур.
Такой подход часто запускает стадии проверки линтерами, тестирование и сборку. Поэтому не забудьте при тестировании кешировать зависимости — можно использовать мануал для GitLab. Для сборки образа устанавливайте их слоем выше, чем вы копируете код. Также настройте правила, чтобы запускать пайплайн только для нужных микросервисов, для которых изменился код.
6. Подумайте над переменными окружения
Существуют специально предназначенные инструменты для работы с энвами, но далеко не всегда бывает время развернуть их на старте. Переменные окружения в файлах доступны с самого начала :). Подход «один файл на одну среду — dev, stage, prod» быстро приводит к огромному нечитабельному списку. Более удобное решение — один файл на один микросервис. Но оно приводит к дублированию переменных; логично вынести общие в еще один файл. В том числе в него же выносим внутренние адреса для общения между собой.
Поскольку обычно помимо конфигурации в окружение попадают и различные секреты, эти файлы не сохраняют в репозиторий, а помещают, например, в Gitlab CI Variables или Github Environment Secrets. Позаботьтесь о разработчиках, которые будут подключаться в будущем — сделайте шаблонный .env с необходимыми для запуска тестов переменными, и опишите в README весь процесс добавления новых энвов. Так вы избежите банальной проблемы — загрузили локально последние обновления и тесты перестали проходить, хотя у других все в порядке. Этот же .env можно использовать и в процессе CI.
7. Обратите внимание на методы API, возвращающие вложенные данные
Обычно фронтенд хочет получить максимальное количество данных в одном запросе. Если с бэкенда вы предоставляете вложенные модели или поля, которые формируются из нескольких микросервисов, это может привести к нежелательным эффектам.
Рассмотрим пример из пункта 2 с «Каталогом» и «Продажами».
Допустим, у нас GraphQL. Мы создаем type Book { title: String! }
, у которого объявляем поле sold_count
— количество проданных экземпляров. Фронтенд сам решает, какие поля ему нужны — он может запросить sold_count
, а может и не запросить. Скорее всего, вы прокидываете выбранные поля как Only в sql-запрос в базу. Нужно отделить атрибут sold_count
и явно обработать ситуацию его наличия или отсутствия, чтобы не ходить лишний раз в микросервис «Продажи» при вызове Book. А если помимо sold_count
у нас появляется еще одно поле из «Продаж», скажем — выручка, то непонятно, обрабатывать поле также отдельно или писать сложную логику, чтобы делать первый эффективный запрос.
В случае с REST проблема аналогичная, только более явная. Мы понимаем маршрут сбора данных с микросевриса для каждого запроса. Но фронтендеру могут понадобиться Books в каких-то сценариях вместе с sold_count
, а где-то без этого поля. Для оптимального выполнения нам нужно либо заводить схемы наподобие BookShort и BookFull, либо делать несколько похожих методов — здесь снова нет однозначного рецепта. Но точно стоит уделить внимание информированию фронтенда — в каком случае что использовать.
Теперь поговорим про многовложенность. Допустим, у продаж есть связь «покупатель», которая ведет в третий микросервис — «Пользователи». У книги есть список комментариев, каждый из которых, в свою очередь, также ведет к «Пользователям». Появилось желание в одном запросе получать список книг и вывести список комментариев к книге вместе с информацией о каждом покупателе. В случае использования REST здесь явно прослеживается пересечение двух вызовов к «Пользователям». Разумно их объединить в один, а затем смапить данные в нужных местах.
В случае предоставления GraphQL эта ситуация может произойти неявно. Например, если раньше был просто запрос со списком покупателей, а затем в схему ответа добавили список комментариев, не заметив, что схема комментария содержит в себе данные пользователя. Таким образом, это еще одно место для повышенного внимания. В конце концов, здесь точно стоит задуматься — а не будет ли удобнее разделить этот сценарий на два запроса на уровне API для фронтенда?
8. Создайте команду
Исторически микросервисы возникали как результат операции «распилить монолит», а типичным признаком её приближения служила нелинейно возрастающая сложность разработки. Тем не менее, микросервисы — удел не только крупных компаний с большими командами. Этот подход успешно применим и на старте проектов.
В этом тексте я затрагиваю много вопросов об архитектуре приложения, но технический успех проекта часто зависит еще и от организации работ. Взаимодействие разработчиков внутри команды между собой, а также с другими командами — тот «клей», который удерживает все аспекты вместе и приносит нужный результат.
Это отдельная большая тема, которую я не буду здесь затрагивать, но отмечу, что ежедневный комфорт в общении и доверие между участниками как внутри команды, так и с внешними заказчиками, дают значительный прирост в скорости работ. Поэтому, когда архитектура системы проработана, а правила работы с кодом в репозитории описаны — примените ваши лучшие наработки для создания сплоченной команды. Вместе вы сможете создать тот самый сервис, которым станут пользоваться миллионы и вам не придется думать, как справиться с неожиданной нагрузкой и что-то перепроектировать.