Kubernetes Pod: .NET Container'ını Çalıştırmak, Lifecycle, Init Container ve Native Sidecar
Pod, aynı ağı paylaşan container'ların çalıştırma birimi; .NET 8+ image'ları port 8080'i dinler, chiseled image non-root ve shell'siz gelir, tam aspnet image'ı ise runAsNonRoot'a takılır. Pod phase'lerini, CrashLoopBackOff'un ikiye katlanan backoff'unu, wait-for-db init container'ının sınırlarını, 1.33'te stable olan native sidecar'ı ve SIGTERM'ü yok sayan bir sidecar'ın pod silmeyi neden 30 saniye uzattığını 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 (ASP.NET Core runtime 10.0.14) ve PostgreSQL 18 ile denenmiştir.
Önce Tahmin Et
Bookstore API'sini (Application Programming Interface) iki farklı image'la, aynı pod spec'iyle çalıştırıyorsun. Spec'te securityContext.runAsNonRoot: true var. Birinci image mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled üzerine, ikincisi mcr.microsoft.com/dotnet/aspnet:10.0 üzerine kurulu; ikisinin Dockerfile'ında da USER satırı yok. Hangisi başlar? Başlayanın içine kubectl exec -- sh ile girebilir misin? Son soru: bir pod'u kubectl delete pod ile sildin; yerine yenisi gelir mi?
Bookstore API İçin İlk Pod Spec'i
Pod, Kubernetes'in çalıştırdığı en küçük birim: aynı node'da, aynı ağ namespace'ini (tek IP (Internet Protocol) adresi, ortak localhost) ve isteğe bağlı olarak ortak volume'ları paylaşan bir veya daha fazla container. Önceki yazıda pod'ları bir Deployment üzerinden yarattık; bu yazıda pod'un kendisine bakıyoruz. Dockerfile'ın ve image'ın nasıl üretildiği Docker serisinin konusu; burada image'ın hazır olduğunu varsayıyoruz.
apiVersion: v1
kind: Pod
metadata:
name: api-standalone
namespace: bookstore
labels:
app.kubernetes.io/name: api-standalone
spec:
containers:
- name: api
image: bookstore-api:1.0.0
ports:
- name: http
containerPort: 8080
env:
- name: ASPNETCORE_ENVIRONMENT
value: Staging
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
ports listesi bilgilendirme amaçlıdır; container zaten 8080'i dinliyorsa listede olmasa da trafik alır. Ama port'a http gibi bir ad vermek, beşinci yazıda Service'i port numarası yerine adla bağlamamızı sağlayacak. ASPNETCORE_ENVIRONMENT ise ASP.NET Core Web API serisinin Environment'lar yazısındaki mekanizmanın Kubernetes tarafı: environment adını bir değişkenle vermek.
$ kubectl apply -f pod.yaml
pod/api-standalone created
$ kubectl get pod api-standalone -o wide
NAME READY STATUS RESTARTS AGE IP NODE
api-standalone 1/1 Running 0 1s 10.244.2.9 k8swork1-1-worker
$ kubectl logs api-standalone
03:37:47.172 info: Bookstore.Api[0] Bookstore.Api 1.0.0 starting on api-standalone: cpus=10 serverGC=True gcHeapLimitMB=7836
03:37:47.195 info: Microsoft.Hosting.Lifetime[14] Now listening on: http://[::]:8080
03:37:47.196 info: Microsoft.Hosting.Lifetime[0] Hosting environment: Staging
$ kubectl port-forward pod/api-standalone 18710:8080 &
$ curl -s localhost:18710/api/info
{"pod":"api-standalone","version":"1.0.0","environment":"Staging","storeName":"Bookstore","discountPercent":0,"database":"in-memory"}
Uygulama Environment.MachineName olarak pod'un adını görüyor; Kubernetes pod'un hostname'ini pod adına eşitler. cpus=10 ve gcHeapLimitMB=7836 değerleri de ilginç: pod'a henüz resource limit vermediğimiz için .NET node'un bütün CPU'larını (Central Processing Unit) ve belleğini kendisininmiş gibi görüyor. Bunun production'daki bedelini dokuzuncu yazıda ölçeceğiz. kubectl port-forward ise pod'a makinenden ulaşmanın debug amaçlı yolu; trafiği API server üzerinden tüneller, gerçek trafik için kullanılmaz.
.NET Container Image'larının Varsayılanları
.NET 8'den beri resmi Linux image'larında iki önemli varsayılan var. Birincisi port: ASP.NET Core artık 80'i değil 8080'i dinliyor ve bu ASPNETCORE_HTTP_PORTS=8080 environment variable'ıyla image'a gömülü. Değişikliğin nedeni non-root çalışmak: 1024'ün altındaki portlar bazı ortamlarda root yetkisi ister. İkincisi kullanıcı: image'larda UID (User Identifier) 1654 olan app adlı bir kullanıcı ve onu gösteren APP_UID değişkeni tanımlı. Bizim image'ın gerçek ayarları:
$ docker inspect bookstore-api:1.0.0 --format '{{.Config.User}} {{json .Config.Env}}'
1654 ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","APP_UID=1654","ASPNETCORE_HTTP_PORTS=8080","DOTNET_RUNNING_IN_CONTAINER=true","DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true","DOTNET_VERSION=10.0.14","ASPNET_VERSION=10.0.14","APP_VERSION=1.0.0"]
$ docker inspect bookstore-api:1.0.0-full --format '{{.Config.User}}'
İkinci satır boş. Fark buradan geliyor: chiseled image'lar USER'ı 1654 olarak ayarlı gelir, Ubuntu tabanlı tam aspnet:10.0 image'ı ise app kullanıcısını tanımlar ama varsayılan olarak root ile çalışır; non-root'a geçmeyi sen seçersin. Baştaki ilk sorunun cevabı:
$ kubectl get pod api-full
NAME READY STATUS RESTARTS AGE
api-full 0/1 CreateContainerConfigError 0 4s
$ kubectl describe pod api-full
Warning Failed 3s (x2 over 3s) kubelet spec.containers{api}: Error: container has runAsNonRoot and image will run as root (pod: "api-full_bookstore(dba6f453-9516-4eaa-8eab-e5160506244e)", container: api)
kubelet image'ın root olarak çalışacağını gördü ve container'ı hiç başlatmadı. Bu iyi bir şey: runAsNonRoot bir sigorta. Çözüm ya Dockerfile'a USER $APP_UID eklemek ya da pod'da runAsUser: 1654 vermek; birincisi image'ı her ortamda güvenli yapar. Pod Security Standards'ın restricted profili bu ayarı zaten zorunlu tutuyor; güvenlik ayarlarını 15. yazıda bütün hâlinde ele alacağız.
İkinci soru: chiseled image'da shell var mı?
$ kubectl exec api-standalone -- sh -c 'id'
error: Internal error occurred: Internal error occurred: error executing command in container: failed to exec in container: failed to start exec "5804ebd957b839fade007c678e61230b76bd9cd9d2fbef242a3d89dd2f0ab009": OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH
$ kubectl exec api-standalone -- dotnet --list-runtimes
Microsoft.AspNetCore.App 10.0.14 [/usr/share/dotnet/shared/Microsoft.AspNetCore.App]
Microsoft.NETCore.App 10.0.14 [/usr/share/dotnet/shared/Microsoft.NETCore.App]
Chiseled image'larda shell ve paket yöneticisi yok; yalnız .NET'in çalışması için gereken dosyalar var. dotnet binary'si olduğu için o çalışıyor, sh çalışmıyor. Saldırı yüzeyi ve image boyutu küçülüyor (bizim chiseled image 187 MB, tam image 372 MB), ama "pod'a girip bakarım" alışkanlığı da bitiyor. Bunun Kubernetes'teki cevabı ephemeral debug container'ları; onu 16. yazıda göreceğiz. Bu yazının ilerisinde de shell'siz image'ın bir sonucuyla karşılaşacağız.
Pod Phase'leri, Container State'leri ve restartPolicy
Pod'un status.phase alanı beş değerden birini alır: Pending (kabul edildi ama container'lar henüz çalışmıyor; scheduling ve image indirme dahil), Running (node'a bağlandı, en az bir container çalışıyor ya da yeniden başlıyor), Succeeded (bütün container'lar başarıyla bitti, yeniden başlamayacak), Failed (bütün container'lar bitti, en az biri hatayla) ve Unknown. Her container'ın ise ayrıca bir state'i var: Waiting, Running ya da Terminated. kubectl get pods'un STATUS sütununda gördüğün CrashLoopBackOff, Init:0/2 ya da Terminating bunların hiçbiri değil; kubectl'in phase, container state'leri ve reason'lardan ürettiği bir özet.
spec.restartPolicy pod'daki container'lar bittiğinde ne olacağını söyler: Always (varsayılan), OnFailure ya da Never. Bir web API için doğru değer Always; diğer ikisi Job'lar için (13. yazı). Hemen çöken bir container'ın ne yaptığını görmek için API'yi Startup__FailFast=true ile başlattım; bu ayarla uygulama log yazıp exit code 1 ile çıkıyor:
$ kubectl apply -f pod-crash.yaml
# her saniye: zaman, READY, STATUS, RESTARTS (yalnız değişen satırlar)
03:39:50 0/1 ContainerCreating 0
03:39:51 1/1 Running 0
03:39:52 1/1 Running 1
03:39:53 0/1 Error 1
03:40:04 0/1 Error 2
03:40:29 0/1 Error 3
03:41:29 1/1 Running 4
03:41:30 0/1 Error 4
03:42:36 0/1 CrashLoopBackOff 4
03:42:52 1/1 Running 5
03:42:53 0/1 Error 5
$ kubectl logs api-crash --previous
03:43:42.610 info: Bookstore.Api[0] Bookstore.Api 1.0.0 starting on api-crash: cpus=10 serverGC=True gcHeapLimitMB=7836
03:43:42.617 crit: Bookstore.Api[0] Startup:FailFast is true, exiting with code 1
Yeniden başlatmalar arasındaki boşluk büyüyor: yaklaşık 12, 25, 60 ve 83 saniye. Kubernetes dokümantasyonuna göre kubelet yeniden başlatmaları 10 saniyeden başlayıp her seferinde ikiye katlanan, 300 saniyede (5 dakika) tavan yapan bir exponential backoff ile geciktirir; container 10 dakika sorunsuz çalışırsa sayaç sıfırlanır. Benim ölçümüm saniyede bir yapılan bir kubectl döngüsünden geldiği için birkaç saniye kayma var, ama ikiye katlanma açıkça görünüyor. STATUS sütunu bu arada Error, Running ve CrashLoopBackOff arasında gidip geliyor; CrashLoopBackOff bir phase değil, "şu an backoff süresini bekliyorum" demek. Asıl bilgi --previous ile okunan, bir önceki container'ın logunda. 1.33'te alpha olarak gelen ReduceDefaultCrashLoopBackOffDecay feature gate'i bu süreleri 1 saniyeden başlatıp 60 saniyede durdurmayı deniyor, ama varsayılan olarak kapalı.
Init Container'lar ve "wait-for-db" Yaklaşımı
Init container'lar uygulama container'larından önce, sırayla ve her biri bitene kadar çalışır; biri başarısız olursa kubelet onu yeniden dener ve sıradakine geçmez. En yaygın kullanım "veritabanı hazır olana kadar bekle":
apiVersion: v1
kind: Pod
metadata:
name: api-with-sidecar
namespace: bookstore
spec:
initContainers:
- name: wait-for-db
image: busybox:1.37
command: ["sh", "-c", "until nc -z -w 2 postgres 5432; do echo waiting for postgres; sleep 2; done"]
- name: log-forwarder
image: busybox:1.37
restartPolicy: Always
command: ["sh", "-c", "echo sidecar up; while true; do sleep 3600; done"]
containers:
- name: api
image: bookstore-api:1.0.0
ports:
- name: http
containerPort: 8080
Cluster'da henüz postgres adında bir Service yokken pod'u uyguladım:
$ kubectl get pod api-with-sidecar
NAME READY STATUS RESTARTS AGE
api-with-sidecar 0/2 Init:0/2 0 8s
$ kubectl logs api-with-sidecar -c wait-for-db
nc: bad address 'postgres'
waiting for postgres
nc: bad address 'postgres'
Init:0/2, iki init container'dan hiçbirinin henüz tamamlanmadığını söylüyor. PostgreSQL Deployment'ını ve Service'ini uyguladığımda init container 25 saniyelik bekleyişten sonra exit code 0 ile bitti ve pod 2/2 Running oldu.
Bu yaklaşımın sınırlarını bilmek gerekiyor. Birincisi, yalnız başlangıçta çalışır: API ayağa kalktıktan sonra veritabanı giderse init container seni korumaz; uygulamanın bağlantı hatalarına retry ile dayanması gerekir. İkincisi, port açık diye veritabanı hazır değildir; PostgreSQL port'u dinlerken hâlâ recovery yapıyor olabilir. Üçüncüsü, bağımlılığı deploy sırasına gömer: veritabanı bakımdayken API'nin yeni pod'ları hiç başlamaz. Bu yüzden çoğu durumda daha sağlıklı çözüm, uygulamanın kendisinin dayanıklı olması ve hazır olmadığını readiness probe ile söylemesi (sekizinci yazı). Migration'ı init container'da çalıştırmak ise ayrı bir tuzak; onu 13. yazıda Job ile çözeceğiz.
Native Sidecar: restartPolicy: Always Olan Init Container
Yukarıdaki spec'teki ikinci init container farklı: restartPolicy: Always taşıyor. Bu, Kubernetes'in native sidecar'ı. 1.28'de alpha olarak geldi, 1.29'da varsayılan olarak açıldı ve 1.33'te stable oldu. Log toplayıcı, proxy, sertifika yenileyici gibi pod'un bütün ömrü boyunca ana uygulamanın yanında çalışması gereken yardımcılar için tasarlandı.
Native sidecar'dan önce sidecar'lar sıradan bir ikinci container olarak containers listesine yazılırdı. Bunun üç sorunu vardı: başlama sırası garanti değildi (API, proxy hazır olmadan istek atabiliyordu), kapanma sırası garanti değildi (proxy, API son isteklerini bitirmeden kapanabiliyordu) ve Job'larda hiç bitmeyen sidecar Job'un tamamlanmasını engelliyordu. Native sidecar üçünü de çözüyor: init container sırasında başlar, kubelet sonraki container'a geçmeden önce onun başlamasını bekler (startup probe varsa onun başarılı olmasını), ana container'lar durduktan sonra ters sırayla durdurulur ve Job'un tamamlanmasını engellemez. Gerçek koşudaki zaman damgaları:
wait-for-db: started=2026-11-08T03:38:24Z finished=2026-11-08T03:38:49Z reason=Completed exit=0
log-forwarder: started=2026-11-08T03:38:49Z
api: started=2026-11-08T03:38:49Z
Kapanma tarafında ise beklemediğim bir şey gördüm. Pod'u 03:39:08'de sildim ve her saniye container state'lerini kaydettim:
03:39:08 wait-for-db=T@2026-11-08T03:38:49Z/exit0 log-forwarder=running api=running
03:39:39 wait-for-db=T@2026-11-08T03:38:49Z/exit0 log-forwarder=T@2026-11-08T03:39:38Z/exit137 api=T@2026-11-08T03:39:08Z/exit0
03:39:40 Error from server (NotFound): pods "api-with-sidecar" not found
.NET uygulaması SIGTERM'ü (termination signal) aldığı saniye düzgünce kapandı, exit code 0. Sıralama doğru işledi: sidecar'a SIGTERM ancak API durduktan sonra gitti. Ama sidecar 30 saniye boyunca kapanmadı ve sonunda exit code 137 ile, yani SIGKILL (kill signal) ile öldürüldü. Neden: busybox'taki sh, PID (Process Identifier) 1 olarak çalışırken SIGTERM için bir handler tanımlamadıysan sinyali yok sayar. Linux'ta PID 1'e varsayılan sinyal davranışı uygulanmaz. Pod da varsayılan terminationGracePeriodSeconds olan 30 saniyenin sonuna kadar bekledi.

Sidecar komutuna bir trap ekleyince tablo değişti:
command: ["sh", "-c", "trap 'echo sidecar stopping; exit 0' TERM; echo sidecar up; while true; do sleep 1 & wait; done"]
delete at 03:40:52
03:40:53 wait-for-db=T@2026-11-08T03:40:51Z/exit0 log-forwarder=T@2026-11-08T03:40:52Z/exit0 api=T@2026-11-08T03:40:52Z/exit0
03:40:54 Error from server (NotFound): pods "api-with-sidecar" not found
Pod 31 saniye yerine 2 saniyede gitti. Bu küçük ayrıntı rolling update'leri doğrudan etkiler: her eski pod'un 30 saniye fazladan beklemesi, 10 replica'lı bir deploy'u dakikalarca uzatır. Onuncu yazıda graceful shutdown'u .NET tarafıyla birlikte ayrıntılı ele alacağız.
Neden Tek Başına Pod Yaratılmaz?
$ kubectl delete pod api-standalone
pod "api-standalone" deleted from bookstore namespace
$ kubectl get pod api-standalone
Error from server (NotFound): pods "api-standalone" not found
Baştaki son sorunun cevabı: hayır. Tek başına yaratılan bir pod'un sahibi (owner) yok; onu izleyip yeniden yaratacak bir controller yok. Node arızasında, node bakıma alındığında (kubectl drain) ya da eviction'da pod kaybolur ve geri gelmez. Pod'un spec'i de büyük ölçüde değiştirilemez: image dışındaki container alanlarının çoğunu canlı bir pod'da güncelleyemezsin; yeni bir pod yaratmak zorundasın. Production'da pod'u hep bir controller üzerinden yarat: sürekli çalışan servisler için Deployment (dördüncü yazı), durumlu iş yükleri için StatefulSet, tek seferlik işler için Job. Tek başına pod yalnız bu yazıdaki gibi denemeler ve debug için.
Üretimde Dikkat
Image'ı non-root çalışacak şekilde build et ve bunu pod spec'inde runAsNonRoot: true ile zorla. Chiseled image kullanıyorsan ikisi kendiliğinden uyumlu; tam image kullanıyorsan Dockerfile'a USER $APP_UID eklemeyi unutma. Shell'siz image tercihinin operasyon tarafını (debug container'ları, dump almak) önceden planla.
Sidecar'ları native sidecar olarak tanımla ve SIGTERM'ü doğru işlediklerinden emin ol. Hazır bir proxy ya da log ajanı kullanıyorsan genellikle sorun yok; kendi yazdığın shell tabanlı sidecar'larda trap ya da exec kullan. Sidecar'a da resource request vermeyi unutma; pod'un toplam kaynak hesabına dahil.
Init container'ları kısa ve deterministik tut. Uzun süren ya da dış sistemlere bağımlı init adımları deploy süresini öngörülemez kılar ve progressDeadlineSeconds'ı aşan rollout'lara yol açar.
Sık Düşülen Tuzaklar
Port karışıklığı. .NET 6 döneminden kalma manifest'lerde containerPort: 80 ve ASPNETCORE_URLS=http://+:80 görürsün. .NET 8 ve sonrası image'larda uygulama 8080'i dinler; manifest 80 diyorsa probe'lar ve Service sessizce başarısız olur.
CrashLoopBackOff'u Kubernetes sorunu sanmak. Bu durum yalnız "container sürekli çıkıyor, kubelet giderek daha uzun bekleyerek yeniden başlatıyor" demektir. Sebep neredeyse her zaman uygulamada: eksik configuration, ulaşılamayan bağımlılık, yanlış komut. İlk bakılacak yer kubectl logs --previous.
Ana container olarak sh çalıştıran image'lar. Entrypoint'i sh -c "dotnet Bookstore.Api.dll" gibi bir shell sarmalayıcısıysa SIGTERM shell'de kalır, uygulamaya ulaşmaz ve her kapanış 30 saniye sürer. Dockerfile'da exec formu (ENTRYPOINT ["dotnet", "Bookstore.Api.dll"]) kullan.
Init container'da migration çalıştırmak. Üç replica üç kez aynı migration'ı aynı anda çalıştırmaya çalışır. Bunu 13. yazıda ayrıntılı göreceğiz.
Küçük Bir Deneme
★☆☆ api-standalone pod'unu uygula, kubectl get pod api-standalone -o yaml çıktısında status.conditions listesini bul ve PodScheduled, Initialized, ContainersReady, Ready koşullarının zaman damgalarını sırala.
★★☆ Sidecar'ı trap'siz bırak ama pod'a terminationGracePeriodSeconds: 5 ver. Pod'u sil ve sidecar'ın exit code'unu ve kapanma süresini ölç. Sonra aynı denemeyi trap'li sidecar'la tekrarla.
★★★ Sidecar'a /tmp/ready dosyasını kontrol eden bir startup probe ekle ve dosyayı ancak 15 saniye sonra yaratacak şekilde komutu değiştir. API container'ının başlama zamanının da 15 saniye kaydığını containerStatuses'tan doğrula.
Pod'u tanıdık ve neden onu doğrudan yaratmamamız gerektiğini gördük. Bir sonraki yazıda pod'ları bizim yerimize yöneten Deployment'a geçiyoruz: label ve selector'lar, ReplicaSet'in rolü, rolling update parametreleri, rollout geçmişi ve geri alma.