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

Kubernetes'te Scaling: HPA, VPA, In-Place Resize, KEDA ve PodDisruptionBudget

Mert Özen 8 Kas 2026 14 dk 4 görüntülenme
Kubernetes'te Scaling: HPA, VPA, In-Place Resize, KEDA ve PodDisruptionBudget

HPA CPU yüzdesini node'a değil container'ın request'ine göre hesaplar; request yoksa hiç ölçeklemez. HPA v2 behavior ayarlarını, in-place resize sonrası .NET GC'nin yeni limiti görmemesini ve GC.RefreshMemoryLimit çözümünü, KEDA ile RabbitMQ queue'suna göre scale-to-zero'yu, drain'i kilitleyen PodDisruptionBudget'ı ve topology spread tuzağını gerçek kind çıktılarıyla görüyoruz.

Sürüm notu: Bu yazıdaki örnekler kind 0.33 ile Kubernetes 1.37, kubectl 1.37, metrics-server 0.9.1, KEDA (Kubernetes Event-driven Autoscaling) 2.21, RabbitMQ 4.4 ve .NET 10 ile denenmiştir.

Önce Tahmin Et

Bookstore API'sinin (Application Programming Interface) bir pod'u limits.memory: 256Mi ile çalışıyor. Kampanya günü bellek yetmiyor ve pod'u yeniden başlatmadan, Kubernetes 1.35'te GA (General Availability) olan in-place resize ile limiti 512Mi'ye çıkarıyorsun. Kubernetes "resize completed" diyor, restart sayısı hâlâ 0. Soru: içerideki .NET runtime'ının GC'si (Garbage Collector) artık 512Mi'ye göre mi çalışıyor, yoksa hâlâ 256Mi'ye göre mi? İkinci soru: CPU (Central Processing Unit) request'i tanımlanmamış bir Deployment'a CPU hedefli bir HPA (Horizontal Pod Autoscaler) bağlarsan ne olur, pod sayısı hiç artmaz mı, sonsuza kadar mı artar? Cevaplar yazının içinde, gerçek cluster çıktılarıyla.

HPA Neyi Ölçer: metrics-server ve Request'e Göre Yüzde

HPA bir controller'dır: belirli aralıklarla (varsayılan 15 saniye) hedef Deployment'ın pod'larının metriklerini okur, istenen replica sayısını hesaplar ve Deployment'ın scale subresource'unu günceller. CPU ve memory metrikleri için cluster'da metrics-server olması gerekir; managed cluster'larda genelde hazır gelir, kind'da biz kuruyoruz. kind'daki kubelet sertifikaları cluster CA (Certificate Authority) tarafından imzalanmadığı için metrics-server'ı yalnız test ortamında --kubelet-insecure-tls argümanıyla çalıştırdık:

kubectl apply -f metrics-server.yaml
kubectl top pods -n bookstore
NAME                             CPU(cores)   MEMORY(bytes)
api-79544c7fc7-m95j8             4m           45Mi
api-79544c7fc7-rqdgf             3m           52Mi

HPA'nın "yüzde" dediği şey node'un CPU'su değil, container'ın requests.cpu değeridir. Request'i 100m olan pod 50m kullanıyorsa kullanım %50'dir. Bu yüzden request tanımlanmamış bir pod için yüzde hesaplanamaz. Aynı API'yi request'siz bir Deployment olarak (api-norequest) açıp ona da HPA bağladığımızda kubectl get hpa şunu gösterdi:

NAME            REFERENCE                  TARGETS              MINPODS   MAXPODS   REPLICAS   AGE
api             Deployment/api             cpu: 3%/60%          2         6         2          45s
api-norequest   Deployment/api-norequest   cpu: <unknown>/60%   2         6         2          45s

describe sebebi açıkça yazıyor:

ScalingActive  False   FailedGetResourceMetric  the HPA was unable to compute the replica count: failed to get cpu utilization: missing request for cpu in container api-norequest of Pod api-norequest-5d9865c7b5-h7jd5

Yani ikinci sorunun cevabı: HPA ne artırır ne azaltır, minReplicas'ta donup kalır ve bunu yalnız event'lerde söyler. Dashboard'da "HPA var" görüp rahatlayan ekip yük geldiğinde iki pod'la kalır. Request değerlerinin nasıl seçileceğini serinin dokuzuncu yazısı (Resource Request ve Limit'ler) anlattı; HPA'nın doğru çalışması o değerlerin gerçekçi olmasına bağlı.

HPA v2 ve behavior: Ne Kadar Hızlı Büyüsün, Ne Kadar Yavaş Küçülsün

autoscaling/v2 HPA hem birden çok metriği hem de büyüme ve küçülme hızını tarif etmeyi destekler. Bookstore API için yazdığımız HPA:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
  namespace: bookstore
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 6
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Pods
          value: 2
          periodSeconds: 30
    scaleDown:
      stabilizationWindowSeconds: 120
      policies:
        - type: Pods
          value: 1
          periodSeconds: 60

Hesap basit: desiredReplicas = ceil(currentReplicas × currentMetric / targetMetric). Oran 1'e %10'dan yakınsa (varsayılan tolerance) hiçbir şey yapılmaz. behavior bu ham sonucu terbiye eder: scale-up'ta stabilization yok, ama 30 saniyede en fazla 2 pod eklenir; scale-down'da son 120 saniyedeki en yüksek öneri esas alınır ve dakikada en fazla 1 pod silinir. Yükü bir busybox pod'undan dört paralel döngüyle /api/diag/burn?seconds=1 çağırarak ürettik. kubectl get hpa api -w çıktısının önüne saat ekledik:

06:28:45 api    Deployment/api   cpu: 3%/60%      2         6         2          60s
06:29:00 api    Deployment/api   cpu: 135%/60%    2         6         2          75s
06:29:15 api    Deployment/api   cpu: 1914%/60%   2         6         4          90s
06:29:45 api    Deployment/api   cpu: 814%/60%    2         6         6          2m
06:31:00 api    Deployment/api   cpu: 1%/60%      2         6         6          3m15s
06:33:01 api    Deployment/api   cpu: 1%/60%      2         6         5          5m16s
06:34:01 api    Deployment/api   cpu: 1%/60%      2         6         4          6m16s
06:35:01 api    Deployment/api   cpu: 1%/60%      2         6         3          7m16s
06:36:01 api    Deployment/api   cpu: 1%/60%      2         6         2          8m16s

Üç şeye dikkat et. %1914 bir hata değil: container'da CPU limit'i yok, request 100m, pod node'da boşta duran iki çekirdeği kullanabiliyor. Ham formül 2 × 1914 / 60 ≈ 64 pod isterdi; policy 2'şer ekledi, maxReplicas 6'da durdurdu. Load generator 06:30:24'te durdu, metrik 06:31:00'da %1'e indi, ama ilk scale-down 06:33:01'de geldi: 120 saniyelik pencere. Sonra her dakika bir pod. Ani bir düşüşte pod'ları hemen silmemek, birkaç dakika sonra gelecek ikinci dalgada cold start yaşamamak demek. .NET uygulamalarında cold start (JIT, connection pool, EF (Entity Framework) Core model'i) birkaç saniye sürdüğü için scale-down'ı yavaş tutmak genelde doğru tercih.

Kubernetes 1.37 ile GA olan bir ayrıntı daha var: behavior.scaleUp.tolerance ve behavior.scaleDown.tolerance. Cluster genelindeki %10'luk tolerance'ı HPA başına değiştirmeye yarar; 50 pod'luk bir Deployment'ta %10, beş pod'luk bir sapmayı yok saymak demektir. kubectl explain hpa.spec.behavior.scaleUp.tolerance alanın açıklamasını gösterir: ayarlanmazsa "the default cluster-wide tolerance is applied (by default 10%)".

VPA ve In-Place Resize: Pod'u Yatay Değil Dikey Büyütmek

HPA pod sayısını değiştirir; VPA (Vertical Pod Autoscaler) ise pod'un request ve limit değerlerini. VPA cluster'a ayrı kurulan bir projedir (kubernetes/autoscaler deposunda, Ekim 2026 itibarıyla 1.8.1). Geçmiş kullanımı izleyip öneri üretir, updateMode'a göre uygular: Off yalnız öneri yazar, Initial yalnız yeni pod'lara uygular, Recreate pod'u evict edip yeniden yaratır, InPlaceOrRecreate (VPA 1.6'da GA) önce pod'u yerinde büyütmeyi dener. Auto modu deprecated; bugün Recreate gibi davranıyor. Aynı Deployment'ta CPU üzerinden hem HPA hem VPA çalıştırmak klasik bir çatışmadır: biri pod ekler, öteki request'i büyütür, ikisi de birbirinin metriğini değiştirir.

VPA'nın yerinde büyütme yapabilmesini sağlayan çekirdek özellik in-place pod resize. Kubernetes 1.33'te beta olarak varsayılan açık geldi, 1.35'te GA oldu. Pod spec'indeki resources artık resize subresource'u üzerinden değiştirilebiliyor:

apiVersion: v1
kind: Pod
metadata:
  name: api-resize
  namespace: bookstore
spec:
  containers:
    - name: api
      image: bookstore-api:1.2.0
      ports:
        - name: http
          containerPort: 8080
      resizePolicy:
        - resourceName: cpu
          restartPolicy: NotRequired
        - resourceName: memory
          restartPolicy: NotRequired
      resources:
        requests:
          cpu: 500m
          memory: 256Mi
        limits:
          cpu: "1"
          memory: 256Mi
kubectl -n bookstore patch pod api-resize --subresource resize --patch \
  '{"spec":{"containers":[{"name":"api","resources":{"requests":{"cpu":"1","memory":"512Mi"},"limits":{"cpu":"2","memory":"512Mi"}}}]}}'
Normal  ResizeStarted    5s    kubelet  Pod resize started: {"containers":[{"name":"api","resources":{"limits":{"cpu":"2","memory":"512Mi"},"requests":{"cpu":"1","memory":"512Mi"}}}],"generation":2}
Normal  ResizeCompleted  5s    kubelet  Pod resize completed: {"containers":[{"name":"api","resources":{"limits":{"cpu":"2","memory":"512Mi"},"requests":{"cpu":"1","memory":"512Mi"}}}],"generation":2}

Restart sayısı 0. Şimdi baştaki soruya geldik. Bookstore API'nin /api/diag/runtime endpoint'i Environment.ProcessorCount ve GC.GetGCMemoryInfo().TotalAvailableMemoryBytes değerlerini döndürüyor. Resize'dan önce ve sonra:

{"processorCount":1,"isServerGC":false,"totalAvailableMemoryMB":192,"heapSizeMB":0,"gcHeapHardLimitMB":0,"workingSetMB":67}
{"processorCount":1,"isServerGC":false,"totalAvailableMemoryMB":192,"heapSizeMB":0,"gcHeapHardLimitMB":0,"workingSetMB":68}

Kubernetes cgroup limitini 512Mi'ye çıkardı, ama .NET hâlâ 192 MB görüyor (256Mi'nin %75'i, dokuzuncu yazıdaki varsayılan heap hard limit). Runtime bu değerleri process başlarken okur; CPU limiti 2 çekirdeğe çıktığı halde ProcessorCount da 1'de kaldı. .NET 8 ile gelen GC.RefreshMemoryLimit() GC'ye limitleri yeniden okutur. Bunu çağıran küçük bir endpoint ekledik:

// in-place resize sonrası cgroup memory limitini yeniden okur (.NET 8+)
app.MapPost("/api/diag/refresh-memory-limit", () =>
{
    long before = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / 1024 / 1024;
    GC.RefreshMemoryLimit();
    long after = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / 1024 / 1024;
    return Results.Ok(new { beforeMB = before, afterMB = after, processorCount = Environment.ProcessorCount });
});
{"beforeMB":192,"afterMB":384,"processorCount":1}

Memory tarafı düzeldi, ProcessorCount yine 1: thread pool ve Server GC heap sayısı gibi çekirdek sayısına bağlı kararlar process ömrü boyunca sabit kalıyor. Pratik sonuç şu: .NET servisinde memory resize'ın gerçekten işe yaraması için ya uygulama RefreshMemoryLimit çağırmalı ya da resizePolicy'de memory için RestartContainer seçilmeli. İkincisini denedik; container yeniden başladı (RESTARTS 1) ve yeni process 384 MB gördü:

NAME          READY   STATUS    RESTARTS     AGE
api-resize2   1/1     Running   1 (8s ago)   8s
{"processorCount":1,"isServerGC":false,"totalAvailableMemoryMB":384,"heapSizeMB":0,"gcHeapHardLimitMB":0,"workingSetMB":67}

Container restart'ı pod restart'ından ucuzdur: pod aynı node'da, aynı IP (Internet Protocol) adresiyle kalır, scheduling ve image pull yoktur. Resize'ın iki sınırı da gerçek çıktıyla görüldü. QoS (Quality of Service) class'ı değiştirecek bir resize reddedilir (Pod QOS Class may not change as a result of resizing), node'un kapasitesini aşan bir istek de bizim 1.37 cluster'ımızda API server tarafından anında geri çevrildi: node didn't have enough allocatable resources: cpu, requested: 32000, allocatable: 10000.

KEDA: Queue Uzunluğuna Göre Worker ve Scale-to-Zero

CPU, HTTP (Hypertext Transfer Protocol) API'si için iyi bir sinyaldir; queue tüketen bir worker için değildir. Sipariş onay e-postası gönderen Bookstore.Worker zamanının çoğunu SMTP (Simple Mail Transfer Protocol) beklemekle geçirir, CPU'su düşük kalır, queue ise büyür. KEDA bu boşluğu doldurur: queue, stream, cron gibi dış kaynakları okuyan scaler'ları vardır ve arka planda kendi yönettiği bir HPA yaratır. 0 ile 1 arasındaki geçişi ise HPA'ya bırakmadan kendisi yapar. Worker'ın tüketim kodu Message Queue'larla Çalışmak serisindeki RabbitMQ.Client 7 yazısıyla aynı; burada önemli olan iki satır prefetchCount: 1 ve manual ack:

public sealed class OrderEmailConsumer(IConfiguration configuration, ILogger<OrderEmailConsumer> logger)
    : BackgroundService
{
    private const string QueueName = "order-emails";

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        ConnectionFactory factory = new() { Uri = new Uri(configuration["RabbitMq:Uri"]!) };
        await using IConnection connection = await factory.CreateConnectionAsync(stoppingToken);
        await using IChannel channel = await connection.CreateChannelAsync(cancellationToken: stoppingToken);

        await channel.QueueDeclareAsync(QueueName, durable: true, exclusive: false, autoDelete: false,
            cancellationToken: stoppingToken);
        await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false, stoppingToken);

        AsyncEventingBasicConsumer consumer = new(channel);
        consumer.ReceivedAsync += async (_, ea) =>
        {
            string orderId = Encoding.UTF8.GetString(ea.Body.Span);
            // bir sipariş onay e-postasını hazırlayıp göndermeyi taklit eder
            await Task.Delay(TimeSpan.FromMilliseconds(500), stoppingToken);
            await channel.BasicAckAsync(ea.DeliveryTag, multiple: false, stoppingToken);
            logger.LogInformation("Sent confirmation e-mail for order {OrderId}", orderId);
        };

        await channel.BasicConsumeAsync(QueueName, autoAck: false, consumer, stoppingToken);
        logger.LogInformation("Consuming {Queue} on {Host}", QueueName, Environment.MachineName);
        await Task.Delay(Timeout.Infinite, stoppingToken).ContinueWith(_ => { }, TaskScheduler.Default);
    }
}

KEDA'yı Helm ile kurduk (helm install keda kedacore/keda --version 2.21.0 -n keda --create-namespace; Helm'in kendisi ilerideki Helm serisinin konusu). KEDA 2.21'in resmi test listesinde Kubernetes 1.34–1.36 var; 1.37 üzerinde bu yazıdaki senaryo sorunsuz çalıştı, ama production'da sürüm uyumluluk tablosuna bakmadan upgrade yapma. Worker Deployment'ı replicas: 0 ile başlıyor, ölçeği KEDA belirliyor:

apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: rabbitmq
  namespace: bookstore
spec:
  secretTargetRef:
    - parameter: host
      name: rabbitmq
      key: uri
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: worker
  namespace: bookstore
spec:
  scaleTargetRef:
    name: worker
  minReplicaCount: 0
  maxReplicaCount: 5
  pollingInterval: 5
  cooldownPeriod: 30
  triggers:
    - type: rabbitmq
      metadata:
        protocol: amqp
        queueName: order-emails
        mode: QueueLength
        value: "20"
      authenticationRef:
        name: rabbitmq

value: "20" "pod başına 20 mesaj" demek. Bağlantı adresi Secret'tan geliyor; AMQP (Advanced Message Queuing Protocol) URI'si (Uniform Resource Identifier) kullanıcı adı ve şifre içerdiği için manifest'e yazılmaz. Queue'ya 300 mesaj bastık:

06:33:45 keda-hpa-worker   Deployment/worker   <unknown>/20 (avg)   1         5         0          24s
06:33:52 keda-hpa-worker   Deployment/worker   300/20 (avg)         1         5         1          31s
06:34:07 keda-hpa-worker   Deployment/worker   75250m/20 (avg)      1         5         4          46s
06:34:22 keda-hpa-worker   Deployment/worker   55800m/20 (avg)      1         5         5          61s
06:34:52 keda-hpa-worker   Deployment/worker   0/20 (avg)           1         5         5          91s
06:35:22 keda-hpa-worker   Deployment/worker   <unknown>/20 (avg)   1         5         0          2m1s
Normal  KEDAScaleTargetActivated    2m36s  keda-operator  Scaled apps/v1.Deployment bookstore/worker from 0 to 1, triggered by s0-rabbitmq-order-emails
Normal  KEDAScaleTargetDeactivated  67s    keda-operator  Deactivated apps/v1.Deployment bookstore/worker from 5 to 0

KEDA 0'dan 1'e kendisi çıkardı, 1'den 5'e HPA taşıdı (75250m, pod başına 75,25 mesaj demek). Queue boşaldıktan sonra cooldownPeriod dolunca Deployment yeniden 0'a indi. Scale-to-zero'nun bedeli ilk mesajın gecikmesidir: pod'un schedule edilmesi, image'ın (yoksa) çekilmesi ve .NET host'unun açılması. Bizim denemede worker image'ı node'a yüklenmediği için ilk pod'lar ImagePullBackOff'a düştü; KEDA ölçeklemeyi doğru yaptı ama mesajlar ancak image yüklendikten sonra işlendi. Sipariş e-postası birkaç saniye gecikebilir; ödeme onayı gecikemiyorsa minReplicaCount: 1 bırak. Kubernetes 1.37'de HPA'nın kendi scale-to-zero desteği (HPAScaleToZero) varsayılan açık beta oldu, ama yalnız Object ve External metriklerle çalışıyor ve dış kaynaktan metrik sağlayacak bir adapter yine gerekiyor; queue senaryosunda KEDA hâlâ en kısa yol.

PodDisruptionBudget ve Topology Spread: Ölçeklerken Ayakta Kalmak

Pod sayısını artırmak, pod'ların aynı node'a yığılmadığı sürece işe yarar. topologySpreadConstraints pod'ları node'lara (ya da zone'lara) dengeli dağıtır. İlk denememiz kind'da beklenmedik bir şekilde takıldı:

Warning  FailedScheduling  2m7s  default-scheduler  0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 node(s) didn't match pod topology spread constraints. preemption: 0/3 nodes are available: 1 Preemption is not helpful for scheduling, 2 No preemption victims found for incoming pod.

Sebep: nodeTaintsPolicy'nin varsayılanı Ignore. Taint'li control-plane node'u da bir topology domain'i sayılıyor ve orada 0 pod var; worker'lardan birine üçüncü pod konursa skew 2 olur, maxSkew: 1 izin vermez. Rolling update sırasında eski ve yeni ReplicaSet'in pod'ları da birlikte sayıldığı için yeni pod hiç yer bulamadı. Düzeltilmiş hali:

      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          nodeTaintsPolicy: Honor
          matchLabelKeys:
            - pod-template-hash
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: api

matchLabelKeys: [pod-template-hash] yalnız aynı revision'ın pod'larını sayar. Production'da topology key genelde topology.kubernetes.io/zone olur; amaç bir zone kaybında servisin yarısının ayakta kalması.

PDB (PodDisruptionBudget) ise gönüllü kesintileri sınırlar: kubectl drain, cluster upgrade'i, cluster autoscaler'ın node silmesi gibi eviction API'si üzerinden gelen işlemleri. Pod'un çökmesi veya node'un kendiliğinden ölmesi PDB'yi umursamaz. İlk denemede minAvailable: 2 yazdık, replica sayısı da 2'ydi:

evicting pod bookstore/api-6c84998f86-9wqzq
error when evicting pods/"api-6c84998f86-9wqzq" -n "bookstore" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
...
error: unable to drain node "k8swork2-1-worker" due to error: error when evicting pods/"api-6c84998f86-9wqzq" -n "bookstore": global timeout reached: 30s, continuing command...

Replica sayısına eşit bir minAvailable "hiçbir pod gönüllü olarak gidemez" demektir; managed cluster'ın node upgrade'i saatlerce takılır ya da sağlayıcı belirli bir süre sonra PDB'yi yok sayar. Doğrusu maxUnavailable: 1:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api
  namespace: bookstore
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: api
NAME   MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
api    N/A             1                 1                     3m19s
evicting pod bookstore/api-6c84998f86-9wqzq
pod/api-6c84998f86-9wqzq evicted
node/k8swork2-1-worker drained

maxUnavailable HPA ile büyüyen Deployment'larda daha iyi davranır: 2 replica'da da 6 replica'da da bir pod'a izin verir. Drain bir de cannot delete Pods that declare no controller hatasıyla durdu: controller'sız tek başına pod'lar (bizim test için açtığımız curl pod'u) drain'i bloklar. Production'da controller'sız pod olmamalı.

Node'lar Bitince: Cluster Autoscaler ve Karpenter

HPA'nın ekleyeceği pod için node'da yer yoksa pod Pending kalır. Node sayısını artırmak cluster seviyesinde bir iştir. Cluster Autoscaler, Pending pod'ları görüp bulut sağlayıcısının node group'unu büyütür: AKS (Azure Kubernetes Service) ve GKE (Google Kubernetes Engine) tarafında node pool'u, EKS (Amazon Elastic Kubernetes Service) tarafında managed node group'u; boş kalan node'ları da boşaltıp siler. Karpenter (AWS, yani Amazon Web Services kaynaklı; artık AKS'te de "node auto provisioning" adıyla var) önceden tanımlı node group yerine pod'ların ihtiyacına göre uygun VM (Virtual Machine) tipini seçip doğrudan açar. İkisi de node silerken eviction kullanır, yani PDB'ye uyar; PDB'si hiç izin vermeyen bir uygulama node'un silinmesini ve dolayısıyla maliyet düşüşünü engeller. Bu araçlar kind'da çalışmaz; managed cluster'a geçişi serinin on yedinci yazısında konuşacağız.

Üretimde Dikkat

Scaling zinciri en zayıf halkası kadar hızlıdır: metrics-server 15 saniyede bir ölçer, HPA 15 saniyede bir karar verir, yeni pod schedule edilir, image çekilir, .NET uygulaması açılır, readiness probe geçer. Bizim ölçümde ilk ölçek kararı yük başladıktan yaklaşık 30 saniye sonra geldi. Trafik saniyeler içinde patlıyorsa HPA yetişmez; minReplicas'ı normal trafiğin taşıyabileceği kadar yüksek tut, HPA'yı ani zirvelerin değil yavaş büyümenin aracı olarak gör. Yeni pod'un hızlı hazır olması için image'ı küçük tut, readiness'ı gerçek hazır olma anına bağla (serinin sekizinci yazısı). CPU hedefini %60–70 civarında seç: %90 hedef yeni pod gelene kadar mevcutları boğar. Ölçmek için kubectl describe hpa event'leri ve HPA'nın kendi metrikleri yeterli bir başlangıç; replica sayısını, queue derinliğini ve p95 gecikmeyi aynı grafikte görmek ilerideki Observability serisinin konusu. Son olarak, HPA bir Deployment'ı yönetiyorsa manifest'te replicas alanını sabit tutma; her kubectl apply HPA'nın kararını ezip replica sayısını geri çeker. Alanı manifest'ten kaldırmak en temiz yol.

Sık Düşülen Tuzaklar

Request'siz pod'a CPU hedefli HPA bağlamak: <unknown> hedef, sıfır ölçekleme. Memory'ye göre HPA kurmak: .NET'te GC belleği hemen iade etmediği için memory kullanımı yük kalksa da düşmeyebilir ve HPA hiç küçülmez; memory metriğini ancak gerçekten iş yüküyle orantılıysa kullan. In-place resize sonrası .NET'in yeni limiti göreceğini varsaymak: yukarıdaki çıktılar görmediğini gösteriyor. Aynı CPU metriği üzerinden HPA ve VPA'yı birlikte çalıştırmak. minAvailable'ı replica sayısına eşitlemek ve node upgrade'lerini kilitlemek. KEDA'da minReplicaCount: 0 seçip ilk mesajın gecikmesini hesaba katmamak. Topology spread'de control-plane'in ya da cordon'lanmış node'ların domain olarak sayıldığını unutmak. Son olarak scale-down'ın uzun süren isteği kesmesi: HPA'nın sildiği pod da SIGTERM (Unix'in sonlandırma sinyali) alır, graceful shutdown'ın (serinin onuncu yazısı) doğru kurulmamışsa her scale-down bir avuç 502 üretir.

Küçük Bir Deneme

★☆☆ HPA'daki scaleDown.stabilizationWindowSeconds değerini 0 yap ve yükü 30 saniye açıp kapat. kubectl get hpa -w çıktısında replica sayısının nasıl dalgalandığını önceki çıktıyla karşılaştır.

★★☆ Bookstore API pod'unu limits.memory: 256Mi ile çalıştır, POST /api/diag/alloc-managed?mb=150 ile belleği doldur, sonra pod'u 512Mi'ye resize et ve RefreshMemoryLimit çağırmadan önce ve sonra 150 MB'lık ikinci bir allocation dene. Hangisinde OutOfMemoryException görüyorsun?

★★★ KEDA ScaledObject'e ikinci bir trigger olarak cron scaler ekle: hafta içi 09:00–18:00 arasında en az bir worker ayakta kalsın, gece queue boşsa sıfıra insin. Hangi trigger'ın hangi anda belirleyici olduğunu kubectl describe hpa keda-hpa-worker ile izle.

Ölçeklenen pod'ların hepsi stateless'tı: hangisi silinse veri kaybolmuyordu. Bir sonraki yazıda işin zor tarafına geçiyoruz: volume, PersistentVolumeClaim ve StatefulSet ile durum tutan uygulamalar ve "veritabanını cluster'a koyalım mı" sorusu.