Mert Özen Her Satırda Daha İleri
DevOps Deployment Stratejileri — #03

Rolling Deployment: Sunucuları Sırayla Güncellemek, Kubernetes'te maxSurge ve maxUnavailable

Mert Özen 8 Kas 2026 12 dk 2 görüntülenme
Rolling Deployment: Sunucuları Sırayla Güncellemek, Kubernetes'te maxSurge ve maxUnavailable

Rolling deployment filoyu parça parça günceller; doğru yapılırsa kesinti olmaz ama iki sürüm bir süre yan yana yaşar. Nginx arkasında üç instance: naif script 90 GET'in 2'sini düşürdü, drain + readiness kontrollü script 237 GET ve 90 POST'u hatasız geçirdi. Kubernetes'te maxSurge/maxUnavailable ve minReadySeconds seçimi, progressDeadlineSeconds aşılınca otomatik geri dönüş olmadığı ve rolling'in uygun olmadığı durumlar.

Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.402), Nginx 1.30, Docker 29 ve kind 0.33 ile Kubernetes 1.37 üzerinde denenmiştir.

Önce Tahmin Et

Bookstore API'si (Application Programming Interface) Nginx arkasında üç instance olarak çalışıyor. Yeni sürümü yaymak için en basit script'i yazıyorsun: her instance'ı sırayla silip yenisini başlatıyor, araya üç saniye bekleme koyuyor. Bu sırada saniyede on GET ve bir yandan da POST istekleri geliyor. Kaç istek hata alır? Sıfır mı, birkaç tane mi, yoksa her instance değişiminde bir saniyelik kesinti mi?

İkinci soru: Aynı işi Kubernetes'te maxSurge: 1 ve maxUnavailable: 0 ile yapıyorsun, ama yeni sürüm hiç ready olmuyor. 60 saniyelik progressDeadlineSeconds dolunca Kubernetes eski sürüme kendisi geri döner mi?

Rolling Deployment Ne Yapar, Neyi Varsaymaz?

Rolling deployment filoyu tek seferde değil, parça parça günceller. Her adımda bir ya da birkaç instance trafikten çıkar, yeni sürüme geçer, sağlıklı olduğu doğrulanınca trafiğe geri döner. Ek sunucu gerekmez, bu yüzden en ucuz zero-downtime stratejisidir ve Kubernetes'in varsayılan stratejisi de budur.

Bedeli iki tane. Birincisi, deploy boyunca eski ve yeni sürüm aynı anda trafik alır, yani bir kullanıcının bir isteği v1'e, sonraki isteği v2'ye gidebilir. İkincisi, geri dönüş de bir rolling deploy'dur: hatayı gördüğünde filonun yarısı yeni sürümdeyse, onları tek tek geri almak da aynı süreyi ister. Bu yazıda önce bir VM (Virtual Machine) filosunu taklit eden üç container'la rolling'i elle kuruyoruz, sonra Kubernetes'te hangi kararı senin verdiğine bakıyoruz.

Başlangıç: Nginx Arkasında Üç Instance

Upstream tanımını ayrı bir dosyada tutuyoruz, çünkü script her adımda yalnız bu dosyayı yeniden yazacak:

upstream bookstore_api {
    zone bookstore_api 64k;
    server depwork-api-1:8080 resolve;
    server depwork-api-2:8080 resolve;
    server depwork-api-3:8080 resolve;
}
http {
    resolver 127.0.0.11 valid=1s ipv6=off;

    include /etc/nginx/bookstore/upstream.conf;

    server {
        listen 80;
        location / {
            proxy_pass http://bookstore_api;
        }
    }
}

resolve parametresi Nginx 1.27.3'ten beri açık kaynak sürümde de var. Container yeniden yaratılıp yeni IP (Internet Protocol) adresi aldığında Nginx adı resolver'daki süreye göre tekrar çözüyor. Bu parametre olmasaydı Nginx açılışta çözdüğü eski IP'ye bağlanmaya devam ederdi. VM filosunda bu dert yok, adresler sabit; ama Docker'da ve her dinamik ortamda bu satır olmadan rolling çalışmaz. Nginx tarafının ayrıntıları ilerideki Web Server'lar ve Hosting serisinin load balancing yazısında.

Naif Rolling: Sırayla Sil, Başlat

İlk script, çoğu ekibin ilk yazdığı script:

#!/usr/bin/env bash
# rolling-naive.sh VERSION: instance'ları sırayla yeniden başlatır; drain yok, health check yok
set -euo pipefail
for i in 1 2 3; do
    docker rm -f "depwork-api-$i" >/dev/null
    docker run -d --name "depwork-api-$i" --network depwork-net --stop-timeout 40 \
        "depwork-bookstore-api:$1" >/dev/null
    echo "$(date +%T) api-$i -> $1"
    sleep 3
done

Yanında iki probe çalışıyor. Biri saniyede yaklaşık on GET gönderiyor, diğeri 200 ms süren POST /api/orders'ı peş peşe çağırıyor:

(./tools/probe.sh http://localhost:18600/api/books 14 > get.txt) &
(./tools/probe.sh http://localhost:18600/api/orders 14 POST > post.txt) &
sleep 2; ./rolling-naive.sh 1.1.0; wait
awk '{c[$3] += $1} END {for (k in c) print k, c[k]}' get.txt
06:28:04 api-1 -> 1.1.0
06:28:07 api-2 -> 1.1.0
06:28:11 api-3 -> 1.1.0
GET
000 2
200 88
POST
201 41

90 GET'in ikisi bir saniye içinde cevap alamadı (000). Nginx'in error log'u sebebini gösteriyor:

[error] 30#30: *36 connect() failed (111: Connection refused) while connecting to upstream, client: 172.66.2.5, server: , request: "GET /api/books HTTP/1.1", upstream: "http://172.19.0.2:8080/api/books", host: "localhost:18600"
[error] 30#30: depwork-api-1 could not be resolved (3: Host not found)

docker rm -f container'ı SIGKILL (kill sinyali) ile öldürür. Bu, bir VM'in aniden kapanmasından ya da process'in çökmesinden farksızdır. Kestrel'in elindeki istekleri bitirme şansı olmuyor. Nginx önce bağlantı reddi alıyor, sonra adı çözemiyor; arada eski IP'ye giden bağlantı denemeleri curl'ün bir saniyelik sabrını aşıyor. POST'larda hata görmememiz şans değil, Nginx'in davranışı: proxy_next_upstream varsayılan olarak error timeout. Bağlantı kurulamadığında, yani istek upstream'e hiç gitmediğinde, Nginx POST'u da bir sonraki sunucuya gönderir. İstek gönderildikten sonra bağlantı koparsa iş değişir: POST, LOCK ve PATCH gibi idempotent olmayan method'lar non_idempotent açıkça verilmedikçe tekrar denenmez ve hata kullanıcıya gider. Yani bu denemede POST'u kurtaran şey, en kötü anı tam isteğin ortasına denk getirmemiş olmamız.

Bir şey daha var: script yeni container'ı başlattıktan sonra sağlıklı olup olmadığına hiç bakmıyor. Yeni sürüm açılışta çökse script mutlu mesut ikinci instance'a geçer ve filonun tamamını bozuk sürüme çevirir.

Doğru Rolling: Drain, Güncelle, Doğrula, Geri Al

Düzgün bir rolling her instance için dört adım atar. Önce instance'ı load balancer'dan çıkarır, sonra içerideki işlerin bitmesini bekler. Ardından instance'ı graceful şekilde durdurup yenisini başlatır ve readiness gelmeden trafiğe geri vermez:

#!/usr/bin/env bash
# rolling.sh VERSION: her seferinde bir instance'ı boşalt, güncelle, doğrula ve geri al
set -euo pipefail
version=$1
conf=nginx/bookstore/upstream.conf

write_upstream() {
    local i flag
    {
        echo "upstream bookstore_api {"
        echo "    zone bookstore_api 64k;"
        for i in 1 2 3; do
            flag=""
            if [ "$i" = "$1" ]; then flag=" down"; fi
            echo "    server depwork-api-$i:8080 resolve$flag;"
        done
        echo "}"
    } > "$conf"
    docker exec depwork-nginx nginx -t -q
    docker exec depwork-nginx nginx -s reload 2>/dev/null
}

wait_ready() {
    local _
    for _ in $(seq 1 30); do
        if docker exec depwork-nginx wget -q -O /dev/null "http://depwork-api-$1:8080/health/ready" 2>/dev/null; then
            return 0
        fi
        sleep 1
    done
    echo "api-$1 did not become ready" >&2
    return 1
}

for i in 1 2 3; do
    write_upstream "$i"
    sleep 2
    docker stop "depwork-api-$i" >/dev/null
    docker rm "depwork-api-$i" >/dev/null
    docker run -d --name "depwork-api-$i" --network depwork-net --stop-timeout 40 \
        -e WARMUP_SECONDS=3 "depwork-bookstore-api:$version" >/dev/null
    wait_ready "$i"
    write_upstream none
    echo "$(date +%T) api-$i -> $version ready"
done

Burada iki ayrıntı önemli. down işaretli sunucuya Nginx yeni istek göndermez. nginx -s reload ise yeni config'le yeni worker'lar açar, eski worker'lar ellerindeki istekleri bitirip kapanır. Yani reload'ın kendisi de bir drain. Nginx 1.29.6'dan beri açık kaynak sürümde drain parametresi de var, ama dokümana göre o modda sunucuya yalnız ona "bağlı" (sticky session ile yapışmış) istekler gider. Sticky kullanmıyorsan down ile aynı sonucu verir.

Script'in ilk sürümünde bir hata vardı ve çıktısı şöyleydi:

06:28:51 api-3 -> 1.0.0 ready
06:28:57 api-3 -> 1.0.0 ready
06:29:04 api-3 -> 1.0.0 ready

Üç adımda da "api-3" güncellenmiş, api-1 ve api-2'ye hiç dokunulmamış. Sebep bash'in değişken scope'u: write_upstream içindeki for i in 1 2 3 döngüsü, dıştaki döngünün i değişkenini ezip 3'te bırakıyordu. Fonksiyona local i flag eklemek yetti. Bunu anlatıyorum çünkü deploy script'leri de koddur. Test edilmemiş bir rolling script'i filonun üçte birini üç kez güncelleyip "başarılı" diye bitebilir. Script'in sonunda her instance'ın sürümünü doğrulayan bir kontrol bu hatayı anında yakalardı.

Düzeltilmiş script'i aynı iki probe ile 1.0.0'dan 1.1.0'a koşturdum:

06:29:35 api-1 -> 1.1.0 ready
06:29:41 api-2 -> 1.1.0 ready
06:29:46 api-3 -> 1.1.0 ready
GET
200 237
POST
201 90

237 GET ve 90 POST, hepsi başarılı. GET probe'unun saniye saniye dökümü karışık sürüm penceresini gösteriyor:

   8 06:29:34 200 1.0.0
   6 06:29:35 200 1.0.0
   2 06:29:35 200 1.1.0
   5 06:29:36 200 1.0.0
   3 06:29:36 200 1.1.0
   4 06:29:37 200 1.0.0
   4 06:29:37 200 1.1.0
   3 06:29:38 200 1.0.0
   5 06:29:38 200 1.1.0
   5 06:29:39 200 1.0.0
   4 06:29:39 200 1.1.0
   3 06:29:40 200 1.0.0
   5 06:29:40 200 1.1.0
   8 06:29:41 200 1.1.0

06:29:35 ile 06:29:40 arasında aynı istemci her saniye hem 1.0.0 hem 1.1.0 cevap aldı. Bu altı saniyelik pencere üç instance ve hızlı açılan bir uygulama için. Yirmi instance'lık, her biri bir dakikada ısınan bir filoda bu pencere yarım saati bulur.

Karışık Sürüm Penceresinin Sonuçları

Rolling'in asıl sorusu "hata oluyor mu?" değil, "bu pencerede iki sürüm birbirini bozuyor mu?" sorusu. Tipik kırılmalar şunlar:

  • Yeni sürüm bir JSON (JavaScript Object Notation) field'ını yeniden adlandırdı. Angular uygulaması bir isteği v2'den alıp sonraki isteği v1'e gönderince field kayboluyor.
  • Yeni sürüm cache'e yeni formatta yazıyor, eski sürüm aynı key'i okuyup deserialize edemiyor.
  • Yeni sürüm queue'ya yeni tipte mesaj bırakıyor, henüz güncellenmemiş consumer onu tanımıyor.
  • Migration yeni kolonu NOT NULL ekledi, eski sürümün INSERT'leri patlıyor.

Hepsinin ilacı serinin ikinci yazısındaki N ve N-1 uyumluluğu: her değişiklik, eski sürüm hâlâ çalışırken de doğru olmalı. Bunu sağlayamıyorsan rolling yanlış strateji. Ya değişikliği geriye uyumlu adımlara bölersin (veritabanı için altıncı yazıdaki expand-contract), ya da trafiği tek hamlede çeviren blue-green'e geçersin.

Kubernetes'te Senin Kararın: maxSurge, maxUnavailable ve Süreler

Kubernetes'te rolling'i Deployment controller yapar; ReplicaSet'lerin nasıl büyüyüp küçüldüğünü ilerideki Kubernetes serisinin Deployment ve ReplicaSet yazısında anlatıyoruz. Burada yalnız strateji kararını konuşalım. Kararı birkaç satır veriyor:

spec:
  replicas: 4
  minReadySeconds: 5
  progressDeadlineSeconds: 60
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0

maxSurge deploy sırasında istenen sayının kaç fazlasına çıkılabileceğini, maxUnavailable kaç eksiğine düşülebileceğini söyler. İkisi de varsayılan olarak %25. Yüzde verildiğinde maxSurge yukarı, maxUnavailable aşağı yuvarlanır. Bu yüzden dört replica'da varsayılan, bir fazlası ve bir eksiği demek. Seçimi kapasite belirler:

AyarNe zamanBedeli
maxSurge: 1, maxUnavailable: 0Kapasite tam kullanılıyor, tek pod eksilmesi bile yük sorunu yaratırCluster'da bir pod'luk boş yer gerekir, deploy yavaş ilerler
maxSurge: 0, maxUnavailable: 1Cluster'da boş kaynak yok (lisans, GPU (Graphics Processing Unit), sabit node)Deploy boyunca kapasite bir pod eksik
maxSurge: 25%, maxUnavailable: 25%Varsayılan, çoğu stateless servis için makulBüyük deployment'larda aynı anda çok pod değişir
maxSurge: 100%, maxUnavailable: 0Hızlı ve güvenli geçiş, kaynak bolBir anlığına iki kat kaynak

İki süre ayarı daha var. minReadySeconds (varsayılan 0) yeni pod ready olduktan sonra kaç saniye daha sağlıklı kalırsa "available" sayılacağını belirler. Açılıştan birkaç saniye sonra çöken bir sürümü rolling devam etmeden yakalamak için 5 ile 30 saniye arası bir değer iyi bir sigorta. progressDeadlineSeconds (varsayılan 600) ise rollout'un ne kadar süre ilerleme göstermezse "takıldı" sayılacağını söyler.

Bu ayarlarla kind üzerinde dört replica'yı 1.1.0'a geçirdim:

kubectl set image deploy/bookstore-api api=depwork-bookstore-api:1.1.0
kubectl rollout status deploy/bookstore-api --timeout=120s
deployment.apps/bookstore-api image updated
Waiting for deployment "bookstore-api" rollout to finish: 1 out of 4 new replicas have been updated...
Waiting for deployment "bookstore-api" rollout to finish: 2 out of 4 new replicas have been updated...
Waiting for deployment "bookstore-api" rollout to finish: 3 out of 4 new replicas have been updated...
Waiting for deployment "bookstore-api" rollout to finish: 1 old replicas are pending termination...
deployment "bookstore-api" successfully rolled out

Çıktı kısaltıldı; rollout 37 saniye sürdü. Her yeni pod üç saniyelik warm-up'ı, readiness probe'unu ve beş saniyelik minReadySeconds'ı bekledi, sonra bir eski pod kapandı. Şimdi hiç ready olmayan bir sürüm deneyelim:

kubectl set env deploy/bookstore-api WARMUP_SECONDS=100000
kubectl rollout status deploy/bookstore-api --timeout=150s
deployment.apps/bookstore-api env updated
Waiting for deployment "bookstore-api" rollout to finish: 1 out of 4 new replicas have been updated...
error: deployment "bookstore-api" exceeded its progress deadline
NAME                             READY   STATUS    RESTARTS   AGE
bookstore-api-597cdc7fb4-62s9l   0/1     Running   0          61s
bookstore-api-d996bbcc4-6m22j    1/1     Running   0          99s
bookstore-api-d996bbcc4-lvdn4    1/1     Running   0          90s
bookstore-api-d996bbcc4-r6mtw    1/1     Running   0          118s
bookstore-api-d996bbcc4-r7zdm    1/1     Running   0          108s

Available=True MinimumReplicasAvailable: Deployment has minimum availability.
Progressing=False ProgressDeadlineExceeded: ReplicaSet "bookstore-api-597cdc7fb4" has timed out progressing.

Baştaki ikinci sorunun cevabı burada: Kubernetes geri dönmedi. Rollout'u "ProgressDeadlineExceeded" diye işaretledi ve durdu. maxUnavailable: 0 sayesinde dört eski pod trafiği taşımaya devam ediyor, kullanıcı bir şey fark etmedi. Ama Deployment yarım halde bekliyor. Geri dönmek (kubectl rollout undo) ya da ileri gitmek senin, pipeline'ının veya Argo Rollouts gibi bir aracın işi. kubectl rollout status'un non-zero exit code'u pipeline'ın burada durması için tam doğru sinyal.

Rolling Ne Zaman Uygun Değil?

Tek instance varsa. Sırayla güncellenecek ikinci bir instance yoksa rolling, adı değişmiş bir recreate'tir. Ya ikinci instance eklersin ya da blue-green yaparsın.

Şema değişikliği geriye uyumlu değilse. Yeni kod yeni şemayı, eski kod eski şemayı istiyorsa iki sürüm aynı anda yaşayamaz.

Oturum ya da bellek içi durum varsa. Stateless olmayan bir uygulamada instance değiştirmek kullanıcı kaybettirir (serinin ikinci yazısı).

Hızlı ve tam geri dönüş şartsa. Rolling'in geri dönüşü de rolling'dir; blue-green'in bir switch'lik geri dönüşüyle yarışamaz.

Yeni sürümü önce gerçek kullanıcıların küçük bir kısmıyla denemek istiyorsan. Rolling'in "yüzde"si instance sayısına bağlıdır ve kullanıcıya yapışmaz; bunun için canary var.

Üretimde Dikkat

Rolling'in sağlığını üç sayı anlatır. Birincisi deploy penceresindeki 5xx ve kopan bağlantı sayısı; drain ve graceful shutdown doğru kurulduysa bu sıfır olmalı, değilse ayarları serinin ikinci yazısındaki zincire göre gözden geçir. İkincisi karışık sürüm penceresinin süresi: instance sayısı × (drain + açılış + readiness + minReadySeconds) / aynı anda değişen instance sayısı. Bu süre uzadıkça uyumluluk riski büyür. Üçüncüsü yeni sürümün ilk instance'ındaki hata oranı. İlk instance aslında bedava bir canary'dir; onun metriklerine bakmadan ikinciye geçme.

Filo büyüdükçe script'e iki şey eklemek gerekir: aynı anda kaç instance'ın değişeceğini (batch size) parametre yapmak ve bir eşik koymak. Örneğin ilk batch'ten sonra hata oranı arttıysa durup geri almak. Bu, Kubernetes'in progressDeadlineSeconds ile yaptığının elle yazılmış hali.

Sık Düşülen Tuzaklar

  • Instance'ı load balancer'dan çıkarmadan durdurmak. Graceful shutdown iyi olsa bile load balancer kapanan instance'a istek göndermeye devam eder.
  • "Container çalışıyor" ile "hazır" arasındaki farkı atlamak. docker run başarılı döndü diye sonraki instance'a geçmek, bozuk sürümü bütün filoya yaymanın en kısa yolu.
  • Deploy script'ini test etmemek. Yukarıdaki local hatası gibi bir bug, rapor yeşilken filoyu yarım bırakır.
  • progressDeadlineSeconds aşıldığında Kubernetes'in otomatik geri döndüğünü sanmak. Dönmez, rollout'u "başarısız" işaretleyip bekler.
  • maxUnavailable yüzdesini küçük deployment'larda düşünmeden bırakmak. Üç replica'da %25 aşağı yuvarlanıp 0 olur, maxSurge %25 yukarı yuvarlanıp 1 olur. Davranış beklediğinle aynı olabilir de olmayabilir de; sayıyı açıkça yaz.

Küçük Bir Deneme

★☆☆ rolling.sh'nin sonuna her instance'tan /api/version'ı okuyup hepsinin hedef sürümde olduğunu doğrulayan bir kontrol ekle. local i flag satırını silip hatayı tekrar üret ve kontrolün yakaladığını gör.

★★☆ Script'i BATCH_SIZE parametresi alacak şekilde değiştir (aynı anda kaç instance güncellenecek) ve altı instance'ta 1, 2 ve 3 için karışık sürüm penceresinin süresini ölç.

★★★ kind'da dört replica'lık bir Deployment için maxSurge: 0, maxUnavailable: 1 ve maxSurge: 100%, maxUnavailable: 0 ayarlarını dene. Rollout boyunca kubectl get rs -w çıktısında ReplicaSet sayılarının nasıl değiştiğini kaydet ve hangisinin kaç saniye sürdüğünü karşılaştır.

Rolling ucuz, ama iki sürümü uzun süre yan yana yaşatıyor ve geri dönüşü yavaş. Bir sonraki yazıda bu iki zayıflığı para karşılığı satın alıyoruz: Nginx ile iki ayrı environment kuran, trafiği tek bir reload ile çeviren ve aynı reload ile geri dönen blue-green deployment.