Искусство выбора: баланс между гибкостью микросервисов и простотой монолита

Логотип компании
Искусство выбора: баланс между гибкостью микросервисов и простотой монолита

Изображение: Smile Studio/Shutterstock.com

Эксперт расскажет об опыте компании CUSTIS по разработке приложений на основе микросервисной архитектуры, рассмотрит примеры, где целесообразно её использовать, а в каких проектах этого лучше не делать.

В начале 2010-х годов микросервисная архитектура произвела настоящий фурор в области разработки ПО. Тогда казалось, что найден идеальный рецепт для решения проблемы неповоротливости громоздких приложений, когда любое новое требование выливалось в месяцы работы.

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

Плюсы и минусы монолита и микросервисов

Микросервисная архитектура предполагает построение приложения как ряда частей, которые разворачиваются независимо друг от друга. Ключевой характеристикой такой архитектуры является её модульность: мы можем заменить любую часть системы, не оказывая влияния на другие её части. Противоположностью микросервисов является монолит — приложение, которое можно развернуть только целиком. При этом у каждого из этих подходов есть преимущества и недостатки.

Ресурсоёмкость

Главный минус микросервисов — необходимость выделения дополнительных ресурсов при работе с ними. Команде требуется значительное время на то, чтобы грамотно разграничить бизнес-области, выделить API, поднять инфраструктуру, которая позволит управлять разворачиванием сервисов и т. п. При разработке монолита затраты на это, как правило, ниже. Однако в долгосрочной перспективе грамотно спроектированные микросервисы позволят легче и дешевле вносить изменения в систему. В ряде случаев для доработок нужно будет погрузиться в работу только одного модуля, что снизит порог входа для новых членов команды.

Параллельная работа

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

Использование под разные проекты

С точки зрения вендора преимущество использования микросервисов состоит в том, что разные части такого приложения могут быть легко заменены под разных клиентов. Например, если двум компаниям необходимы принципиально разные бизнес-процессы в области Х, то вендор напишет два принципиально разных микросервиса, у которых будет одно API, но индивидуальная реализация. Кстати, сами заказчики также могут дорабатывать приложение, заменяя его части.

Взаимодействие частей системы

В микросервисах описание API позволяет верхнеуровнево смотреть на приложение и понимать, как его части взаимодействуют друг с другом. При этом API должно быть универсальным и подходить для реализации разных бизнес-процессов, а его проработка, как правило, требует дополнительных трудозатрат. А ещё могут возникнуть сложности с поддержкой сразу двух API при внесении изменений. В данном случае у работы с монолитом есть преимущество: лёгкое изменение взаимодействия разных частей системы, поскольку они связаны через вызовы функций, а не через API.

Разделение областей ответственности

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

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

Технологическая гибкость

С технологической точки зрения архитектура микросервисов позволяет легче внедрять новые технологии, например фреймворки или языки программирования. В случае монолита внедрение может быть затруднено, если это потребует большого объёма работ в моменте. В микросервисах же переход может быть инкрементальным, по потребности. Бонусом к технологической гибкости идёт дополнительная мотивация команды разработчиков, поскольку они получают возможность использовать последние версии фреймворков. А компания — диверсифицировать свои риски. Java-разработчики стали слишком дорогими? Давайте усиливать C#-команду!

Скорость компиляции

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

Сложность отладки

Быстрота компиляции, однако, оборачивается сложностями при тестировании функций, зависящих от других сервисов. Приходится использовать разработческий стенд, запускать другой сервис локально или поддерживать специальные скрипты, которые позволяют быстро развернуть весь пул микросервисов у разработчика на машине, например через docker-compose. У монолита нет такой проблемы — он целостен.

В целом можно сказать, что выбор между монолитом и микросервисами — это выбор между трудоёмкостью разработки и лёгкостью доработки. Монолит на начальных этапах требует меньших трудозатрат, поскольку позволяет не думать о грамотном разделении приложения на части. Это может быть большим плюсом, когда ещё не известен спрос на продукт, или проводится тестирование гипотезы. В этом случае микросервисы лишь затянут получение обратной связи. Но при большом объёме функционала они дают преимущество на длинной дистанции, позволяя сократить расходы на понимание кода и количество мест, где команда может потенциально допустить ошибку. Работа над микросервисами легче распараллеливается, позволяет более гибко использовать технологии, пробовать и выбирать подходящие.

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

Сложности и способы их решения

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

Проектирование архитектуры

Главная сложность, как правило, заключается в том, как оптимально разделить приложение на ряд модулей. Если нарезать их слишком мелко, то доработки будут приводить к изменению сразу нескольких из модулей вместо одного. Неоптимальная декомпозиция порождает дополнительные расходы, связанные с выставлением API, согласованным развёртыванием сервисов и т. д. Грамотно разделить приложение на части можно при помощи подхода Monolith First, который предполагает изначальное написание монолита и дальнейшую его декомпозицию на ряд модулей, границы которых уже сложились естественным образом. На всякий случай добавлю, что поручать проектирование микросервисов стоит лишь архитектору с соответствующими знаниями и опытом.

При работе с микросервисами важно спроектировать способы обеспечения целостности данных. В отличие от монолита, работающего с одной базой данных (БД), где целостность обеспечивается за счёт транзакций, микросервисы предполагают сразу несколько БД. Например, необходимо спроектировать работу системы на случай, если сервис, отвечающий за баланс клиента, списал средства, а сервис, отвечающий за бронирование заказа, упал. Для поддержки целостности данных микросервисов можно использовать такие паттерны, как Outbox и Caga. Outbox гарантирует, что сообщение уходит тогда и только тогда, когда вызывающая система успешно зафиксировала транзакцию. Принимающая сторона при этом должна реализовать идемпотентную обработку сообщений, поскольку очереди, как правило, не гарантируют, что сообщение будет доставлено один и только один раз. Saga предполагает, что каждый из сервисов последовательно выполняет необходимые действия и фиксирует свою транзакцию. Если какой-то из сервисов упал, то запускаются компенсирующие транзакции, которые откатывают каждую из частей в первоначальное состояние.

Ещё одна сложность, с которой сталкиваются при работе с микросервисами, — отображение данных на UI, когда в качестве источника выступают сразу несколько систем. Например, на интерфейсе заказов нужно отобразить информацию о складах, а также сделать фильтрацию и сортировку заказов по ним. Поскольку информация о заказах и складах лежит в разных БД, то применить фильтры и сортировку на уровне БД не получится. Один из способов решения проблемы — делать между системами дополнительные API, чтобы вначале получать отсортированные и отфильтрованные склады, а затем уже выполнять поиск заказов с учётом полученных ранее данных. Такой способ хорошо работает на небольших объёмах информации, а также с фильтрацией и сортировкой из одной-двух внешних систем. Однако если необходимо выводить на UI данных из множества систем, где их объём превышает десятки или сотни тысяч записей, необходимы другие решения. В одном их проектов мы реализовали это при помощи выставления View: если микросервису А требуются дополнительные данные из B для UI, то А делает Join на View, подготовленную в B. В нашем случае все микросервисы физически лежали в одной БД, но в разных схемах, поэтому фильтрация и сортировка были перенесены на уровень БД.

Разработка

На этапе разработки часто встаёт вопрос, как сократить дополнительные расходы на создание очередного микросервиса. Мы рекомендуем два способа: либо берём какой-то другой микросервис, копируем его, а затем чистим от всех доменных особенностей, либо делаем заготовку, из которой при помощи скриптов можно легко создать ядро нужного сервиса. Первый способ хорош, когда не предполагается большого разнообразия микросервисов. Второй — более трудоёмкий, но и более удобный при частых задачах по созданию новых модулей.

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

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

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

Близкой к проблеме синхронизации общих кусков кода я бы обозначил проблему синхронизации зависимостей. Как быть, если А и B зависят от некоего пакета Х, и вышла его новая версия? Лучшая практика — пересадить оба модуля на новую версию пакета Х одновременно. Это может быть дороже в моменте, но позволяет фокусно тратить ресурсы на пересадку. В конечном счёте дешевле пересадить все модули сразу (и поправить в них критические изменения), чем заниматься ими по очереди. Мы стараемся распространять зависимости через ядро, чтобы достаточно было сделать пересадку ядра на новую версию пакета, а уже после — пересадить все микросервисы на новую версию ядра.

Ещё одной сложностью, связанной с разработкой, может стать локальная отладка микросервисов, когда для работы сервиса А нужен B, а для него — ещё С и т. д. Эти зависимости не всегда видны в начале отладки. В этом случае мы рекомендуем использовать специально выделенный стенд, тогда разработчик запускает у себя лишь тот сервис, который он отлаживает, и если этому сервису нужны данные из других, то сервис обращается к указанному стенду. Либо подготовить скрипты, которые позволят быстро поднимать все нужные сервисы на машине разработчика. Сделать это можно, например, при помощи docker-compose. Лучше иметь оба инструмента в наличии, поскольку работа со стендом с точки зрения специалиста проще, но в некоторых специфических случаях невозможна.

Выпуск версий

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

При выпуске версий ещё может случиться проблема с миграцией данных. Бывают случаи, когда в начале должна быть применена часть миграций из сервиса А, затем — миграции сервиса B, после чего должны быть финализированы миграции А. В такой ситуации можно выпускать промежуточные версии и отслеживать установку этих версий. В нашем примере версию сервиса А следует разбить на 2 подверсии и выпускать микросервисы в такой последовательности: А.1, B, А.2.

Сопровождение

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

Помимо этого, важно обращать внимание на обработку ошибок. Например, ошибка произошла в сервисе А, но это повлияло на функцию из сервиса B, и ему необходимо сообщить пользователю какую-то информацию. В некоторых случаях эта информация должна содержать подробности ошибки А, в других случаях — нет. Здесь хорошей практикой будет возможность посмотреть стек вызовов между сервисами и найти модель, который первым упал и вызвал падение других модулей по цепочке.

Часто возникает вопрос, как можно оптимизировать растущее потребление ресурсов, ведь суммарно микросервисам по сравнению с монолитом нужно больше памяти и CPU. Увеличенный расход появляется за счёт того, что каждому сервису нужен свой контейнер, каждый сервис пишет технические логи и т. д. Мы рекомендуем изначально закладывать бюджет на соответствующие мощности, а также более внимательно следить за использованием ресурсов. Например, иногда приложение может писать неоправданно много логов, поэтому уменьшение их записи приведёт к меньшим потребностям в ресурсах.

Как вы видите, все проблемы, возникающие на любом их этапов работы с микросервисами, решаемы, но требуют привлечения специалистов более высокой квалификации и (или) написания некоторого дополнительного кода.

Как перейти от монолита к микросервисной архитектуре

Перед тем, как отвечать на вопрос как, давайте задумаемся о том, когда нужен такой переход. Переписывание монолита на микросервисы может понадобиться, если мы хотим перевести его на новые технологии, однако за один раз это сделать невозможно из-за высокой трудоёмкости. Ещё одна мотивация отказа от монолита в том, что наиболее нагруженные его части перестают справляться с нагрузкой, и нам необходимо масштабировать их, но сделать это бережно по отношению к ресурсам: не за счёт разворачивания всего монолита на нескольких серверах, а за счёт дублирования его наиболее нагруженных частей. Приведу несколько аспектов, на которые стоит обратить внимание при переходе от монолита к микросервисам.

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

  2. Подумайте о том, как будут взаимодействовать монолит и микросервис. Не всегда в legacy-код получится легко интегрировать принятые сейчас паттерны, могут понадобиться компромиссы. В некоторых случаях возможны фатальные ошибки. В частности, на одном из проектов мы столкнулись с тем, что периодически монолит намертво зависал, пытаясь вызвать микросервис. Расследование показало, что дело было в одной строчке кода, которая при стечении ряда обстоятельств блокировала все потоки монолита.

  3. Обучите людей. Если ваши архитекторы, разработчики, аналитики и инженеры сопровождения никогда не слышали о том, что такое шина, Outbox и т. д., то без обучения не обойтись. Им важно рассказать концепции, заложенные в паттерны, а не только механику их использования. По опыту, многие вопросы отпадают, когда люди понимают именно суть подхода.

  4. Используйте перевод монолита на микросервисы для мотивации команды. У многих ли специалистов есть строчка в резюме о том, что он участвовал в декомпозиции монолита? Это достаточно сложная в технологическом и организационном плане задача. Поэтому талантливые специалисты будут рады присоединиться к команде, чтобы поучаствовать в чём-то значительном.

Вывод

Как видно, микросервисы имеют не только плюсы, но множество минусов и сложностей в их использовании. Тем не менее, они дают гибкость и позволяют экономить ресурсы в долгосрочном плане. Микросервисы позволяют масштабировать только наиболее нагруженные части системы,а не систему целиком. Также доработка микросервисов при их грамотном проектировании дешевле доработок монолита за счёт того, что команда погружается лишь в небольшую часть приложения. Монолиты в ряде случаев предпочтительнее микросервисов, например при тестировании гипотез, в случае новых приложений, в которых пока сложно чётко очертить границы модулей. Без знания этих особенностей и не имея достаточного опыта, компаниям трудно сделать выбор. Мы в CUSTIS всегда подробно обсуждаем с клиентами индивидуальные особенности таких проектов, а также специфику предметной области, в которой они будут реализованы. И только после этого предлагаем наиболее подходящий вариант архитектурного решения с учётом нашей экспертизы и многолетней практики.

Как в жизни, так и в мире разработки выбор между микросервисами и монолитом может быть сложным. Представьте себе, что монолит — это бургер, который вы съедаете за один раз. Это быстро, просто и удобно, особенно когда вы голодны. Но микросервисы? Это как тарелка с разными ингредиентами этого бургера: котлета, помидоры, салат и соус. Каждый ингредиент может быть улучшен и заменён, в идеале без вреда для остальных. Вы можете наслаждаться каждым вкусом по отдельности или комбинировать их, создавая новые сочетания.

Так что, какой выбор сделать? Зависит от вашего аппетита. Но помните, что важно не только то, что вы выбираете, но и как вы это используете. Будь то микросервисы или монолит, главное — это создавать что-то вкусное и полезное для пользователей. Приятного аппетита и успешной разработки!

Опубликовано 30.04.2024

Похожие статьи