uurstudios
Tüm yazılar
DevOpsNode.jsDağıtımBackend

Kesintisiz dağıtım: rolling restart, bağlantı boşaltma ve keep-alive tuzağı

30 Eylül 2026 5 dk okumaUğur Can Elsun

"Deploy'u gece yapalım, kimse fark etmesin" yaklaşımı küçük projede bir süre idare eder. Ama kullanıcıların belirli saatlerde yığıldığı bir sistemde, acil bir düzeltmeyi tam o saatte yayına almanız gerekebilir. O an sürecin durup yeniden başladığı birkaç saniye, yüzlerce başarısız istek demek.

Portföyümdeki eğitim platformu çalışmasında bunu ölçtüm. Devralınan sürümde dağıtım "süreci durdur, yenisini başlat"tan ibaretti. Sınav saati provasında dağıtım anında 841 isteğin 446'sı hata aldı. Düzeltilmiş sürümde aynı prova sırasında 27.249 istek geldi ve hiçbiri hata almadı. Aradaki farkı üç parçada anlatacağım: süreçleri sırayla yenilemek, bağlantıları düzgün boşaltmak ve keep-alive soketlerinin kurduğu tuzak.

Durdur-başlat neden bu kadar kötü

Kâğıt üstünde durdur-başlat birkaç saniyelik bir boşluk yaratır. Pratikte daha kötüsü oluyor. Eğitim platformunun v1 sürümünde eski sunucu yük altındayken durdurulduğunda, yenisi ayağa kalkana kadar 27,8 saniye geçti. Sebep: sunucu, istemcilerin çoktan vazgeçtiği istekleri işlemeye devam ediyordu. Kuyrukta bekleyen işler bitmeden yeni süreç düzgün hizmet veremedi.

Provanın hata kayıtlarında üç tür hata var: portun kimse tarafından dinlenmediği anlarda ECONNREFUSED, süreç ölürken açık bağlantılarda ECONNRESET, kuyrukta bekleyen isteklerde zaman aşımı. Kullanıcının gözünden hepsi aynı: "Sınavımı gönderemedim."

Rolling restart: yenisi hazır olmadan eskisini kapatmayın

Temel kural basit: aynı anda en az bir sağlıklı süreç trafiği karşılıyor olmalı. Platformun v2 sürümünde Node.js cluster modülüyle çekirdek başına bir işçi çalışıyor. Ana sürece SIGHUP sinyali gönderildiğinde işçiler tek tek yenileniyor:

process.on("SIGHUP", async () => {
  const old = Object.values(cluster.workers).filter(Boolean);
  for (const w of old) {
    const fresh = spawn();
    await new Promise((r) => fresh.once("listening", r)); // yenisi dinlemeye başlasın
    await new Promise((r) => {
      w.once("exit", r);
      w.send({ cmd: "drain" });                            // eskisini boşalt
      setTimeout(() => { if (!w.isDead()) w.kill("SIGKILL"); }, 10_000);
    });
  }
});

Sıra önemli. Yeni işçi listening olayını vermeden eski işçiye dokunulmuyor. Eski işçi kapanana kadar da bir sonrakine geçilmiyor. Böylece kapasite hiçbir anda bir işçiden fazla düşmüyor. Yük altında bu işlem 4,6 saniye sürdü.

Kubernetes kullanıyorsanız aynı fikrin karşılığı Deployment'ın RollingUpdate stratejisi. maxUnavailable: 0 ve maxSurge: 1 ayarı "önce yeni pod'u aç, hazır olunca eskisini kapat" anlamına geliyor. Ama tek başına yetmez; "hazır"ın ne demek olduğunu sizin tanımlamanız gerekiyor.

Sağlık kontrolü: canlı mı, hazır mı?

İki ayrı uç nokta tutmak gerekiyor:

  • /health: Süreç ayakta ve HTTP'ye cevap veriyor mu? Başka hiçbir şeye bakmaz, hızlı döner.
  • /ready: Trafiği gerçekten karşılayabilir mi? Eğitim platformunda veritabanına SELECT 1 atıyor ve sonuca göre 200 ya da 503 dönüyor.

Yük dengeleyici ya da Kubernetes trafiği /ready'ye göre yönlendirmeli. Ayrım önemli çünkü veritabanı bağlantısı kopmuş bir süreci yeniden başlatmak çoğu zaman bir şeyi düzeltmez, ama ona trafik göndermemek hatayı önler. Canlılık kontrolünü veritabanına bağlarsanız, veritabanında yaşanan kısa bir aksaklıkta bütün süreçlerin art arda yeniden başlatıldığını görebilirsiniz.

Bağlantı boşaltma (drain)

Eski süreci kapatmak "şimdi öl" demek olmamalı. Doğru sıra şöyle:

  1. Yeni bağlantı kabul etmeyi bırakın (ya da yük dengeleyiciye hazır olmadığını bildir).
  2. Devam eden istekleri bitirin.
  3. Açık kalan bağlantıları kapatın.
  4. Veritabanı bağlantısını kapatıp çıkın.
  5. Belirli bir süre içinde bitmezse zorla kapatın.

Node.js'te bunun doğal aracı server.close(): yeni bağlantı almayı bırakır, açık bağlantılar kapanınca geri çağırmayı çalıştırır. İlk sürümü tam olarak böyle yazdım. Sonra provada 75 hata ölçtüm.

Keep-alive tuzağı: ECONNRESET nereden geldi

HTTP keep-alive, istemcinin aynı TCP bağlantısını birden çok istek için kullanmasını sağlar. Yük üreteci de, tarayıcılar da, yük dengeleyiciler de bunu yapar. Sorun şurada: Node.js 19'dan beri server.close(), o an boşta bekleyen keep-alive soketlerini de kapatıyor. İstemci ise o soketin hâlâ açık olduğunu düşünüyor.

Olayların sırası şöyle:

  1. İstemci bir isteği bitirdi, soketi havuza koydu.
  2. Sunucu boşaltmaya başladı ve boşta görünen o soketi kapattı.
  3. Aynı milisaniyelerde istemci havuzdaki soketi alıp yeni isteği yazdı.
  4. Kapanma bilgisi istemciye ulaşmadan yazılan istek ECONNRESET ile düştü.

Bu bir yarış durumu. Düşük trafikte neredeyse hiç görünmüyor, yük altında onlarca kez oluyor. Çözüm, istemciye soketi bir daha kullanmamasını kibarca söylemek:

function drain() {
  if (draining) return;
  draining = true; // bundan sonra her yanıta "Connection: close" ekleniyor
  setTimeout(() => {
    server.close(() => { db.close(); process.exit(0); });
  }, 2000).unref();
  setTimeout(() => server.closeAllConnections?.(), 8000).unref();
}

Boşaltma başladıktan sonra gönderilen her yanıtta Connection: close başlığı var. İstemci bu başlığı görünce soketi havuza geri koymuyor, bir sonraki isteği yeni bağlantıyla açıyor. Yeni bağlantı da artık yeni işçiye gidiyor. İki saniye sonra server.close() çağrıldığında boşta bekleyen ve yeniden kullanılacak soket kalmamış oluyor. Sekizinci saniyede hâlâ açık bağlantı varsa hepsi kapatılıyor, onuncu saniyede süreç hâlâ yaşıyorsa ana süreç onu öldürüyor. Bu değişiklikten sonra aynı provada 27.249 istekte sıfır hata ölçüldü.

Yük dengeleyicinin arkasında aynı tuzağın başka bir yüzü

Uygulamanız bir proxy ya da yük dengeleyicinin arkasındaysa keep-alive bir kez daha karşınıza çıkıyor. Proxy, uygulamaya açtığı bağlantıları da havuzda tutuyor. Uygulamanın boşta bağlantı zaman aşımı proxy'ninkinden kısaysa, uygulama soketi kapatırken proxy aynı sokete istek yazabiliyor ve kullanıcı 502 görüyor. Kural: uygulamanın keep-alive zaman aşımı, önündeki proxy'nin boşta bağlantı zaman aşımından uzun olmalı. Node.js'te server.keepAliveTimeout varsayılan olarak 5 saniye; önündeki servisin ayarına bakıp buna göre yükseltmek gerekebilir. Bunu yaparken headersTimeout değeri de keep-alive süresinin üstünde tutulmalı.

Kubernetes'te benzer bir yarış pod kapanırken yaşanıyor. Pod'a kapanma sinyali gitmesiyle yük dengeleyicinin o pod'u listeden çıkarması aynı anda olmuyor. Bu yüzden birçok ekip preStop aşamasına birkaç saniyelik bir bekleme koyuyor. Böylece süreç, trafik kesildikten sonra boşaltmaya başlıyor. terminationGracePeriodSeconds (varsayılanı 30 sn) bu beklemeyi ve boşaltmayı kapsayacak kadar uzun olmalı.

Veritabanı değişiklikleri: iki sürüm aynı anda yaşar

Rolling restart sırasında birkaç saniye boyunca eski ve yeni kod aynı veritabanına yazıyor. Yeni sürüm bir sütunu silerse ya da adını değiştirirse eski işçiler o saniyelerde hata verir. Bu yüzden şema değişiklikleri iki adımda yapılıyor: önce eski kodu bozmayan eklemeler (yeni sütun, yeni indeks), sonraki dağıtımda eski yapının kaldırılması.

Bu projede şema göçü ana süreçte, işçiler başlamadan bir kez çalışıyor ve yalnızca CREATE INDEX IF NOT EXISTS içeriyor. İkinci kez çalışması zarar vermiyor, eski kod da yeni indekslerden etkilenmiyor.

Kontrol listesi

  • Yeni süreç hazır olmadan eskisi kapanmıyor mu?
  • /health ve /ready ayrı mı, trafik /ready'ye göre mi yönleniyor?
  • Boşaltmada yeni bağlantı kabulü duruyor, yanıtlara Connection: close ekleniyor mu?
  • Boşaltmanın bir üst süre sınırı ve zorla kapatma adımı var mı?
  • Uygulamanın keep-alive zaman aşımı önündeki proxy'ninkinden uzun mu?
  • Şema değişikliği eski kodu bozmuyor mu?
  • Dağıtımı yük altında en az bir kez prova ettiniz mi? Eğitim platformundaki keep-alive hatası boş sunucuda hiç görünmedi, ancak yük altında ortaya çıktı.

Kesintisiz dağıtım kurmak istediğiniz bir Node.js projeniz varsa bana yazabilirsiniz.

İlgili yazılar

Bir projeniz mi var?

Fikrinizi anlatın, 24 saat içinde size özel bir teklif ve yol haritasıyla dönüş yapayım.

Teklif Al
WhatsApp