Привет, Хабр! Меня зовут Павел, я ведущий разработчик. Сегодня без 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‑канале «@pro_it_live» отдельно выложу короткий чек‑лист для релиза, схему 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‑команды и блокировки.