Перейти к содержимому
Bitmason
Спасение проектов

Миграция монолита без катастрофы

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

6 мин чтения

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

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

Нужна ли миграция вообще

Начните с проблемы, а не с архитектуры. Фраза «нам нужны микросервисы» описывает решение. Проблема же обычно одна из этих:

  • Релизы идут медленно или с риском, потому что любое изменение затрагивает всё.
  • Одна часть системы должна масштабироваться совсем не так, как остальные.
  • Команды блокируют друг друга, потому что все работают в одном коде.
  • Часть стека больше не поддерживается, и обновить её на месте нельзя.

Запишите проблему и спросите себя, какое исправление самое дешёвое. Медленные релизы часто оказываются проблемой сборочного конвейера и тестов, а не архитектуры. Одну нагруженную точку API часто можно масштабировать кешем, очередью или репликой для чтения. Устаревший фреймворк иногда удаётся обновлять модуль за модулем внутри той же кодовой базы.

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

Найдите швы, прежде чем резать

Швом называют место, где систему можно разделить с наименьшими потрясениями. Хорошие швы проходят по границам бизнеса, а не по структуре папок. Ищите области, которые:

  • Говорят на своём языке. Если склад говорит «отгрузка», а отдел продаж «заказ» о связанных, но разных вещах, эта разница и есть граница.
  • Меняются по своим причинам. Код, который всегда правят вместе, должен жить вместе. Код, который меняется в другом ритме, можно рассматривать как кандидата на перенос.
  • Общаются с остальной системой через несколько понятных вызовов, а не через десятки общих таблиц.

Здесь помогает история версий. Файлы, которые меняются вместе в одних и тех же коммитах, показывают реальную связность, и она часто отличается от задуманной. Прежде чем выбрать первую часть, нарисуйте найденные зависимости, включая зависимости в базе данных.

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

Вытесняйте старую систему по одному маршруту

Паттерн «фикус-душитель» (strangler fig), названный Мартином Фаулером в честь растения, которое разрастается вокруг дерева-хозяина, остаётся самым безопасным общим подходом. Вы не переписываете монолит. Вы ставите что-то перед ним и по частям уводите от него трафик.

На практике это выглядит так:

  1. Поставьте перед монолитом слой маршрутизации. Это может быть обратный прокси, API-шлюз или тонкий фасад в самом приложении.
  2. Постройте за этим слоем новую версию одной возможности.
  3. Направьте в новую версию небольшую долю трафика или одну группу пользователей и сравните результаты со старой.
  4. Увеличивайте долю, пока старый путь в коде не перестанет получать запросы, а затем удалите его.

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

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

Решите, кто владеет данными

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

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

Прийти к этому можно только постепенно:

  • Выясните, кто что пишет. Перечислите все таблицы и весь код, который в них пишет. Беспокоиться стоит о таблицах, в которые пишут из многих мест.
  • Сначала сведите запись в одно место. Прежде чем переносить данные, сделайте так, чтобы сам монолит писал в каждую таблицу через единственный модуль. Одно это часто убирает половину связности.
  • Скопируйте, затем переключите. Дайте новому компоненту собственное хранилище, синхронизируйте его со старым (захват изменений данных, change data capture, или двойная запись со сверкой) и переключите чтение раньше записи.
  • Проверьте, что копии совпадают. Запустите задачу сверки, которая сравнивает старые и новые данные и сообщает о расхождениях. Не переключайтесь, пока она какое-то время не проработает без расхождений.

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

Выпускайте релизы и измеряйте сдвиги

Миграция, которая останавливает работу над функциями, за несколько месяцев теряет поддержку бизнеса. Продолжайте выпускать релизы всё это время. Флаги функций (feature flags) позволяют выкатывать перенесённый код выключенным и включать его, когда он готов. Короткоживущие ветки и небольшие pull request не дают старому и новому коду разойтись.

Несколько привычек помогают команде оставаться честной:

  • Договоритесь о примерном соотношении усилий между миграцией и функциями и пересматривайте его на каждом планировании.
  • Новые функции в области, которую сейчас переносят, пишутся в новом коде, а не в старом.
  • У каждого среза есть откат, который действительно проверен.

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

Когда подходит модульный монолит

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

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

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

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

С чего начать

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

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

Вопросы по статье

Сколько обычно длится миграция монолита?

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

Нужно ли замораживать новые функции на время миграции?

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

Можно ли мигрировать без полного набора тестов?

Да, но перед переносом каждой части напишите вокруг неё характеризационные тесты (characterisation tests). Они фиксируют, что старый код делает сегодня, и позволяют доказать, что новый делает то же самое.

Нужны ли в итоге миграции микросервисы?

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

  • Платформы

    Мультитенантная архитектура SaaS: как разделить данные клиентов

    Илья Исматов5 мин чтения

  • Инженерия

    Как выбрать стек для SaaS с жёсткими требованиями комплаенса

    Илья Исматов5 мин чтения

Что вы создаёте?

Расскажите о своём проекте. Мы свяжемся с вами, чтобы обсудить объём работ, сроки и первые шаги.

Начать