← notlar

2026-04-22

Blue/green ile zero-downtime deploy

Deploy sırasında aktif request'leri kaybetmemek için iki backend container'ı paralel tutup nginx'in önünden port swap yapıyorum. Eskiden deploy'u gece yapıyorduk, "downtime penceresi" açılıyordu. Şimdi gün içi de gönderebiliyorum.

Temel fikir

İki slot var: blue ve green. Her ikisi de aynı imajın farklı tag'ini farklı portta çalıştırıyor. Aktif slot bir dosyada tutuluyor:

/var/lib/pricoda/<service>-active-slot   →   "blue" veya "green"

Nginx, aktif slot'un port'una proxy_pass ile bağlı. Deploy sırasında:

  1. Pasif slot'ta yeni container'ı başlat (eski aktif çalışmaya devam ediyor).
  2. Yeni container health check geçsin diye 120s bekle.
  3. Nginx conf'unda proxy_pass port'unu sed ile swap et, nginx -s reload.
  4. 60s grace period, eski container hâlâ uçuşta olan request'leri bitirsin.
  5. Eski container'ı docker stop --time 90 ile durdur.

Reload graceful. Mevcut bağlantılar yeni conf'a değmiyor, kapanınca düşüyorlar. Yeni bağlantılar yeni upstream'e gidiyor.

Spring Boot tarafı: graceful shutdown

SIGTERM gelince Tomcat'in hemen kapanmaması lazım. Spring'in graceful mod'u:

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 90s

graceful moduyla yeni request almıyor ama açık olanları bitiriyor. 90s timeout yeterli, Decodo + parse kombinasyonu en kötü ~1 dk sürüyor.

Ama bir tuzak var: HTTP request'ler drain oluyor, async executor'daki task'lar drain olmuyor. Uzun scraping job'u SIGTERM alınca yarıda kesiliyor. Bunu henüz çözmedim, planda:

executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(120);

timeout-per-shutdown-phase da 120s'ye çıkıyor, docker stop --time 150 oluyor. ~%90+ async job korunur.

Nginx hot-swap

Nginx config'de upstream port'u placeholder değil, literal. Her deploy'da sed ile değiştiriyorum:

sed -i "s/proxy_pass http:\\/\\/127.0.0.1:8082/proxy_pass http:\\/\\/127.0.0.1:8083/" \
  /etc/nginx/conf.d/pricoda-global-backend.conf
sudo nginx -t && sudo nginx -s reload

İki detay:

  • nginx -t çok önemli. Test etmeden reload atarsan ve config bozuksa downtime alırsın. Test geçmiyorsa deploy'u durdur.
  • nginx -s reload SIGHUP gönderir, master yeni worker başlatır, eski worker'lar mevcut bağlantıları bitirip kapanır. Graceful.

Jenkins agent'ının sed çalıştırıp sonra sudo'suz nginx -s reload demesi için iki şey gerekti:

  • /etc/nginx/conf.d/ dizinine devops grubu yazma izni.
  • /etc/sudoers.d/devops-nginx → NOPASSWD nginx -t ve nginx -s reload.

İkisini de tek seferlik yaptım, bir daha dokunmadım.

Health check ve auto-rollback

Yeni container ayağa kalktığında /actuator/health 200 dönene kadar bekliyorum. 120s limitim var:

for i in {1..60}; do
  if curl -fs http://127.0.0.1:$NEW_PORT/actuator/health > /dev/null; then
    echo "Healthy at attempt $i"
    break
  fi
  sleep 2
  if [ $i -eq 60 ]; then
    echo "Health check failed, rolling back"
    docker stop $NEW_CONTAINER && docker rm $NEW_CONTAINER
    exit 1
  fi
done

Başarısızsa yeni container'ı öldürüyorum, nginx'e dokunmuyorum (hâlâ eski slot'a pointing). Deploy başarısız ama prod etkilenmedi.

Slot tracking

Aktif slot'un hangisi olduğunu bilmek lazım ki yeni deploy pasif olana gitsin. Basit bir file:

CURRENT=$(cat /var/lib/pricoda/pricoda-global-backend-active-slot)
if [ "$CURRENT" = "blue" ]; then
  NEW_SLOT=green
  NEW_PORT=8083
else
  NEW_SLOT=blue
  NEW_PORT=8082
fi

Deploy sonunda slot dosyasını güncelle. Bir dahaki deploy diğerine gidecek.

Yakalanan hatalar

  • Scheduled task'lar iki yerde tetiklenebilir: blue-green grace period'da (60s) iki container paralel. @Scheduled cron'u iki kez çalışır. Şu an scheduled search kapalı olduğu için acil değil ama ShedLock eklemek gerekecek.
  • Database migration: yeni kod eski şemayla çalışmalı (backward-compatible) veya migration önden. Kırıcı migration'ları deploy'dan bağımsız ayrı fazda yapıyorum.
  • Session state: stateless olmak zorunda. Session sticky olursa grace period'da user yanlış slot'ta bulunur. Bizde JWT olduğu için sorun yok.

Sonuç

Deploy artık iş saatlerinde de gönderilebiliyor. En kötü senaryoda (yeni container açılamadıysa) auto-rollback devreye giriyor ve kimse fark etmiyor. 5 dakikalık deploy penceresi 0 dakikaya düştü.

Tek eksik: async executor graceful drain. Onu da ekleyince deploy sırasında en uzun scraping job'u bile korunmuş olacak.