RE:NODE

Приложения11 мин чтения

Миграции EF Core в продакшене: bundles, скрипты, откаты

Как безопасно применять миграции Entity Framework Core в продакшене: Migrate при запуске, migration bundles, идемпотентные SQL-скрипты, expand-contract и откаты.

0 прочтений

Есть три надёжных способа применить миграции Entity Framework Core к продакшен-базе: вызвать Database.Migrate() при запуске приложения, запустить migration bundle (efbundle, самодостаточный исполняемый файл, который создаёт dotnet ef migrations bundle) как шаг перед запуском приложения или сгенерировать идемпотентный SQL-скрипт и выполнить его самостоятельно. Для одного приложения на одном сервере применение при запуске приемлемо и просто; bundle, запускаемый из стартовой команды, - лучший вариант по умолчанию, потому что он разделяет «мигрировать» и «обслуживать», не добавляя инфраструктуры; проверенный SQL-скрипт - правильный ответ, когда базой данных владеет кто-то кроме приложения. Что бы вы ни выбрали, сами миграции должны безопасно выполняться на живом приложении, а это дисциплина в том, как вы их пишете, а не настройка инструмента.

В этой статье каждый способ разобран с точными командами, а затем то, от чего зависит, пройдёт ли деплой хорошо: проверка того, что сгенерировал EF, изменение схемы без поломки работающей версии и что на самом деле означает «откат».

Как работают миграции EF Core#

Миграция - это класс C# с методом Up, который меняет схему, и методом Down, который отменяет изменение; он генерируется сравнением текущей модели со снимком модели на момент предыдущей миграции.

bash
$ dotnet tool install --global dotnet-ef$ dotnet ef migrations add AddOrderStatus$ dotnet ef migrations list$ dotnet ef database update

Инструментам нужен пакет Microsoft.EntityFrameworkCore.Design в стартовом проекте. dotnet-ef можно также установить как локальный инструмент в манифест инструментов, что закрепляет его версию вместе с репозиторием, - так лучше для команды, потому что версия инструмента должна совпадать с версией EF Core, которую использует проект.

migrations add записывает в папку Migrations три вещи: класс миграции, файл designer с метаданными и обновлённый ModelSnapshot. Коммитьте все три. По снимку следующая миграция узнаёт, что изменилось, а конфликты слияния в нём - это то, как два разработчика, добавляющие миграции в разных ветках, узнают друг о друге.

Со стороны базы данных это таблица __EFMigrationsHistory, в которой хранится ID каждой применённой миграции и версия EF Core, которая её применила. Каждый способ применения миграций читает эту таблицу, выясняет, каких миграций не хватает, и применяет их по порядку. Больше ничто состояние не отслеживает, поэтому ручные изменения схемы - индекс, наспех добавленный руками, - для EF невидимы и обычно сталкиваются с более поздней миграцией, которая пытается добавить то же самое.

Проверка того, что сгенерировал EF#

EF генерирует миграции из разницы, а разница не знает ваших намерений. Читайте каждую миграцию, прежде чем её коммитить.

Переименования. Переименование свойства может быть сгенерировано как переименование или как удаление старого столбца и добавление нового - в зависимости от того, что ещё изменилось. Удаление и добавление теряет все значения в столбце. Когда EF генерирует операцию, которая может привести к потере данных, он печатает «An operation was scaffolded that may result in the loss of data. Please review the migration for accuracy.» Воспринимайте эту строку как знак «стоп» и замените сгенерированные операции на migrationBuilder.RenameColumn(...), если вы имели в виду переименование.

Новые столбцы, не допускающие null. Добавление обязательного свойства в таблицу со строками даёт столбец со значением по умолчанию для типа - пустой строкой, нулём, 0001-01-01. Данные редко должны говорить именно это. Либо сначала сделайте столбец допускающим null и заполните его (см. раздел о expand-contract ниже), либо задайте осознанное значение по умолчанию через HasDefaultValue или defaultValue: в миграции.

Изменения данных. Миграции могут не только менять схему, но и выполнять SQL:

csharp
protected override void Up(MigrationBuilder migrationBuilder){    migrationBuilder.AddColumn<string>(        name: "Status", table: "Orders", nullable: true);    migrationBuilder.Sql(        "UPDATE Orders SET Status = 'paid' WHERE PaidAt IS NOT NULL");}

Держите изменения данных небольшими и основанными на множествах. Миграция, которая загружает сущности в C# и перебирает их в цикле, медленная, зависит от классов модели, которые изменятся в последующих миграциях, и ломается так, что это трудно исправить, когда она уже где-то применена.

Операторы, которые не могут выполняться в транзакции. Некоторые операции - CREATE INDEX CONCURRENTLY в PostgreSQL, некоторые операторы ALTER DATABASE в SQL Server - отказываются выполняться внутри транзакции. Для них передайте suppressTransaction: true в migrationBuilder.Sql(...) и смиритесь с тем, что миграция больше не выполняется по принципу «всё или ничего».

Четыре способа применить миграции#

СпособГде выполняетсяНужен ли SDK на сервереДля чего подходит
dotnet ef database updateМашина разработчика или CIДаБазы данных для разработки
Database.Migrate() при запускеВнутри приложенияНетОдин экземпляр, простые конфигурации
Migration bundle (efbundle)Перед приложением, на том же сервереНетВариант по умолчанию для большинства деплоев
Идемпотентный SQL-скриптТам, где DBA выполняет SQLНетПроверенные, контролируемые изменения

dotnet ef database update против продакшена с ноутбука - способ, которого стоит избегать. Ему нужна строка подключения к продакшену на машине разработчика, он использует тот код, который сейчас случайно оказался в локальной копии, и больше никто не знает, что это произошло. Используйте его только для баз данных разработки.

Применение миграций при запуске#

Самый простой продакшен-способ - одна строка до того, как приложение начнёт обслуживать запросы:

Program.cs
var app = builder.Build();using (var scope = app.Services.CreateScope()){    var db = scope.ServiceProvider.GetRequiredService<ShopDb>();    db.Database.SetCommandTimeout(TimeSpan.FromMinutes(5));    await db.Database.MigrateAsync();}app.Run();

Что здесь сделано правильно: схема всегда соответствует запускаемому коду, и нет отдельного шага, о котором можно забыть. Что неправильно:

  • Пользователю базы данных приложения нужны права на изменение схемы. Обычно приложение должно только читать и писать данные. Миграция при запуске означает, что учётная запись, которой ваше веб-приложение пользуется каждый день, может ещё и удалять таблицы.
  • Неудачная миграция - это неудачный запуск. Приложение не запускается, платформа его перезапускает, и миграция снова падает - цикл перезапусков, настоящая причина которого похоронена в стеке вызовов.
  • Несколько экземпляров соревнуются. Два экземпляра, запускающиеся одновременно, могут оба попытаться применить одну и ту же миграцию. В EF Core 9 появилась блокировка на уровне всей базы данных вокруг миграций, чтобы от этого защититься, но на более старых версиях это реальный риск.
  • Долгие миграции упираются в таймауты. Таймаут команды по умолчанию - 30 секунд; добавление индекса к большой таблице может занять больше, отсюда SetCommandTimeout выше.

В EF Core 9 Migrate() также стал выбрасывать исключение, если в модели есть изменения, не покрытые ни одной миграцией (PendingModelChangesWarning). Это ловит ошибку «забыл добавить миграцию» при запуске, а не на первом упавшем запросе, что полезно, но удивляет тех, кто обновляется с EF Core 8.

Для одного приложения на одном сервере с базой данных, которой владеет приложение, применение при запуске - разумный выбор. Используйте для миграции отдельную привилегированную строку подключения, а для приложения - обычную, чтобы повседневная учётная запись оставалась ограниченной.

Migration bundles#

Bundle - это один исполняемый файл, содержащий ваши миграции и всё необходимое для их применения. Там, где он запускается, не нужны ни SDK, ни ваш исходный код:

bash
$ dotnet ef migrations bundle --self-contained -r linux-x64 -o efbundle --force$ ./efbundle --connection "Host=203.0.113.10;Database=shop;Username=shop_owner;Password=..."

Без --connection bundle использует строку подключения, с которой настроен ваш DbContext, прочитанную из конфигурации приложения. --self-contained -r linux-x64 делает его независимым от runtime .NET на сервере; --force перезаписывает предыдущий bundle.

Собирайте bundle в CI рядом с приложением, поставляйте оба и запускайте bundle первым в стартовой команде:

bash
./efbundle --connection "$MIGRATIONS_CONNECTION" && exec dotnet Shop.Web.dll

Если миграция падает, && не даёт приложению запуститься на наполовину мигрированной схеме, а ошибка оказывается последним, что есть в логе, а не похоронена в шуме запуска. Миграция выполняется под собственной строкой подключения с правами на схему, прочитанной из переменной окружения, а приложение использует учётную запись, которая может только читать и писать данные. В RE:NODE переменные окружения задаются на вкладке Startup, а стартовая команда выполняется при каждом запуске - так что bundle запускается при каждом перезапуске, не находит работы и завершается примерно за секунду. Публикация в CI и деплой результата из ветки описаны в статье деплой приложения ASP.NET Core с GitHub.

Идемпотентные SQL-скрипты#

Скрипт превращает миграции в обычный SQL, который любой может прочитать перед выполнением:

bash
$ dotnet ef migrations script --idempotent -o migrate.sql$ dotnet ef migrations script AddOrders AddOrderStatus -o step.sql

Первая форма выдаёт все миграции с самого начала, каждую обёрнутую в проверку по __EFMigrationsHistory, так что скрипт можно выполнить на базе данных любой версии, и он применит только недостающее. Вторая выдаёт SQL между двумя названными миграциями - именно это вы отдаёте на ревью для одного релиза.

Скрипты - правильный выбор, когда продакшен-базой владеет администратор баз данных, когда изменения должен проверить человек или когда учётная запись приложения никогда не должна иметь прав на схему. Это ещё и честный способ увидеть, что сделает миграция: чтение SQL часто показывает, что «маленькое» изменение перестраивает таблицу. У идемпотентных скриптов есть ограничения у некоторых провайдеров - определённые операторы ведут себя иначе, когда обёрнуты в условный блок, - поэтому выполните скрипт на копии продакшена, прежде чем выполнять его на продакшене.

В SQL Server результат выполняется через sqlcmd или SSMS; в PostgreSQL - через psql -f; в MySQL - клиентом mysql. Если ваша база данных на отдельном тарифе хостинга баз данных, вы подключаетесь с хостом, портом и учётными данными из панели. Подключение для каждого движка описано в статье .NET с PostgreSQL, MySQL или SQL Server.

Изменение схемы без поломки работающей версии#

Во время деплоя старая версия приложения какое-то мгновение работает с новой схемой, а если миграция выполняется до перезапуска, то всё время, пока длится перезапуск. Миграция, которую старый код не выдерживает, ломает сайт на это окно. Паттерн, который этого избегает, - сначала расширить, потом сузить (expand-contract):

  1. Расширение. Добавьте новый столбец как допускающий null или новую таблицу. Старый код их игнорирует. Деплой.
  2. Перенос данных. Новый код пишет и в старые, и в новые столбцы; миграция или фоновая задача заполняет существующие строки. Деплой.
  3. Переключение. Новый код читает только из нового столбца. Деплой.
  4. Сужение. Сделайте столбец не допускающим null, если так нужно, и удалите старый столбец в более поздней миграции, когда его уже ничто не читает. Деплой.

Четыре деплоя ради переименования кажутся излишеством - до первого случая, когда одношаговое переименование кладёт сайт на всё время перезапуска. Для небольшого приложения с коротким перезапуском и спокойными часами сжатие до двух шагов (расширение плюс переключение, затем сужение) - разумный компромисс. Удаление столбца, который работающий код всё ещё выбирает, разумным не бывает никогда. Паттерн с большим числом примеров разобран в статье миграции без простоя, а сторона приложения - в статье деплой без простоя на небольшом сервере.

Откаты и неудачные миграции#

EF Core умеет выполнять методы Down: dotnet ef database update AddOrders откатывает все миграции после AddOrders, а dotnet ef migrations script AddOrderStatus AddOrders выдаёт SQL для такого отката. Это откат схемы. Это не откат данных: Down, который возвращает удалённый столбец, возвращает его пустым.

Практические правила:

  • Делайте backup перед каждой миграцией, которая что-то удаляет или переписывает. Дамп, который вы хотя бы раз восстанавливали, - единственный настоящий откат для данных. Как делать это правильно, описано в статье backup и восстановление баз данных; в тарифах хостинга баз данных RE:NODE слоты для backup включены, и backup можно сделать по требованию из панели перед деплоем.
  • Предпочитайте движение вперёд. Если миграция оказалась неверной, новая миграция, которая её исправляет, обычно безопаснее отката, потому что она тестируется как любое другое изменение и не зависит от метода Down, который никто никогда не запускал.
  • Знайте, как выглядит частичный сбой. В SQL Server и PostgreSQL каждая миграция применяется в транзакции, так что сбой оставляет эту миграцию неприменённой. В MySQL DDL-операторы фиксируются неявно, так что миграция, упавшая на полпути, может оставить часть своих изменений без строки в истории. Чтобы это исправить, нужно прочитать схему, вручную довести или отменить изменение и только потом повторить попытку.
  • Никогда не редактируйте миграцию, которая была применена где-то в общем окружении. Измените её после того, как она отработала на продакшене, и следующее окружение получит схему, отличающуюся от продакшена. Вместо этого добавьте новую миграцию. dotnet ef migrations remove - только для миграций, которые ещё не применялись.

FAQ#

Стоит ли использовать EnsureCreated вместо миграций?

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

Можно ли объединить много старых миграций в одну?

Да, но осторожно: удалите старые файлы миграций, добавьте одну новую миграцию, которая создаёт текущую схему, и вставьте её ID в __EFMigrationsHistory в существующих базах, чтобы она не применилась дважды. Большинству проектов это никогда не нужно; сотни миграций стоят немного.

Почему bundle падает с «unable to create an object of type DbContext»?

Инструменты не смогли построить ваш контекст во время разработки, обычно потому что он зависит от конфигурации, которая существует только при работе приложения. Добавьте IDesignTimeDbContextFactory<T>, который создаёт контекст со строкой подключения из окружения.

Работают ли миграции одинаково в PostgreSQL, MySQL и SQL Server?

Команды одинаковые; сгенерированный SQL и поведение транзакций - нет. Сгенерируйте скрипт один раз для каждого провайдера, на который нацелены, и прочитайте его, особенно для MySQL, где DDL нельзя откатить.

Где хранить строку подключения для миграций?

В переменной окружения на сервере, отдельно от собственной строки подключения приложения, чтобы учётная запись с правами на схему использовалась только для миграций. Как читаются обе, описано в статье конфигурация и секреты в ASP.NET Core.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000