Mert Özen Her Satırda Daha İleri
DevOps GitOps ve Argo CD — #04

Sync Wave'ler ve Hook'lar: Migration Job'ını Deploy'dan Önce Çalıştırmak

Mert Özen 15 Kas 2026 10 dk 5 görüntülenme
Sync Wave'ler ve Hook'lar: Migration Job'ını Deploy'dan Önce Çalıştırmak

Argo CD'de migration Job'ını deploy'dan önce çalıştırmanın yolu onu bir hook yapmak ve sırayı sync wave'lerle vermek. Düz Job'ın ikinci sürümde immutable hatası vermesini, PreSync hook'unun boş bir ortamda veritabanından önce çalışıp başarısız olmasını, Sync hook'u ve wave -1/0/1 ile doğru sırayı, PostSync smoke test'ini, SyncFail ve PreDelete hook'larını ve expand-contract ile uyumu gerçek çıktılarla görüyoruz.

Sürüm notu: Bu yazıdaki örnekler kind 0.33 ile Kubernetes 1.37, Argo CD 3.6.1, .NET 10, EF (Entity Framework; .NET'in veritabanı erişim kütüphanesi) Core 10.0.15, Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 ve PostgreSQL 18 ile denenmiştir.

Önce Tahmin Et

Bookstore API'sinin veritabanı migration'ı bir Kubernetes Job'ı olarak config repo'sunda duruyor ve Argo CD onu diğer manifest'lerle birlikte uyguluyor. İlk deploy sorunsuz geçti. Bir sonraki sürümde migrator image'ının tag'ini 1.0.0'dan 1.1.0'a çeviren bir commit atıyorsun. Automated sync açık. Sence ne olur: Job yeni image ile tekrar mı çalışır, eski Job silinip yenisi mi yaratılır, yoksa sync hata mı verir?

Migration'ı Düz Bir Job Olarak Koymak

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'si EF Core kullanıyor ve şemayı migration'larla kuruyor. Kubernetes serisinin on üçüncü yazısında migration'ı Deployment'tan ayırıp bir Job olarak çalıştırmıştık; aynı fikri Argo CD ile kuruyoruz. Bu seride API cluster içinde PostgreSQL kullandığı için PostgreSQL migration'ları ayrı bir projede (Bookstore.Migrations.Postgres) duruyor ve o projenin küçük bir console uygulaması bekleyen migration'ları uygulayıp çıkıyor:

using Bookstore.Data;
using Bookstore.Migrations.Postgres;
using Microsoft.EntityFrameworkCore;

// Bekleyen PostgreSQL migration'larını uygular ve çıkar: 0 = başarılı, 1 = hata.
// Connection string: ConnectionStrings__BookstoreDb environment variable'ı.
await using BookstoreContext db = new DesignTimeContextFactory().CreateDbContext(args);

try
{
    List<string> pending = [.. await db.Database.GetPendingMigrationsAsync()];
    Console.WriteLine(pending.Count == 0
        ? "No pending migrations."
        : $"Applying {pending.Count} migration(s): {string.Join(", ", pending)}");
    await db.Database.MigrateAsync();
    Console.WriteLine($"Done. Last migration: {(await db.Database.GetAppliedMigrationsAsync()).LastOrDefault()}");
    return 0;
}
catch (Exception ex)
{
    Console.Error.WriteLine($"Migration failed: {ex.GetType().Name}: {ex.Message}");
    return 1;
}

Kubernetes serisindeki migration bundle'ı (dotnet ef migrations bundle) neden kullanmadık? Migration projesi, çalıştırılabilir bir proje olan Bookstore.Api'yi referans ediyor ve bundle build'i iki farklı RID (Runtime Identifier; hedef işletim sistemi ve mimari) çıktısından gelen aynı adlı apphost ve runtimeconfig.json dosyaları yüzünden NETSDK1152 hatasıyla durdu. Context ayrı bir class library'de olsaydı bundle çalışırdı; Bookstore'un yapısını bozmamak için birkaç satırlık bir migrator yazmak daha sade geldi. Argo CD açısından fark yok: ikisi de "çalış, bitir, çık" yapan bir image.

Job manifest'i base'e eklendi:

apiVersion: batch/v1
kind: Job
metadata:
  name: migrate
  labels:
    app.kubernetes.io/name: migrate
spec:
  backoffLimit: 2
  activeDeadlineSeconds: 300
  template:
    metadata:
      labels:
        app.kubernetes.io/name: migrate
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: gitopsseries-bookstore-migrator
          env:
            - name: ConnectionStrings__BookstoreDb
              valueFrom:
                secretKeyRef:
                  name: api-db
                  key: connection-string
NAME      STATUS     COMPLETIONS   DURATION   AGE
migrate   Complete   1/1           3s         20s
Applying 1 migration(s): 20261115025642_InitialCreate
Done. Last migration: 20261115025642_InitialCreate

Job çalıştı ve katalog doldu; /api/books?pageSize=2 artık "total":20 dönüyor. Log'un başında Npgsql'in chiseled image'da bulamadığı Kerberos kütüphanesiyle ilgili iki satır daha var; Kubernetes serisinin on üçüncü yazısında açıkladığımız gibi Kerberos kullanmıyorsan zararsız. Şimdi Önce Tahmin Et'teki commit:

batch  Job          bookstore-dev  migrate   OutOfSync  Healthy        error when patching "/dev/shm/2661775648": Job.batch "migrate" is invalid: spec.template: Invalid value: {...}: field is immutable

Cevap üçüncü seçenek: sync hata veriyor. Job'ın pod template'i immutable; Kubernetes yeni image'ı var olan Job'a yazmayı reddediyor. Kubernetes serisinde bunu Job adına sürüm koyarak (migrate-1-1-0) çözmüştük. Argo CD'de daha temiz bir yol var: Job'ı normal bir resource olmaktan çıkarıp bir hook yapmak.

Sync Phase'leri ve Hook'lar

Argo CD bir sync'i phase'lere böler. Sırayla PreSync, Sync ve PostSync çalışır; bir phase'teki hook'lar başarısız olursa sync durur ve SyncFail hook'ları çalışır. Bir resource'u hook yapan şey argocd.argoproj.io/hook annotation'ı. Hook'lar her sync'te yeniden yaratılır ve Argo CD onların bitmesini bekler; bir Job için "bitti" Complete olmak, başarısızlık Failed olmak demek. Ne zaman silineceklerini argocd.argoproj.io/hook-delete-policy belirler:

  • BeforeHookCreation: aynı adlı eski hook bir sonraki sync'te yenisi yaratılmadan önce silinir. Hiç policy yazmazsan varsayılan bu.
  • HookSucceeded: hook başarılı olunca silinir.
  • HookFailed: hook başarısız olunca silinir.

İlk denememiz plandaki gibi migration'ı PreSync'e almaktı. Yanına bir PostSync smoke test'i ve bir SyncFail bildirimi ekledik:

apiVersion: batch/v1
kind: Job
metadata:
  name: smoke-test
  annotations:
    argocd.argoproj.io/hook: PostSync
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation,HookSucceeded
spec:
  backoffLimit: 1
  activeDeadlineSeconds: 120
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: smoke-test
          image: curlimages/curl:8.17.0
          args:
            - --fail
            - --silent
            - --show-error
            - --retry
            - "5"
            - --retry-all-errors
            - --max-time
            - "5"
            - http://api/api/books?pageSize=1
apiVersion: batch/v1
kind: Job
metadata:
  name: sync-failed
  annotations:
    argocd.argoproj.io/hook: SyncFail
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: notify
          image: curlimages/curl:8.17.0
          # Gerçek kurulumda bir chat'e ya da webhook'a post eder; burada yalnız log yazar.
          command: ["sh", "-c", "echo \"sync of $(APP) failed, migrations or deploy did not finish\""]
          env:
            - name: APP
              value: bookstore-api

Commit'ten sonra beklenmedik bir şey oldu: Argo CD eski, hook olmayan migrate Job'ını "pruned" diye sildi ve uygulamayı Synced gösterdi, ama hiçbir hook çalışmadı. Event'te sebep yazıyordu: "Partial sync operation ... succeeded". Self-heal yalnız OutOfSync olan resource'u sync eden seçici (selective) bir sync başlatmıştı ve doküman açık: hook'lar seçici sync'te çalışmaz. Tam bir sync'i elle başlattık:

argocd app sync bookstore-api-dev
Phase:              Succeeded
Duration:           18s
Message:            successfully synced (no more tasks)

GROUP  KIND         NAMESPACE      NAME        STATUS     HEALTH   HOOK      MESSAGE
batch  Job          bookstore-dev  migrate     Succeeded  Synced   PreSync   Reached expected number of succeeded pods
       Service      bookstore-dev  postgres    Synced     Healthy            service/postgres unchanged
       Service      bookstore-dev  api         Synced     Healthy            service/api unchanged
apps   Deployment   bookstore-dev  api         Synced     Healthy            deployment.apps/api unchanged
apps   StatefulSet  bookstore-dev  postgres    Synced     Healthy            partitioned roll out complete: 1 new pods have been updated...
batch  Job          bookstore-dev  smoke-test  Succeeded  Synced   PostSync  Reached expected number of succeeded pods

Migration PreSync'te, smoke test PostSync'te çalıştı; migrator "No pending migrations." dedi, çünkü şema zaten güncel. Smoke test başarılı olunca HookSucceeded yüzünden silindi. Immutable hatası da kayboldu: BeforeHookCreation eski Job'ı her sync'te silip yenisini yarattığı için Job'ın template'i artık hiç güncellenmiyor.

Boş Bir Ortamda PreSync'in Sorunu

Dev'de veritabanı zaten çalışıyordu. Peki yeni bir ortamda? Application'ı argocd app delete bookstore-api-dev ile silip (finalizer olduğu için kaynakları da silindi) aynı YAML (manifest'lerin yazıldığı metin formatı) dosyasından yeniden yarattık; PostgreSQL'in diski olan PVC (PersistentVolumeClaim; pod'un istediği kalıcı disk) ve Git dışında tuttuğumuz Secret kaldı, geri kalan her şey silindi. Bir dakika sonra:

GROUP  KIND         NAMESPACE      NAME         STATUS     HEALTH   HOOK     MESSAGE
batch  Job          bookstore-dev  migrate      Running    Synced   PreSync  job.batch/migrate created
       Service      bookstore-dev  api          OutOfSync  Missing
       Service      bookstore-dev  postgres     OutOfSync  Missing
apps   Deployment   bookstore-dev  api          OutOfSync  Missing
apps   StatefulSet  bookstore-dev  postgres     OutOfSync  Missing
batch  Job          bookstore-dev  sync-failed             Healthy           
Migration failed: NpgsqlException: Name or service not known
sync of bookstore-api failed, migrations or deploy did not finish
one or more synchronization tasks completed unsuccessfully. Retrying attempt #3 at 2026-11-15T03:25:32Z.

PreSync, Sync phase'indeki her şeyden önce çalışır; veritabanının StatefulSet'i ve Service'i de Sync phase'inde. Migration, henüz var olmayan postgres adını çözemedi, Job üç denemede başarısız oldu, sync başarısız sayıldı ve sync-failed hook'u log'una mesajını yazdı. Automated sync'in varsayılan retry'ı devreye girdi ve aynı hatayı tekrar tekrar üretmeye başladı. Döngüyü argocd app terminate-op bookstore-api-dev ile durdurduk. "PreSync'te migration" tarifi, veritabanı Argo CD'nin başka bir Application'ında ya da cluster dışında yönetiliyorsa doğru; aynı Application'daysa sıralama yanlış.

Sync Wave'ler: Phase İçinde Sıra

04-sync-phases-waves-tr.png

Phase'ler kaba sırayı verir; bir phase'in içindeki sırayı argocd.argoproj.io/sync-wave annotation'ı belirler. Değer bir tamsayı, varsayılanı 0 ve negatif olabilir. Argo CD resource'ları önce phase'e, sonra wave'e, sonra kind'a (namespace'ler önce gibi) ve ada göre sıralar; bir wave'deki her şey Healthy olmadan sonraki wave'e geçmez ve wave'ler arasında varsayılan 2 saniyelik bir bekleme var. Migration'ı Sync phase'ine bir hook olarak aldık ve veritabanını ondan önceki, API'yi sonraki wave'e koyduk:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  annotations:
    argocd.argoproj.io/sync-options: Prune=false
    argocd.argoproj.io/sync-wave: "-1"
apiVersion: batch/v1
kind: Job
metadata:
  name: migrate
  annotations:
    # Wave 0'da bir Sync hook'u: postgres (wave -1) healthy olduktan sonra, api'den (wave 1) önce çalışır.
    argocd.argoproj.io/hook: Sync
    argocd.argoproj.io/sync-wave: "0"
    argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  annotations:
    argocd.argoproj.io/sync-wave: "1"

postgres Service'ine de "-1" yazdık. Commit'ten sonra automated sync boş ortamı baştan kurdu:

GROUP  KIND         NAMESPACE      NAME         STATUS     HEALTH   HOOK      MESSAGE
       Service      bookstore-dev  postgres     Synced     Healthy            service/postgres created
apps   StatefulSet  bookstore-dev  postgres     Synced     Healthy            partitioned roll out complete: 1 new pods have been updated...
       Service      bookstore-dev  api          Synced     Healthy            service/api created
batch  Job          bookstore-dev  migrate      Succeeded  Synced   Sync      Reached expected number of succeeded pods
apps   Deployment   bookstore-dev  api          Synced     Healthy            deployment.apps/api created
batch  Job          bookstore-dev  smoke-test   Succeeded  Synced   PostSync  Reached expected number of succeeded pods
batch  Job          bookstore-dev  sync-failed             Healthy            

Namespace event'lerinden çıkardığımız zaman çizelgesi wave'lerin gerçekten sırayla aktığını gösteriyor: postgres pod'u 03:25:57'de, migration pod'u 03:25:59'da yaratıldı ve 03:26:02'de bitti; API pod'u 03:26:07'de, smoke test 03:26:09'da başladı ve 03:26:12'de tamamlandı. Migrator bu kez "No pending migrations." dedi: PVC silinmediği için şema da duruyordu. Listedeki sync-failed önceki başarısız sync'ten kalan hook; BeforeHookCreation onu yalnız bir sonraki SyncFail'de siler.

Bir de silme tarafı var: 3.3 ile gelen PreDelete hook'u yalnız Application silinirken, kaynaklar silinmeden önce çalışır (normal sync'te ve prune'da çalışmaz); PostDelete de silme bittikten sonra. Bir ortamı kaldırmadan önce veritabanı yedeği almak ya da dış bir kaydı temizlemek için kullanılır. Prune sırasında wave sırası tersine döner: yüksek wave'deki resource'lar önce silinir.

Expand-Contract ile Uyum

Wave'ler sıralamayı çözer ama bir gerçeği değiştirmez: migration bittiği an eski sürümün pod'ları hâlâ çalışıyor ve yeni şemayla konuşuyor. Wave 1'deki rolling update sırasında eski ve yeni sürüm birlikte trafik alıyor. Deployment Stratejileri serisinin "Migration'ların Deploy'daki Yeri" yazısındaki expand-contract kuralı burada da geçerli: sync'in içinde çalışan migration yalnız expand adımı (yeni kolon, yeni tablo, nullable alan) olmalı. Kolon silmek ya da yeniden adlandırmak gibi contract adımları, eski sürüm tamamen kalktıktan sonraki ayrı bir deploy'a aittir. Argo CD hook'u bu kuralı senin yerine uygulamaz; migration'ı "deploy'dan önce" çalıştırmak onu güvenli yapmaz. Bookstore'dan somut bir örnek: catalog.Books tablosundaki Description kolonunu Summary olarak yeniden adlandırmak istiyorsun. Tek migration ile yaparsan wave 0 kolonu yeniden adlandırır, wave 1'in rolling update'i bitene kadar eski pod'lar Description'ı sorgular ve kitap detay sayfası hata verir. Expand-contract ile önce yeni kolon eklenir ve uygulama ikisine birden yazar; okuma yeni kolona geçer; ancak eski sürüm hiçbir yerde çalışmadığında ayrı bir commit eski kolonu siler. Her adım ayrı bir sync, ayrı bir commit ve gerekirse ayrı bir git revert.

Üretimde Dikkat

Hook Job'ları idempotent olmalı. Hook her sync'te çalışır: bir değişiklik olmasa bile elle başlatılan her sync, her retry ve her yeni ortam migration'ı tekrar çalıştırır. EF Core'un __EFMigrationsHistory tablosu ve EF Core 9'dan beri gelen migration kilidi bunu güvenli kılar; elle yazılmış bir SQL script'i için aynı garantiyi sen vermelisin. Uzun migration'lara dikkat: Job'daki activeDeadlineSeconds migration'ı keser, Argo CD'nin sync'inde ise varsayılan bir zaman sınırı yok (controller.sync.timeout.seconds: "0"); takılan bir hook sync'i saatlerce "Running"de tutabilir. Hook Job'larında ttlSecondsAfterFinished kullanma: doküman da uyarıyor, Kubernetes Job'ı Argo CD sonucunu okumadan silerse sync, artık var olmayan bir hook'u bekleyebilir; temizliği hook-delete-policy'ye bırak. Son olarak doküman SyncFail hook'larını temizlik işleri için öneriyor; bildirim için Argo CD Notifications daha sağlam bir yol (son yazı).

Sık Düşülen Tuzaklar

Migration'ı düz bir Job olarak bırakmak. İkinci sürümde immutable template hatası alırsın.

Veritabanı aynı Application'dayken migration'ı PreSync'e koymak. Var olan ortamlarda çalışır, ilk kurulumda ya da felaket sonrası yeniden kurulumda çalışmaz.

Wave'lerin Application'lar arasında çalıştığını sanmak. Wave yalnız tek bir Application'ın sync'i içinde sıra verir; bookstore-api'nin wave'i bookstore-web'i bekletmez. Uygulamalar arası sıra için App of Apps'te Application'ların kendisine wave vermek ve Application health check'ini geri açmak gerekir (beşinci yazı).

Self-heal'in hook'ları çalıştıracağını varsaymak. Self-heal yalnız drift olan resource'u sync eder ve seçici sync'te hook çalışmaz.

Migration'ı sürüm sürüm geri alınabilir sanmak. git revert image'ı geri alır, şemayı geri almaz.

Küçük Bir Deneme

★☆☆ smoke-test'in URL'ini /api/does-not-exist yap ve commit'le. Sync'in sonucu ne oluyor, sync-failed hook'u çalışıyor mu? Uygulamanın health'i ne gösteriyor?

★★☆ Bookstore'a nullable bir Subtitle kolonu ekleyen bir migration yaz, yeni migrator ve API image'larını aynı commit'te overlay'e koy ve rollout sırasında eski pod'un hâlâ sorunsuz cevap verdiğini doğrula. Sonra kolonu silen migration'ın neden ayrı bir deploy'a ait olduğunu açıkla.

★★★ Bir PreDelete hook'u ekle: Application silinmeden önce pg_dump ile veritabanının yedeğini bir PVC'ye alsın. Hook başarısız olursa silmenin bloklandığını ve Application'ın DeletionError durumunda beklediğini göster.

Tek bir Application'ın içindeki sıra artık bizde. Bir sonraki yazıda ölçeği büyütüyoruz: api ve web uygulamalarını dev, staging ve prod için tek bir ApplicationSet ile üretmek, App of Apps ve AppProject ile sınır çizmek.