Це продовження статті «Міграції: Основи (Частина 1)». Читайте послідовно.
database update не підходить для productionУ першій частині ми використовували dotnet ef database update — зручна команда для розробки. Але для production-середовища вона має принципові обмеження.
Перша проблема: вона потребує dotnet ef tools на сервері. Виробничі сервери зазвичай мінімальні — там немає SDK, немає dev-tools, і так і повинно бути. Встановлення dotnet ef tools на prod-сервер — антипатерн з міркувань безпеки і управляємося (хто відповідає за версійність інструментів?).
Друга проблема: підключення до бази даних. database update потребує прямого підключення до БД. Але prod-база часто захищена VPN, firewall, і доступна лише через bastion host або спеціалізований deployment агент.
Третя проблема: права доступу. Application user (обліковий запис що використовує ваш застосунок) має мінімальні права: SELECT, INSERT, UPDATE, DELETE для конкретних таблиць. ALTER TABLE, CREATE TABLE виконуються окремим migration user з ширшими правами. Змішування цих ролей — загроза безпеці.
Четверта проблема: аудит і контроль. В enterprise-середовищах кожна зміна схеми БД має бути схвалена DBA, залогована, перевірена. Команда database update робить це «за лаштунками» — жодного SQL-файлу для review.
Саме тому для production існують два офіційних підходи: SQL-скрипти і Migrations Bundles.
Команда migrations script генерує SQL-файл з усіма DDL-операціями що відповідають вашим міграціям. Цей файл можна:
Згенерований migrations.sql без додаткових флагів містить всі міграції від початку:
IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL
BEGIN
CREATE TABLE [__EFMigrationsHistory] (
[MigrationId] nvarchar(150) NOT NULL,
[ProductVersion] nvarchar(32) NOT NULL,
CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId])
);
END;
GO
BEGIN TRANSACTION;
CREATE TABLE [Categories] (
[Id] int NOT NULL IDENTITY,
[Name] nvarchar(max) NOT NULL,
CONSTRAINT [PK_Categories] PRIMARY KEY ([Id])
);
GO
CREATE TABLE [Products] (
[Id] int NOT NULL IDENTITY,
[Name] nvarchar(max) NOT NULL,
[Price] decimal(18,2) NOT NULL,
[CategoryId] int NOT NULL,
CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId])
REFERENCES [Categories] ([Id]) ON DELETE CASCADE
);
GO
-- ... більше DDL ...
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20250329120000_InitialCreate', N'9.0.3');
GO
COMMIT;
GO
Зверніть увагу: скрипт обгортає кожну міграцію у BEGIN TRANSACTION; ... COMMIT;. Якщо DDL операція провалиться — транзакція відкочується.
# Скрипт від конкретної міграції (FROM) до іншої (TO)
dotnet ef migrations script AddDiscountToProducts AddAuditLog --output delta.sql
# Синтаксис:
# dotnet ef migrations script [FROM] [TO] [опції]
# FROM: назва початкової міграції (не включається у скрипт, є базою)
# TO: назва кінцевої міграції (включається)
Це дозволяє генерувати delta-скрипти — тільки зміни між двома версіями. Ідеально для incremental deployment:
# У CI: генерація скрипту від поточної версії prod до нової
CURRENT_MIGRATION=$(dotnet ef migrations list | grep Applied | tail -1 | awk '{print $1}')
dotnet ef migrations script $CURRENT_MIGRATION --output release-delta.sql
Стандартний SQL-скрипт не можна запустити двічі — він впаде з помилкою Table already exists. Це проблема у багатьох сценаріях:
--idempotent вирішує це:
dotnet ef migrations script --idempotent --output idempotent-migrations.sql
Згенерований файл для кожної міграції включає перевірку:
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20250329120000_InitialCreate'
)
BEGIN
-- DDL операції першої міграції
CREATE TABLE [Categories] ( ... );
CREATE TABLE [Products] ( ... );
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20250329120000_InitialCreate', N'9.0.3');
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20250329130000_AddProductFields'
)
BEGIN
-- DDL операції другої міграції
ALTER TABLE [Products] ADD [Discount] decimal(18,2) NULL;
ALTER TABLE [Products] ADD [IsActive] bit NOT NULL DEFAULT CAST(0 AS bit);
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20250329130000_AddProductFields', N'9.0.3');
END;
GO
Цей скрипт можна запускати скільки завгодно разів — повторні запуски не будуть виконувати вже застосовані блоки.
Migrations Bundle — відносно новий підхід (EF Core 6+) що вирішує інший набір проблем. Bundle — це самостійний виконуваний файл що містить всі міграції і може застосувати їх без dotnet ef tools, без вихідного коду, без .NET SDK.
Уявіть: ви публікуєте нову версію застосунку у Docker. Поряд з образом AppService — образ MigrationRunner. Він запускається один раз при деплої, застосовує потрібні міграції і завершується. Ніяких dotnet ef на сервері.
# Базовий bundle (для поточної платформи)
dotnet ef migrations bundle --output efbundle
# Для Linux (у Windows CI для деплою на Linux):
dotnet ef migrations bundle \
--target-runtime linux-x64 \
--output efbundle-linux
# Self-contained: включає .NET runtime, не потребує встановленого .NET
dotnet ef migrations bundle \
--self-contained \
--target-runtime linux-x64 \
--output efbundle-selfcontained
# Базовий запуск (використовує connection string з конфігурації)
./efbundle
# З явним connection string
./efbundle --connection "Server=prod-server;Database=ShopDb;..."
# Застосувати до конкретної міграції
./efbundle --target AddDiscountToProducts
# Verbose output (для діагностики)
./efbundle --verbose
Типовий Dockerfile для migration runner:
# Dockerfile.migrations
FROM mcr.microsoft.com/dotnet/runtime-deps:9.0 AS final
WORKDIR /app
# Копіюємо self-contained bundle
COPY ./efbundle-linux ./efbundle
RUN chmod +x ./efbundle
# Запускаємо bundle як ENTRYPOINT
ENTRYPOINT ["./efbundle", "--verbose"]
# docker-compose.yml: migration runner запускається перед app
services:
migrations:
build:
context: .
dockerfile: Dockerfile.migrations
environment:
- ConnectionStrings__DefaultConnection=${DB_CONNECTION}
depends_on:
db:
condition: service_healthy
app:
build: .
depends_on:
migrations:
condition: service_completed_successfully # чекає завершення migrations
dotnet ef migrations bundle у CI/CD pipeline поряд зі zbіркою основного застосунку.Існують три основних стратегії застосування міграцій у CI/CD. Вибір залежить від вашої інфраструктури, команди і вимог безпеки.
Найпростіша стратегія: застосунок сам застосовує міграції при запуску. Це відбувається програматично через Database.MigrateAsync():
// Program.cs — застосувати міграції при старті
var app = builder.Build();
// Застосувати пендинг міграції перед запуском застосунку
using (var scope = app.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// MigrateAsync = database update у коді
// Безпечно: якщо нема pending міграцій — нічого не робить
await context.Database.MigrateAsync();
}
await app.RunAsync();
Переваги:
Недоліки:
Захист від concurrent migrations при горизонтальному масштабуванні:
// Distributed lock перед MigrateAsync
using (var scope = app.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
// Отримати advisory lock (PostgreSQL) або sp_getapplock (SQL Server)
await using var tx = await context.Database.BeginTransactionAsync();
try
{
// PostgreSQL: pg_try_advisory_lock повертає true лише для одного процесу
var lockAcquired = await context.Database
.ExecuteSqlRawAsync("SELECT pg_try_advisory_lock(12345)");
if (lockAcquired == 1)
{
logger.LogInformation("Migration lock acquired, running migrations...");
await context.Database.MigrateAsync();
logger.LogInformation("Migrations completed.");
}
else
{
logger.LogInformation("Another instance is migrating, waiting...");
// Чекаємо поки інший інстанс завершить
await WaitForMigrationsAsync(context);
}
await tx.CommitAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "Migration failed");
throw; // Не запускати застосунок з несумісною схемою!
}
}
У Kubernetes-оточенні найприродніша стратегія — окремий Job що виконується перед деплоєм pod-ів застосунку:
# migrations-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrations-v2-1-0 # версія застосунку у назві Job
spec:
template:
spec:
containers:
- name: migrations
image: myapp/migrations:2.1.0 # той самий образ що і app, але з ENTRYPOINT efbundle
env:
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: db-credentials
key: connection-string
command: ["./efbundle", "--verbose"]
restartPolicy: Never
backoffLimit: 3 # 3 спроби при невдачі
# deployment.yaml: App Deployment залежить від Job completion
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
# ... spec
template:
spec:
initContainers:
- name: wait-for-migrations
image: bitnami/kubectl:latest
# Чекаємо поки migration Job завершиться успішно
command:
- /bin/sh
- -c
- |
until kubectl get job db-migrations-v2-1-0 \
-o jsonpath='{.status.succeeded}' | grep -q "1"; do
echo "Waiting for migrations to complete..."
sleep 5
done
containers:
- name: myapp
# ...
Переваги:
Найбільш контрольована стратегія: міграція як окремий крок у deployment pipeline, перед деплоєм застосунку:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
migrate:
name: Apply Database Migrations
runs-on: ubuntu-latest
environment: production # потребує manual approval у GitHub
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '9.0.x'
- name: Restore tools
run: dotnet tool restore # відновлює dotnet-ef з .config/dotnet-tools.json
- name: Generate migration script
run: |
dotnet ef migrations script \
--idempotent \
--output migrations.sql \
--project src/YourApp.Infrastructure \
--startup-project src/YourApp.Api
- name: Review migration script (artifact)
uses: actions/upload-artifact@v3
with:
name: migration-sql
path: migrations.sql
- name: Apply migrations
env:
DB_CONNECTION: ${{ secrets.PROD_DB_CONNECTION }}
run: |
# Виконати скрипт через sqlcmd (SQL Server) або psql (PostgreSQL)
sqlcmd -S $DB_SERVER -U $DB_USER -P $DB_PASSWORD \
-d $DB_NAME -i migrations.sql
deploy:
name: Deploy Application
needs: migrate # ← deploy розпочнеться лише після успішної міграції!
runs-on: ubuntu-latest
steps:
# ... deployment кроки
| Стратегія | Складність | DBA Review | Rollback | Multi-Instance | Рекомендовано для |
|---|---|---|---|---|---|
Database.MigrateAsync | ⭐ Мінімальна | ❌ | Складний | ⚠️ Потребує lock | Стартапи, MVP, малі команди |
| Kubernetes Job | ⭐⭐ Середня | ⚠️ Обмежений | ✅ Simple | ✅ Один Job | К8s-орієнтовані команди |
| CI/CD Pipeline Step | ⭐⭐⭐ Висока | ✅ Повний | ✅ Manual | ✅ Один runner | Enterprise, regulated industries |
Крім CLI, EF Core надає API для програмної роботи з міграціями:
// Отримати список всіх міграцій у проєкті
IEnumerable<string> allMigrations = context.Database.GetMigrations();
// Отримати список вже застосованих міграцій (читає __EFMigrationsHistory)
IEnumerable<string> appliedMigrations = await context.Database.GetAppliedMigrationsAsync();
// Отримати список ще не застосованих міграцій
IEnumerable<string> pendingMigrations = await context.Database.GetPendingMigrationsAsync();
// Застосувати всі pending міграції (= dotnet ef database update)
await context.Database.MigrateAsync();
// Перевірка: схема БД відповідає поточній моделі?
bool canConnect = await context.Database.CanConnectAsync();
// Діагностичний звіт про стан міграцій
public async Task<MigrationStatus> GetMigrationStatusAsync(AppDbContext context)
{
var all = context.Database.GetMigrations().ToList();
var applied = (await context.Database.GetAppliedMigrationsAsync()).ToList();
var pending = (await context.Database.GetPendingMigrationsAsync()).ToList();
return new MigrationStatus
{
TotalMigrations = all.Count,
AppliedCount = applied.Count,
PendingCount = pending.Count,
PendingMigrations = pending,
LastApplied = applied.LastOrDefault(),
IsUpToDate = pending.Count == 0
};
}
// Використання у Health Check
public class MigrationHealthCheck : IHealthCheck
{
private readonly IServiceScopeFactory _scopeFactory;
public MigrationHealthCheck(IServiceScopeFactory scopeFactory)
=> _scopeFactory = scopeFactory;
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken ct = default)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
try
{
var pending = await dbContext.Database.GetPendingMigrationsAsync(ct);
var pendingList = pending.ToList();
if (pendingList.Count == 0)
return HealthCheckResult.Healthy("Database schema is up to date.");
return HealthCheckResult.Degraded(
$"{pendingList.Count} pending migration(s): {string.Join(", ", pendingList)}");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Cannot connect to database.", ex);
}
}
}
// Реєстрація:
builder.Services.AddHealthChecks()
.AddCheck<MigrationHealthCheck>("db-migrations", tags: ["db", "ready"]);
У типовому project set-up DbContext конфігурується через DI:
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(
builder.Configuration.GetConnectionString("DefaultConnection"),
sqlOptions =>
{
// Опції специфічні для міграцій
sqlOptions.MigrationsAssembly("YourApp.Infrastructure"); // якщо міграції в окремому проєкті
sqlOptions.CommandTimeout(300); // Timeout для довгих міграцій (великі таблиці)
});
});
// appsettings.Development.json
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=ShopDb_Dev;Trusted_Connection=True"
}
}
// appsettings.Production.json (у Git: без секретів!)
{
"ConnectionStrings": {
"DefaultConnection": "#{DB_CONNECTION_STRING}#" // placeholder для CI/CD заміни
}
}
При типовій Clean Architecture — DbContext та міграції живуть у Infrastructure проєкті:
└── src/
├── Domain/ (Domain entities — без EF залежностей!)
├── Application/ (Use cases, interfaces)
├── Infrastructure/ (DbContext, Migrations/, Configurations/)
│ ├── AppDbContext.cs
│ ├── Migrations/
│ │ ├── 20250329_InitialCreate.cs
│ │ └── AppDbContextModelSnapshot.cs
│ └── Configurations/
│ └── ProductConfiguration.cs
└── Api/ (WebAPI, Controllers, Program.cs)
CLI для такої структури:
# --project: де знаходяться Migrations та DbContext
# --startup-project: де знаходиться Program.cs (конфігурація DI)
dotnet ef migrations add InitialCreate \
--project src/Infrastructure \
--startup-project src/Api
dotnet ef database update \
--project src/Infrastructure \
--startup-project src/Api
Або через IDesignTimeDbContextFactory — якщо Infrastructure не залежить від Api:
// src/Infrastructure/DesignTime/AppDbContextFactory.cs
// Використовується ЛИШЕ design-time інструментами (dotnet ef)
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
// Читаємо конфіг без запуску повного хоста
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile("appsettings.Development.json", optional: true)
.AddEnvironmentVariables()
.Build();
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
optionsBuilder.UseSqlServer(config.GetConnectionString("DefaultConnection"));
return new AppDbContext(optionsBuilder.Options);
}
}
database update.__EFMigrationsHistory бази — її файл не можна змінювати. EF Core зберігає хеш моделі і виявить невідповідність. Замість цього — створіть нову міграцію що виправляє попередню.AddProductCategory, AddOrderStatus, AddUserPreferences — окремими міграціями. Маленькі міграції легше ревʼювити, простіше відкочувати, менш ризиковані.database update), потім відкотити (database update 0), потім знову застосувати — перевіряє що весь ланцюг коректний.migrations add. Вони мають бути в одному Git commit. Ніколи не комітьте лише файл міграції без оновленого snapshot — стан репозиторію буде некоректним.# ❌ ПОМИЛКА: Запускати database update на production з dev machines
dotnet ef database update --connection "Server=prod-server;..."
# Чому погано: обходить review, немає аудиту, ризик помилки
# ✅ ПРАВИЛЬНО: Генерувати скрипт і передавати DBA
dotnet ef migrations script --idempotent --output release-3.2.1.sql
# ❌ ПОМИЛКА: Видаляти застосовані міграції з папки Migrations/
rm Migrations/20250329_OldMigration.cs
# ✅ ПРАВИЛЬНО: Старі міграції ніколи не видаляти!
# (squash робиться інакше — у статті 24)
# ❌ ПОМИЛКА: Ігнорувати конфлікти у ModelSnapshot
# (залишати <<<< HEAD markers у файлі)
# ✅ ПРАВИЛЬНО: Вирішити конфлікт і перегенерувати snapshot
git checkout --theirs Migrations/AppDbContextModelSnapshot.cs
dotnet ef migrations add ResolveMergeConflict # якщо є реальні зміни
Для вашого проєкту:
InitialCreate, AddDiscount, AddOrdersdatabase updatemigrations script --idempotent -o full.sqlIF NOT EXISTS блоки для кожної міграціїdotnet ef migrations bundle --output efbundle./efbundle --verbose — порівняйте вивід з dotnet ef database update./efbundle ще раз — переконайтесь що повторний запуск безпечнийefbundle? Чи можна його закомітити у Git? (Де зберігається бінарник?)Database.MigrateAsync у Program.csРеалізуйте автоматичне застосування міграцій при старті:
await context.Database.MigrateAsync() у Program.csILogger: що відбулось (Applied/Already up to date)Реалізуйте GitHub Actions workflow:
maindotnet ef migrations script --idempotent -o migrations.sqlmigrations.sql як artifact (для review)sqlcmd чи psql (можна local SQLite для тесту)dotnet publish і деплой Docker образу (mock)Реалізуйте MigrationHealthCheck : IHealthCheck:
GetPendingMigrationsAsync()HealthCheckResult.Healthy при 0 pendingHealthCheckResult.Degraded при > 0 pending (з переліком назв)HealthCheckResult.Unhealthy при DbException/health ендпоінтСпроєктуйте стратегію «нульового простою» для такої зміни:
Вихідний стан: Products таблиця містить CustomerName (varchar, NOT NULL).
Ціль: розбити на FirstName і LastName.
Вимоги: old app (v1) і new app (v2) мають одночасно працювати 30 хвилин під час деплою.
Реалізуйте через 3 окремих деплої:
FirstName, LastName як nullable. Тригер/计算 синхронізує з CustomerNameFirstName/LastName з CustomerName. Зробити NOT NULLCustomerNameНапишіть міграції для кожного деплою і пояснення чому порядок має значення.
Дві частини статті покрили всю основу Enterprise-ready міграцій:
Концептуальна частина (Part 1):
Up() (нова версія) + Down() (відкат), .Designer.cs (BuildTargetModel)database update: читає __EFMigrationsHistory + виконує лише нові міграції у транзакціїProduction-ready частина (Part 2):
migrations script -o file.sql — для DBA review, аудиту, стандартних DB-інструментів--idempotent — IF NOT EXISTS блоки, можна запускати N разів безпечноmigrations bundle --self-contained — автономний бінарник без SDKDatabase.MigrateAsync (просто, для MVP), Kubernetes Job (ізольований), CI Pipeline Step (з review, для enterprise)GetPendingMigrationsAsync(), MigrateAsync(), HealthCheckНаступна стаття — Міграції: Просунуті Сценарії (стаття 24) — deep dive у кастомні SQL в міграціях, data migrations, squash міграцій, та reverse engineering існуючих баз.
Міграції в EF Core — Основи (Частина 1)
Що таке міграції і чому вони існують — від хаосу ручних SQL-скриптів до версійованої схеми БД. Анатомія файлу міграції, ModelSnapshot, команди dotnet ef. Альтернативи — DbUp, Flyway, Liquibase. Скасування міграцій.
Міграції — Просунуті Сценарії (Частина 1)
Коли стандартних міграцій недостатньо — migrationBuilder.Sql() для складних DDL. Data migrations для безпечного переміщення даних разом зі схемою. Проблема перейменування — Rename vs Drop+Create. Squashing міграцій для прибирання накопиченого боргу.