Привет, Хабр! Меня зовут Павел, я ведущий разработчик. Сегодня без Kafka, но снова про место, где прод обычно становится честнее документации: миграции базы данных.
Есть очень удобный код:
var app = builder.Build(); using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); await db.Database.MigrateAsync(); await app.RunAsync();
Локально выглядит прекрасно. Приложение стартует, EF Core сам применяет миграции, разработчик доволен, база вроде тоже не возражает.
А потом это попадает в прод. В Kubernetes поднимаются три pod’а, каждый просыпается с мыслью “я сейчас наведу порядок в схеме”, база берет DDL-блокировку, readiness не проходит, релиз нервно смотрит в мониторинг. Где-то рядом человек открывает вкладку с вакансиями, просто чтобы успокоиться.

Сам метод не злой. Он полезен в локальной разработке, тестовом окружении, маленьких внутренних сервисах и одноразовых утилитах.
Проблема начинается, когда runtime-migration становится частью обычного production startup.
Первый риск — несколько инстансов. Если сервис масштабируется, миграцию могут попытаться применить сразу несколько процессов. Начиная с EF Core 9 появился механизм блокировки миграций, который защищает от части проблем конкурентного применения. Но он не делает сам подход бесплатным: остальные pod’ы все равно ждут, старт приложения связан с DDL, а релиз зависит от состояния БД.
Второй риск — права. Чтобы приложение применяло миграции, его пользователь в БД должен уметь менять схему. В проде это часто лишнее доверие к коду, который должен обслуживать запросы, а не заниматься ремонтом асфальта под колесами.
Третий риск — непосмотренный SQL. В миграции на C# все может выглядеть невинно:
migrationBuilder.AddColumn<string>( name: "Comment", table: "Orders", nullable: false, defaultValue: "");
А в базе это уже конкретная DDL-операция с блокировками, перепроверкой данных и неприятным вопросом: “а сколько строк в Orders?”

У Microsoft в документации по EF Core прямо перечислены ограничения применения миграций приложением в production: конкурентный запуск, лишние права, отсутствие возможности заранее проверить SQL и риск проблем при откате.
Рекомендуемые варианты спокойнее:
сгенерировать SQL-скрипт и применить его контролируемо;
использовать migration bundle;
запускать миграцию отдельным шагом pipeline или отдельным Kubernetes Job;
держать только один исполнитель миграции;
не давать обычному приложению DDL-права в production.
То есть приложение должно стартовать как приложение. А не как маленький DBA с доступом к болгарке.
DDL живет по своим правилам
Самая неприятная часть миграций в проде: приложение думает объектами, а база думает таблицами, индексами и блокировками.
Например, в PostgreSQL обычный CREATE INDEX блокирует записи в таблицу. Для больших таблиц часто нужен:
migrationBuilder.Sql( """ CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_orders_created_at ON orders(created_at); """, suppressTransaction: true);
CONCURRENTLY позволяет строить индекс без блокировки обычных INSERT, UPDATE, DELETE, но у него есть цена: операция идет дольше и не может выполняться внутри transaction block. Поэтому в EF Core приходится явно подавлять транзакцию для такого SQL.
То же самое с ограничениями и колонками. Добавить колонку, заполнить ее, поставить NOT NULL, удалить старую колонку — это не всегда одна миграция. В проде это часто несколько релизов.

Expand/contract вместо “сломать и починить”
Самый скучный и поэтому полезный подход — делать схему совместимой с несколькими версиями приложения.
Допустим, надо переименовать status в state.
Плохой вариант:
Переименовать колонку.
Выложить код.
Надеяться, что старые pod’ы уже умерли.
Хороший вариант:
Шаг | Что делаем |
|---|---|
Expand | Добавляем новую nullable-колонку |
Dual write | Новый код пишет и в |
Backfill | Пачками переносим старые данные |
Switch read | Код начинает читать |
Contract | После всех проверок удаляем |
Это медленнее, чем “давайте одним ALTER”. Зато при rollback старый код еще понимает схему, а новый код не падает на старых данных.

Как я бы делал в нормальном релизе
Для production-проекта я бы разделил код и схему.
На build-этапе собрать migration bundle или SQL-script.
На review посмотреть SQL глазами, а не только миграцию на C#.
На staging прогнать на данных, похожих по объему на production.
Перед выкаткой выполнить migration job ровно один раз.
Только потом выкатывать приложение.
Для больших таблиц делать backfill пачками.
Destructive changes переносить в отдельный релиз.
И еще один важный пункт: rollback-план должен быть до миграции, а не после фразы “а почему у нас колонка исчезла?”
Мини-чек-лист перед миграцией
Перед релизом я бы спросил:
SQL миграции сгенерирован и просмотрен?
Есть ли операции, которые берут долгую блокировку?
Проверяли на таблице похожего размера?
Нужен ли
CREATE INDEX CONCURRENTLY?Можно ли применить миграцию повторно без взрыва?
Приложение совместимо со старой и новой схемой?
Откат приложения переживет уже примененную миграцию?
Есть ли backfill-план?
Есть ли метрики длительности миграции и ошибок?
У обычного app-user нет лишних DDL-прав?
Главная мысль:
Миграция базы — это часть релиза, а не побочный эффект старта приложения.
Database.Migrate() удобен, пока не становится единственным человеком в комнате с правом менять схему. В production лучше, когда миграции выполняются явно, проверяемо и отдельно от старта сервиса.
В Telegram-канале «Продовый оффсет» отдельно выложу короткий чек-лист для релиза, схему expand/contract и памятку по PostgreSQL DDL: где нужен CONCURRENTLY, где suppressTransaction, а где лучше просто не делать вид, что таблица на 200 млн строк это маленькая DTO.
На что опирался
Microsoft: Applying Migrations — варианты применения миграций, SQL scripts, bundles и ограничения runtime-подхода.
Microsoft: MigrationBuilder.Sql — параметр
suppressTransaction.PostgreSQL: CREATE INDEX — поведение
CREATE INDEX CONCURRENTLY.PostgreSQL: ALTER TABLE — DDL-команды и блокировки.
