• Bazel, Symfony и долгие миграции

    Зелёная лоза фикуса обвивает ряд освещённых солнцем греческих колонн

    Я никогда не умел подолгу сидеть в одном лагере разработчиков. Человек я любопытный, вопросы мои обычно заканчиваются кодом, а в резюме для этого есть удобное слово — «инженер».

    Недавно я играл в Gothic Remake. По сюжету нужно принять пару решений: выбрать лагерь и сторону. Но лагерь, выбранный в начале игры, — место, где ты живёшь и спишь, а не пожизненный контракт. Записался в Старый лагерь — всё равно можно брать заказы у наёмников из Нового и торговать с Болотным. А если Гомез слишком достанет, никто не мешает уйти.

    В разное время я занимался бэкендом и базами данных, писал фронтенд и управлял командой. Одной из самых нетипичных задач стал переезд большого монорепозитория с Bazel 7 на Bazel 9: со старого подхода с WORKSPACE на MODULE.bazel и bzlmod.

    WORKSPACE, если вы не трогали Bazel (вам повезло), — это старый способ объявлять внешние репозитории: корневой файл плюс макросы, которые он подгружал, и все внешние репозитории в них описаны вручную. На смену пришёл bzlmod: модули с версиями сами объявляют свои зависимости, а разрешением версий в графе занимается Bazel. В большом монорепозитории такая замена задевает куда больше, чем один конфиг. На старое устройство завязаны таргеты, тулчейны и CI, и пока ты меняешь механизм, релизы должны продолжать выходить.

    Когда я взялся за задачу, она выглядела почти неподъёмной. План «ничего не релизим, я мигрирую мир» никто бы не подписал.

    Тут мне вспомнился интернет-магазин, который я больше десяти лет назад переводил с самописного движка на Symfony. Движок был впечатляюще кривым: сплошные магические числа, функции с цикломатической сложностью за тысячу, ядро, рендерившее страницу по пять–восемь секунд. Я тогда писал об этой миграции и не подозревал, что та же идея вернётся ко мне через билд-граф.

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

    Strangler-подход поменял саму единицу работы. Вопрос «как заменить всю систему» превратился в «где провести одну маленькую честную границу». В магазине такой границей стал SSI-инклюд в NGINX: страницу по-прежнему рендерил старый движок, а один блок на ней приходил уже из нового приложения. Старый код продолжал работать, новый забирал ответственность по кусочку, и каждую границу можно было проверить отдельно.

    Через десяток с лишним лет миграция Bazel задала тот же вопрос снова. PHP на этот раз не было, зато были сотни внешних зависимостей, тулчейнов, билд-таргетов и CI-джоб. Java, TypeScript, Go, Python, Bash… Но суть та же: старой и новой системе нужно прожить рядом достаточно долго, чтобы ответственность переходила маленькими кусками.

    В Bazel 7 механизм сосуществования был даже встроен. Добавляешь пустой MODULE.bazel, включаешь bzlmod и переносишь зависимости по одной; правила репозиториев, до которых руки ещё не дошли, переезжают в WORKSPACE.bzlmod — его Bazel читает вместо WORKSPACE, пока включён bzlmod. Весь механизм по шагам разобран в официальном гайде по миграции.

    Чего в гайде нет, так это месяцев. Мой цикл был простой: перенести небольшой кусок, собрать затронутые таргеты и посмотреть, что реально изменилось, — lockfile, разрешённые версии, форма графа зависимостей. bzlmod вправе выбрать версии иначе, чем WORKSPACE, поэтому «вообще ничего не поменялось» — нереалистичный критерий успеха. Зато я мог ответить на вопрос поуже: объясняется ли каждое изменение тем куском, который я только что перенёс? Если нет — откат, и идём выяснять, что старый механизм делал у меня за спиной.

    Через пару недель сам цикл стал скучным. А вот выбор, где провести следующую границу, скучным не был: в гайде миграция аккуратная, настоящие репозитории аккуратными не бывают. У нас rules_docker не имел ни BCR-модуля, ни живых мейнтейнеров; тащить мёртвый набор правил через extension мы не стали и перевезли сборку образов на rules_oci: отдельная миграция внутри большой. Java-зависимости жили на старом механизме пинов: lockfile пришлось перепинить и проверить каждую версию, которая сдвинулась. Некоторые шаги отказывались быть маленькими; важно было, чтобы каждый по-прежнему можно было рассмотреть отдельно.

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

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

    Был и настоящий дедлайн: в Bazel 8 легаси-систему WORKSPACE отключили по умолчанию, а в Bazel 9 убрали совсем. Репозиторий, застрявший на «почти переехали», остался бы на Bazel 7 насовсем.

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