Zero-Downtime'ın Ön Koşulları: Graceful Shutdown, Readiness, Stateless Uygulama ve Geriye Uyumluluk
Hiçbir deployment stratejisi, kapanırken işini bitirmeyen ve hazır olduğunu doğru söylemeyen bir uygulamayı kurtaramaz. Gerçek koşularla: ShutdownTimeout 2 sn'ye inince 5 sn'lik istek RST ile kopuyor, Docker'ın 1 sn'lik bekleme süresi 137 (SIGKILL) üretiyor, readiness drain sırasında 503 dönerken API 200 vermeye devam ediyor, ayrı Data Protection key ring'i CryptographicException fırlatıyor ve silinen Angular chunk'ı ChunkLoadError'a dönüşüyor.
Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.402), Angular 22.2, Nginx 1.30 ve Docker 29 ile denenmiştir.
Önce Tahmin Et
Bookstore API'sine (Application Programming Interface) beş saniye süren bir istek geliyor. İstek başladıktan bir saniye sonra container'a docker stop gönderiyorsun. Sence istemci ne görür: 200 mü, 502 mi, yoksa bağlantı kopması mı? Bir de şunu düşün: aynı denemeyi HostOptions.ShutdownTimeout iki saniyeye düşürülmüş bir uygulamada yaparsan cevap değişir mi?
İkinci soru Angular tarafından: Kullanıcı sayfayı sabah açtı, öğlen yeni sürümü deploy ettin ve eski JavaScript dosyalarını sildin. Kullanıcı sekmeyi yenilemeden "Books" linkine tıklıyor. Ekranda ne olur? İki cevabı da not et; ikisini de gerçek koşuyla göreceğiz.
Strateji Seçmeden Önce Uygulama Hazır mı?
Serinin ilk yazısında rolling, blue-green ve canary'yi bir haritaya koyduk. Hepsinin ortak bir varsayımı var: bir instance kapanırken elindeki işi düzgün bitirir, yeni instance trafiğe hazır olduğunu doğru söyler, kullanıcı hangi instance'a düşerse düşsün aynı sonucu alır ve eski sürümle yeni sürüm bir süre yan yana çalışabilir. Bu dört varsayımdan biri tutmuyorsa en pahalı blue-green altyapısı bile kullanıcıya hata gösterir. Bu yazıda dördünü de Bookstore üzerinde tek tek ölçüyoruz: graceful shutdown, readiness, stateless olmak ve geriye uyumluluk.
Graceful Shutdown: In-Flight İstekler Ne Oluyor?
Önce varsayılan ayarlarla deneyelim. /api/slow?ms=5000 endpoint'i beş saniye bekleyip cevap dönüyor. İsteği gönderiyoruz, bir saniye sonra container'ı durduruyoruz:
docker run -d --name depwork-api-a --stop-timeout 40 -p 18610:8080 depwork-bookstore-api:1.0.0
curl -s -w ' -> %{http_code} in %{time_total}s\n' "localhost:18610/api/slow?ms=5000" &
sleep 1
docker stop depwork-api-a
İstemcinin gördüğü:
{"version":"1.0.0","instance":"ee49ca47b125","waitedMs":5000} -> 200 in 5.078387s
Uygulama logunda kapanış sırası:
info: Microsoft.Hosting.Lifetime[0]
Application is shutting down...
info: Bookstore.Api.DrainOnShutdown[0]
SIGTERM received, readiness is now Unhealthy, draining for 0s
İstek tamamlandı, docker stop dört saniyede döndü ve exit code 0 oldu. Arkada olan şu: Docker container'daki ana process'e SIGTERM (terminate sinyali) gönderiyor. .NET'in generic host'u bu sinyali yakalıyor ve StopAsync'i başlatıyor. Kestrel önce yeni bağlantı kabul etmeyi bırakıyor, sonra elindeki isteklerin bitmesini bekliyor. Ne kadar bekleyeceğini HostOptions.ShutdownTimeout belirliyor; .NET 6'dan beri varsayılan değeri 30 saniye (dotnet/runtime kaynağında TimeSpan.FromSeconds(30)).
Şimdi aynı denemeyi ShutdownTimeout iki saniyeyle yapalım. Kestrel'in debug loglarını da açıyoruz:
docker run -d --name depwork-api-a --stop-timeout 40 -p 18610:8080 \
-e Shutdown__TimeoutSeconds=2 \
-e Logging__LogLevel__Microsoft.AspNetCore=Debug \
depwork-bookstore-api:1.0.0
-> 000 in 3.030439s
dbug: Microsoft.AspNetCore.Server.Kestrel.Connections[16]
Some connections failed to close gracefully during server shutdown.
dbug: Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets[8]
Connection id "0HNPT90568R56" sending RST because: "The connection was aborted because the server is shutting down and request processing didn't complete within the time specified by HostOptions.ShutdownTimeout."
info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
Request finished HTTP/1.1 GET http://localhost:18610/api/slow?ms=5000 - 499 - - 3035.1442ms
İstemci 3 saniyede bağlantının koptuğunu gördü (curl'ün 000'ı "HTTP (Hypertext Transfer Protocol) cevabı yok" demek). Kestrel iki saniye bekledi, sonra bağlantıya RST (reset) paketi gönderdi ve mesajında sebebi açıkça yazdı. Sunucu tarafındaki log isteği 499 ile kapatıyor. Bu yüzden ShutdownTimeout'u en uzun süren meşru isteğinden kısa tutmamalısın. Bir rapor export'u 40 saniye sürüyorsa 30 saniyelik varsayılan onu da keser.
Orkestratörün Sabrı da Bir Ayar
ShutdownTimeout'un tek başına yetmediğini bu denemeyi hazırlarken kendim yaşadım. İlk koşuda --stop-timeout vermemiştim ve istek yine 000 ile koptu. Container'a baktığımda şunu gördüm:
docker inspect -f 'stoptimeout={{.Config.StopTimeout}} signal={{.Config.StopSignal}}' depwork-api-a
docker inspect -f '{{.State.ExitCode}}' depwork-api-a
stoptimeout=1 signal=
137
137, 128 + 9 demek, yani process SIGKILL (kill sinyali) ile öldürülmüş. Docker dokümanına göre Linux container'larında varsayılan bekleme 10 saniye. Bu makinedeki Docker Desktop ise container'lara 1 saniye yazmıştı. Uygulama ne kadar düzgün kapanırsa kapansın, onu çalıştıran şey bir saniye sonra fişi çekiyorsa hiçbir işe yaramaz. Kural basit: orkestratörün bekleme süresi (Docker'da --stop-timeout, systemd'de TimeoutStopSec, ilerideki Kubernetes serisinde göreceğimiz terminationGracePeriodSeconds) en az drain süresi artı ShutdownTimeout kadar olmalı. Deploy script'lerine bu değeri açıkça yaz, varsayılana güvenme.
Readiness: Trafiği Ne Zaman Alacağını Uygulama Söylesin
Kapanan bir instance'ın içerideki işi bitirmesi yetmez, load balancer'ın ona yeni istek göndermeyi de bırakması gerekir. Açılan instance için de tersi geçerli: process ayağa kalktı diye trafiğe hazır olmayabilir. Bookstore'da katalog cache'i dolmadan gelen istekler veritabanına yığılıyor. Bu yüzden iki ayrı health endpoint'i var. ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Health Check: Liveness, Readiness ve Veritabanı Kontrolü" yazısında mekaniğini anlatmıştık; burada deploy açısından kullanıyoruz:
builder.Services.AddSingleton<AppState>();
builder.Services.AddHostedService<CatalogWarmup>();
builder.Services.AddHostedService<DrainOnShutdown>();
builder.Services.Configure<HostOptions>(options =>
options.ShutdownTimeout = TimeSpan.FromSeconds(builder.Configuration.GetValue("Shutdown:TimeoutSeconds", 30)));
builder.Services.AddHealthChecks()
.AddCheck<ReadinessCheck>("ready", tags: ["ready"]);
// ...
app.MapHealthChecks("/health/live", new() { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new() { Predicate = check => check.Tags.Contains("ready") });
public sealed class ReadinessCheck(AppState state) : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
if (state.Draining)
{
return Task.FromResult(HealthCheckResult.Unhealthy("draining"));
}
return Task.FromResult(state.CatalogLoaded
? HealthCheckResult.Healthy()
: HealthCheckResult.Unhealthy("catalog cache is warming up"));
}
}
public sealed class DrainOnShutdown(AppState state, IConfiguration configuration, ILogger<DrainOnShutdown> logger) : IHostedLifecycleService
{
public async Task StoppingAsync(CancellationToken cancellationToken)
{
// SIGTERM geldi: önce readiness düşer, Kestrel bu süre boyunca istek kabul etmeye devam eder
state.Draining = true;
int seconds = configuration.GetValue("Shutdown:DrainSeconds", 0);
logger.LogInformation("SIGTERM received, readiness is now Unhealthy, draining for {Seconds}s", seconds);
await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken);
}
public Task StartingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
DrainOnShutdown bir IHostedLifecycleService. .NET 10'daki Host.StopAsync sırası şöyle: önce bütün StoppingAsync metotları, sonra ApplicationStopping, sonra Kestrel dahil hosted service'lerin StopAsync'i. Yani StoppingAsync içinde beklediğimiz süre boyunca Kestrel hâlâ açık ve istek alıyor, ama /health/ready artık 503 dönüyor. Load balancer'ın health check'i bunu görüp instance'ı havuzdan çıkarıyor, içerideki istekler de bu arada bitiyor. Kubernetes'teki karşılığı preStop hook'u; onun mekaniği ilerideki Kubernetes serisinde.
Container'ı WARMUP_SECONDS=3 ve Shutdown__DrainSeconds=5 ile başlatıp her yarım saniyede iki endpoint'i yokladım. Çıktı kısaltılmış haliyle:
06:26:25 ready=Unhealthy live=Healthy
06:26:26 ready=Unhealthy live=Healthy
06:26:27 ready=Unhealthy live=Healthy
06:26:28 ready=Healthy live=Healthy
06:26:29 ready=Healthy live=Healthy
06:26:30 ready=Unhealthy/503 api=200
06:26:31 ready=Unhealthy/503 api=200
06:26:32 ready=Unhealthy/503 api=200
06:26:33 ready=Unhealthy/503 api=200
06:26:34 ready=Unhealthy/503 api=200
06:26:35 docker stop returned
06:26:35 ready=/000 api=000
İlk üç saniyede process ayakta (live=Healthy) ama readiness Unhealthy: katalog cache'i yükleniyor. SIGTERM geldiği an readiness 503'e dönüyor, API ise beş saniye boyunca 200 vermeye devam ediyor. Ancak drain bittikten sonra port kapanıyor. Drain süresini, load balancer'ın instance'ı "unhealthy" ilan etmesi için gereken süreden uzun seç. Health check her 5 saniyede bir yapılıyor ve iki başarısız cevap isteniyorsa 10 saniyeden kısa bir drain işe yaramaz.
Dikkat edilecek nokta şu: drain süresi ShutdownTimeout'un içinden yiyor. Host, CancelAfter(ShutdownTimeout) ile tek bir token oluşturuyor ve StoppingAsync de bu token'ı alıyor. 30 saniyelik timeout'un 10 saniyesini drain'e ayırırsan in-flight istekler için 20 saniye kalır.
Stateless Olmak: Kullanıcı Hangi Instance'a Düşerse Düşsün
Rolling ya da canary sırasında aynı kullanıcının iki isteği iki farklı instance'a gidebilir. Instance'ın belleğinde tuttuğun her şey (session, IMemoryCache'teki sepet, diske yazılan upload'lar) bu anda kaybolur ya da tutarsızlaşır. En sinsi örnek ASP.NET Core Data Protection, çünkü onu sen değil framework kullanıyor: authentication cookie'leri, antiforgery token'ları ve IDataProtector ile şifrelediğin her şey key ring'deki anahtarlara bağlı. Bookstore'a sepet token'ı üreten ve çözen iki endpoint ekledim, iki container başlattım, token'ı A'dan alıp B'ye gönderdim:
token=CfDJ8GsWHk8MPAVCmtIEpPw0vezQGo...
A: cart:42
B: 500
warn: Microsoft.AspNetCore.DataProtection.Repositories.FileSystemXmlRepository[60]
Storing keys in a directory '/root/.aspnet/DataProtection-Keys' that may not be persisted outside of the container. Protected data will be unavailable when container is destroyed. For more information go to https://aka.ms/aspnet/dataprotectionwarning
fail: Microsoft.AspNetCore.Server.Kestrel[13]
Connection id "0HNPT90M6QESD", Request id "0HNPT90M6QESD:00000001": An unhandled exception was thrown by the application.
System.Security.Cryptography.CryptographicException: The key {4f1e166b-3c0c-4205-9ad2-04a4fc34bdec} was not found in the key ring. For more information go to https://aka.ms/aspnet/dataprotectionwarning
Her container kendi key ring'ini /root/.aspnet/DataProtection-Keys altında üretmiş ve framework bunu açılışta uyarıyla haber vermiş. A'nın şifrelediğini B çözemiyor. Gerçek hayatta bu, rolling deploy sırasında kullanıcıların rastgele logout olması ya da form gönderirken antiforgery hatası alması demek. Üstelik deploy bittikten sonra da devam eder, çünkü her yeni container yeni bir key ring ile açılır. Çözüm, anahtarları bütün instance'ların paylaştığı bir yere koymak ve uygulama adını sabitlemek:
IDataProtectionBuilder dataProtection = builder.Services.AddDataProtection();
string? keysPath = builder.Configuration["DataProtection:KeysPath"];
if (keysPath is not null)
{
dataProtection
.PersistKeysToFileSystem(new DirectoryInfo(keysPath))
.SetApplicationName("bookstore");
}
docker volume create depwork-dpkeys
docker run -d --name depwork-api-a -p 18610:8080 -v depwork-dpkeys:/keys \
-e DataProtection__KeysPath=/keys depwork-bookstore-api:1.0.0
docker run -d --name depwork-api-b -p 18611:8080 -v depwork-dpkeys:/keys \
-e DataProtection__KeysPath=/keys depwork-bookstore-api:1.0.0
A: cart:42
B: cart:42 (200)
Production'da paylaşılan dosya sistemi yerine Redis, veritabanı ya da Azure Blob Storage kullanılır ve anahtarlar ProtectKeysWith... ile şifrelenir. Loglardaki "No XML (Extensible Markup Language) encryptor configured" uyarısı bunu hatırlatıyor. Ayrıntısı Web Güvenliği (OWASP Top 10) serisinin "OWASP A04: Cryptographic Failures (2) — Parola Saklama, Data Protection ve Secret Yönetimi" yazısında. Aynı mantık session ve cache için de geçerli: session'ı distributed cache'e koy, upload'ları instance diskine değil object storage'a yaz. Sticky session'ı çözüm olarak görme, en fazla bir yara bandı. Instance kapandığında yapıştırdığın kullanıcı yine kaybolur.
N ve N-1 Aynı Anda Çalışabilmeli
Recreate dışındaki her strateji bir süre iki sürümü yan yana çalıştırır. Bu sürenin uzunluğu rolling'de dakikalar, canary'de saatler, mobil uygulamalı bir API'de aylar olabilir. Bu süre boyunca üç contract'ın geriye uyumlu kalması gerekiyor:
- HTTP API contract'ı: yeni sürüm field ekleyebilir, eski field'ı silemez ya da anlamını değiştiremez. İstemci (Angular, mobil, başka servis) hâlâ eski sürümle konuşuyor olabilir.
- Message schema'ları: queue'da bekleyen mesajı yeni consumer, yeni producer'ın mesajını eski consumer okuyabilmeli. Microservices serisinin "Service Contract'ları ve Versioning" yazısındaki tolerant reader burada şart.
- Veritabanı şeması: eski kod yeni şemada, yeni kod eski şemada çalışabilmeli. Bunu serinin altıncı yazısında expand-contract ile ayrıntılı göreceğiz.
Pratik bir test: bir pull request'i merge etmeden önce "bu değişiklik deploy edilirken eski sürümden gelen bir istek ya da mesaj ne olur?" diye sor. Cevabı "500 alır" ise değişikliği ikiye bölmen gerekiyor.
Angular'a Özgü: Silinmiş Chunk'lar ve ChunkLoadError
Tarayıcı da bir "eski sürüm instance'ı". Kullanıcının açık sekmesi, deploy'dan önceki main-*.js'i çalıştırıyor ve lazy route'ların chunk adlarını o build'den biliyor. Bookstore'un Angular 22 uygulamasında /books lazy yükleniyor. v1'i Nginx'te yayınladım, tarayıcıda açtım, sonra v2'yi deploy edip eski dosyaları sildim. Nginx config'i klasik SPA (Single Page Application) fallback'i:
server {
listen 80;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
}
Sekmeyi yenilemeden "Books"a tıklayınca tarayıcı konsolunda gördüklerim:
[error] Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html". Strict MIME type checking is enforced for module scripts per HTML spec.
[error] ERROR TypeError: Failed to fetch dynamically imported module: http://localhost:18603/chunk-MEEZWYMZ.js
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' localhost:18603/chunk-MEEZWYMZ.js
200 text/html
Eski chunk artık yok, try_files de onun yerine index.html'i 200 ile dönüyor. Tarayıcı JavaScript beklerken HTML (HyperText Markup Language) aldığı için MIME (Multipurpose Internet Mail Extensions) tipi kontrolüne takılıyor. Kullanıcı tarafında link hiçbir şey yapmıyor gibi görünüyor. Bu sorunu çözmenin üç katmanı var. Birincisi, hash'li dosyalar için fallback'i kapatıp gerçek 404 dönmek ve cache kurallarını ayırmak:
server {
listen 80;
root /usr/share/nginx/html;
location = /index.html {
add_header Cache-Control "no-cache";
}
location ~* \.(js|css)$ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
İkincisi, deploy sırasında önceki sürümün hash'li dosyalarını silmemek. Yeni build'i eski dosyaların üzerine kopyala, sadece index.html değişsin, eski chunk'ları da bir iki sürüm sonra temizle. Dosya adları içerik hash'i taşıdığı için çakışma olmaz. Üçüncüsü, buna rağmen chunk bulunamazsa uygulamanın kendini toparlaması. Angular router'ın navigation hata handler'ı burada işe yarıyor:
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withNavigationErrorHandler } from '@angular/router';
import { routes } from './app.routes';
const reloadKey = 'chunk-reload-at';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(
routes,
withNavigationErrorHandler(({ error, url }) => {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes('Failed to fetch dynamically imported module')) {
return;
}
// yeni sürüm yayında: hedef URL'yi tam sayfa olarak yükle, 10 sn içinde ikinci kez deneme
const last = Number(sessionStorage.getItem(reloadKey) ?? 0);
if (Date.now() - last > 10_000) {
sessionStorage.setItem(reloadKey, String(Date.now()));
location.assign(url);
}
}),
),
],
};
v3'ü açıp v4'ü deploy ettim, bu kez eski chunk'ları bilerek sildim. Tıklamada konsola şu düştü:
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
[error] ERROR TypeError: Failed to fetch dynamically imported module: http://localhost:18603/chunk-MBQB7IDL.js
Handler hemen ardından /books'u tam sayfa olarak yükledi ve ekranda "Catalog v4" göründü. sessionStorage'daki zaman damgası sonsuz reload döngüsünü engelliyor: chunk gerçekten bozuksa ikinci denemede durur. Service worker kullanıyorsan (@angular/service-worker) aynı fikri SwUpdate.versionUpdates ile kurarsın: VERSION_READY geldiğinde kullanıcıya "yeni sürüm var" deyip yenilemesini istersin, unrecoverable event'inde de sayfayı yeniden yüklersin. SignalR kullanan sayfalarda da aynı gerçek var: sunucu kapandığında bağlantı kopar. JavaScript client'ında withAutomaticReconnect() varsayılan olarak 0, 2, 10 ve 30 saniye bekleyerek tekrar dener, ama reconnect sonrasında grup üyeliklerini yeniden kurmak senin kodunun işi.
Üretimde Dikkat
Bu ön koşulların hepsi deploy sırasında ölçülebilir. Her deploy'da şu dört sayıyı izle: deploy penceresindeki 5xx sayısı (load balancer loglarından), 499 ya da kopan bağlantı sayısı (graceful shutdown'ın yetip yetmediği), yeni instance'ın açılıştan readiness'e kadar geçen süresi (warm-up) ve deploy'dan sonraki ilk saatte gelen 404'lü .js istekleri (ChunkLoadError'ların sunucu tarafındaki izi). Bunlardan biri sıfır değilse sorun stratejide değil, uygulamanın hazırlığında demektir.
Shutdown zincirindeki sürelerin sırası her ortamda aynı olmalı: health check aralığı × başarısız eşik < drain süresi < drain + ShutdownTimeout < orkestratörün bekleme süresi. Bu zinciri bir kez kâğıda yazıp her ortamın ayarıyla karşılaştırmak, birçok "deploy'da birkaç istek patlıyor" şikâyetini bitirir.
Sık Düşülen Tuzaklar
- "Kubernetes kullanıyoruz, zero-downtime otomatik" varsayımı. Kubernetes yeni pod'u readiness'e göre servise alır ve eskisine SIGTERM gönderir, o kadar. Readiness'in anlamlı olması, SIGTERM'de drain edilmesi ve in-flight isteklerin bitmesi uygulamanın işidir. Endpoint'in servisten çıkarılmasıyla SIGTERM aynı anda yola çıktığı için kısa bir drain gecikmesi Kubernetes'te de gerekir.
- Liveness ile readiness'i aynı endpoint'e bağlamak. Veritabanı yavaşladığında liveness düşerse orkestratör sağlıklı process'leri öldürür ve sorun büyür.
ShutdownTimeout'u artırıp orkestratörün süresini unutmak. Yukarıdaki 137 tam olarak buydu.- Data Protection key'lerini container içinde bırakmak. Her deploy kullanıcıları logout eder ve bunu kimse deploy'a bağlamaz.
- Angular build'ini yayınlarken hedef klasörü silip yeniden kopyalamak. Açık sekmelerin hepsi bir sonraki lazy route'ta kırılır.
index.html'e uzun cache vermek. CDN (Content Delivery Network) ya da tarayıcı eskiindex.html'i saklarsa deploy'u kullanıcı saatlerce görmez.
Küçük Bir Deneme
★☆☆ Shutdown__TimeoutSeconds'ı 10 yapıp /api/slow?ms=12000 isteği sırasında container'ı docker stop -t 30 ile durdur. İstemci ne görür, Kestrel ne loglar? Sonra -t 5 ile tekrarla ve exit code'a bak.
★★☆ Shutdown__DrainSeconds=5 ile çalışan iki instance'ı Nginx arkasına koy ve birini durdururken saniyede on istek gönder. Açık kaynak Nginx aktif health check yapmadığı için readiness'in 503'ü Nginx'i nasıl etkiler? Hata görmüyorsan sebebini proxy_next_upstream dokümanında ara.
★★★ Angular uygulamasına @angular/service-worker ekle, SwUpdate.versionUpdates'i dinleyen bir service yaz ve deploy sonrası "yeni sürüm hazır" bildirimini gerçekten tetikle. Eski sekmede lazy route'a gitmek bu kez neden çalışıyor?
Uygulama artık kapanırken işini bitiriyor, hazır olduğunda haber veriyor ve hangi instance'a düşersen düş aynı davranıyor. Bir sonraki yazıda bunun üstüne ilk gerçek stratejiyi kuruyoruz: Nginx arkasındaki üç instance'ı sırayla güncelleyen bir rolling deployment ve Kubernetes'te maxSurge ile maxUnavailable kararı.