Oğuzhan KARAGÜZEL

OĞUZHAN KARAGÜZEL

Yazılım geliştirici

EF Core Migration'larını Production'da Güvenle Uygulamak

Yazar: Oğuzhan KARAGÜZEL ·

İşte projelerimizde DB First ilerliyoruz. Tablo değişikliğini önce test veritabanında bir SQL scriptiyle yapıyorum, kodu ona göre güncelliyorum, testler geçiyor. Sonra yayın günü geliyor. Uygulamayı canlıya alıyorum ve uygulama çöküyor. Sebep ise her seferinde aynı: scripti test veritabanında çalıştırdım ama canlıda çalıştırmayı unuttum.

Genelde birkaç dakikada çözüyorum. Scripti canlıda çalıştırıyorum, uygulama ayağa kalkıyor. Ama o birkaç dakika boyunca proje hata veriyor. Hatanın sebebi teknik bir problem değil, benim unutkanlığım.

Asıl sorun şu: kod bir yoldan, tablo başka bir yoldan yayına çıkıyor. İkisini birbirine bağlayan tek şey benim hafızam. Bu yazıda EF Core'un migration araçlarıyla bu bağı nasıl koda dönüştürebileceğimizi, hangi yöntemin ne zaman uygun olduğunu ve production'da nelere dikkat etmek gerektiğini anlatacağım.

Migration aslında ne işe yarar?

Code First'te modelde yaptığımız her değişiklik bir migration sınıfına dönüşür. Bu sınıfın iki metodu vardır: Up değişikliği uygular, Down geri alır. EF Core hangi migration'ların uygulandığını veritabanındaki __EFMigrationsHistory tablosuna yazar.

Bu tablo sayesinde veritabanı hangi değişikliklerin uygulandığını biliyor. "Canlıda bu script çalıştı mı?" sorusunun cevabı artık benim hafızamda değil, veritabanında.

Migration oluşturmanın ve yönetmenin temellerini EF Core Code First Migration Yönetimi yazısında anlattım. DB First, Code First ve ikisinin karışımı arasında nasıl seçim yaptığımı da EF Core İlişkiler ve Mixed Yaklaşım yazısında bulabilirsiniz. Bu yazıda işin canlı tarafına odaklanıyorum.

Dört yöntem

EF Core, migration'ları canlıya taşımak için dört yol sunuyor.

YöntemNasıl çalışırNe zaman uygun
Database.Migrate()Uygulama açılırken bekleyen migration'ları uygularTek sunucu, veritabanını uygulamanın yönettiği projeler
dotnet ef database updateGeliştirici makinesinden ya da CI'dan doğrudan uygularGeliştirme ve test ortamları
SQL scriptMigration'lar SQL dosyasına çevrilir, elle ya da pipeline'da çalıştırılırDeğişikliği uygulamadan önce birinin incelemesi gerekiyorsa
Migration bundleMigration'lar tek bir çalıştırılabilir dosyaya paketlenirCI/CD hatları, Docker

Microsoft'un dokümanı production için SQL script ya da bundle öneriyor. Ben kendi projem Elevare'de Migrate() kullanıyorum. Nedenini aşağıda anlatacağım.

Uygulama açılırken Migrate()

C#
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();

Bu yöntemi seçmemin sebebi, kontrolün bende olduğunu hissetmem. Uygulama ayağa kalktığında tabloların kodla uyumlu olduğundan eminim. Girişte anlattığım unutkanlık burada yaşanamaz, çünkü kod canlıya çıktığı anda tablo değişiklikleri de onunla birlikte geliyor.

Tabii bu yöntemin eksileri de var:

  • Yetki: Uygulamanın veritabanı kullanıcısı tablo oluşturup silebilmeli. Normalde bir uygulamaya bu kadar yetki vermek istemeyiz.
  • Birden fazla sunucu: İki sunucu aynı anda açılırsa ikisi de migration çalıştırmaya kalkabilir. EF Core 9'dan beri migration'lar başlamadan önce veritabanında bir kilit alınıyor. PostgreSQL'de logu açınca bu satır görülür:

    Çıktı
    LOCK TABLE "__EFMigrationsHistory" IN ACCESS EXCLUSIVE MODE

    İkinci sunucu bu kilidin açılmasını bekliyor, sonra uygulanacak bir şey kalmadığını görüp devam ediyor. Yine de kilit beklerken açılış uzayabilir.

  • Hata olursa: Migration yarıda kalırsa uygulama hiç açılmıyor. Açılış süresi de uzuyor; Docker kullanıyorsanız sağlık kontrolünün bekleme süresini buna göre ayarlamanız gerekiyor.

Elevare tek sunucuda çalışıyor ve veritabanını yalnızca uygulamanın kendisi kullanıyor. Bu şartlarda Migrate() benim için en sade yol. Ama her projede bunu kullanmıyorum. Birden fazla sunucuda çalışan ya da her değişikliğin bir DBA'den onay alması gereken bir projede script ya da bundle kullanırım. Hangisini seçeceğime projenin ihtiyacına bakarak karar veriyorum.

SQL script üretmek

Bash
dotnet ef migrations script --idempotent -o migrate.sql

Bu komut bütün migration'ları tek bir SQL dosyasına çeviriyor. Buradaki --idempotent parametresi önemli: her migration'ın başına "bu zaten uygulanmış mı?" kontrolü ekliyor. PostgreSQL için üretilen script'ten bir parça:

SQL
START TRANSACTION;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20261103004538_RenameName') THEN
    ALTER TABLE "Customers" RENAME COLUMN "Name" TO "FullName";
    END IF;
END $EF$;

DO $EF$
BEGIN
    IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20261103004538_RenameName') THEN
    INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
    VALUES ('20261103004538_RenameName', '10.0.14');
    END IF;
END $EF$;
COMMIT;

Her migration kendi transaction'ı içinde. Önce __EFMigrationsHistory tablosuna bakılıyor, migration orada yoksa değişiklik yapılıyor ve tabloya kaydediliyor. Yani aynı scripti iki kez çalıştırsanızda bir şey bozulmaz. Hangi migration'ların uygulandığını bilmediğiniz bir veritabanında bile rahatlıkla kullanabilirsiniz.

Belirli bir aralık istersem:

Bash
dotnet ef migrations script AddOrders AddInvoices

Bu yöntemin en büyük artısı, değişiklik canlıya gitmeden önce birinin SQL'i okuyabilmesi.

Migration bundle

Bash
dotnet ef migrations bundle --self-contained -r linux-x64
./efbundle --connection "Host=...;Database=...;Username=...;Password=..."

Bundle, migration'ları tek bir çalıştırılabilir dosyaya paketliyor. Kendi makinemde Windows için ürettiğim bundle'ı çalıştırınca çıktı şöyleydi:

Çıktı
Acquiring an exclusive lock for migration application.
Applying migration '20261103004538_RenameName'.
Applying migration '20261103004542_DropPhone'.
Applying migration '20261103004552_RenameAndLimit'.
Done.

Aynı bundle'ı ikinci kez çalıştırdığımda:

Çıktı
No migrations were applied. The database is already up to date.
Done.

--self-contained ile üretilen dosya .NET çalışma zamanını da içinde taşıyor, o yüzden sunucuda .NET kurulu olması gerekmiyor. Bunun bedeli boyut: benim küçük örneğimde bile dosya 107 MB oldu. Sunucuda .NET zaten kuruluysa bu parametreyi kullanmayabilirsiniz.

CI'da üretip yayın adımında uygulamadan önce çalıştırmak için çok uygun. Bu yöntemde uygulamanın tablo oluşturma yetkisine ihtiyacı kalmıyor. O yetki yalnızca bundle'ı çalıştıran kullanıcıda oluyor.

Veri kaybettiren değişiklikler

EF Core bir kolonun silineceğini fark ettiğinde migration oluştururken uyarıyor:

Çıktı
An operation was scaffolded that may result in the loss of data.
Please review the migration for accuracy.

Bu uyarıyı hiçbir zaman geçiştirmeyin. Ne kadar kolay veri kaybedilebileceğini görmek için küçük bir deneme yaptım.

Customer sınıfındaki Name property'sinin adını FullName yaptım. EF bunu doğru anladı ve migration'a şunu yazdı:

C#
migrationBuilder.RenameColumn(
    name: "Name",
    table: "Customers",
    newName: "FullName");

Sonra aynı anda iki şey değiştirdim: adını DisplayName yaptım ve [MaxLength(200)] ekledim. Bu sefer EF ad değişikliğini tanıyamadı:

C#
migrationBuilder.DropColumn(
    name: "FullName",
    table: "Customers");

migrationBuilder.AddColumn<string>(
    name: "DisplayName",
    table: "Customers",
    type: "character varying(200)",
    maxLength: 200,
    nullable: false,
    defaultValue: "");

Tabloya önceden "Ayşe Yılmaz" adında bir müşteri eklemiştim. Migration'ı uyguladıktan sonra tablo şöyleydi:

Çıktı
 Id | DisplayName
----+-------------
  1 |

İsim gitmişti. Uyarı da bunu söylüyordu ama migration hatasız uygulandı. Doğrusu, migration dosyasını elle düzeltip DropColumn ve AddColumn yerine RenameColumn ve ardından AlterColumn yazmak. Bu yüzden her migration dosyasını uygulamadan önce açıp okuyorum. Bir de değişiklikleri küçük tutmaya çalışıyorum: ad değişikliği bir migration, tip değişikliği başka bir migration.

Veriyi taşımam gerektiğinde migrationBuilder.Sql() kullanıyorum. Elevare'de önizleme taslaklarını ayrı kolonlara taşırken böyle bir migration yazdım. Önce yeni kolonları ekledim. Sonra onay bekleyen bir isteği olmayan sayfalarda eski kolondaki veriyi yenisine kopyaladım ve eskisini boşalttım. Bu, bir sonraki bölümdeki yöntemin küçük bir örneği.

Kesintisiz yayın: genişlet ve daralt

Bazen eski kodun yeni tabloyla, yeni kodun da eski tabloyla bir süre birlikte çalışması gerekir. Böyle durumlarda değişikliği üç adıma bölüyorum:

  1. Genişlet: Yeni kolonu ekle, eskisine dokunma. Veriyi kopyala.
  2. Geçir: Kodu yeni kolonu kullanacak şekilde yayınla.
  3. Daralt: Her şey yeni kolona geçtikten sonra, ayrı bir migration'la eski kolonu sil.

Bu yöntem daha fazla işe sebep oluyor . Ama hiçbir adımda veri kaybı ya da çökme riski yok.

Geri alma

Bash
dotnet ef database update OncekiMigration
dotnet ef migrations script SimdikiMigration OncekiMigration

İlk komut değişikliği doğrudan geri alıyor, ikincisi geri alma scriptini üretiyor. Ama şunu bilmek lazım: kolon silen bir migration'ı geri almak kolonu geri getirir, içindeki veriyi getirmez.

Yukarıdaki denemede iki migration'ı geri aldım:

Çıktı
Reverting migration '20261103004552_RenameAndLimit'.
Reverting migration '20261103004542_DropPhone'.
Done.

Kolonlar geri geldi, veriler gelmedi:

Çıktı
 Id | FullName | Phone
----+----------+-------
  1 |          |

Bu yüzden canlıda asıl güvencem Down metodu değil, migration'dan önce aldığım yedek. Bir şey ters giderse çoğu zaman geri almak yerine yeni bir düzeltme migration'ı yazıp ileriye doğru gidiyorum.

DB First kullanıyorsanız

EF Core'un migration araçları Code First için. Ama girişteki unutkanlık DB First'te de çözülebilir:

  • Tablo değişikliği scriptlerini repoda numaralı dosyalar olarak tutun.
  • Hangi scriptin uygulandığını bir tabloya kaydedin, yani __EFMigrationsHistory tablosunun yaptığı işi siz yapın. DbUp gibi küçük kütüphaneler bunu hazır olarak sunuyor.
  • Uygulama açılırken veritabanının beklenen sürümde olup olmadığını kontrol edin. Değilse anlaşılır bir hata verin.

Böylece "canlıda çalıştırdım mı?" sorusunun cevabı yine hafızanızda değil, veritabanında olur.

Kontrol listesi

  • Migration dosyasını okudum. Veri kaybı uyarısı yok ya da bilerek kabul ettim.
  • Test veritabanında uyguladım ve uygulamayı orada çalıştırdım.
  • Canlı veritabanının yedeğini aldım.
  • Hangi yöntemle uygulayacağım belli: Migrate(), script ya da bundle.
  • Bir şey ters giderse ne yapacağım belli: yedekten dönüş ya da düzeltme migration'ı.