Mert Özen Her Satırda Daha İleri
DevOps Kubernetes — #13

Job, CronJob ve Worker'lar: EF Core Migration Bundle'ı Job Olarak Çalıştırmak

Mert Özen 8 Kas 2026 11 dk 4 görüntülenme
Job, CronJob ve Worker'lar: EF Core Migration Bundle'ı Job Olarak Çalıştırmak

EF Core migration'ı her replica'nın init container'ında değil, deploy'un başında bir kez çalışan sürümlü bir Job'da koşmalısın; EF Core 9+ migration kilidi bizim denemede eşzamanlı init container'ların yarışını önlemedi. Bundle'ı chiseled image'a paketlemeyi, backoffLimit ve TTL ayarlarını, Job immutability'sini, Complete/Failed'i yakalayan pipeline script'ini ve timeZone'lu CronJob'u gerçek çıktılarla görüyoruz.

Sürüm notu: Bu yazıdaki örnekler kind 0.33 ile Kubernetes 1.37, kubectl 1.37, .NET 10, EF (Entity Framework) Core 10.0.14, Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 ve PostgreSQL 18 ile denenmiştir.

Önce Tahmin Et

Bookstore API'sinin (Application Programming Interface) üç replica'sı var ve her pod açılırken bir init container'da EF Core migration'larını çalıştırıyor. Boş bir veritabanına iki migration uygulanacak: InitialCreate ve AddOrderStatus. Üç pod aynı saniyede başlıyor. EF Core 9'dan beri migration'lar için bir veritabanı kilidi var. Sence ne olur: üç pod sırayla bekleyip tek seferde temiz bir sonuç mu üretir, yoksa biri hata alıp yeniden mi başlar? Cevap aşağıdaki gerçek log'larda; spoiler vermeyeyim ama "kilit var, sorun yok" demek için erken.

Job: Bir Kez Çalışıp Biten İş

Deployment'ın işi pod'ları sonsuza kadar ayakta tutmaktır; process çıkarsa yeniden başlatır. Migration, rapor üretimi, veri düzeltme scripti gibi "çalış, bitir, çık" işleri için Job vardır: pod'u başarıyla bitene kadar (gerekirse yeniden deneyerek) çalıştırır, sonra durur. Bookstore'un migration Job'u:

apiVersion: batch/v1
kind: Job
metadata:
  name: migrate-1-0-0
  namespace: bookstore
  labels:
    app.kubernetes.io/name: migrate
    app.kubernetes.io/part-of: bookstore
spec:
  backoffLimit: 2
  activeDeadlineSeconds: 300
  ttlSecondsAfterFinished: 3600
  template:
    metadata:
      labels:
        app.kubernetes.io/name: migrate
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: bookstore-migrations:1.0.0
          args: ["--connection", "$(DB_CONNECTION)"]
          env:
            - name: DB_CONNECTION
              valueFrom:
                secretKeyRef:
                  name: api-db
                  key: connection-string
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              memory: 256Mi

Üç alan Job'un davranışını belirler. backoffLimit: 2, ilk denemeden sonra en fazla iki yeniden deneme demek; denemeler arasında 10 saniyeden başlayıp ikiye katlanan bir bekleme var. activeDeadlineSeconds: 300, deneme sayısından bağımsız olarak Job'un toplam ömrünü sınırlar; asılı kalan bir migration deploy'u sonsuza kadar bekletmez. ttlSecondsAfterFinished: 3600, biten Job'u ve pod'larını bir saat sonra siler; yoksa her deploy'dan bir Job birikir. restartPolicy: Never her denemeyi yeni bir pod'da yapar, başarısız pod'lar log'larıyla birlikte incelenebilir kalır; OnFailure ise aynı pod'da container'ı yeniden başlatır ve önceki denemenin log'u yalnız --previous ile görülür.

args'taki $(DB_CONNECTION) shell değil, Kubernetes'in kendi değişken açılımıdır: container'ın env'indeki değeri argümana koyar. Chiseled image'da shell olmadığı için bu, connection string'i Secret'tan bundle'a geçirmenin en temiz yolu.

EF Core Migration Bundle'ı Image Olarak Paketlemek

Migration bundle, EF Core 7 yazısında anlattığımız tek dosyalık migration çalıştırıcısı: SDK (Software Development Kit) ve kaynak kod gerekmeden, hedef veritabanına bekleyen migration'ları uygular. Kubernetes'te onu bir image'a koyuyoruz. Bundle'ı SDK image'ında linux-arm64 için self-contained üretip, .NET runtime'ı bile içermeyen runtime-deps chiseled image'a kopyalıyoruz:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY dotnet-tools.json .
COPY Bookstore.Migrations.csproj .
RUN dotnet tool restore && dotnet restore
COPY . .
RUN dotnet ef migrations bundle --self-contained -r linux-arm64 -o /out/efbundle

FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled
COPY --from=build /out/efbundle /efbundle
ENTRYPOINT ["/efbundle"]

dotnet-ef global değil, projenin tool manifest'inden (dotnet-tools.json) geliyor; kullandığımız .NET SDK 10.0.402'de dotnet new tool-manifest bu dosyayı .config klasörüne değil proje köküne yazdı, Dockerfile'daki COPY satırı buna göre. Production'da hedef mimari cluster node'larınınki olmalı (çoğu bulut node'u için linux-x64). Image 47,5 MB. Job'u uyguladık:

job.batch/migrate-1-0-0 created
job.batch/migrate-1-0-0 condition met
NAME            STATUS     COMPLETIONS   DURATION   AGE
migrate-1-0-0   Complete   1/1           3s         3s
Cannot load library libgssapi_krb5.so.2
Error: libgssapi_krb5.so.2: cannot open shared object file: No such file or directory
Failed executing DbCommand (9ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "MigrationId", "ProductVersion"
FROM "__EFMigrationsHistory"
ORDER BY "MigrationId";
Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
Applying migration '20261108040417_InitialCreate'.
Done.

Log'daki iki "hata" zararsız ve bilmek iyi: Npgsql Kerberos (GSSAPI) kütüphanesini arıyor, chiseled image'da yok; Kerberos authentication kullanmıyorsan önemsiz. __EFMigrationsHistory sorgusunun başarısız olması da boş veritabanında beklenen bir durum; EF Core tabloyu hemen ardından yaratıyor. Alert kuralı yazarken bu iki satırı hata saymamak gerekiyor.

Başarısız Job ve Backoff

Yanlış şifreli bir connection string ile aynı Job:

NAME             STATUS   COMPLETIONS   DURATION   AGE
migrate-broken   Failed   0/1           34s        34s
NAME                   READY   STATUS   RESTARTS   AGE
migrate-broken-jkp7w   0/1     Error    0          34s
migrate-broken-wsxrp   0/1     Error    0          3s
migrate-broken-x4wmd   0/1     Error    0          23s
Normal   SuccessfulCreate      34s   job-controller  Created pod: migrate-broken-jkp7w
Normal   SuccessfulCreate      23s   job-controller  Created pod: migrate-broken-x4wmd
Normal   SuccessfulCreate      3s    job-controller  Created pod: migrate-broken-wsxrp
Warning  BackoffLimitExceeded  0s    job-controller  Job has reached the specified backoff limit
Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user "bookstore"

Üç pod, aralarında 11 ve 20 saniye: backoff'un ikiye katlandığı açıkça görülüyor. Yanlış şifre yeniden denemeyle düzelmez; bu tür kalıcı hatalar için Kubernetes 1.31'de GA (General Availability) olan podFailurePolicy belirli exit code'larda Job'u ilk denemede başarısız saydırabilir. Bundle hata durumunda 1 ile çıkıyor ve geçici bağlantı hatalarını da aynı kodla bildiriyor; bu yüzden biz basit backoffLimit'te kaldık.

Bir de Job'un immutable olduğunu bil: pod template'i değiştirilemez. Aynı isimli Job'a yeni image ile apply denediğimizde:

The Job "migrate-1-0-0" is invalid: spec.template: Invalid value: {...}: field is immutable

Bu yüzden Job adına sürümü koyuyoruz (migrate-1-1-0). Yeni Job sadece bekleyen migration'ı uyguladı:

Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
Applying migration '20261108041246_AddOrderStatus'.
Done.
          MigrationId          | ProductVersion
-------------------------------+----------------
 20261108040417_InitialCreate  | 10.0.14
 20261108041246_AddOrderStatus | 10.0.14

Deploy Sırası: Önce Migration, Sonra Uygulama

Migration Job'u deploy'un bir adımı olmalı: Job bitmeden yeni API image'ı rollout edilmez, Job başarısızsa deploy durur. kubectl wait --for=condition=complete tek başına yetmez; Job başarısız olursa timeout'a kadar bekler. Pipeline'da kullandığımız script iki durumu da yakalıyor:

#!/usr/bin/env bash
# kullanım: ./run-migration.sh <job-manifest> <job-name>
set -euo pipefail
manifest="$1"
job="$2"

kubectl apply -f "$manifest"

for _ in $(seq 1 60); do
    conditions=$(kubectl -n bookstore get job "$job" \
        -o jsonpath='{.status.conditions[?(@.status=="True")].type}')
    case "$conditions" in
        *Complete*)
            kubectl -n bookstore logs "job/$job" --tail=2
            echo "migration job $job completed"
            exit 0
            ;;
        *Failed*)
            kubectl -n bookstore logs "job/$job" --tail=1
            echo "migration job $job failed ($conditions), stopping the deploy" >&2
            exit 1
            ;;
    esac
    sleep 5
done

echo "migration job $job did not finish in time" >&2
exit 1
$ ./run-migration.sh m-ok.yaml migrate-1-1-0-rerun
job.batch/migrate-1-1-0-rerun created
No migrations were applied. The database is already up to date.
Done.
migration job migrate-1-1-0-rerun completed

$ ./run-migration.sh m-bad.yaml migrate-1-2-0
job.batch/migrate-1-2-0 created
Found 3 pods, using pod/migrate-1-2-0-hdf2g
28P01: password authentication failed for user "bookstore"
migration job migrate-1-2-0 failed (FailureTarget Failed), stopping the deploy

İkinci çağrı exit code 1 döndü, pipeline'ın sonraki adımı (API rollout'u) çalışmaz. Aynı fikri araçlar kendi yollarıyla yapar: Helm'de pre-upgrade hook'u (ilerideki Helm serisinde), Argo CD'de (CD: Continuous Delivery) sync wave ve PreSync hook'u (ilerideki GitOps ve Argo CD serisinde). Hangi sırayla olursa olsun bir kural değişmez: migration çalıştığı anda eski sürümün pod'ları hâlâ trafik alıyor. Şema değişikliği eski kodla da çalışmalı; bunun yolu Deployment Stratejileri serisinin migration yazısındaki expand-contract yaklaşımı.

Tuzak: Migration'ı Her Replica'nın Init Container'ında Koşmak

Kolay görünen alternatif, bundle'ı API Deployment'ına init container olarak eklemek: ayrı bir pipeline adımı yok, her pod açılırken veritabanını günceller. Önce Tahmin Et'teki senaryoyu kurduk: üç replica, boş bir bookstore_init veritabanı, iki bekleyen migration.

      initContainers:
        - name: migrate
          image: bookstore-migrations:1.1.0
          args: ["--connection", "$(DB_CONNECTION)"]
          env:
            - name: DB_CONNECTION
              valueFrom:
                secretKeyRef:
                  name: api-db-init
                  key: connection-string

Üç pod'un init container log'ları, aynı saniye içinde:

== api-init-5f48695b4b-vx6wn
Acquiring an exclusive lock for migration application. ...
Applying migration '20261108040417_InitialCreate'.
Applying migration '20261108041246_AddOrderStatus'.
Acquiring an exclusive lock for migration application. ...
Failed executing DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
ALTER TABLE "Orders" ADD "Status" text NOT NULL DEFAULT '';
Npgsql.PostgresException (0x80004005): 42701: column "Status" of relation "Orders" already exists
# init container restart sonrası ikinci deneme
No migrations were applied. The database is already up to date.

== api-init-5f48695b4b-mvbrm
Acquiring an exclusive lock for migration application. ...
Applying migration '20261108041246_AddOrderStatus'.
Done.

== api-init-5f48695b4b-btk44
No migrations were applied. The database is already up to date.

Kilit vardı, ama yarışı önlemedi. vx6wn ilk migration'ı uygularken mvbrm beklemiş, kilit serbest kalınca ikinci migration'ı kendisi uygulamış; vx6wn ise başta hesapladığı listeye göre aynı kolonu eklemeye çalışıp 42701 hatası almış. Log'da vx6wn'in kilidi iki kez aldığı görülüyor. Migration transaction içinde çalıştığı için veritabanı tutarlı kaldı ve init container'ın restart'ı sonrası her şey yolunda göründü; yani bu sefer zarar yok. Ama sonuca şans karar verdi, tasarım değil. Daha önemlisi bu yaklaşımın başka sorunları var: her replica DDL (Data Definition Language) yetkisi olan bir kullanıcıyla bağlanıyor, uzun süren bir migration bütün pod'ların açılışını bekletiyor, hatalı bir migration her pod'u Init:CrashLoopBackOff'a sokuyor ve scale-out'ta, hatta her pod restart'ında migration yeniden deneniyor. Migration bir kez, ayrı bir Job'da, deploy'un belirli bir anında çalışmalı.

CronJob: Zamanlanmış Job'lar

CronJob belirli bir takvimde Job yaratır. Bookstore'da üç günden eski ödenmemiş siparişleri temizleyen gece işi:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: cleanup-pending-orders
  namespace: bookstore
spec:
  schedule: "30 3 * * *"
  timeZone: Europe/Istanbul
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 1
      activeDeadlineSeconds: 600
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: cleanup
              image: postgres:18-alpine
              command: ["psql", "$(DB_URL)", "-c"]
              args:
                - DELETE FROM "Orders" WHERE "Status" = 'Pending' AND "CreatedAt" < now() - interval '1 day';
              env:
                - name: PGPASSWORD
                  valueFrom:
                    secretKeyRef:
                      name: postgres
                      key: password
                - name: DB_URL
                  value: postgresql://bookstore@postgres:5432/bookstore
NAME                     SCHEDULE     TIMEZONE          SUSPEND   ACTIVE   LAST SCHEDULE   AGE
cleanup-pending-orders   30 3 * * *   Europe/Istanbul   False     0        <none>          0s

timeZone alanı (1.27'den beri GA) olmadan takvim kube-controller-manager'ın saat dilimine göre yorumlanır; managed cluster'larda bu genelde UTC (Coordinated Universal Time) olur ve "gece 03:30" işi Türkiye saatiyle 06:30'da çalışır. Eski usul CRON_TZ= öneki artık reddediliyor, geçersiz bir saat dilimi de:

The CronJob "cleanup-pending-orders" is invalid: spec.schedule: Invalid value: "CRON_TZ=UTC 30 3 * * *": cannot use TZ or CRON_TZ in schedule, use timeZone field instead
The CronJob "cleanup-pending-orders" is invalid: spec.timeZone: Invalid value: "Turkey/Ankara": not a directory

concurrencyPolicy: Forbid, önceki çalışma bitmeden yenisini başlatmaz; temizlik ya da rapor gibi aynı veriye dokunan işlerde varsayılan Allow tehlikelidir. startingDeadlineSeconds, controller bir sebeple zamanı kaçırırsa (ör. control plane upgrade'i) en fazla 10 dakika geç başlatmaya izin verir. Takvimi beklemeden test etmek için CronJob'dan elle Job üretilir:

$ kubectl -n bookstore create job cleanup-manual-1 --from=cronjob/cleanup-pending-orders
job.batch/cleanup-manual-1 created
DELETE 1
 Id | Customer | Status
----+----------+---------
  2 | Grace    | Pending
  3 | Alan     | Paid

Cron ifadelerinin ve saat dilimi/DST (Daylight Saving Time) tuzaklarının ayrıntısı Background Job'lar serisinin cron yazısında. Kubernetes CronJob'u at-least-once değil "yaklaşık bir kez" garantisi verir: nadiren iki kez ya da hiç çalışmayabilir. İş idempotent olmalı; yukarıdaki DELETE öyle.

Worker'lar: Job Değil, Deployment

Queue dinleyen bir .NET Worker Service, "bitip çıkan" bir iş değildir; sürekli çalışır. Onun yeri Deployment'tır: on birinci yazıdaki Bookstore.Worker gibi, replica sayısını da gerekirse KEDA (Kubernetes Event-driven Autoscaling) belirler. Worker'a HTTP (Hypertext Transfer Protocol) endpoint'i olmadığı için liveness probe olarak bir exec komutu ya da küçük bir health endpoint'i gerekir; graceful shutdown ise BackgroundService'in stoppingToken'ına bağlıdır (Background Job'lar serisinin IHostedService yazısı ve bu serinin onuncu yazısı). Hangfire ya da Quartz.NET gibi kendi scheduler'ı olan bir uygulamayı CronJob'a çevirmek zorunda değilsin; ama birden fazla replica'da aynı job'un iki kez çalışmaması için o kütüphanelerin distributed lock'unu kurman gerekir.

Üretimde Dikkat

Migration Job'unun log'u deploy kaydının parçasıdır: ttlSecondsAfterFinished çok kısa olursa hata sonrası incelenecek pod kalmaz; log'ları merkezi bir yere toplamak ilerideki Observability serisinin konusu. Migration'ı çalıştıran kullanıcı ile uygulamanın kullanıcısını ayır: Job DDL yetkili bir Secret'la, API yalnız DML (Data Manipulation Language) yetkili bir Secret'la çalışsın. Büyük tablolarda kilit tutan migration'lar (index yaratmak, kolon tipi değiştirmek) dakikalar sürebilir; activeDeadlineSeconds'ı buna göre seç ve bu tür değişiklikleri PostgreSQL'de CREATE INDEX CONCURRENTLY gibi kilitsiz yollarla yap. CronJob'ların sessizce başarısız olması en sık görülen production sorunu: son başarılı çalışmanın zamanını izleyen bir alert, job'un kendisi kadar önemli. Son olarak Job pod'larına da resource request ve limit ver; limitsiz bir rapor Job'u node'daki API pod'larını sıkıştırabilir.

Sık Düşülen Tuzaklar

Migration'ı init container'da her replica için koşmak. Aynı isimli Job'a yeni image ile apply edip "field is immutable" ile karşılaşmak; adı sürümle değiştir ya da önce sil. kubectl wait --for=condition=complete ile başarısız Job'u 5 dakika beklemek. restartPolicy: OnFailure seçip başarısız denemelerin log'larını kaybetmek. ttlSecondsAfterFinished vermeyip yüzlerce biten Job biriktirmek. CronJob'da timeZone yazmayıp UTC'ye göre çalışan bir "gece" işi. concurrencyPolicy'yi varsayılan Allow'da bırakıp uzun süren iki raporu aynı anda çalıştırmak. Ve migration'ın eski sürüm pod'larıyla aynı anda yaşayacağını unutup kolon silmek ya da yeniden adlandırmak.

Küçük Bir Deneme

★☆☆ CronJob'un takvimini */1 * * * * yap, successfulJobsHistoryLimit: 1 ver ve beş dakika boyunca kubectl get jobs -w izle. Eski Job'lar ne zaman siliniyor?

★★☆ Migration Job'una podFailurePolicy ekle: container exit code'u 1 olduğunda action: FailJob uygulasın. Yanlış şifreli Secret ile tekrar çalıştır ve kaç pod yaratıldığını önceki çıktıyla karşılaştır. Geçici bir ağ hatasında bu politikanın bedeli ne olur?

★★★ İnit container senaryosunu replica sayısını 5'e çıkarıp üç kez tekrarla ve her seferinde hangi pod'un hangi migration'ı uyguladığını not et. Ardından migration'ları tek bir Job'a taşıyıp API Deployment'ının rollout'unu run-migration.sh başarıyla bittikten sonra başlatan küçük bir deploy script'i yaz.

Bu yazıda her ortam için aynı manifest'leri elle düzenledik: Job adındaki sürüm, image tag'i, Secret adı. Bir sonraki yazıda Kustomize ile base ve overlay'ler kurup dev, staging ve prod farklarını kopyala-yapıştır olmadan yönetiyoruz.