Bạn Có Thể Đang Làm EF Migrations Sai Cách…

Chạy MigrateAsync() khi khởi động? Bạn đang trao cho ứng dụng của mình quyền chủ sở hữu database và hy vọng không có gì sai sót. Có một cách tốt hơn – EF migration bundles cho phép bạn chạy migrations như một bước CI có kiểm soát, giữ cho ứng dụng production của bạn an toàn. Nhưng vấn đề là: đôi khi cách “sai” lại thực sự ổn. Hãy cùng khám phá khi nào nên sử dụng từng cách tiếp cận.

Tài liệu chính thức: Tổng quan về Migrations | Áp dụng Migrations | Bundles

Cách “Sai” (Mà Tôi Vẫn Dùng)

Blog này sử dụng MigrateAsync() khi khởi động – cách tiếp cận mà tôi sắp khuyên bạn không nên dùng. Đây là lý do tại sao điều đó ổn với tôi, và tại sao nó có thể không ổn với bạn.

Trong file Program.cs của tôi có đoạn code sau:

using (var scope = app.Services.CreateScope())
{
    var blogContext = scope.ServiceProvider.GetRequiredService<IMostlylucidDBContext>();
    await blogContext.Database.MigrateAsync();
}

MigrateAsync() áp dụng các migrations đang chờ và tạo database nếu cần. Đơn giản – nhưng có vấn đề:

  1. Phụ thuộc khi khởi động – Database hỏng? Migration thất bại? Ứng dụng của bạn sẽ không khởi động được.
  2. Vi phạm bảo mật – Ứng dụng của bạn cần quyền db_owner. Bạn vừa trao cho ứng dụng runtime của mình chìa khóa để xóa bảng.

Tại sao tôi có thể làm vậy: dữ liệu công khai, mạng Docker đơn lẻ, dự án cá nhân. Bạn có thể không làm được như vậy.

Khi Nào Runtime Migrations Ổn

  • Dev cục bộ – Lặp nhanh hơn nghi thức
  • Dự án cá nhân – Phạm vi ảnh hưởng thấp, không có dữ liệu nhạy cảm
  • Môi trường dev Docker-compose – Sự tiện lợi chiến thắng
  • Xây dựng nguyên mẫu (Prototyping) – Schema thay đổi liên tục

Khi Nào Chúng Không Ổn

  • Nhiều phiên bản ứng dụng – Vô số điều kiện race
  • Dữ liệu nhạy cảm – PII, tài chính, có quy định = cần phân tách đúng cách
  • Production với người dùng thực – Migration thất bại = gián đoạn dịch vụ

Cách Đúng: EF Bundles

EF bundle là một file thực thi độc lập chứa các migrations đã được biên dịch của bạn. Hãy nghĩ về nó như dotnet ef database update được đóng gói thành một file .exe độc lập.

Tại sao bundles thắng thế:

  • Không phụ thuộc runtime – Máy đích không cần SDK hoặc EF CLI
  • Phân tách đúng cách – Ứng dụng không bao giờ cần db_owner; chỉ CI runner cần, và chỉ trong lúc triển khai
  • Hiển thị trong CI – Lỗi hiển thị trong log pipeline, không bị chôn vùi trong quá trình khởi động ứng dụng
  • An toàn rollback – Migration thất bại? Triển khai dừng lại trước khi code lỗi được deploy
  • Tính idempotent – Theo dõi những gì đã được áp dụng, chỉ chạy những gì cần thiết

Lưu ý: Để đạt bảo mật cấp production, hãy sử dụng Managed Identity thay vì connection strings. Nhưng bundles vẫn là một bước tiến lớn so với runtime migrations.

Ví dụ GitHub Actions

- name: Install EF Core tools
  run: dotnet tool install --global dotnet-ef

- name: Add EF tools to PATH
  run: echo "$HOME/.dotnet/tools" >> $GITHUB_PATH

- name: Generate EF migration bundle
  run: |
    dotnet ef migrations bundle \
      --project ${{ env.WEB_PROJECT }} \
      --output efbundle.exe \
      --configuration ${{ env.BUILD_CONFIGURATION }} \
      --runtime ${{ env.RUNTIME_IDENTIFIER }} \
      --context AdminDbContext \
  env:
    AdminSite__ConnectionString: ${{ secrets.PROD_SQL_CONNECTIONSTRING }}

- name: Run EF migration bundle
  run: |
    ./efbundle.exe
  env:
    AdminSite__ConnectionString: ${{ secrets.PROD_SQL_CONNECTIONSTRING }}

Bundle đọc connection strings từ biến môi trường và áp dụng các migrations đang chờ. Đã áp dụng rồi? Nó chỉ thoát với mã thành công.

Local Bundles

Không có CI? Muốn kiểm tra trước khi push? Xây dựng bundles cục bộ.

Trường hợp sử dụng: Kiểm tra trước CI, bàn giao cho DBA (file exe độc lập, không cần SDK), triển khai staging, debug với --verbose.

Tạo Bundle

# Cài đặt EF CLI (một lần)
dotnet tool install --global dotnet-ef

# Bundle cơ bản
dotnet ef migrations bundle \
    --project Mostlylucid.DbContext \
    --startup-project Mostlylucid \
    --output efbundle.exe

# Self-contained (bao gồm runtime - có thể chạy trên máy không có .NET)
dotnet ef migrations bundle \
    --project Mostlylucid.DbContext \
    --startup-project Mostlylucid \
    --output efbundle.exe \
    --self-contained

# Đa nền tảng (vd: build trên Windows, deploy lên Linux)
dotnet ef migrations bundle \
    --project Mostlylucid.DbContext \
    --startup-project Mostlylucid \
    --output efbundle \
    --runtime linux-x64

Chạy Bundle Của Bạn

# Sử dụng connection string mặc định từ appsettings.json
./efbundle.exe

# Ghi đè với connection string cụ thể
./efbundle.exe --connection "Host=localhost;Database=mostlylucid;Username=postgres;Password=secret"

# Sử dụng biến môi trường (khớp với key config của bạn)
$env:ConnectionStrings__DefaultConnection="Host=localhost;..." # PowerShell
export ConnectionStrings__DefaultConnection="Host=localhost;..." # Bash
./efbundle.exe

Tùy Chọn Bundle Hữu Ích

# Xem migrations nào sẽ được áp dụng mà không chạy thực tế
./efbundle.exe --dry-run

# Đầu ra chi tiết để debug
./efbundle.exe --verbose

# Áp dụng migrations đến một migration cụ thể (hữu ích cho kiểm thử)
./efbundle.exe --target-migration "20231115_AddUserTable"

# Kết hợp các tùy chọn
./efbundle.exe --verbose --dry-run

Quy Trình Kiểm Thử Cục Bộ

# 1. Tạo migration
dotnet ef migrations add AddNewFeature \
    --project Mostlylucid.DbContext \
    --startup-project Mostlylucid

# 2. Xây dựng bundle
dotnet ef migrations bundle \
    --project Mostlylucid.DbContext \
    --startup-project Mostlylucid \
    --output efbundle.exe

# 3. Chạy thử trước
./efbundle.exe --dry-run --verbose

# 4. Chạy thực tế
./efbundle.exe --verbose

# 5. Hỏng? Xóa và thử lại
dotnet ef migrations remove \
    --project Mostlylucid.DbContext \
    --startup-project Mostlylucid

Phát hiện lỗi cú pháp, vi phạm ràng buộc, vấn đề khóa ngoại – tất cả trước khi CI hoặc production.

Hiệu Suất Build

Tạo bundle chậm – 30+ giây trên các dự án lớn. Đừng tạo bundle ở mọi lần build.

  • Tạo thủ công khi kiểm thử cục bộ
  • Tạo trong CI chỉ khi triển khai, không phải mọi PR
  • Cache bundles nếu migrations chưa thay đổi

Nếu bạn thực sự muốn tự động tạo, thêm một MSBuild target:

<Target Name="BuildMigrationBundle">
  <Exec Command="dotnet ef migrations bundle --output $(OutputPath)efbundle.exe --force" />
</Target>

Sau đó: dotnet build -t:BuildMigrationBundle

Phương Pháp Kết Hợp (Hybrid)

Điều tốt nhất của cả hai thế giới: tiện lợi ở cục bộ, bảo mật ở production.

if (builder.Environment.IsDevelopment())
{
    using var scope = app.Services.CreateScope();
    var context = scope.ServiceProvider.GetRequiredService<IMostlylucidDBContext>();
    await context.Database.MigrateAsync();
}
// Production: CI pipeline chạy bundle

Các Giải Pháp Thay Thế Cho Bundles

SQL Scripts

Tạo SQL thuần thay vì file thực thi. Tuyệt vời cho việc xem xét của DBA và quy trình quản lý thay đổi hiện có.

# Tất cả migrations
dotnet ef migrations script --output migrations.sql

# Idempotent (an toàn để chạy nhiều lần) - SỬ DỤNG CÁI NÀY
dotnet ef migrations script --idempotent --output migrations.sql

# Phạm vi migrations
dotnet ef migrations script FromMigration ToMigration --output migrations.sql

Ưu điểm: Hiển thị toàn bộ, bất kỳ SQL client nào cũng chạy được, thân thiện với kiểm soát phiên bản, quy trình phê duyệt DBA.

Nhược điểm: Không có tự động theo dõi (dùng --idempotent), thực thi thủ công, có thể bị lệch nếu script bị sửa đổi.

Xem tài liệu chính thức về SQL scripts.

SQL Scripts trong CI

- name: Generate and apply migrations
  run: |
    dotnet ef migrations script --idempotent --output migrations.sql
    # SQL Server
    sqlcmd -S ${{ secrets.DB_SERVER }} -d ${{ secrets.DB_NAME }} -i migrations.sql
    # Hoặc PostgreSQL
    PGPASSWORD=${{ secrets.DB_PASSWORD }} psql -h ${{ secrets.DB_HOST }} -f migrations.sql

DACPAC (Chỉ SQL Server)

DACPACsdựa trên trạng thái chứ không phải dựa trên migration. Bạn định nghĩa schema mong muốn, và SqlPackage so sánh nó với database mục tiêu.

SqlPackage.exe /Action:Publish /SourceFile:MyDatabase.dacpac /TargetConnectionString:"..."

Ưu điểm: Schema dưới dạng code, tự động tạo diff, xử lý mọi thứ (bảng, views, SPs, indexes), công cụ doanh nghiệp.

Nhược điểm: Chỉ SQL Server, schema ở hai nơi (EF models + SQL project), engine diff có thể đưa ra quyết định không tốt, đổi tên cột trông giống như drop+add.

Xem tài liệu SqlPackage.

Bảng So Sánh

Phương Pháp Tốt Nhất Cho Cần .NET Tự Động Theo Dõi Thân Thiện DBA Đa Nền Tảng DB
MigrateAsync() Dev/dự án nhỏ Có (runtime) Không
EF Bundles Pipeline CI/CD Không (self-contained) Tương đối
SQL Scripts Môi trường do DBA kiểm soát Không Với --idempotent
DACPAC SQL Server doanh nghiệp Không Có (dựa trên trạng thái) Không

Mẹo

Bẫy File Designer

Migrations hoạt động ở cục bộ nhưng không trong CI? Kiểm tra bạn đã commit cả hai file:

  • 20231115_AddUserTable.cs – Code migration
  • 20231115_AddUserTable.Designer.cs – Snapshot model

Thiếu file Designer = lỗi im lặng.

Nhiều DbContexts

dotnet ef migrations bundle --context BlogDbContext --output blog-migrations.exe
dotnet ef migrations bundle --context IdentityDbContext --output identity-migrations.exe

Thứ Tự Ưu Tiên Connection String

  1. Tham số --connection
  2. Biến môi trường
  3. appsettings.json

Sử dụng biến môi trường trong CI.

IDesignTimeDbContextFactory

Công cụ EF cần khởi tạo DbContext của bạn. Nếu DbContext của bạn nằm trong một project riêng hoặc có startup phức tạp, hãy triển khai IDesignTimeDbContextFactory<T>:

public class AdminDbContextFactory : IDesignTimeDbContextFactory<AdminDbContext>
{
    public AdminDbContext CreateDbContext(string[] args)
    {
        var config = new ConfigurationBuilder()
            .SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json", optional: true)
            .AddEnvironmentVariables()
            .AddUserSecrets<AdminDbContextFactory>()
            .Build();

        var connectionString = config["AdminSite:ConnectionString"]
            ?? throw new InvalidOperationException("Missing connection string");

        var optionsBuilder = new DbContextOptionsBuilder<AdminDbContext>();
        optionsBuilder.UseSqlServer(connectionString, sql => sql.CommandTimeout(120));

        return new AdminDbContext(optionsBuilder.Options);
    }
}

Sử dụng khi: DbContext trong project riêng, startup phức tạp, cần User Secrets cho design-time.

Còn Về…?

Các câu hỏi phổ biến và phản hồi tôi đã nhận được.

“Tại sao không chạy dotnet ef database update trong CI?”

Đã đề cập ở trên, nhưng phiên bản ngắn: bundles là các artifact di động. Bước triển khai của bạn không cần EF CLI, mã nguồn, hoặc phân giải design-time. Cùng một bundle chạy trong test, staging và prod – không sai lệch.

“Điều này có quá mức cần thiết cho một ứng dụng nhỏ không?”

Có thể. Nếu bạn làm việc một mình, dữ liệu công khai và phạm vi ảnh hưởng thấp – MigrateAsync() là ổn. Nhưng ngay khi bạn thêm nhà phát triển thứ hai, dữ liệu nhạy cảm hoặc nhiều môi trường, bundles sẽ tự trả phí cho chính nó.

“Còn rollbacks thì sao?”

EF không tự động rollback. Các lựa chọn:

  • Tạo một migration Down() và chạy nó (nhưng bạn phải đã viết nó)
  • Khôi phục từ bản sao lưu
  • Viết migration thủ công để hoàn tác các thay đổi

Đối với các hệ thống quan trọng: kiểm thử migrations trên một bản sao database trước.

“Tôi có thể chạy migrations trong Kubernetes init container không?”

Có. Bundle + init container là một mẫu thiết kế tốt:

initContainers:
  - name: migrate
    image: myapp:latest
    command: ["./efbundle.exe"]
    env:
      - name: ConnectionStrings__Default
        valueFrom:
          secretKeyRef:
            name: db-secrets
            key: connection-string

Container ứng dụng đợi init hoàn thành.

“Còn FluentMigrator / DbUp / các công cụ khác?”

Chúng hoạt động tốt. EF bundles là giải pháp gốc của EF, nhưng FluentMigratorDbUp cũng có những người hâm mộ riêng. Sự khác biệt chính: đó là các công cụ chuyên về migration, trong khi EF bundles đến từ model EF hiện có của bạn.

“DBA của tôi muốn xem xét tất cả SQL trước khi chạy”

Sử dụng script --idempotent:

dotnet ef migrations script --idempotent --output migrations.sql

DBA xem xét và phê duyệt. Sau đó:

  • Chạy script thủ công, hoặc
  • Sau khi được phê duyệt, chạy bundle (thực hiện điều tương tự)

“Làm thế nào để xử lý migrations với zero downtime?”

Đó là câu hỏi về chiến lược triển khai, không phải câu hỏi về migrations. Nhìn chung:

  1. Làm cho migrations tương thích ngược (thêm cột nullable, không đổi tên)
  2. Triển khai code mới xử lý cả schema cũ và mới
  3. Chạy migration
  4. Triển khai code chỉ sử dụng schema mới
  5. Dọn dẹp (xóa cột cũ trong migration sau)

Bundles không giải quyết vấn đề này – chúng chỉ làm cho bước 3 dễ dự đoán hơn.

Chỉ mục