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

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.