Kubernetes, gözlemlenebilirlik ve teslimat hattı
Bir Spring Boot servisini Kubernetes'te doğru çalıştırmayı (probe, kaynak, kapanış, ölçekleme), production'da ne olduğunu görmeyi (metrik, log, trace, SLO) ve değişikliği güvenle yayına almayı (CI/CD, deployment stratejileri, expand/contract, Terraform) anlatır.
Bu bölümde
Bir Spring Boot servisini Kubernetes'e koymanın "çalışıyor" ile "production'da sağlam çalışıyor" arasındaki farkını öğreneceksin: probe'ların anlamı, requests/limits'in JVM'e etkisi, kapanışta istek kaybetmemek ve ölçekleme. Ardından servisin içini dışarıdan görmeyi (RED/USE, percentile, tracing, SLO ve burn rate alarmı) ve değişikliği güvenle yayına almayı (pipeline, canary, feature flag, geriye uyumlu migration, Terraform) ele alacağız.
Mülakatta bu konu genelde iki şekilde gelir: bir manifest ya da olay anlatılır ("pod'lar sürekli restart oluyor", "p99 arttı") ve teşhis koyman beklenir; ya da "sıfır kesintiyle kolon adı nasıl değişir", "hangi metriklere alarm kurarsın" gibi tasarım soruları sorulur. Burada ezber değil, mekanizma ve trade-off konuşmak fark yaratır.
Konu anlatımı
Kubernetes'in temel nesneleri
Kubernetes deklaratif bir sistemdir: sen "istenen durumu" (desired state) yazarsın, controller'lar sürekli çalışan bir döngüde (reconciliation loop) gerçek durumu ona yaklaştırır. Bu mantığı anlamak, neredeyse her davranışı açıklar: bir pod ölünce neden yenisi gelir, kubectl delete pod neden "işe yaramaz" gibi görünür, rollout neden kendiliğinden ilerler.
Pod, en küçük çalıştırma birimidir. Bir veya daha fazla container aynı network namespace'i (aynı IP, birbirine localhost ile erişim) ve tanımlanan volume'ları paylaşır. Pod geçicidir (ephemeral): ölür, yerine farklı IP'li yenisi gelir. Bu yüzden pod IP'sine asla doğrudan güvenilmez.
Deployment stateless uygulamaların standart nesnesidir. Deployment bir ReplicaSet yönetir, ReplicaSet de istenen sayıda pod'u ayakta tutar. Pod template'i değişince (yeni image, yeni env) Deployment yeni bir ReplicaSet oluşturur ve eskisinden yenisine kademeli geçer. Eski ReplicaSet'ler (varsayılan 10 tane, revisionHistoryLimit) saklanır; kubectl rollout undo bu sayede çalışır.
Service, değişen pod'ların önüne sabit bir sanal IP ve DNS adı (order-service.orders.svc.cluster.local) koyar. Hangi pod'ların arkasında olduğunu label selector belirler; eşleşen ve Ready olan pod'ların adresleri EndpointSlice nesnelerine yazılır, kube-proxy (iptables/IPVS) ya da service mesh trafiği bunlara dağıtır. Tipleri: ClusterIP (küme içi, varsayılan), NodePort, LoadBalancer, ve DNS'in doğrudan pod IP'lerini döndüğü headless servis (clusterIP: None, StatefulSet'lerle birlikte kullanılır).
Ingress, küme dışından gelen HTTP(S) trafiğini host/path kurallarına göre Service'lere yönlendiren L7 kuralıdır. Kendi başına bir şey yapmaz; bir ingress controller (NGINX, Traefik, bulut sağlayıcının controller'ı) bu kuralları uygular. Ingress API'si dondurulmuş durumdadır; yeni özellikler Gateway API'ye ekleniyor. Yaygın kullanılan ingress-nginx controller'ı Mart 2026'da emekliye ayrıldı (artık güvenlik yaması dahil sürüm çıkmıyor); yeni kurulumlarda Gateway API ya da başka bir controller tercih edilmeli.
ConfigMap ve Secret, konfigürasyonu image'dan ayırır. İkisi de pod'a env değişkeni ya da dosya (volume) olarak verilebilir. Önemli farklar:
- Env olarak verilen değer pod başlarken okunur; ConfigMap değişse de çalışan pod görmez, restart gerekir. Volume olarak bağlanan dosyalar kubelet tarafından belli bir gecikmeyle güncellenir (
subPathile bağlananlar hariç). - Secret varsayılan olarak şifreli değil, base64 kodlu saklanır. Gerçek koruma etcd'de encryption at rest, sıkı RBAC ve tercihen harici bir secret yöneticisi (Vault, bulut KMS, External Secrets Operator) ile sağlanır.
- Spring Boot, volume olarak bağlanan secret'ları
spring.config.import=configtree:/etc/secrets/ile property olarak okuyabilir.
Tipik bir servis manifest'i:
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 4
selector:
matchLabels: { app: order-service }
strategy:
type: RollingUpdate
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
template:
metadata:
labels: { app: order-service }
spec:
containers:
- name: order-service
image: registry.local/order-service:1.4.3
ports: [{ containerPort: 8080 }]
envFrom:
- configMapRef: { name: order-service-config }
env:
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef: { name: order-db, key: password }
---
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector: { app: order-service }
ports: [{ port: 80, targetPort: 8080 }]
Rolling update: maxSurge ve maxUnavailable
Deployment'ın varsayılan stratejisi RollingUpdate'tir. İki parametre hızı ve güvenliği belirler:
| Parametre | Anlamı | Varsayılan | Yuvarlama |
|---|---|---|---|
maxSurge | İstenen replika sayısının üstünde aynı anda en fazla kaç pod olabilir | %25 | Yukarı |
maxUnavailable | Rollout sırasında en fazla kaç pod kullanılamaz olabilir | %25 | Aşağı |
İkisi aynı anda 0 olamaz (rollout ilerleyemezdi). 4 replikalı bir serviste varsayılanlar maxSurge=1, maxUnavailable=1 demektir: önce bir yeni pod açılır, bir eski pod kapatılır, sonra devam edilir.
Kapasitesi sıkışık bir serviste maxUnavailable: 0, maxSurge: 1 güvenli seçimdir: yeni pod Ready olmadan hiçbir eski pod kapanmaz. Bedeli, rollout'un daha yavaş olması ve geçici olarak bir pod'luk ekstra kaynak gerekmesidir.
Rollout'un ilerlemesini readiness belirler. Yeni pod Ready olmazsa rollout durur; progressDeadlineSeconds (varsayılan 600 sn) içinde ilerleme olmazsa Deployment "Progressing=False" durumuna geçer. minReadySeconds (varsayılan 0) bir pod'un Ready olduktan sonra kaç saniye çökmeden kalırsa "available" sayılacağını belirler; açılır açılmaz çöken pod'ların rollout'u ilerletmesini engeller.
Asıl önemli sonuç şu: rolling update sırasında eski ve yeni sürüm aynı anda trafik alır. Bu yüzden API sözleşmesi, Kafka event şeması ve veritabanı şeması iki sürümle birden uyumlu olmak zorundadır. Bölümün sonundaki expand/contract kısmı bunun nasıl yapıldığını anlatıyor.
Probe'lar: liveness, readiness, startup
Kubelet her container için üç ayrı soru sorabilir. Bu üç soruyu karıştırmak, production'daki en yaygın Kubernetes kazalarından birinin nedenidir.
| Probe | Sorduğu soru | Fail olursa |
|---|---|---|
livenessProbe | Bu process kurtarılamaz biçimde bozuldu mu? | Container restart edilir |
readinessProbe | Şu an trafik almaya hazır mı? | Pod Service endpoint'lerinden çıkarılır, öldürülmez |
startupProbe | Uygulama açılışını bitirdi mi? | Açılış süresi aşılırsa container öldürülür; başarılı olana kadar diğer iki probe çalışmaz |
Probe alanlarının varsayılanları: initialDelaySeconds: 0, periodSeconds: 10, timeoutSeconds: 1, successThreshold: 1, failureThreshold: 3. Readiness probe tanımlanmamışsa container çalışmaya başladığı anda Ready kabul edilir; yani Spring context daha ayağa kalkmadan trafik gelir.
Liveness'ta bağımlılık kontrol etmenin zararı. /actuator/health varsayılan olarak DB, Redis, Kafka gibi tüm health indicator'ları içerir. Liveness bu endpoint'e bakarsa, veritabanı 20 saniyelik bir bakıma girdiğinde şu olur:
- Bütün pod'ların health'i aynı anda DOWN döner, çünkü hepsi aynı DB'ye bağlı.
- Kubelet hepsini aynı anda restart eder. Restart DB'yi düzeltmez; sorun pod'da değil.
- DB geri geldiğinde onlarca JVM aynı anda açılır: JIT ısınmamış, cache'ler boş, connection pool'lar aynı anda DB'ye bağlanmaya çalışır (connection storm). 20 saniyelik bir DB bakımı birkaç dakikalık tam kesintiye dönüşür.
Liveness yalnızca "bu process'i öldürüp yeniden başlatmak sorunu çözer mi?" sorusuna evet denecek durumlarda fail etmeli: deadlock, bozulmuş iç durum, kurtarılamaz bir hata. Dış bağımlılık sorunları bu sınıfa girmez.
Readiness ve bağımlılıklar daha nüanslıdır. Readiness'a DB kontrolü eklemek liveness kadar yıkıcı değildir (pod ölmez), ama aynı tuzağın hafif bir hali vardır: tüm pod'lar aynı DB'ye bağlıysa, DB gidince tüm pod'lar NotReady olur, Service'in hiç endpoint'i kalmaz ve ingress her isteğe 503 döner. Oysa pod'lar ayakta kalsa, DB'ye ihtiyaç duymayan endpoint'ler (cache'ten ürün detayı gibi) çalışmaya devam edebilir, DB'ye ihtiyaç duyanlar ise circuit breaker ile hızlıca anlamlı bir hata dönebilirdi. Pratik kural: readiness'a sadece o pod'a özgü hazır olmama durumlarını koy (açılış, cache ısınması, kapanış). Ortak bağımlılığı readiness'a eklemek bilinçli bir karar olmalı, varsayılan değil.
Spring Boot Actuator health groups. Spring Boot uygulamanın erişilebilirlik durumunu (ApplicationAvailability) yönetir ve iki ayrı health grubu sunar:
/actuator/health/liveness: sadecelivenessState. Uygulama context'i refresh oluncaCORRECTolur./actuator/health/readiness: sadecereadinessState.ApplicationRunner/CommandLineRunner'lar bittikten sonraACCEPTING_TRAFFIC, kapanış başlayıncaREFUSING_TRAFFIColur.
Bu gruplar Spring Boot 3.x'te uygulama Kubernetes'te çalıştığı algılanınca otomatik açılır (her ortamda açmak için management.endpoint.health.probes.enabled=true). Spring Boot 4.0 ile liveness ve readiness probe'ları her ortamda varsayılan olarak açık geliyor. Varsayılan olarak bu gruplara başka indicator eklenmez; DB'yi readiness'a eklemek istersen bunu açıkça yaparsın:
management:
endpoint:
health:
probes:
enabled: true
add-additional-paths: true # /livez ve /readyz ana port'ta (8080) da açılır
group:
readiness:
include: readinessState # DB eklemek bilinçli bir karar: "readinessState, db"
add-additional-paths şu yüzden önemli: actuator'ı ayrı bir management port'una (management.server.port) taşıdıysan, probe ana web sunucusu kilitlenmişken bile management port'undan UP alabilir. /livez ve /readyz ana port'ta çalıştığı için gerçek trafik yolunu da doğrular.
Uygulama kendi içinde kurtarılamaz bir durum tespit ederse liveness'ı bilinçli olarak bozabilir:
@Component
class PoisonedStateDetector {
private final ApplicationEventPublisher publisher;
PoisonedStateDetector(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
void onUnrecoverableError(Throwable cause) {
// Kubelet bir sonraki liveness kontrolünde container'ı restart eder.
AvailabilityChangeEvent.publish(publisher, cause, LivenessState.BROKEN);
}
}
Yavaş açılan Spring Boot ve startupProbe. Spring Boot uygulaması, özellikle düşük CPU ile, 30-90 saniyede açılabilir (Hibernate metamodel, Flyway migration'ları, Kafka bağlantıları, JIT ısınması). Liveness'ı bu süreyi karşılayacak şekilde gevşetirsen (ör. initialDelaySeconds: 90), çalışma sırasında kilitlenen bir pod'u da 90 saniye geç fark edersin. startupProbe iki ihtiyacı ayırır: açılış için cömert bir pencere, açıldıktan sonra sıkı bir liveness.
startupProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 5
failureThreshold: 30 # 5 x 30 = 150 sn'ye kadar açılış toleransı
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 2 # 1 sn varsayılanı GC duraklamalarında yanlış alarm üretebilir
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
failureThreshold: 3
Kural: failureThreshold x periodSeconds, en kötü açılış süresini (yük altında, soğuk node'da) karşılamalı.
Liveness ile readiness'ı aynı endpoint'e bağlamak, ikisinin farkını yok eder. Kubernetes dokümanı da iki probe'un birbirinden bağımsız çalıştığını vurgular. Readiness geçici sorunlar içindir, liveness kalıcı bozulma içindir.
Kaynaklar: requests, limits ve QoS
Request, scheduler'ın pod'u yerleştirirken garanti ettiği miktardır. Pod sadece request'lerinin toplamını karşılayabilen bir node'a yerleşir. CPU request'i ayrıca çekişme anında CPU zamanının nasıl paylaşılacağını belirleyen ağırlığa (cgroup CPU weight) dönüşür.
Limit, üst sınırdır ve CPU ile bellek için tamamen farklı davranır:
- CPU limit aşılırsa container öldürülmez, kısılır (throttling). Linux CFS bandwidth kontrolü, her 100 ms'lik periyotta container'a
limit x 100 msCPU zamanı verir. 250m limit, periyot başına 25 ms demektir. - Memory limit aşılırsa kernel'in OOM killer'ı container'daki process'i öldürür:
OOMKilled.
Bu ikisinin birleşimi pod'un QoS sınıfını belirler:
| QoS sınıfı | Koşul | Node bellek baskısında |
|---|---|---|
| Guaranteed | Tüm container'larda CPU ve bellek için request = limit | En son tahliye edilir |
| Burstable | En az bir request veya limit var, Guaranteed değil | Ortada |
| BestEffort | Hiç request/limit yok | İlk tahliye edilir |
CPU throttling'in gizli maliyeti. Throttling ortalama CPU kullanımına bakarak görülmez. 250m limitli bir JVM düşün: bir istek geldiğinde request thread'i, JIT compiler thread'leri ve GC thread'leri aynı anda çalışır. Dört thread paralel çalışırsa 25 ms'lik kota yaklaşık 6 ms duvar saatinde biter ve container periyodun kalan ~94 ms'si boyunca hiç çalışamaz. Dashboard'da ortalama CPU %20 görünürken p99 latency 100 ms'lik basamaklarla artar. Ölçmek için cAdvisor metriklerinden container_cpu_cfs_throttled_periods_total / container_cpu_cfs_periods_total oranına bakılır.
Bu yüzden birçok ekip JVM servislerinde şu düzeni tercih eder: gerçekçi bir CPU request (yük testiyle ölçülmüş), CPU limit yok ya da cömert; bellek için request = limit (bellek sıkıştırılamaz bir kaynak, öngörülebilir olmalı).
OOMKilled ve exit code 137. Container sinyalle öldürülünce exit code 128 + sinyal numarası olur. SIGKILL 9 olduğu için 137, SIGTERM 15 olduğu için 143 görürsün. 137 her zaman OOM demek değildir: grace period dolduğu için SIGKILL yiyen container da 137 ile çıkar. Ayırt etmek için kubectl describe pod çıktısındaki Last State: Terminated, Reason: OOMKilled satırına bakılır.
İki farklı "bellek doldu" durumunu karıştırma:
java.lang.OutOfMemoryError: Java heap space: JVM'in kendi heap sınırına (-Xmx) çarpması. Process yaşamaya devam edebilir (ve genelde bozuk durumda yaşar).OOMKilled: process'in toplam belleğinin (heap + heap dışı) container limitini aşması. Kernel process'i anında öldürür; JVM'in haberi bile olmaz, heap dump alınmaz.
Sürekli çöken container CrashLoopBackOff durumuna düşer: kubelet yeniden başlatmayı 10 sn, 20 sn, 40 sn diye katlayarak 5 dakikaya kadar erteler; container 10 dakika sorunsuz çalışırsa sayaç sıfırlanır.
JVM bellek ayarı ve container limiti
(Bellek alanlarının ve GC'nin detayı için bkz. Bölüm 3.) JVM'in bellek kullanımı heap'ten ibaret değildir:
- Metaspace: yüklenen sınıfların metadata'sı. Spring Boot uygulamasında 100-200 MB'ı bulabilir.
- Code cache: JIT'in derlediği native kod.
- Thread stack'leri: 64-bit Linux'ta thread başına varsayılan 1 MB ayrılır (fiilen kullanılan daha az). 200 Tomcat thread'i ve yardımcı thread'lerle yüzlerce MB adres alanı.
- Direct buffer'lar: Netty, Kafka client, NIO.
- GC'nin kendi yapıları, JNI ve glibc malloc arenaları.
Bu yüzden -Xmx değerini container limitine eşitlemek, heap dolmadan bile OOMKilled almanın garantili yoludur. JDK 10'dan beri (8u191'e backport edildi) JVM container limitlerini görür. Doğru yaklaşım heap'i limitin yüzdesi olarak vermektir:
env:
- name: JAVA_TOOL_OPTIONS
value: >-
-XX:MaxRAMPercentage=70
-XX:+ExitOnOutOfMemoryError
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps
-Xmx verilmezse varsayılan MaxRAMPercentage %25'tir; çoğu servis için gereksiz küçüktür. %70-75 yaygın başlangıçtır ama doğru değer ölçülür: -XX:NativeMemoryTracking=summary ile açıp jcmd <pid> VM.native_memory summary ile heap dışı kullanım görülür. ExitOnOutOfMemoryError heap OOM'unda process'i öldürür ki Kubernetes temiz bir restart yapsın; yarı bozuk bir JVM'in trafik almaya devam etmesinden iyidir.
CPU tarafında JVM, container'ın CPU limitinden kaç işlemcisi olduğunu hesaplar (JDK 19 ve 17.0.5'ten itibaren CPU request'i, yani cgroup shares, artık hesaba katılmaz). 250m limit, availableProcessors() == 1 demektir ve bunun zincirleme etkileri vardır:
- JDK 21 ve 25'te ergonomi, tek CPU veya yaklaşık 1792 MB'tan az bellek gördüğünde G1 yerine Serial GC seçer (G1'i her ortamda varsayılan yapmayı öneren JEP 523 henüz aday durumunda, bir sürüme hedeflenmedi).
ForkJoinPool.commonPool()paralelliği 1 olur;CompletableFuture.supplyAsync(...)executor verilmeden çağrılırsa her görev için yeni bir thread açılır (Javadoc'taki "paralellik en az 2 değilse" kuralı).- Thread pool'larını
availableProcessors()'a göre boyutlayan kütüphaneler (Netty event loop, bazı client'lar) küçük kalır.
Gerekirse -XX:ActiveProcessorCount=2 ile JVM'e kaç işlemci varsayacağı söylenir; ama asıl çözüm gerçekçi CPU kaynağı vermektir.
Graceful shutdown
Bir pod silindiğinde (rollout, scale-down, node drain) şu adımlar paralel ilerler:
sequenceDiagram
participant API as API server
participant EP as EndpointSlice / kube-proxy / ingress
participant K as kubelet
participant App as Spring Boot
API->>EP: Pod "terminating": endpoint ready=false (yayılması zaman alır)
API->>K: Pod'u durdur (grace period sayacı başlar)
K->>App: preStop hook (ör. 10 sn sleep)
K->>App: SIGTERM
App->>App: Yeni isteği reddet, uçuştaki istekleri bitir
K->>App: Grace period dolduysa SIGKILLSorun, endpoint güncellemesinin anlık olmamasıdır. Kube-proxy'nin iptables kurallarını, ingress controller'ın upstream listesini ve service mesh'in kendi endpoint görünümünü güncellemesi saniyeler sürebilir. Bu sürede uygulama SIGTERM alıp yeni bağlantı kabul etmeyi bırakırsa, hâlâ ona yönlendirilen istekler connection refused ya da 502 ile düşer.
Çözüm üç parçalıdır:
- preStop ile beklemek: SIGTERM'den önce 5-10 saniye bekleyip yönlendirme katmanlarının pod'u listeden çıkarmasına zaman tanımak. Hook tamamlanmadan SIGTERM gönderilmez ve hook süresi grace period'dan düşer. Kubernetes 1.34 ile GA olan
sleepaksiyonu, içindesholmayan distroless image'larda da çalışır. - Uygulama tarafında graceful shutdown: Spring Boot'ta
server.shutdown=graceful, SIGTERM gelince yeni istekleri reddeder ve uçuştaki isteklerispring.lifecycle.timeout-per-shutdown-phase(varsayılan 30 sn) kadar bekler. Spring Boot 3.4'ten itibaren graceful shutdown varsayılan olarak açıktır; daha eski sürümlerde varsayılanimmediateidi. Bu timeout herSmartLifecyclekapanış fazı için ayrı uygulanır. Context kapanışında Kafka listener container'ları da (kendi lifecycle fazlarında) durur ve offset'lerini commit eder,@Scheduledişler ve executor'lar kapanır. - Yeterli grace period:
terminationGracePeriodSeconds(varsayılan 30 sn) preStop süresi + shutdown fazı + pay'ı karşılamalı.
spec:
terminationGracePeriodSeconds: 45 # 10 (preStop) + 30 (shutdown fazı) + 5 pay
containers:
- name: order-service
lifecycle:
preStop:
sleep: { seconds: 10 } # K8s 1.30+ (beta, varsayılan açık), 1.34'te GA; daha eskide exec: ["sh", "-c", "sleep 10"]
# application.yml
server:
shutdown: graceful # Boot 3.4+ varsayılan; eski sürümde açıkça yaz
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
SIGTERM'in JVM'e ulaşması gerekir. Dockerfile'da ENTRYPOINT java -jar app.jar (shell form) yazarsan PID 1 /bin/sh olur ve sinyali Java'ya iletmez; container grace period sonunda SIGKILL ile ölür ve hiçbir graceful shutdown çalışmaz. Exec form kullan: ENTRYPOINT ["java", "-jar", "/app/app.jar"], ya da script kullanıyorsan son satırda exec java ... yaz.
HPA ve PodDisruptionBudget
HorizontalPodAutoscaler replika sayısını metriğe göre ayarlar:
desiredReplicas = ceil( currentReplicas x currentMetricValue / desiredMetricValue )
Detaylar mülakatta fark yaratır:
- CPU ve bellek utilization'ı request'e göre hesaplanır. Pod'daki container'lardan birinin bile ilgili request'i yoksa o pod'un utilization'ı tanımsızdır ve HPA o metrik için hiçbir ölçekleme yapmaz.
- Oran 1.0'a %10'dan yakınsa (varsayılan tolerans) işlem yapılmaz; küçük dalgalanmalarda zıplamaz.
- Scale-down için varsayılan 5 dakikalık stabilizasyon penceresi vardır: pencere içindeki en yüksek öneri uygulanır.
- JVM'in açılıştaki CPU sıçraması (JIT) HPA'yı yanıltmasın diye, pod'un ilk Ready geçişinden önceki CPU örnekleri 5 dakikalık başlangıç penceresinde hesaba katılmaz; Ready olmamış pod'lar scale-up hesabında %0 kabul edilir.
- Bellek, JVM için kötü bir ölçekleme metriğidir: heap bir kez büyüdükten sonra GC sonrasında da OS'e kolay geri verilmez; replika artsa bile pod başına bellek düşmez ve HPA sürekli scale-up ister.
- Daha anlamlı metrikler: istek hızı (Prometheus Adapter ile custom metric), Kafka consumer'ları için consumer lag (KEDA). Bir consumer group'ta partition sayısından fazla consumer boşta kalır; max replika partition sayısıyla sınırlanmalı.
- HPA bir Deployment'ı yönetiyorsa manifest'teki
replicasalanını kaldır; aksi halde herkubectl applyya da GitOps senkronu replika sayısını sabit değere geri çeker.
PodDisruptionBudget (PDB), gönüllü kesintilerde (node drain, cluster autoscaler'ın node kapatması, küme yükseltmesi) aynı anda kaç pod'un gidebileceğini sınırlar:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: order-service
spec:
maxUnavailable: 1
selector:
matchLabels: { app: order-service }
PDB'nin sınırları sık sorulur:
- Sadece Eviction API üzerinden gelen kesintilere uygulanır. Pod'u ya da Deployment'ı doğrudan silmek PDB'yi atlar.
- Node çökmesi gibi gönülsüz kesintileri engelleyemez (ama bütçeden düşer).
- Deployment'ın rolling update'i PDB ile sınırlanmaz; onu
maxSurge/maxUnavailableyönetir. minAvailablereplika sayısına eşitse (ör. 3 replika,minAvailable: 3) hiçbir pod tahliye edilemez ve node drain sonsuza kadar bekler. Tek replikalı serviste PDB, bakımı kilitler.
PDB'yi pod'ların farklı node ve availability zone'lara yayılmasıyla (topologySpreadConstraints) birlikte düşün: dört replikanın dördü aynı node'daysa PDB bir node arızasında seni korumaz.
Gözlemlenebilirlik: metrik, log, trace
Monitoring önceden bildiğin soruları cevaplar ("CPU kaç?"). Gözlemlenebilirlik (observability), önceden düşünmediğin soruları sistemin dışarı verdiği sinyallerden cevaplayabilmektir ("neden sadece Android 14 kullanıcılarının ödeme isteği yavaş?"). Üç ana sinyal türü vardır, ve her birinin maliyet profili farklıdır:
| Sinyal | Ne verir | Maliyet | Kullanım |
|---|---|---|---|
| Metrik | Zamana göre toplanmış sayılar | Ucuz, sabit (kardinaliteye bağlı) | Alarm, dashboard, trend |
| Log | Tek tek olayların detayı | Hacimle doğrusal, pahalı | Bir olayın nedenini anlamak |
| Trace | Bir isteğin servisler arası yolculuğu ve süre dağılımı | Örnekleme oranına bağlı | Darboğazın nerede olduğunu bulmak |
Üçünü birbirine bağlayan şey ortak tanımlayıcılardır: log satırında traceId, metrikte exemplar olarak örnek bir traceId, trace'te servis adı ve sürüm. Tipik araştırma akışı: alarm (metrik) → ilgili zaman aralığındaki yavaş trace'ler → o trace'in log'ları.
RED, USE ve dört altın sinyal
RED (Tom Wilkie) istek işleyen servisler içindir:
- Rate: saniyedeki istek sayısı
- Errors: hata veren isteklerin sayısı/oranı
- Duration: isteklerin süre dağılımı (percentile'larla)
USE (Brendan Gregg) kaynaklar içindir:
- Utilization: kaynağın meşgul olduğu zaman oranı
- Saturation: kaynağın kaldıramadığı, kuyrukta bekleyen iş
- Errors: kaynak seviyesindeki hatalar
Google SRE kitabındaki dört altın sinyal (latency, traffic, errors, saturation) ikisinin birleşimi gibidir.
Bir sipariş servisi için somut karşılıklar:
| Bileşen | Metrik | Metod |
|---|---|---|
| HTTP API | istek hızı, 5xx oranı, p99 süre (endpoint bazında) | RED |
| Hikari connection pool | aktif/boşta connection, bekleyen thread sayısı (hikaricp_connections_pending), connection alma süresi | USE (saturation: pending) |
| JVM | heap kullanımı, GC duraklama süresi, thread sayısı | USE |
| Container | CPU throttling oranı, bellek working set'inin limite oranı | USE |
| Kafka consumer | partition bazında lag, işleme süresi, DLT'ye düşen mesaj sayısı, rebalance sayısı | RED + saturation |
| Outbox | yayınlanmamış kayıt sayısı, en eski yayınlanmamış kaydın yaşı | saturation |
Kuyruk tabanlı bileşenlerde "kaç mesaj bekliyor" yerine "en eski bekleyen ne kadar yaşlı" çoğu zaman daha iyi bir sinyaldir; doğrudan kullanıcının yaşadığı gecikmeyi anlatır.
Micrometer ve Prometheus
Micrometer, metrikler için SLF4J'nin loglama için yaptığını yapar: uygulama kodu tek bir API'ye (MeterRegistry) yazar, çıktı Prometheus, Datadog, OTLP gibi backend'lere gider. Spring Boot Actuator HTTP istekleri, JVM, Hikari, Kafka client, executor'lar için metrikleri hazır üretir; micrometer-registry-prometheus bağımlılığı eklenince /actuator/prometheus endpoint'i açılır ve Prometheus bu endpoint'i belli aralıklarla çeker (pull model).
Temel meter tipleri:
- Counter: sadece artan sayı (sipariş sayısı). Kendisine değil,
rate()'ine bakılır; restart'ta sıfırlanmasırate()tarafından tolere edilir. - Gauge: anlık değer (kuyruk uzunluğu). Okunduğu anda bir fonksiyon çağrılır.
- Timer: süre ve sayı birlikte; histogram yayınlayabilir.
- DistributionSummary: süre olmayan dağılımlar (sipariş tutarı, payload boyutu).
İş metrikleri eklemek:
@Service
public class OrderMetrics {
private final Counter placed;
private final Counter rejectedInsufficientStock;
public OrderMetrics(MeterRegistry registry, OutboxEventRepository outbox) {
this.placed = Counter.builder("orders.placed")
.description("Başarıyla oluşturulan siparişler")
.register(registry);
this.rejectedInsufficientStock = Counter.builder("orders.rejected")
.tag("reason", "insufficient_stock") // düşük kardinaliteli, sabit değer kümesi
.register(registry);
// Her scrape'te çağrılır: ucuz bir sorgu olmalı (partial index ile count)
Gauge.builder("outbox.pending", outbox, OutboxEventRepository::countByPublishedAtIsNull)
.register(registry);
}
public void orderPlaced() { placed.increment(); }
public void orderRejectedForStock() { rejectedInsufficientStock.increment(); }
}
Prometheus'ta isimler orders_placed_total, http_server_requests_seconds_count gibi dönüşür. Örnek sorgular:
# 5 dakikalık 5xx oranı
sum(rate(http_server_requests_seconds_count{job="order-service", status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count{job="order-service"}[5m]))
# Tüm pod'lar üzerinden p99 süre (histogram açıksa)
histogram_quantile(0.99,
sum by (le) (rate(http_server_requests_seconds_bucket{job="order-service"}[5m])))
Kardinalite Prometheus'un en pahalı hatasıdır. Her benzersiz label kombinasyonu ayrı bir zaman serisidir. userId, orderId, ham URL path'i (/orders/123) gibi sınırsız değerli bir label milyonlarca seri üretir; Prometheus'un belleği ve sorgu süresi patlar. Spring MVC uri tag'ini şablon olarak yazar (/api/orders/{id}), bu yüzden güvenlidir; ama kendi tag'lerini eklerken değer kümesinin küçük ve sınırlı olduğundan emin ol. Kimlik bilgileri log'a ve trace attribute'una gider, metriğe gitmez.
Neden ortalama değil percentile
Ortalama, dağılımın kuyruğunu gizler. 1000 isteğin 985'i 50 ms, 15'i 3 sn sürsün:
- Ortalama: (985 x 50 + 15 x 3000) / 1000 = 94 ms. "Gayet iyi" görünür.
- p99: 3 sn. Her 100 kullanıcıdan biri 3 saniye bekliyor.
Kuyruk etkisi fan-out'ta büyür. Bir sayfa 10 servisi paralel çağırıyorsa ve her birinin p99'u 1 sn ise, sayfanın en az bir yavaş çağrıya takılma olasılığı 1 - 0.99^10 ≈ %9.6'dır. Tek tek servislerin p99'u, kullanıcının p90'ı olur.
İki teknik ayrıntı sık atlanır:
- Percentile'lar toplanamaz. Her pod'un kendi hesapladığı p99'un ortalaması anlamsızdır. Micrometer'ın istemci tarafında hesapladığı percentile'lar (
percentiles=0.99) bu yüzden tek pod içindir. Pod'lar üzerinden doğru percentile için histogram bucket'ları yayınlanır vehistogram_quantilesunucu tarafında hesaplar:management.metrics.distribution.percentiles-histogram.http.server.requests=true. - Histogram'dan hesaplanan percentile bir tahmindir; doğruluğu bucket sınırlarına bağlıdır. SLO eşiğini (ör. 300 ms) tam bir bucket sınırı yapmak için
management.metrics.distribution.slo.http.server.requests=100ms,300ms,1sgibi sınırlar eklenir. "İsteklerin yüzde kaçı 300 ms'nin altında?" sorusu böylece tahmin değil, kesin sayım olur.
Distributed tracing: OpenTelemetry, trace, span, context propagation
Bir trace, tek bir isteğin sistemdeki tüm yolculuğudur; span'lerden oluşan bir ağaçtır. Her span bir işi temsil eder (HTTP isteği, DB sorgusu, Kafka'ya gönderim): adı, başlangıç/bitiş zamanı, attribute'ları, durumu ve parent span'inin id'si vardır. Trace id 16 byte, span id 8 byte'tır.
Servisler arası bağı kuran şey context propagation'dır: çağıran servis mevcut trace id ve span id'yi giden isteğe ekler, çağrılan servis bunları okuyup kendi span'ini o trace'in çocuğu olarak açar. Standart format W3C Trace Context'in traceparent header'ıdır:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
Alanlar: sürüm (00), 32 hex trace id, 16 hex parent span id, flag'ler (01 = örneklendi). tracestate vendor'a özgü bilgi, baggage ise uygulama seviyesinde taşınan anahtar-değerler içindir.
Spring Boot 3'te tracing Micrometer Tracing ile yapılır (Spring Cloud Sleuth'un yerini aldı); altta OpenTelemetry ya da Brave tracer kullanılır. Micrometer'ın Observation API'si tek bir enstrümantasyonla hem metrik hem span üretir. Varsayılan örnekleme oranı %10'dur (management.tracing.sampling.probability). Spring Boot 4.0 OTLP ile metrik ve trace dışa aktarımını tek pakette toplayan spring-boot-starter-opentelemetry starter'ını ekledi. Alternatif olarak OpenTelemetry Java agent'ı kod değiştirmeden bytecode enstrümantasyonu yapar.
Kafka üzerinden propagation. HTTP'de header neyse Kafka'da record header'ıdır. Spring Kafka'da observation açıldığında (KafkaTemplate ve listener container'ın ContainerProperties'inde observationEnabled=true; Spring Boot'ta spring.kafka.template.observation-enabled ve spring.kafka.listener.observation-enabled) producer traceparent'ı header'a yazar, consumer okur ve işleme span'ini aynı trace'e bağlar.
Outbox'ta trace kopması senior seviyesinde bir detaydır. Transactional outbox'ta event'i HTTP isteğini işleyen thread değil, ayrı bir zamanlanmış relay gönderir. Relay'in kendi trace context'i vardır; sipariş isteğiyle bağ kopar. Çözüm: outbox satırına yazarken o anki traceparent'ı da bir kolona kaydetmek ve relay gönderirken bunu Kafka header'ı olarak koymak (ya da relay'in span'ine link olarak eklemek). Debezium'un outbox event router'ı da benzer şekilde tracing header'ını tablodan taşıyabilir.
Async sınırlar. Trace context ve MDC ThreadLocal'da durur. @Async, CompletableFuture, kendi executor'ın ya da virtual thread'e geçişte context kaybolur. Spring Framework 6.1'deki ContextPropagatingTaskDecorator (Micrometer context-propagation kütüphanesiyle) executor'lara context taşır.
Örnekleme. Head-based örnekleme kararı trace başında verir: ucuzdur ama nadir yavaş istekleri kaçırabilir. Tail-based örnekleme (ör. OpenTelemetry Collector'ın tail sampling processor'ı) trace bittikten sonra "hatalı veya 1 sn'den uzun olanları tut" diyebilir; daha akıllıdır ama trace'leri bellekte tamponlamak gerekir.
Structured logging ve correlation id
Production log'u insanların değil makinelerin okuduğu bir veridir. Düz metin log'da grep ile "orderId=123" aramak kırılgandır; JSON log'da orderId bir alandır, filtrelenir, gruplanır.
Spring Boot 3.4'ten itibaren yerleşik structured logging vardır: logging.structured.format.console ile Elastic Common Schema (ecs), Logstash (logstash) ya da Graylog (gelf) formatında JSON çıktısı alınır. Micrometer Tracing aktifse traceId ve spanId MDC'ye otomatik konur ve log satırına girer.
logging:
structured:
format:
console: ecs
public UUID placeOrder(PlaceOrderCommand command) {
try (var ignored = MDC.putCloseable("productId", String.valueOf(command.productId()))) {
log.info("Placing order quantity={}", command.quantity());
// ...
}
}
Trace id ile correlation id aynı şey değildir. Trace id teknik bir isteği izler ve örneklemeye takılmadan her log'a girer. İş korelasyonu için (bir siparişin oluşturulmasından kargoya verilmesine kadar günler süren süreç) orderId gibi bir iş anahtarı log alanı olarak taşınır. Edge'de (API gateway) üretilen bir X-Request-Id müşteri destek ekibine verilebilecek bir referanstır.
Log tarafındaki disiplin maddeleri: kişisel veri ve kart bilgisi log'a yazılmaz (KVKK/GDPR ve PCI DSS; bankacılıkta bu bir denetim bulgusudur), secret'lar asla loglanmaz, her hata bir kez ve yakalandığı yerde stack trace ile loglanır (katman katman tekrar loglamak gürültüdür), log hacmi bir maliyettir ve DEBUG production'da kapalıdır.
SLI, SLO ve error budget
- SLI (Service Level Indicator): ölçülen şey. İyi tanım "iyi olay / geçerli olay" oranıdır. Ör. "geçerli HTTP isteklerinden 5xx olmayanların oranı" ya da "isteklerin 300 ms altında tamamlananlarının oranı".
- SLO (Objective): SLI için hedef. "30 günlük kayan pencerede isteklerin %99.9'u başarılı."
- SLA (Agreement): müşteriyle yapılan, ihlalinde yaptırımı olan sözleşme. SLA genelde SLO'dan gevşektir; iç hedef dış taahhütten sıkı tutulur.
- Error budget = 1 - SLO. %99.9 için %0.1.
Zaman cinsinden: 30 gün x 24 saat x 60 dk = 43.200 dk; %0.1'i 43,2 dakikadır. İstek cinsinden: ayda 10 milyon istek alan bir servis 10.000 hatalı isteğe "izinlidir".
Error budget bir karar aracıdır. Bütçe varken ekip risk alabilir (sık deploy, deney); bütçe bittiğinde yeni feature yerine güvenilirlik işi öne alınır ve riskli değişiklikler durdurulur. %100 SLO hedeflemek bu yüzden yanlıştır: hiçbir değişikliğe izin vermez ve kullanıcının kendi internetinden daha güvenilir olmanın da anlamı yoktur.
SLO kullanıcının bakış açısından seçilir. "DB CPU'su %70'in altında" bir SLO değildir; "ödeme sayfası isteklerinin %99'u 1 sn'nin altında" bir SLO'dur.
Alerting: semptom bazlı ve burn rate
Alarmın tek kuralı: gece birini uyandıran her alarm, kullanıcıyı etkileyen ve insan müdahalesi gerektiren bir durumu göstermeli. Bu yüzden alarmlar nedenlere değil semptomlara kurulur:
- Neden alarmı: "CPU %80'i geçti." CPU %80'de kullanıcı hiçbir şey hissetmiyor olabilir; ya da CPU %30'dayken connection pool tükendiği için her istek fail ediyor olabilir.
- Semptom alarmı: "Hata oranı error budget'ı normalden 14 kat hızlı tüketiyor."
Neden metrikleri dashboard'larda ve teşhiste kullanılır. İstisna, yakında kesinti yaratacağı kesin olan saturation'lardır (disk 4 saat içinde dolacak).
Burn rate, bütçenin hangi hızla tüketildiğidir. Burn rate 1, bütçenin tam pencere sonunda biteceği hızdır. Burn rate 14.4 ile 30 günlük bütçenin %2'si 1 saatte tükenir (0.02 x 720 saat = 14.4). Google SRE Workbook'un önerdiği çok pencereli, çok hızlı (multiwindow, multi-burn-rate) düzen:
| Bütçe tüketimi | Uzun pencere | Kısa pencere | Burn rate | Aksiyon |
|---|---|---|---|---|
| %2 | 1 saat | 5 dk | 14.4 | Page (uyandır) |
| %5 | 6 saat | 30 dk | 6 | Page |
| %10 | 3 gün | 6 saat | 1 | Ticket |
Kısa pencere, sorun bittikten sonra alarmın saatlerce açık kalmasını engeller: iki pencere birden eşiği aşınca alarm çalar.
# %99.9 SLO, izin verilen hata oranı 0.001
(
sum(rate(http_server_requests_seconds_count{job="order-service",status=~"5.."}[1h]))
/ sum(rate(http_server_requests_seconds_count{job="order-service"}[1h]))
) > (14.4 * 0.001)
and
(
sum(rate(http_server_requests_seconds_count{job="order-service",status=~"5.."}[5m]))
/ sum(rate(http_server_requests_seconds_count{job="order-service"}[5m]))
) > (14.4 * 0.001)
Her alarmın bir runbook linki, bir sahibi ve açık bir "ne yapmalı" bölümü olmalı. Kimsenin aksiyon almadığı alarm silinir; yoksa alarm yorgunluğu gerçek alarmı da gömer.
CI/CD hattı
Terimler sık karıştırılır:
- Continuous Integration: herkes sık (en az günde bir) ana dala entegre eder, her commit otomatik build ve testten geçer.
- Continuous Delivery: ana dal her an production'a çıkabilir durumdadır; çıkış bir buton.
- Continuous Deployment: testlerden geçen her değişiklik otomatik olarak production'a çıkar.
Tipik bir hat ve her aşamanın nedeni:
| Aşama | İçerik | Neden bu sırada |
|---|---|---|
| Build + unit test | Derleme, hızlı testler (saniyeler) | En ucuz geri bildirim en önde |
| Statik analiz ve güvenlik | Sonar/SpotBugs, bağımlılık zafiyet taraması (OWASP Dependency-Check, Trivy, Snyk), secret taraması | Kod incelemesinden önce mekanik hataları yakalar |
| Integration test | Testcontainers ile gerçek PostgreSQL/Kafka | Dakikalar sürer; unit testler geçtikten sonra |
| Contract test | Pact / Spring Cloud Contract | Bağımsız deploy edilen servislerin sözleşmesi |
| Image build ve imza | Jib/Buildpacks, image taraması, imzalama | Test edilen kodun paketlenmiş hali |
| Staging deploy | GitOps (Argo CD/Flux) ile otomatik | Gerçek ortamda smoke test |
| Production | Canary veya blue-green, otomatik analiz | Riski kademeli açmak |
| Doğrulama | SLI'lar izlenir, gerekirse otomatik rollback | Deploy'un bitmesi işin bitmesi değil |
İki ilke: build once, deploy many (staging'de test edilen image digest'inin aynısı production'a gider; ortam farkı sadece konfigürasyondur) ve hızlı geri bildirim (test piramidinin hattaki karşılığı budur: bkz. Bölüm 16). Flaky testler hattın güvenilirliğini yok eder; ekip kırmızı build'i görmezden gelmeye başlar.
Trunk-based development CI'nin doğal sonucudur: kısa ömürlü branch'ler (en fazla bir-iki gün), sık merge, bitmemiş iş feature flag arkasında ana dala girer. Uzun ömürlü feature branch'leri büyük, riskli merge'ler ve geç fark edilen entegrasyon hataları üretir.
Deployment stratejileri: rolling, blue-green, canary
| Strateji | Nasıl | Ek kaynak | Rollback | Risk maruziyeti | Dikkat |
|---|---|---|---|---|---|
| Rolling | Pod'lar kademeli değişir | Az (maxSurge kadar) | Yeni rollout (dakikalar) | Kademeli ama kontrolsüz: trafik payı pod oranına bağlı | İki sürüm aynı anda çalışır |
| Blue-green | Yeni ortam (green) tam kurulur, trafik tek seferde çevrilir | 2 kat | Anında (trafiği geri çevir) | Hepsi bir anda | DB şeması iki sürüme uyumlu olmalı; Kafka consumer'ları iki ortamda da çalışabilir |
| Canary | Trafiğin küçük bir yüzdesi yeni sürüme, metrikler iyiyse kademeli artırılır | Az | Hızlı (canary'yi kapat) | En düşük | Metrik analizi ve trafik bölme altyapısı gerekir |
Canary'nin gücü otomasyondan gelir: Argo Rollouts ya da Flagger gibi araçlar her adımda canary'nin hata oranını ve latency'sini stabil sürümle karşılaştırır, eşik aşılırsa otomatik geri alır. Trafik bölme service mesh, ingress controller ya da Gateway API ile yapılır.
Kafka consumer'ları HTTP gibi "trafik yüzdesi" ile bölünemez: aynı consumer group'taki eski ve yeni pod'lar partition'ları paylaşır. Bu yüzden event işleme mantığındaki değişikliklerde feature flag, ya da yeni sürümü ayrı bir consumer group ile gölge modda (sonucu yazmadan) çalıştırmak daha kontrollüdür.
Feature flag
Feature flag deploy ile release'i ayırır: kod production'a kapalı olarak çıkar, özellik çalışma anında açılır. Martin Fowler'ın sınıflandırması:
- Release toggle: bitmemiş işi ana dalda saklamak. Kısa ömürlü olmalı.
- Ops toggle (kill switch): pahalı bir özelliği yük altında kapatmak (flash sale'de öneri motorunu kapatmak).
- Experiment toggle: A/B testi.
- Permission toggle: belirli kullanıcılara (beta, iç kullanıcılar) açmak.
public PriceSummary price(Cart cart, Customer customer) {
if (featureFlags.isEnabled("new-campaign-engine", customer.id())) {
return newCampaignEngine.price(cart);
}
return legacyPricer.price(cart);
}
Maliyetleri: her flag kod yolu sayısını ikiye katlar (test kombinasyonları), kaldırılmayan flag teknik borçtur (her flag'e bir son kullanma tarihi ve sahip ver), aynı kullanıcı için değerlendirme tutarlı olmalı (bir istekte açık, sonraki istekte kapalı olmamalı), bankacılıkta flag değişiklikleri denetim izine (audit log) yazılmalıdır.
Veritabanı migration'larını geriye uyumlu yapmak: expand/contract
Rolling update ve rollback ihtimali yüzünden her şema değişikliği iki koşulu sağlamalı: eski kod yeni şemayla çalışmalı (rollout sırasında ve rollback sonrasında), yeni kod da eski veriyle çalışmalı. Bunun yöntemi expand/contract'tır (Parallel Change).
Örnek: orders.address kolonunu shipping_address olarak yeniden adlandırmak. ALTER TABLE ... RENAME COLUMN tek adımda yapılırsa, rollout sırasında hâlâ çalışan eski pod'lar address kolonunu bulamaz ve hata verir. Doğru sıra:
| Adım | Release | Şema / kod |
|---|---|---|
| 1. Expand | N | ADD COLUMN shipping_address text (nullable). Kod değişmez. |
| 2. Çift yazma | N+1 | Kod iki kolona da yazar, eskisinden okur. |
| 3. Backfill | (iş) | Eski satırlar küçük batch'lerle kopyalanır: UPDATE ... WHERE id BETWEEN .... |
| 4. Okumayı taşı | N+2 | Kod yeni kolondan okur, iki kolona da yazmaya devam eder. |
| 5. Eskiye yazmayı bırak | N+3 | Kod sadece yeni kolonu kullanır. |
| 6. Contract | N+4 | DROP COLUMN address. Bu noktada eski kolonu bilen hiçbir sürüm çalışmıyor ve geri dönülmeyecek. |
Her adım tek başına geri alınabilir. Bedeli zamandır: basit bir rename birkaç release'e yayılır. Bu yüzden isimlendirmeye baştan özen göstermek ucuzdur.
PostgreSQL'e özgü kilit tuzakları (bkz. Bölüm 10):
ADD COLUMNile sabit (volatile olmayan) birDEFAULTPostgreSQL 11'den beri tabloyu yeniden yazmaz; hızlıdır.NOT NULLeklemek tüm tabloyu tarar veACCESS EXCLUSIVEkilidi altında yapar. ÖnceADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID(anlık), sonra ayrı bir adımdaVALIDATE CONSTRAINT(yazmaları bloklamadan tarar). PostgreSQL 12+'da ardından gelenSET NOT NULL, geçerli CHECK constraint'ini görüp tabloyu yeniden taramaz.- Index'ler
CREATE INDEX CONCURRENTLYile oluşturulur; bu komut transaction içinde çalışamaz, Flyway'de o script'i transaction dışında çalıştırmak gerekir. - Kısa bir kilit bile, önünde uzun süren bir sorgu varsa kuyruğa girer ve arkasındaki tüm sorguları bloklar. Migration oturumunda
SET lock_timeout = '3s'ile hızlıca vazgeçip tekrar denemek, tüm tabloyu kilitlemekten iyidir.
Migration'ı nerede çalıştırdığın da önemlidir. Spring Boot açılışında Flyway çalıştırmak basittir ve Flyway PostgreSQL'de advisory lock ile pod'ları sıraya sokar. Ama uzun bir migration açılışı uzatır, startupProbe'u aşar ve CrashLoopBackOff'a yol açar; diğer pod'lar da kilidi beklerken asılı kalır. Büyük ölçekte migration'lar deploy'dan önce ayrı bir pipeline adımında ya da Kubernetes Job'ında çalıştırılır.
Aynı ilke event şemaları için de geçerlidir: Kafka event'ine alan eklemek (opsiyonel, varsayılanlı) geriye uyumludur; alan silmek ya da anlamını değiştirmek, tüm consumer'lar güncellenmeden yapılmaz.
Terraform temelleri
Terraform deklaratif bir Infrastructure as Code aracıdır: istenen altyapıyı HCL ile yazarsın, Terraform mevcut durumla farkı hesaplayıp provider API'leri (AWS, GCP, Kubernetes, Kafka...) üzerinden uygular.
Temel kavramlar:
- Provider: belirli bir API ile konuşan eklenti.
- Resource: Terraform'un yönettiği bir nesne (
aws_db_instance). Data source: Terraform'un yönetmediği, sadece okuduğu nesne. - Variable / output: parametreler ve dışarı verilen değerler.
- Module: tekrar kullanılabilir resource grubu (ör. "standart PostgreSQL + parametre grubu + alarm"). Versiyonlanır.
Akış: terraform init (provider ve backend kurulumu) → terraform plan (fark hesabı) → terraform apply (uygulama). CI'da plan bir artefakt olarak üretilir (terraform plan -out=tfplan), PR'da incelenir ve onaylanan aynı plan uygulanır (terraform apply tfplan); arada değişen bir şey varsa apply reddedilir.
State, Terraform'un hangi config bloğunun hangi gerçek kaynağa (ID) karşılık geldiğini tuttuğu dosyadır. Plan bu eşlemeyi kullanarak "bu RDS zaten var, sadece parametresi değişecek" der. State'in özellikleri:
- Hassastır: içinde veritabanı şifreleri gibi değerler düz metin olarak bulunabilir. Git'e konmaz, erişimi kısıtlanır, at-rest şifrelenir.
- Paylaşılmalıdır: ekipte herkesin aynı state'i görmesi için remote backend (S3, GCS, Azure Blob, HCP Terraform) kullanılır.
- Kilitlenmelidir: iki kişi aynı anda
applyyaparsa state bozulur. S3 backend'de klasik yöntem DynamoDB tablosuyla kilitlemeydi; Terraform 1.11'den itibaren S3'ün yerel kilit dosyası (use_lockfile = true) GA oldu ve DynamoDB argümanları deprecated.
terraform {
backend "s3" {
bucket = "acme-terraform-state"
key = "orders/prod/terraform.tfstate"
region = "eu-central-1"
encrypt = true
use_lockfile = true # Terraform 1.11+; öncesinde dynamodb_table ile kilit
}
}
Drift, gerçek altyapının state ve config'ten sapmasıdır; en sık nedeni konsoldan elle yapılan değişikliktir. terraform plan varsayılan olarak önce gerçek durumu okur (refresh) ve farkı gösterir; terraform plan -refresh-only sadece drift'i raporlar. Zamanlanmış bir CI işiyle drift düzenli kontrol edilir.
Pratik kurallar: state'i ortam ve bileşen bazında böl (prod ağının state'i ile bir servisin bucket'ı aynı state'te olmasın; bir hatanın etki alanı küçülür), kritik kaynaklarda lifecycle { prevent_destroy = true } kullan, kaynak taşırken moved bloğu (1.1+), mevcut kaynağı Terraform'a alırken import bloğu (1.5+) kullan. Lisans notu: HashiCorp Ağustos 2023'te Terraform'u BSL lisansına geçirdiğini duyurdu (1.6 ve sonrası BSL); açık kaynak çatalı OpenTofu'dur.
Quiz'deki sorular: çözüm ve nedenleri
q16: Pod sürekli restart oluyor: manifest incelemesi
Senaryo. Sipariş servisi bazen OOMKilled, bazen CrashLoopBackOff oluyor; DB 20 saniyelik bakıma girince tüm pod'lar aynı anda restart oluyor. Manifest özeti:
env:
- name: JAVA_TOOL_OPTIONS
value: "-Xmx1024m"
resources:
requests: { cpu: "250m", memory: "1Gi" }
limits: { cpu: "250m", memory: "1Gi" }
livenessProbe:
httpGet: { path: /actuator/health, port: 8080 }
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 2
Belirtiler ile nedenleri eşleştirmek, mülakatta en iyi izlenimi veren yaklaşımdır:
| Belirti | Neden |
|---|---|
| OOMKilled | -Xmx1024m = limit 1Gi; heap dışı bellek limiti aşırıyor |
| CrashLoopBackOff | 250m CPU ile yavaş açılış + 10 sn sonra başlayan, 2 hatada öldüren liveness |
| DB bakımında hepsi restart | Liveness, DB'yi içeren /actuator/health'e bakıyor |
1. Heap = limit. 1 GiB heap'in üstüne metaspace, code cache, thread stack'leri, direct buffer'lar ve GC yapıları eklenir; toplam process belleği 1 GiB'ı aşınca kernel container'ı öldürür (exit code 137, reason OOMKilled). Heap kullanımı düşük görünse bile olur, çünkü heap dolmadan heap dışı alan limiti aşabilir. Düzeltme: -XX:MaxRAMPercentage=70 ve NMT ile ölçüm; ayrıca -XX:+ExitOnOutOfMemoryError.
2. Liveness'ta bağımlılık. /actuator/health DB health'ini içerir. DB gidince tüm pod'lar aynı anda liveness fail eder ve restart edilir; restart DB'yi düzeltmez, DB dönünce de soğuk açılış ve connection storm yaşanır. Düzeltme: liveness /actuator/health/liveness.
3. Readiness yok. Pod container çalışmaya başladığı anda Ready sayılır; Spring context hazır olmadan trafik alır, kapanırken de trafik almaya devam eder. Düzeltme: /actuator/health/readiness.
4. Açılış ve liveness yarışı. 250m CPU ile Spring Boot'un açılışı kolayca 40-60 sn sürer. Liveness 10. saniyede başlar, 5 sn arayla 2 hatada (yaklaşık 15. saniyede) container'ı öldürür. Uygulama hiç açılamaz ve CrashLoopBackOff'a düşer. Düzeltme: startupProbe (ör. periodSeconds: 5, failureThreshold: 30).
5. CPU limiti 250m. CFS throttling açılışı ve JIT'i yavaşlatır, p99'u bozar. JVM kendini tek işlemcili sanır: JDK 21/25'te Serial GC seçer, common pool paralelliği 1 olur ve CompletableFuture'ın executor'sız async metotları her görev için yeni thread açar. Düzeltme: gerçekçi CPU request (ör. 1), CPU limitini kaldırmak ya da gevşetmek.
6. Graceful shutdown eksik. preStop yok, grace period varsayılan. Düzeltme: preStop ile 5-10 sn bekleme, server.shutdown=graceful (Boot 3.4+'ta varsayılan), toplamı karşılayan terminationGracePeriodSeconds.
Düzeltilmiş manifest:
spec:
terminationGracePeriodSeconds: 45
containers:
- name: order-service
image: registry.local/order-service:1.4.3
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=70 -XX:+ExitOnOutOfMemoryError"
resources:
requests: { cpu: "1", memory: "1Gi" }
limits: { memory: "1Gi" }
startupProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
failureThreshold: 3
timeoutSeconds: 2
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
lifecycle:
preStop:
sleep: { seconds: 10 } # K8s < 1.30: exec: { command: ["sh", "-c", "sleep 10"] }
Quiz'deki açıklamaya düzeltmeler. İki nokta eksik ya da fazla basitleştirilmiş:
- Quiz, Spring Boot'un probe gruplarını "Kubernetes'te çalıştığını algılayınca" açtığını söylüyor. Bu Spring Boot 3.x için doğru; Spring Boot 4.0'da liveness ve readiness grupları her ortamda varsayılan olarak açık.
- Quiz "HPA CPU'ya göre çalışır" diyor. HPA CPU dışında bellek, custom (Prometheus Adapter) ve external metriklerle de çalışır; CPU sadece en yaygın kullanımdır. Ayrıca utilization request'e göre hesaplanır; request'i eksik pod'larda utilization tanımsızdır ve HPA o metrikle ölçekleme yapmaz.
Production karşılığı. Bu soru bir "kaynak ve probe hijyeni" sorusudur. Senior cevap, belirtiyi nedenle eşleştirir, her düzeltmenin neden o sayı olduğunu söyler (failureThreshold x periodSeconds ≥ en kötü açılış) ve ölçümle doğrulamayı ekler: throttling oranı, NMT çıktısı, restart sayısı ve kube_pod_container_status_last_terminated_reason metriği.
q17: Gözlemlenebilirlik ve teslimat hattı
Senaryo. Prometheus + Grafana, OpenTelemetry, Terraform ve trunk-based development kullanan bir ekip; hangi ifadeler doğru?
Doğru cevaplar: a, c, d, e, g.
- a (doğru): RED servisler için Rate, Errors, Duration; USE kaynaklar için Utilization, Saturation, Errors. RED istek odaklı (Tom Wilkie), USE kaynak odaklı (Brendan Gregg). Birini ötekinin yerine kullanmak boşluk bırakır: RED connection pool'un dolmak üzere olduğunu göstermez, USE kullanıcının yavaşlık yaşadığını doğrudan göstermez.
- c (doğru): %99.9 aylık SLO, 30 günlük ayda 43.200 dakikanın %0.1'i, yani yaklaşık 43,2 dakikalık error budget'tır. Önemli olan sayıdan çok kullanımıdır: bütçe biterse yeni feature yerine güvenilirlik işi öne alınır.
- d (doğru): Trace id W3C
traceparentheader'ıyla servisler arasında taşınır, Kafka'da record header'ı olarak gider ve MDC üzerinden log'lara yazılır; log-trace korelasyonu bu sayede kurulur. - e (doğru):
terraform plangerçek durumu okuyup (refresh) state ve config ile karşılaştırır ve yapılacak değişiklikleri gösterir. State, eşzamanlı apply'ları önlemek için kilitli bir remote backend'de tutulur; hassas veri içerebildiği için erişimi kısıtlanır. - g (doğru):
userIdgibi sınırsız değerli bir label her kullanıcı için ayrı zaman serisi üretir; Prometheus'un belleği ve sorgu performansı çöker. Kimlikler log ve trace'e konur.
Yanlış seçenekler:
- b (yanlış): Ortalama kuyruğu gizler; %1'lik 5 saniyelik istekler ortalamada neredeyse görünmez. Histogram ve percentile (p95, p99) kullanılır, pod'lar üzerinden toplanacaksa histogram bucket'larıyla.
- f (yanlış): Trunk-based development'ın özü kısa ömürlü branch'ler ve sık merge'dir; bitmemiş iş feature flag arkasında ana dala girer. Tarif edilen şey feature branch workflow'dur.
Arkasındaki kavram. Bu soru gözlemlenebilirliğin "ne ölçülür" (RED/USE), "nasıl özetlenir" (percentile), "nasıl karar verilir" (SLO/error budget), "nasıl bağlanır" (trace context) ve "ne pahalıdır" (kardinalite) katmanlarını ve teslimatın iki temel pratiğini (IaC state yönetimi, trunk-based) bir arada yokluyor. Production'da bunların hepsi bir alarmın kalitesinde birleşir: SLO tabanlı, percentile'a dayalı, trace'e bağlanabilen ve düşük kardinaliteli metriklerden üretilen bir alarm.
Üç seviyede cevap
Soru: "Liveness ve readiness probe arasındaki fark nedir? Spring Boot servisinde nasıl ayarlarsın?"
seviye: Başlangıç etiketler: [kubernetes, health-check] kaynak: Tahmini
Liveness uygulamanın ayakta olup olmadığını kontrol eder, readiness da trafik almaya hazır olup olmadığını. İkisini de /actuator/health'e bağlarım; böylece DB ya da Redis giderse pod restart olur ve sorun çözülür.
Liveness fail ederse container restart edilir, readiness fail ederse pod Service'ten çıkarılır ama öldürülmez. Spring Boot'ta /actuator/health/liveness ve /actuator/health/readiness gruplarını kullanırım; liveness'a DB koymam, çünkü DB gidince bütün pod'lar restart olur. Spring Boot yavaş açılıyorsa startupProbe eklerim ki liveness açılışta öldürmesin.
Üç ayrı soru var: startup "açıldı mı", liveness "restart bu process'i düzeltir mi", readiness "şu an trafik verilmeli mi". Liveness'a sadece process'in kendi durumunu koyarım; dış bağımlılık restart ile düzelmez ve ortak bir bağımlılıkta tüm filoyu aynı anda öldürür. Readiness'a ortak DB'yi eklemeyi de varsayılan yapmam: DB gidince tüm pod'lar NotReady olur, Service boşalır, DB gerektirmeyen endpoint'ler de 503 döner. Onun yerine circuit breaker ile hızlı ve anlamlı hata dönmeyi tercih ederim.
Ayarlarda sayıları gerekçelendiririm: startupProbe'da failureThreshold x periodSeconds yük altındaki en kötü açılışı karşılar; liveness timeout'u 1 sn varsayılanında bırakmam çünkü uzun bir GC duraklaması yanlış restart üretir. Actuator'ı ayrı management port'una aldıysam add-additional-paths ile /livez ve /readyz'yi ana port'ta açarım ki probe gerçek trafik yolunu test etsin. Kapanışta Spring readiness'ı REFUSING_TRAFFIC yapar ama endpoint yayılması gecikmeli olduğu için preStop'ta birkaç saniye beklerim. İzlemede restart sayısını ve son sonlanma nedenini alarm olarak tutarım; açıklanamayan restart'lar genelde yanlış ayarlanmış bir probe'dur.
Junior cevap tanımı biliyor ama tam olarak production'ı çökerten yanlışı öneriyor: bağımlılığı liveness'a bağlamak. Mid cevap doğru ayrımı ve startupProbe'u biliyor; ancak readiness'ın da ortak bağımlılıkta benzer bir etki yarattığını ve sayıların nasıl seçildiğini anlatmıyor. Senior cevap probe'ları "hangi aksiyonu tetikler" üzerinden tanımlıyor, failure mode'u (filo çapında restart, boş Service) ve alternatifini (circuit breaker) söylüyor, management port tuzağı ve kapanıştaki gecikme gibi işletme detaylarını ekliyor.
Takip sorusu: Uygulamanın Kafka consumer'ı kalıcı olarak koptu ama HTTP tarafı çalışıyor. Bu durumu hangi probe'a yansıtırsın, yoksa hiçbirine mi?
Soru: "Pod'un OOMKilled oluyor ama JVM metriklerinde heap kullanımı düşük görünüyor. Nasıl araştırırsın?"
seviye: İleri etiketler: [kubernetes, jvm, memory] kaynak: Tahmini
Heap yetmiyordur, -Xmx'i artırırım ya da memory limitini yükseltirim. Bir de heap dump alıp memory leak var mı bakarım.
OOMKilled heap OOM'u değil, process'in toplam belleğinin container limitini aşmasıdır; heap dump da alınmaz çünkü kernel process'i doğrudan öldürür. Heap dışında metaspace, thread stack'leri, direct buffer'lar var. -Xmx'i limite eşitlemişlerse sorun budur; MaxRAMPercentage=70 gibi bir değere geçerim. kubectl describe pod ile reason'ın gerçekten OOMKilled olduğunu ve exit code 137'yi doğrularım.
Önce teşhisi kesinleştiririm: Last State: Terminated, Reason: OOMKilled. Exit 137 tek başına yetmez, grace period sonunda SIGKILL da 137 verir. Sonra container'ın working_set belleğini JVM'in heap ve non-heap metrikleriyle aynı grafikte üst üste koyarım; aradaki fark heap dışı tüketimdir.
Heap dışını ölçmek için bir pod'da Native Memory Tracking açar (-XX:NativeMemoryTracking=summary), jcmd VM.native_memory summary ile zaman içinde diff alırım. Tipik şüpheliler: thread sayısının büyümesi (sınırsız bir executor ya da her istekte açılan client), Netty/Kafka direct buffer'ları, sınıf yükleme (metaspace sürekli artıyorsa classloader sızıntısı), glibc malloc arenaları (çok çekirdekli node'da MALLOC_ARENA_MAX ile sınırlanabilir). Kalıcı çözümde heap'i yüzdeyle veririm, direct memory'yi -XX:MaxDirectMemorySize ile, thread havuzlarını açıkça sınırlarım ve limiti ölçülen tepe kullanım artı payla belirlerim. Limit artırmak semptomu erteler; büyüme sınırsızsa sadece daha geç ölür. Bundan sonra container belleğinin limite oranına bir uyarı (alarm değil) kurarım.
Junior cevap iki farklı olayı (Java heap OOM ve kernel OOM kill) karıştırıyor ve işe yaramayacak bir aracı (heap dump) öneriyor. Mid cevap mekanizmayı ve en yaygın nedeni doğru biliyor. Senior cevap teşhisi doğruluyor (137'nin belirsizliği), ölçümle ilerliyor (working set vs JVM metrikleri, NMT diff), somut şüpheli listesi veriyor ve "limiti artırmak" ile "kök nedeni çözmek" arasındaki farkı açıkça söylüyor.
Takip sorusu: CPU limiti 500m olan bir pod'da Runtime.availableProcessors() kaç döner ve bu uygulamanın davranışını nerelerde değiştirir?
Soru: "Production'da sipariş API'sinin p99 latency'si iki katına çıktı. Nasıl araştırırsın?"
seviye: İleri etiketler: [observability, latency] kaynak: Tahmini
Log'lara bakarım, hata var mı diye. CPU ve bellek grafiklerine bakarım; yüksekse pod sayısını artırırım.
Önce ne zaman başladığına ve o sırada deploy olup olmadığına bakarım. Endpoint bazında p99'a bakıp hangi endpoint'in etkilendiğini bulurum. Sonra yavaş isteklerin trace'lerini açıp zamanın nerede geçtiğini görürüm: DB sorgusu mu, dış servis mi. DB ise slow query log ve EXPLAIN ANALYZE'a bakarım.
Önce kapsamı daraltırım: tüm endpoint'ler mi, biri mi; tüm pod'lar mı, bazıları mı; başlangıç bir deploy, config değişikliği, trafik artışı ya da bağımlılıktaki bir olayla çakışıyor mu? Tüm pod'larda ve tüm endpoint'lerde ise ortak bir kaynağa (DB, connection pool, ağ) bakarım; tek pod'da ise node, GC ya da throttling.
Sonra hipotezleri metrikle test ederim. Hikari'de bekleyen thread sayısı ve connection alma süresi artıyorsa sorun DB değil havuzdur; havuzu tutan yavaş bir işlem ya da transaction içindeki bir dış çağrı olabilir. CPU throttling oranı yüksekse ortalama CPU düşük görünse bile latency basamaklıdır. GC duraklamaları p99 ile örtüşüyorsa bellek tarafına geçerim. Ardından p99 bucket'ındaki exemplar'lardan ya da tail sampling'le tutulmuş trace'lerden somut isteklere inerim ve span'lerde zamanın gittiği yeri görürüm. Bir deploy ile çakışıyorsa önce rollback yapar, sonra araştırırım; önce etkiyi azaltmak gelir. Bitince eksik kalan sinyali (ör. connection alma süresi dashboard'da yoktu) kalıcı hale getiririm.
Junior cevap rastgele bakıyor ve "pod artır" diyor; bu, sorun bir DB kilidi ya da havuz tükenmesiyse durumu kötüleştirir. Mid cevap mantıklı bir sıra izliyor ama kapsamı daraltma ve hipotez testi yapmıyor; trace'e doğrudan atlıyor. Senior cevap sistematik: zaman korelasyonu, kapsam (hepsi mi biri mi), USE metrikleriyle hipotez, sonra trace'le kanıt. Ayrıca "önce etkiyi azalt (rollback)" ve "olaydan sonra gözlemlenebilirlik boşluğunu kapat" diyerek operasyonel olgunluk gösteriyor.
Takip sorusu: Grafana'daki p99 paneli pod'ların kendi hesapladığı p99'ların ortalamasını gösteriyorsa bu panele ne kadar güvenirsin?
Soru: "Bir kolonun adını sıfır kesintiyle nasıl değiştirirsin?"
seviye: Orta etiketler: [database-migration, zero-downtime] kaynak: Tahmini
Flyway'de ALTER TABLE orders RENAME COLUMN address TO shipping_address migration'ı yazarım, entity'deki alan adını değiştiririm ve deploy ederim.
Rolling update sırasında eski pod'lar eski kolon adını kullanmaya devam eder, bu yüzden tek adımda rename kırılır. Önce yeni kolonu eklerim, kod iki kolona da yazar, eski veriyi kopyalarım, sonra okumayı yeni kolona alırım, en sonda eski kolonu silerim.
Expand/contract ile, her adımı tek başına geri alınabilir tutarak yaparım: nullable yeni kolon; kod iki kolona yazar ve eskiden okur; batch'lerle backfill; okuma yeni kolona; eskiye yazma biter; birkaç release sonra eski kolon silinir. Kontrol noktası şu soru: "Şu an çalışan veya rollback ile dönebileceğim her sürüm bu şemayla çalışır mı?"
Uygulama ayrıntıları: backfill'i tek bir dev UPDATE ile değil, id aralıklarıyla ve aralarda bekleyerek yaparım; yoksa uzun transaction replikasyon gecikmesi, şişkinlik ve kilit yaratır. Migration oturumlarında lock_timeout koyarım; NOT NULL gerekiyorsa CHECK ... NOT VALID sonra VALIDATE kullanırım. Migration'ı uygulama açılışında değil ayrı bir pipeline adımında çalıştırırım ki uzun bir işlem startupProbe'u patlatmasın. Kolon bir Kafka event'inde ya da başka bir servisin okuduğu bir view'da da geçiyorsa aynı süreci oraya da uygularım. Bu kadar zahmetin karşılığı olarak da rename'in gerçekten gerekli olup olmadığını sorarım; bazen sadece kod tarafında yeniden adlandırmak yeterlidir.
Junior cevap, rolling update'te iki sürümün aynı anda çalıştığını hesaba katmıyor; rollout'un ilk dakikasında eski pod'lar hata verir, rollback da kırık olur. Mid cevap doğru sırayı biliyor. Senior cevap sırayı bir "geri alınabilirlik" kuralına bağlıyor, PostgreSQL'e özgü kilit davranışını ve backfill'in operasyonel etkisini düşünüyor, migration'ın nerede çalışacağını ve event/sözleşme tarafını ekliyor, sonunda da maliyet sorusunu soruyor.
Takip sorusu: Yeni sürüm, eski sürümün okuyamadığı yeni bir enum değeri yazmaya başladı. Rollback yaparsan ne olur, bunu nasıl önlersin?
Sorulmadı ama sorulabilir
StatefulSet ne zaman Deployment yerine kullanılır?
StatefulSet her pod'a kalıcı bir kimlik (kafka-0, kafka-1), sabit DNS adı (headless Service ile) ve kendine ait PersistentVolumeClaim verir; pod'lar sırayla açılır ve kapanır. Veritabanları, Kafka broker'ları, ZooKeeper/KRaft quorum'u gibi kimliği ve diski önemli olan sistemler içindir. Stateless bir mikroservis için gereksizdir ve rollout'u yavaşlatır. Uygulama ekipleri genelde veritabanını Kubernetes'te kendileri işletmek yerine yönetilen servis ya da bir operator kullanır.
Tek cümle: "Pod'un kimliği ve diski önemliyse StatefulSet, değilse Deployment; veritabanını kendim StatefulSet'le işletmek yerine operator ya da yönetilen servis tercih ederim."
Requests ve limits değerlerini nasıl belirlersin?
Tahminle değil ölçümle: yük testinde ve production'da pod başına CPU ve bellek kullanımının p95-p99'una bakılır. CPU request'i normal yükteki kullanım civarına, bellek limiti ölçülen tepe artı pay olarak konur. Vertical Pod Autoscaler'ın "recommendation" modu (uygulamadan sadece öneri üretir) iyi bir başlangıç verisidir. Request'i çok yüksek tutmak node'ları boşa doldurur (maliyet), çok düşük tutmak gürültülü komşu sorununa ve HPA'nın yanlış hesaplamasına yol açar.
Tek cümle: "Request'i ölçülen normal kullanıma, bellek limitini ölçülen tepe artı paya koyar, CPU limitini throttling ölçümüne bakarak ya hiç koymaz ya cömert tutarım."
Kafka consumer'ları deploy sırasında nasıl etkilenir?
Her pod kapanışında ve açılışında consumer group rebalance olur. Eski eager rebalance'ta tüm consumer'lar bütün partition'larını bırakır; rolling update boyunca işleme defalarca durur. Azaltma yolları: CooperativeStickyAssignor (sadece taşınan partition'lar durur), static membership (group.instance.id; pod kısa süre içinde aynı kimlikle dönerse rebalance olmaz, StatefulSet ile doğal eşleşir), graceful shutdown'da offset commit'i ve max.poll.interval.ms ile işleme süresinin uyumu. Kafka 4.0 ile gelen yeni consumer rebalance protokolü (KIP-848) bu maliyeti broker tarafında daha da azaltır (bkz. Bölüm 12).
Tek cümle: "Deploy'larda rebalance fırtınasını cooperative-sticky assignor ve static membership ile azaltır, kapanışta offset'leri commit ettiğimden emin olurum."
Service mesh ne getirir, ne götürür?
Istio veya Linkerd gibi bir mesh, her pod'a (ya da node'a) bir proxy koyarak uygulama kodunu değiştirmeden mTLS, trafik bölme (canary), retry/timeout politikaları ve L7 metrikleri sağlar. Maliyeti: her isteğe ek bir ağ atlaması ve gecikme, ek bellek/CPU, karmaşık bir kontrol düzlemi ve hata ayıklamanın zorlaşması. En sinsi tuzak retry çarpanıdır: uygulama 3 kez, mesh 3 kez deniyorsa bir istek alt servise 9 kez gider; çökmekte olan bir servisi bitirir. Retry tek bir katmanda tanımlanmalıdır.
Tek cümle: "Mesh mTLS ve trafik yönetimini koddan alır ama gecikme ve operasyonel karmaşıklık ekler; retry'ı uygulama ile mesh arasında tek bir yerde tanımlarım."
Secret'ları nasıl yönetirsin?
Kubernetes Secret'ı tek başına bir güvenlik çözümü değildir (base64). Tercih edilen düzen: kaynak gerçeklik Vault ya da bulut secret manager'dadır; External Secrets Operator veya CSI driver bunları pod'a senkronize eder; etcd'de encryption at rest açıktır; RBAC ile namespace dışından okunamaz. Secret'lar Git'e (Helm values dahil) şifresiz girmez; GitOps için Sealed Secrets ya da SOPS kullanılır. Rotasyon için uygulamanın yeniden başlatılmadan yeni değeri alabilmesi (dosya olarak bağlama ve yeniden okuma, ya da dinamik DB kimlik bilgileri) ayrıca tasarlanır.
Tek cümle: "Secret'ın sahibi Vault ya da bulut secret manager'ıdır; Kubernetes'e operator ile gelir, Git'e asla düz metin girmez ve rotasyonu baştan tasarlarım."
GitOps nedir?
GitOps'ta küme durumunun tek doğruluk kaynağı Git'tir. Argo CD veya Flux gibi bir controller küme içinde çalışır, Git'teki manifest'leri sürekli kümeyle karşılaştırır ve farkı uygular (pull model). CI pipeline'ı kümeye kubectl apply yapmaz; image'ı üretir ve Git'teki image etiketini günceller. Faydaları: her değişiklik bir commit (denetim izi, review, kolay geri alma), drift'in otomatik düzeltilmesi, CI'ın küme kimlik bilgisi taşımaması.
Tek cümle: "GitOps'ta deploy bir Git commit'idir; küme içindeki controller Git'teki durumu uygular, böylece denetim izi ve drift düzeltmesi bedavaya gelir."
Sık yapılan hatalar
- Liveness'ı
/actuator/health'e bağlamak → DB gibi ortak bir bağımlılık gidince tüm pod'lar aynı anda restart olur ve kesinti büyür → liveness/actuator/health/liveness, bağımlılıklar ise bilinçli bir kararla ve dikkatle readiness'a. -Xmx'i memory limitine eşitlemek → heap dışı bellek limiti aşar, OOMKilled →MaxRAMPercentageile limitin %70-75'i, NMT ile ölçüm.- Düşük CPU limiti ve ortalama CPU'ya bakmak → throttling ortalamada görünmez, p99'u bozar ve JVM tek işlemci sanar → gerçekçi request, limitsiz ya da cömert limit, throttling oranını izlemek.
- startupProbe yerine büyük
initialDelaySeconds→ çalışma sırasında kilitlenen pod geç fark edilir → açılış için startupProbe, çalışma için sıkı liveness. - preStop ve graceful shutdown olmadan rollout → her deploy'da birkaç 502 → preStop bekleme +
server.shutdown=graceful+ yeterli grace period, exec form ENTRYPOINT. - PDB'nin rolling update'i koruduğunu sanmak → rollout'u
maxUnavailableyönetir; PDB sadece eviction'ları sınırlar → rollout için strateji parametreleri, bakım için PDB. minAvailable'ı replika sayısına eşitlemek → node drain sonsuza kadar bloklanır →maxUnavailable: 1gibi ilerlemeye izin veren bir değer.- Metriğe kullanıcı/sipariş id'si label olarak eklemek → kardinalite patlaması → kimlikler log ve trace'e, metrikte sınırlı değer kümeleri.
- Pod'ların p99'larının ortalamasını almak → matematiksel olarak anlamsız → histogram bucket'larını toplayıp
histogram_quantile. - CPU, bellek gibi nedenlere page alarmı kurmak → alarm yorgunluğu, gerçek sorunların kaçması → SLO ve burn rate tabanlı semptom alarmları, nedenler dashboard'da.
- Tek adımlı rename/drop migration'ı → rolling update ve rollback sırasında eski sürüm kırılır → expand/contract.
- Terraform state'ini Git'e koymak ya da kilitsiz paylaşmak → secret sızıntısı ve bozuk state → kilitli, şifreli remote backend.
- Konsoldan elle "küçük düzeltme" yapmak → drift, bir sonraki apply'da geri alınır ya da beklenmedik değişiklik planlanır → her değişiklik koddan, drift tespiti zamanlanmış.
Hızlı tekrar
S: Liveness, readiness ve startup probe fail olunca ne olur?
C: Liveness: container restart. Readiness: pod Service endpoint'lerinden çıkarılır, öldürülmez. Startup: başarılı olana kadar diğer probe'lar çalışmaz; süre aşılırsa container öldürülür.
S: Liveness probe'a neden DB kontrolü konmaz?
C: DB gidince tüm pod'lar aynı anda restart olur; restart DB'yi düzeltmez, üstüne soğuk açılış ve connection storm ekler.
S: CPU limiti ile memory limiti aşılınca ne olur?
C: CPU'da CFS kotası dolar ve container periyodun geri kalanında kısılır (throttling); bellekte kernel process'i öldürür (OOMKilled).
S: Exit code 137 ve 143 ne anlatır?
C: 137 = 128 + 9 (SIGKILL): OOM kill ya da grace period sonunda zorla öldürme. 143 = 128 + 15 (SIGTERM) ile normal kapanış. OOM olup olmadığını Reason: OOMKilled gösterir.
S: -Xmx neden container limitine eşit olmamalı?
C: JVM heap dışında metaspace, code cache, thread stack'leri, direct buffer ve GC yapıları kullanır; toplam limiti aşar. Heap MaxRAMPercentage ile limitin yüzdesi olarak verilir.
S: 250m CPU limitli bir JVM'de availableProcessors() kaç döner ve sonuçları nedir?
C: 1. JDK 21/25'te Serial GC seçilir, common pool paralelliği 1 olur ve executor'sız CompletableFuture async metotları her görev için yeni thread açar.
S: preStop'ta neden birkaç saniye beklenir?
C: Endpoint kaldırma ile SIGTERM paralel ilerler; kube-proxy ve ingress'in pod'u listeden çıkarması zaman alır. Beklemezsen o sürede gelen istekler düşer.
S: HPA CPU utilization'ı neye göre hesaplar?
C: Container'ın CPU request'ine göre. Request'i eksik pod için utilization tanımsızdır ve HPA o metrik için ölçekleme yapmaz.
S: PodDisruptionBudget hangi kesintileri sınırlar?
C: Eviction API üzerinden gelen gönüllü kesintileri (drain, autoscaler). Rolling update'i ve pod'un doğrudan silinmesini sınırlamaz, node çökmesini engelleyemez.
S: RED ve USE neyi ölçer?
C: RED servisler için Rate, Errors, Duration; USE kaynaklar için Utilization, Saturation, Errors.
S: Pod'lar üzerinden doğru p99 nasıl hesaplanır?
C: Her pod histogram bucket'larını yayınlar, Prometheus'ta bucket'lar toplanıp histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m]))) ile hesaplanır. Pod'ların kendi p99'larının ortalaması anlamsızdır.
S: %99.9 aylık SLO'nun error budget'ı kaç dakikadır ve 14.4 burn rate ne demektir?
C: 30 günde yaklaşık 43,2 dakika. 14.4 burn rate, 30 günlük bütçenin %2'sinin 1 saatte tükenmesi demektir; page eşiği olarak kullanılır.
S: traceparent header'ı neleri taşır?
C: Sürüm, 32 hex trace id, 16 hex parent span id ve örnekleme flag'leri: 00-<trace-id>-<span-id>-01.
S: Expand/contract'ın kontrol sorusu nedir?
C: "Şu an çalışan ya da rollback ile dönebileceğim her sürüm bu şemayla çalışır mı?" Her adım tek başına geri alınabilir olmalı.
S: Terraform state neden remote ve kilitli tutulur?
C: Ekip aynı state'i görmeli, eşzamanlı apply state'i bozmasın; state hassas veri içerebildiği için şifreli ve erişimi kısıtlı olmalı. S3'te Terraform 1.11+ use_lockfile ile kilitlenir.
Kaynaklar
- Configure Liveness, Readiness and Startup Probes: probe alanlarının ve startupProbe'un resmi anlatımı.
- Pod Lifecycle: kapanış sırası, grace period, restart backoff ve probe sonuçlarının etkisi.
- Deployments: maxSurge/maxUnavailable, minReadySeconds, progressDeadlineSeconds ve rollback.
- Disruptions: PDB'nin neyi kapsayıp neyi kapsamadığı.
- Horizontal Pod Autoscaling: ölçekleme formülü, tolerans, stabilizasyon penceresi ve Ready olmayan pod'ların ele alınışı.
- Spring Boot: Graceful Shutdown: güncel varsayılan davranış ve zaman aşımı ayarı.
- Spring Boot: Actuator Endpoints (Kubernetes Probes): liveness/readiness health grupları ve yapılandırması.
- Spring Boot: Tracing: Micrometer Tracing, örnekleme ve log korelasyonu.
- Google SRE Book: Service Level Objectives ve SRE Workbook: Alerting on SLOs: SLI/SLO/error budget ve burn rate alarmlarının kaynağı.
- The USE Method ve The RED Method: iki metodun özgün anlatımları.
- W3C Trace Context:
traceparentvetracestatestandardı. - Prometheus: Histograms and Summaries: percentile'ların neden toplanamadığı ve bucket seçimi.
- Parallel Change: expand/migrate/contract deseni.
- Terraform: Upgrading to v1.11: S3 yerel state kilidinin GA olması ve DynamoDB argümanlarının deprecation'ı.