Разбиваем монолит на два микросервиса и реализуем CI/CD
Когда команда растет, а функциональность монолита расширяется, поддерживать и развивать такую систему становится все труднее. Разработчики начинают тратить много времени на разбор сложных зависимостей, а внесение изменений в одну часть приложения может неожиданно повлиять на работу других компонентов. К тому же масштабирование монолита часто приводит к неэффективному использованию ресурсов, т.к. приходится разворачивать всё приложение целиком. Разбиение монолита на микросервисы помогает решить эти проблемы за счет изоляции функциональности в отдельные компоненты. При этом очень важно правильно определить границы будущих сервисов и спланировать процесс миграции так, чтобы не нарушить работу существующей системы. В этой статье я расскажу про свой опыт разделения монолитного приложения сервиса блогов на два микросервиса с одновременным внедрением практик непрерывной интеграции и доставки (CI/CD). Мы рассмотрим весь процесс от анализа существующей кодовой базы до настройки автоматизированных пайплайнов развертывания. Большое внимание уделим работе с данными при разделении сервисов. Это одна из самых сложных задач при переходе к микросервисной архитектуре - ведь нужно определить, как разделить базу данных и обеспечить согласованность данных между сервисами. Я поделюсь с вами практическими решениями, которые помогли справиться с этими вызовами в реальном проекте. Также рассмотрим технические тонкости настройки CI/CD для микросервисной архитектуры: как организовать независимую сборку и развертывание сервисов, настроить автоматизированное тестирование и мониторинг. На реальных примерах я покажу конфигурацию GitHub Actions для автоматизации этих процессов. Анализ исходного монолитаПрежде чем приступать к разделению монолита, нужно тщательно проанализировать текущую архитектуру системы и определить потенциальные границы между будущими сервисами. В моём случае исходное приложение представляло собой блог-платформу с функциями создания постов и комментариев к ним. Первым делом я составил карту зависимостей между различными компонентами системы. Для этого использовал популярный инструмент Structure101, который помог визуализировать связи между классами и пакетами. Оказалось, что функционал работы с постами был достаточно изолирован от остальной части приложения - он взаимодействовал с другими модулями только через четко определенные интерфейсы. При анализе кодовой базы выяснилось, что приложение изначально было спроектировано с использованием подхода "package by feature", где каждая функциональная область выделена в отдельный пакет. Это значительно упростило задачу выделения границ между будущими сервисами. Модули постов и комментариев имели минимальные пересечения на уровне бизнес-логики. Особое внимание пришлось уделить анализу схемы базы данных. У нас была единая MongoDB база для хранения всех данных приложения. При этом коллекции постов и комментариев были связаны через идентификаторы, но не имели сложных связей или каскадных операций. Это позволило относительно просто разделить данные между будущими сервисами. Для валидации предполагаемых границ сервисов я проанализировал паттерны доступа к данным с помощью мониторинга запросов в production-среде. Выяснилось, что операции с постами и комментариями действительно представляют собой независимые потоки данных с минимальным пересечением. Интересным моментом стало обнаружение неявных зависимостей через анализ логов. Например, выяснилось что некоторые служебные операции, такие как очистка устаревших данных, неявно полагались на синхронный доступ к обеим коллекциям. Такие места потребовали дополнительного внимания при проектировании взаимодействия между будущими сервисами. Важную роль в анализе сыграло и изучение бизнес-процессов. Оказалось, что функционал работы с постами имеет свой собственный жизненный цикл, не зависящий от других частей системы. Посты создаются, редактируются и публикуются независимо от комментариев. Это стало еще одним аргументом в пользу выделения их в отдельный сервис. После завершения анализа я определил два основных кандидата на выделение в отдельные сервисы:
Для проверки правильности такого разделения я применил метрики связности и сцепления из теории объектно-ориентированного проектирования. Компоненты, связанные с постами, показали высокую внутреннюю связность (cohesion) и низкое сцепление (coupling) с остальными частями системы, что подтвердило целесообразность их выделения. В процессе анализа также были выявлены потенциальные проблемные места, которые потребовали особого внимания при миграции:
Результаты анализа легли в основу плана миграции и помогли избежать многих потенциальных проблем на этапе реализации. Тщательное изучение существующей системы - ключевой фактор успеха при разделении монолита на микросервисы. Для определения оптимальных границ между сервисами я также применил принципы предметно-ориентированного проектирования (DDD). Анализ бизнес-процессов показал, что посты и комментарии представляют собой отдельные ограниченные контексты (bounded contexts) с собственными бизнес-правилами и жизненным циклом. При картировании предметной области выяснилось, что пост является агрегатом, содержащим такие сущности как заголовок, содержание, метаданные и статус публикации. Комментарии же образуют отдельный агрегат со своими правилами валидации и модерации. Между этими агрегатами существует только связь по идентификатору поста, что упрощает их разделение. Для визуализации связей между различными частями системы я использовал методику Event Storming. Во время сессий моделирования стало очевидно, что большинство бизнес-событий, связанных с постами (создание, редактирование, публикация), не требуют синхронного взаимодействия с другими компонентами системы. Это подтвердило возможность их асинхронной обработки в отдельном сервисе. Особое внимание я уделил анализу технического долга в существующем коде. С помощью инструмента SonarQube были выявлены проблемные участки с высокой цикломатической сложностью и дублированием кода. Оказалось, что много сложной логики сконцентрировано вокруг процессов модерации контента. Это натолкнуло на мысль о необходимости рефакторинга этой части перед разделением сервисов. При проектировании будущей архитектуры я столкнулся с интересной проблемой - как организовать поиск по контенту, который теперь будет распределен между разными сервисами. Решением стало использование паттерна CQRS (Command Query Responsibility Segregation) с отдельным сервисом для поисковых запросов, который агрегирует данные из обоих сервисов. Анализ производительности показал, что операции с постами генерируют значительную нагрузку на базу данных, особенно во время публикации большого количества материалов. Выделение этой функциональности в отдельный сервис позволит лучше масштабировать систему под конкретные нагрузки. Важным этапом стало исследование существующих интеграционных тестов. Многие из них проверяли сквозную функциональность, затрагивающую как посты, так и комментарии. Потребовалось продумать стратегию разделения тестов и создания новых проверок для межсервисного взаимодействия. Для оценки влияния разделения на пользовательский опыт я проанализировал основные пользовательские сценарии. Выяснилось, что большинство операций с постами (создание, редактирование) не требуют немедленной синхронизации с комментариями. Это позволило реализовать эвентуальную согласованность между сервисами без ущерба для удобства использования. Отдельного внимания заслужил анализ механизмов аутентификации и авторизации. В монолите использовалась единая система управления доступом. При разделении потребовалось спроектировать механизм проверки прав доступа для межсервисного взаимодействия, учитывая вопросы безопасности и производительности. В процессе анализа я также уделил внимание метрикам и мониторингу. Существующая система логирования была тесно связана с монолитной архитектурой. Для будущей распределенной системы потребовалось продумать новый подход к сбору и анализу метрик, включая трассировку запросов между сервисами. Архитектура микросервиса в Docker контейнере Монолит micro SD распиновка в обход контроллера Написание микросервиса Монолит-протокол как способ обхода ограничений на распространение информации Стратегии разделенияПроцесс разделения монолита на микросервисы требует продуманной стратегии, которая позволит провести миграцию без прерывания работы существующей системы. В моём случае я выбрал поэтапный подход с постепенным выделением функциональности в новые сервисы. Первым шагом стало создание отдельного репозитория для сервиса постов. При этом я не стал сразу удалять код из монолита, а использовал паттерн "странглер" (strangler pattern). Суть подхода в том, что новый сервис постепенно "обвивает" существующую функциональность, перехватывая всё больше запросов, пока полностью не заменит старый код. Реализация паттерна странглер началась с настройки прокси-слоя, который мог перенаправлять запросы либо в монолит, либо в новый сервис. Это позволило гибко управлять маршрутизацией трафика и постепенно увеличивать нагрузку на новый сервис. Для этого я использовал простой конфигурационный флаг в nginx, который определял процент запросов, направляемых в новый сервис. Работа с общими данными потребовала особого внимания. Вместо единовременного разделения базы данных я применил подход с дублированием данных. Новый сервис получил собственную базу PostgreSQL, но при этом данные о постах какое-то время продолжали храниться и в монолите. Это позволило обеспечить плавный переход и возможность быстрого отката в случае проблем. Для синхронизации данных между старой и новой базой я разработал систему событий. Каждое изменение в данных порождало событие, которое обрабатывалось обеими системами. Такой подход обеспечил эвентуальную согласованность данных в период миграции. При этом я столкнулся с интересной проблемой - как обрабатывать конфликты при одновременном изменении данных в обеих системах. Решением стало использование временных меток и стратегии "последний победил". Сложным моментом оказалась работа с транзакциями, затрагивающими оба сервиса. Например, при удалении поста нужно было также удалить все связанные комментарии. В монолите это решалось просто - через единую транзакцию. В распределенной системе пришлось применить паттерн Saga, где каждая операция имеет компенсирующее действие в случае сбоя. Для реализации саг я использовал очередь сообщений RabbitMQ. Каждый шаг транзакции публиковал события о своём состоянии, а координатор саги отслеживал успешность выполнения всей цепочки операций. В случае сбоя на любом этапе запускались компенсирующие действия, возвращающие систему в согласованное состояние. Версионирование API стало еще одним важным аспектом миграции. Я решил сразу заложить поддержку нескольких версий API в новом сервисе, используя семантическое версионирование. Это позволило постепенно переводить клиентов на новые версии без риска сломать существующую функциональность. Особое внимание я уделил обработке граничных случаев. Например, что делать с запросами, которые начали обрабатываться в монолите, но должны завершиться в новом сервисе? Для таких ситуаций был разработан механизм передачи контекста запроса между сервисами с сохранением всей необходимой информации о состоянии операции. При разделении сервисов я также столкнулся с проблемой распределенного кэширования. В монолите использовался единый Redis для кэширования часто запрашиваемых данных. При разделении пришлось пересмотреть стратегию кэширования и внедрить механизм инвалидации кэша между сервисами. Интересным решением стало использование асинхронной репликации данных между сервисами. Вместо попыток поддерживать строгую консистентность в реальном времени, я сделал ставку на эвентуальную согласованность. Это значительно упростило архитектуру и повысило устойчивость системы к сбоям. В процессе миграции неожиданно проявилась проблема с мониторингом - существующие метрики были завязаны на монолитную архитектуру. Пришлось разработать новую систему сбора метрик, учитывающую распределенный характер системы. Я внедрил распределенную трассировку запросов с помощью Jaeger, что позволило отслеживать путь запросов через все сервисы. Для обеспечения отказоустойчивости я применил паттерн Circuit Breaker. Каждый вызов между сервисами оборачивался в предохранитель, который отслеживал ошибки и автоматически прерывал связь при превышении порога сбоев. Это предотвращало каскадные отказы в системе и давало время на восстановление после сбоев. Важным аспектом разделения монолита стала работа с очередями сообщений. Для обмена данными между сервисами я выбрал Apache Kafka из-за её высокой производительности и надёжности. Каждый сервис публикует события об изменениях в своей доменной области, а другие сервисы подписываются на релевантные для них события. Например, при создании нового поста сервис постов публикует событие PostCreated. Монолитный сервис, отвечающий за комментарии, подписывается на эти события и обновляет свой локальный кэш информации о постах. Такой подход позволил значительно ослабить связи между сервисами и сделать их более автономными. Для организации постепенного перехода я разработал систему переключателей функциональности (feature toggles). Это позволило включать новые возможности для ограниченного круга пользователей и быстро откатываться в случае обнаружения проблем. Переключатели реализованы через распределённый конфигурационный сервис etcd, что обеспечило мгновенное применение изменений на всех узлах системы. В процессе миграции данных между сервисами я столкнулся с проблемой обработки больших объёмов информации. Простое копирование всех данных могло привести к длительным простоям. Я разработал процесс инкрементальной миграции, где данные переносились небольшими порциями во время низкой нагрузки на систему. Один из неожиданных вызовов - обработка долгоживущих транзакций. В монолите некоторые операции могли длиться несколько минут, затрагивая разные компоненты системы. При разделении на микросервисы такие операции пришлось переработать с использованием паттерна Process Manager. Координатор процесса отслеживает состояние длительной операции и управляет её выполнением через несколько сервисов. Интересной задачей стала организация тестирования в период миграции. Я создал набор интеграционных тестов, которые параллельно проверяли работу функциональности как в монолите, так и в новом сервисе. Это позволило удостовериться, что обе реализации возвращают идентичные результаты. Для упрощения отладки межсервисного взаимодействия я внедрил распределённое логирование. Каждому запросу присваивается уникальный идентификатор корреляции, который передаётся через все сервисы. Это позволило собирать полную картину обработки запроса даже в распределённой системе. Серьёзное внимание я уделил обработке частичных сбоев. В распределённой системе всегда есть вероятность, что один из сервисов временно недоступен. Для таких случаев я реализовал стратегию graceful degradation - система продолжает работать с ограниченной функциональностью, предоставляя пользователям базовые возможности. Немало времени ушло на проектирование механизма согласованного кэширования между сервисами. Я применил подход cache-aside с инвалидацией через события. Когда данные изменяются в одном сервисе, он публикует событие об изменении, которое приводит к инвалидации соответствующих записей в кэше других сервисов. В процессе разделения я также занимался оптимизацией сетевого взаимодействия. Для уменьшения латентности между сервисами был внедрён протокол gRPC, который обеспечил более эффективную передачу данных по сравнению с классическим REST API. Это особенно помогло в случаях, когда требовалось передавать большие объёмы данных между сервисами. Отдельного внимания заслуживает реализация механизма отката изменений. Каждая значимая операция по миграции данных или функциональности сопровождалась планом отката. Это включало в себя скрипты для возврата данных, откат конфигурации маршрутизации и восстановление предыдущей версии кода. При проектировании взаимодействия между сервисами я старался следовать принципу "умные конечные точки, простые каналы связи". Вся бизнес-логика сосредоточена в сервисах, а обмен данными происходит через простые, хорошо документированные протоколы. Это упростило отладку и снизило сложность системы в целом. Реализация CI/CDПосле разделения монолита на микросервисы необходимо организовать процесс непрерывной интеграции и доставки (CI/CD) для каждого сервиса. Это позволит автоматизировать сборку, тестирование и развёртывание приложений, обеспечивая быструю и надёжную доставку изменений в продакшен. Для автоматизации процессов я выбрал GitHub Actions из-за тесной интеграции с GitHub и простоты настройки. Первым делом создал базовый пайплайн для сборки и тестирования кода:
Важным аспектом стала организация параллельного выполнения тестов. Длительные интеграционные тесты я распределил между несколько job-ов, что позволило сократить общее время прогона пайплайна:
Для упрощения отката изменений я реализовал систему blue-green deployment. При развёртывании новой версии сервиса создаётся параллельная инсталляция, и только после успешной проверки работоспособности происходит переключение трафика:
Для обеспечения надёжности развёртывания я внедрил систему канареечных релизов. Новая версия сервиса сначала получает небольшой процент трафика, который постепенно увеличивается при отсутствии ошибок:
Практический примерТеперь рассмотрим конкретную реализацию разделения монолита на примере блог-платформы. В исходном приложении функционал постов был тесно связан с остальной системой через общую базу данных MongoDB. При разделении мы перенесли его в отдельный сервис с собственной базой PostgreSQL. Первым делом настроили межсервисное взаимодействие через REST API. Для создания поста клиент отправляет запрос в новый сервис:
Разбиваем на массивы Разбиваем задачу, на подзадачи Строку разбиваем на слова Является ли нормальной практикой создание микросервиса семафоров? Назначить/узнать порт микросервиса под IISNODE Разбиваем винт на несколько томов... но как?? На слова разбиваем на предложения никак ...есть идеи? Реализуем себя вместе! Реализуем дрожание объекта Реализуем сериализацию для текстового редактора! Реализуем децимацию с дробным коэффициентом (уменьшение частоты дискретизации) WCF реализуем пинг сервера и клиента(контроль соединения) |