Strangler Fig: как переезжать с монолита на сервисы без даунтайма
За двенадцать лет в архитектуре я видел три классических способа разобраться с монолитом, который перестал помещаться в голову: переписать с нуля, заморозить и поддерживать минимально, и постепенно отращивать сервисы вокруг него. Первый вариант почти всегда заканчивается тем, что через год переписанная часть оказывается младше старой по фичам, а старая всё ещё работает в проде. Второй вариант — обычная инженерная капитуляция. Третий обычно называют Strangler Fig: переименование Мартина Фаулера прижилось, потому что метафора душащего фикуса хорошо описывает, как новый код постепенно обвивает старый и заменяет его, пока старый не отомрёт.
Идея простая на словах и неприятная на практике. На входе ставишь маршрутизатор, часть запросов уводишь в новый сервис, часть оставляешь в монолите. Через время доля нового растёт, доля старого падает, и в какой-то момент монолит выключается. Между этими крайними точками — длинный мутный период, в котором живут оба, и именно его инженеры обычно недооценивают.
Разберу, как этот переезд выглядит изнутри: где режется монолит, что ставится на входе, как быть с данными и общими таблицами, и какие ловушки регулярно встречаю в чужих миграциях, которые потом приходится разбирать.
Когда Strangler Fig правда уместен
Шаблон не панацея. Он стоит дороже, чем выглядит, и для маленьких систем чаще проигрывает обычному рефакторингу внутри монолита. Я применяю его, когда совпадают три условия.
- Монолит большой и идёт в прод не реже раза в неделю. Если релиз раз в квартал — сначала чините процесс, миграция не поможет.
- Внутри монолита есть видимые швы: модули, которые слабо связаны с остальными по данным и можно отрезать без боли через половину таблиц.
- Бизнес готов терпеть месяцы, а не дни. Strangler Fig — это марафон, где скорость замедляется к концу: последние 10% старого кода обычно самые липкие.
Если хоть одно условие не выполняется — лучше начать с менее радикальных шагов. Раздать модули по командам, ввести явные границы внутри одной кодовой базы, навести порядок с релизами. Это не хайповый микросервисный путь, но в краткосроке даёт больше пользы, чем три недели споров про gateway.
Шаг первый: найти швы, а не нарезать по слоям
Главная ошибка, которую делают, когда впервые читают про Strangler Fig — пытаются вынести «слой UI», «слой бизнес-логики» или «слой данных». Это не швы, это слои. Швы идут поперёк слоёв и режут по бизнес-функциям: «оформление заказа», «расчёт цены», «уведомления», «биллинг». Каждая такая функция должна тащить за собой свою кусочек UI, свой эндпоинт, свою логику и, в идеале, свои таблицы.
Поиск швов начинается с того, что архитектор и продакт садятся вместе и спрашивают: «если бы у нас было два релизных цикла, какие куски системы хочется отделить друг от друга?». Дальше эти куски сверяются с реальностью: какие из них действительно слабо связаны по данным. Связь определяется простым вопросом: сколько SQL-запросов или джоинов делает кандидат через таблицы соседа. Если кандидат на «биллинг» в каждом запросе джоинится с заказами и пользователями — это не сервис, это вытащенный наружу слой запросов.
Хороший шов выглядит так: модуль владеет своей таблицей, читает из чужих таблиц только идентификаторы и иногда денормализованные снимки, а пишет только в свою. Если найти такой кусок не получается, начните не с распила, а с приведения внутреннего модуля к этому виду внутри монолита. Это даст вам и проверочный полигон, и пользу даже если миграция дальше не пойдёт.
Шаг второй: маршрутизатор на входе
Без точки разветвления Strangler Fig не работает. Нужен компонент, который принимает запрос и решает, отдать его в монолит или в новый сервис. Варианты по сложности и контролю:
- Reverse-proxy: nginx, Envoy, Caddy. Минимум кода, маршруты по path или host. Подходит, если решение принимается по URL и не нужно лазить в тело запроса.
- API gateway: Kong, Tyk, AWS API Gateway, тот же Envoy с фильтрами. То же самое плюс рейт-лимиты, аутентификация на входе, заголовки. Удобно, если уже стоит и так.
- Свой тонкий BFF. Если нужно ходить в две системы и склеивать ответ, либо принимать решение по содержимому запроса. Пишется на Go или Kotlin за пару дней.
Я почти всегда начинаю с reverse-proxy и переключаю на свой BFF только когда стало явно тесно. Главное правило роутинга на этом этапе — он должен быть хорошо логгируемым. На каждый запрос пиши, куда он ушёл и почему: новый сервис, монолит, fallback после ошибки. Без этого через две недели вы перестанете понимать, какие фичи где живут.
location /api/orders/ {
if ($http_x_canary = "new") {
proxy_pass http://orders-svc:8080;
break;
}
proxy_pass http://monolith:8080;
}
location /api/billing/ {
proxy_pass http://billing-svc:8080;
}Этот пример нарочно простой. В реальности туда добавляется идентификатор клиента, флаги фич, процент канареечного трафика. Но логика та же: один путь — одно решение, явно записанное в конфиге, который кто-то ревьюит.
Шаг третий: данные — самая болезненная часть
Маршрутизация — это лёгкое. Сложное начинается, когда новый сервис должен читать и писать те же данные, что и монолит. Здесь развилка из трёх вариантов, каждый со своими компромиссами.
Вариант А: общая база данных на двоих
Самый простой и самый плохо стареющий путь. Новый сервис ходит в ту же базу, что монолит. Можно стартовать быстро, но дальше начинаются проблемы: изменения схемы согласуются между двумя командами, миграции идут через очередь, рефакторинг таблицы — повод для встречи на час. По моему опыту, общая база допустима максимум на квартал, как переходное состояние. Если задерживаетесь дольше, у вас уже не два сервиса, а распределённый монолит.
Вариант Б: change data capture или outbox
Новый сервис получает свою базу, а синхронизация делается через события. Монолит публикует изменения через outbox или CDC (Debezium на Postgres MySQL — рабочая связка), новый сервис их потребляет и поддерживает свою копию данных. Сложнее в эксплуатации, но даёт чистое разделение по данным.
Тут всплывает уже знакомая по другим темам идемпотентность. Каждое событие должно нести стабильный идентификатор, потребитель — хранить таблицу обработанных id, иначе перезапуски и ребалансы превратят данные в кашу.
Вариант В: новый сервис источник правды, монолит читает
Если функция уже выделена и почти не трогается старым кодом — переноси данные сразу. Делается в три такта: сначала пишем в обе системы, потом переключаем чтение на новую, потом отключаем запись в старую. На каждом такте есть точка отката, и каждый длится недели, а не часы. Этот путь самый правильный для долгой жизни и самый болезненный для команды, которая хочет результат завтра.
Шаг четвёртый: дублирующая запись и сверка
На переходный период почти всегда возникает дублирующая запись: одно и то же действие пишется и в старую, и в новую систему. Это нужно, чтобы можно было откатить трафик обратно в монолит, если новый сервис поведёт себя неожиданно.
Дублирующая запись без сверки — пустая трата ресурсов. Раз в час или раз в день должен идти процесс, который сравнивает данные в двух системах и кричит, если разошлись. Сверка не должна быть на бизнес-уровне: «совпадает ли сумма по клиенту». Она должна быть на уровне записей: «для этого id поле X в системе А равно полю X в системе Б». Иначе вы будете находить расхождения в проде через жалобы пользователей, а не через мониторинг.
func reconcile(ctx context.Context, ids []string) []Mismatch {
var out []Mismatch
for _, id := range ids {
oldRow, err1 := legacy.GetOrder(ctx, id)
newRow, err2 := svc.GetOrder(ctx, id)
if err1 != nil || err2 != nil {
out = append(out, Mismatch{ID: id, Reason: "fetch error"})
continue
}
if oldRow.Total != newRow.Total || oldRow.Status != newRow.Status {
out = append(out, Mismatch{ID: id, Reason: "field diff"})
}
}
return out
}Этот код намеренно тупой. На таких сверках не место для хитрой логики: чем проще, тем быстрее найдёте дрейф. Логи и метрики — обязательны, иначе через месяц никто не вспомнит, было ли расхождение и сколько.
Шаг пятый: удаление старого
Самая недооценённая часть Strangler Fig — финальное удаление старого кода. По моим наблюдениям, около трети миграций так и не доходят до этого шага. Команда переключила трафик, новый сервис работает, старая ветка кода висит, иногда даже принимает пару процентов запросов через legacy-эндпоинт, и так живёт годами. Это худший из миров: вы платите за два сервиса и одну непогашенную сложность.
Чтобы этот шаг состоялся, его нужно фиксировать в плане миграции с самого начала. У старой ветки кода должна быть дата выключения и владелец, который отвечает за то, что после этой даты вы реально удалите код, а не просто отключите роутинг. Перед удалением — две недели тишины, когда трафик в монолит формально нулевой, и метрики это подтверждают.
Если что-то всё-таки идёт в старую ветку, останавливайтесь. Скорее всего, есть забытая интеграция: внутренний скрипт, ночной отчёт, аналитический пайплайн. Их нужно найти и переключить, а не удалять старый код «через timeout».
Типичные ошибки, которые встречаю в чужих миграциях
- Отсутствие плана отката. На каждом шаге миграции должна быть кнопка «вернуть как было за час». Если её нет, команда боится переключать трафик и зависает на 10% канарейки месяцами.
- Слишком крупный первый кусок. Первая отрезаемая функция должна быть маленькой и непугающей. Не «оформление заказа», а «отправка email о подтверждении заказа». Цель — пройти весь цикл от шва до удаления старого кода и научиться, а не построить идеальный сервис на старте.
- Шаринг не только базы, но и кеша или очереди. Если новый сервис ходит в тот же Redis или Kafka-топик, что и старый, вы шарите состояние ещё в одном месте. Это потом всплывёт в инциденте.
- Игнорирование фоновых задач. Cron-джобы и ночные пересчёты обычно живут в монолите и о них вспоминают последними. План миграции должен включать их в первый день, а не в последний.
- Канарейка по случайному распределению, а не по клиенту. Если 5% трафика идут в новый сервис рандомно, один и тот же пользователь будет видеть разные ответы при перезагрузке страницы. По клиенту — стабильнее и проще дебажить.
Что выигрываешь, что теряешь
Выигрываешь возможность откатиться. В отличие от переписывания с нуля, у тебя на каждом этапе работает прод и есть путь назад. Команда учится в реальной нагрузке, а не в воображаемом будущем.
Теряешь время и простоту инфраструктуры. На переходный период у тебя две системы вместо одной, два набора метрик, два деплоя. Это нужно учитывать в оценке: не «через два спринта», а «через три-четыре квартала с уменьшающейся отдачей».
Главный вопрос, который стоит задать перед стартом — кто закроет миграцию, если старший инженер уйдёт через полгода. Strangler Fig сильно зависит от непрерывности владения. Если ответа нет, лучше отложить и вложиться сначала в команду, потом в архитектуру.
Что запомнить
Strangler Fig работает не как технология, а как процесс с обязательными шагами: швы, маршрутизатор, данные, дублирующая запись со сверкой, удаление старого. Если выпадает любой из них — мигрируете не вы, а монолит вас. План отката, маленькие первые шаги, явное владение и дата выключения — это не бюрократия, это то, что отличает законченную миграцию от двух систем, которые делят прод ещё пять лет.
Если будете применять — начните с одной маленькой функции и пройдите её до конца. Полученный опыт даст в десять раз больше, чем месяц обсуждений на тему «какой gateway выбрать».