Перевірка міграцій БД «up → down без слідів» у CI: пакети Migration Checker для Symfony та Laravel

💡 Усі статті, обговорення, новини про DevOps — в одному місці. Приєднуйтесь до DevOps спільноти!

Вітаю! Мене звати Олександр Рослов. Я програміст із 12-річним досвідом техлід-позицій. Регулярно працюю з PHP-проєктами, де міграції є щоденною реальністю: десятки змін схеми, review в PR, CI, деплої, інколи — термінові rollback’и.

Про що і для кого стаття

У цій статті я коротко розповідаю:

  • яку проблему міграцій я закриваю і чому вона часто «не болить», доки не стане пізно;
  • як працює підхід «up → down → schema diff» як автоматична перевірка якості;
  • що саме роблять мої пакети Migration Checker для Symfony та Laravel і як їх запускати локально та в CI;
  • які типові фейли проходять код-рев’ю, і як їх ловить автоматична перевірка.

Чому вирішив написати? Бо бачу, що тема «міграції як артефакт, який треба тестувати» досі недооцінена: часто ми рев’юємо міграції очима, але не маємо механічної гарантії, що відкат поверне схему в нульовий стан. А саме це відрізняє «міграції працюють» від «міграції безпечні».

Стаття буде корисна:

  • PHP-розробникам на Symfony/Laravel, які регулярно додають міграції;
  • тим, хто займається код-рев’ю і хоче менше «людського фактора»;
  • DevOps/Tech Lead’ам, які відповідають за стабільні деплої та rollback;
  • командам з raw SQL у міграціях (тригери, індекси, constraints), де ризики вищі.

У більшості команд міграції сприймаються як «код, що еволюціонує схему». Але з практики проблема часто не в up, а в тому, що down не повертає базу в початковий стан: лишаються таблиці, індекси, тригери, «тимчасові» колонки, зміни default-значень, коментарі або інші артефакти. Це може довго не проявлятися — аж поки не знадобиться rollback, відкат релізу або прогін міграцій у чистому середовищі.

Саме через це я зробив набір пакетів Migration Checker, який формалізує просте правило: після up → down стан схеми має бути ідентичним, і це має перевірятися автоматично (локально та в CI).

Чому я взагалі до цього дійшов

Класичний біль міграцій виглядає так:

  • up() додає колонку/індекс/constraint/тригер, а down() забуває його прибрати.
  • down() «відкотив» таблицю, але лишились супутні об’єкти або зміни метаданих.
  • У CI ми проганяємо міграції «тільки вгору», але не перевіряємо, що rollback реально повертає схему назад.

І тут вступає в гру код-рев’ю. Бо реальність така: навіть сильні рев’ювери не тримають у голові всі нюанси БД, і в PR легко пропустити дрібні, але критичні деталі.

Приклади з код-рев’ю: що найчастіше прослизає

Нижче — типові ситуації, які я бачу на рев’ю міграцій (і через які, власне, й народився Migration Checker).

1) «Колонку видалили — значить все ок»... але індекс лишився

На рев’ю зазвичай дивляться на головну дію: «додали колонку — прибрали колонку». Але індекс міг створюватися:

  • окремою командою;
  • у сирому SQL;
  • або автоматично через ORM-хелпер.

В результаті down() колонку прибрав, а індекс — ні. І поки ніхто не робить rollback — все тихо.

Як це виглядає в рев’ю:

«Так, dropColumn() є. LGTM».

Як це виглядає при реальному відкаті:

«Relation already exists / index already exists / cannot drop column because...»

2) «Додали FK» — але відкат не симетричний

У up() легко додати foreign key, але в down():

  • забути dropForeign;
  • переплутати ім’я constraint’а;
  • або відкотити таблицю, залишивши constraint (залежить від СУБД і сценарію).

На рев’ю часто не ловиться, бо назви constraint’ів в різних командах можуть генеритись по-різному, а людина не завжди запускає міграцію локально «вгору-вниз».

3) «Тригер/функція» через raw SQL — і down() пустий

Все, що виходить за межі стандартного DSL міграцій, на рев’ю стає ризикованим:

  • CREATE TRIGGER,
  • CREATE FUNCTION,
  • специфічні типи (наприклад, enum/типи в Postgres).

Часто down() або взагалі пропускають, або пишуть «потім доробимо».

На рев’ю це виглядає як:

«Так, воно треба для бізнес-логіки, давайте мерджити, rollback не плануємо».

А потім rollback «раптом» планується.

4) «Rename» — один з найпідступніших сценаріїв

Перейменування таблиці/колонки вгору може бути коректним, але відкат:

  • повертає ім’я, але забуває про індекси;
  • або індекси повертаються з іншими назвами;
  • або ORM генерує різні імена залежно від версії.

Це складно виявити очима в PR, бо треба «симулювати» кінцеву схему.

5) «Змінили default / nullability / charset / collation» — а назад не повернули

Міграція може міняти:

  • DEFAULT;
  • NOT NULL;
  • charset/collation;
  • коментарі;
  • порядок колонок (інколи важливо);
  • типи (наприклад, intbigint).

На рев’ю такі правки виглядають дрібними, але down() часто роблять «спрощеним»: повернули тип — і забули default/collation.

6) «Дропнули таблицю в down()» як універсальний відкат

Це взагалі окремий жанр:

  • up() додає кілька змін;
  • down() просто dropTable() «щоб гарантовано відкотити».

Таке часто проходить рев’ю, бо «працює». Але воно несиметричне і може:

  • знести таблицю, яка існувала до міграції (якщо помилились контекстом);
  • або знищити дані в dev/stage, де відкат роблять частіше.

Як працює Migration Checker

Алгоритм простий:

  1. snapshot-схеми;
  2. up;
  3. down;
  4. snapshot-схеми;
  5. diff;
  6. up і далі по списку.

Якщо після rollback лишився хоч один об’єкт — інструмент показує різницю і падає.

Як це допомагає саме в процесі код-рев’ю

Я сприймаю Migration Checker як «другу пару очей», яка:

  • не втомлюється;
  • не пропускає дрібниці;
  • не покладається на «я впевнений, що down ок»;
  • економить час код-рев’ю.

І практично це змінює рев’ю так:

  • Рев’ювер більше фокусується на сенсі міграції (чи правильно ми міняємо домен), а не на «чи всі дрібні об’єкти видалені».
  • Якщо в PR є raw SQL — checker стає обов’язковою страховкою.
  • Зникає клас «LGTM, але down потім» — бо CI просто не пропустить.

Встановлення та локальний запуск

Symfony

composer require --dev roslov/migration-checker-bundle
php bin/console migration-checker:check --env=test -vv

Laravel

composer require --dev roslov/laravel-migration-checker
php artisan migration-checker:check --env=testing -vv

Підключення до CI

Нижче наведу приклад перевірки міграцій на Jenkins’і:

// Підготовлюємо тестове середовище
sh """
    docker stop ${service}-test-db || true
    docker rm --force ${service}-test-db || true
    docker network rm ${service}-network || true
    docker network create ${service}-network
"""
// Запускаємо тестову базу
sh """
    docker run --name ${service}-test-db --network=${service}-network -d --rm \
        -e MYSQL_ROOT_PASSWORD=rootpass \
        -e MYSQL_DATABASE=${service}_test \
        -e MYSQL_USER=${service}testuser \
        -e MYSQL_PASSWORD=${service}testpass \
        mysql:8.4.5 \
            --character-set-server=utf8mb4 \
            --collation-server=utf8mb4_0900_ai_ci \
            --log_bin_trust_function_creators=1 \
            --explicit_defaults_for_timestamp=0
"""
// Очікуємо, доки база буде готова приймати запити
sh """
    while ! docker exec ${service}-test-db \
        mysql --user=${service}testuser --password=${service}testpass \
        -e 'SELECT 1' \
        >/dev/null 2>&1; do
        echo 'Waiting for database connection...'
        sleep 1
    done
"""
if (service == 'service1') {
    // Запускаємо перевірку міграцій для сервіса `service1`.
    // В цьому випадку це Symfony-мікросервіс, в якому ми прописали підключення до тестової бази
    // в файлі `.env.test`
    sh """
        docker run --network=${service}-network --rm \
            ${service}-migration-check \
            php bin/console migration-checker:check --env=test -vv
    """
}
if (service == 'service2') {
    // Запускаємо перевірку міграцій для сервіса `service2`.
    // В цьому випадку це Laravel-мікросервіс, в якому ми НЕ прописали підключення до тестової бази
    // в файлі `.env.testing`, тому використовуємо змінну середовища для підключення
    sh """
        docker run --network=${service}-network --rm \
            -e DB_URL="mysql://${service}testuser:${service}testpass@${service}-test-db:3306/${service}_test
?serverVersion=8.4.5&charset=utf8mb4" \
            ${service}-migration-check \
            php artisan migration-checker:check --env=testing -vv
    """
}
// Зупиняємо тестове середовище
sh """
    docker stop ${service}-test-db
    docker network rm ${service}-network
"""

Де подивитися й спробувати

Пакети наближаються до стабільних релізів та вже «прикручені» до CI кількох комерційних проєктів. Буду вдячний за коментарі та знайдені баги.

👍ПодобаєтьсяСподобалось1
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Оце реально корисно для CI: PR падає, якщо down() не прибрав усе назад, і diff одразу видно. Дякую — забираю в пайплайн

У Symfony є налаштування доктрини: transactional, all_or_nothing. Використання цих параметрів дозволяє уникнути проміжних станів бд.

symfony.com/...​/index.html#configuration

Як часто і у яких випадках ви користуєтесь down() на production оточеннях?

Питання не в проміжних станах. А в тому що частенько багатьом колегам фіолетово як ролбек їх міграції пройде. І в автора статті такий самий біль:

людина не завжди запускає міграцію локально «вгору-вниз»

Загалом на проді живого активного проекту запустити ролбек можна буде лише один раз. І йти оновлювати резюме. XD

І так, коли в тяп-ляп релізі на прод всрали міграцію.. імхо краще написати нову, яка лікує, а не намагатись підлатати down в старій.

Повністю згоден, що в продакшн-середовища такі фікси робляться новою up-міграцією. І навіть в цьому випадку її простіше зробити вже маючи перевірену down-міграцію (просто скопіювавши код з down() поточної міграції в up() нової). Але також є різні тестові та стейджинг-середовища, де допустимо виконати down-міграцію, а потім реверт коду.

Ще є кейси, де локально треба переключитись на іншу гілку з оновленою схемою бази (наприклад, допомогти колезі продебажити код), а потім повернутись назад. Так от відсутність правильної down-міграції призведе до великих втрат часу (і часто відмові в допомозі, щоб потім не морочитись).

Підписатись на коментарі