Express API'yi Production'a Hazırlamak: Configuration, pino ile Structured Logging, Health Check, Graceful Shutdown ve Cluster
Production'a hazır bir Express API, configuration'ı açılışta şemayla doğrular, pino ile request id'li ve secret'ları gizlenmiş JSON log yazar, liveness ile readiness'i ayırır ve SIGTERM'de yeni bağlantıyı kesip açık request'leri bitirdikten sonra pool'u kapatır. Gerçek çıktılarla 98 MB yerine 22 MB inen export, tam inen dosya ve timeout'ta kontrollü kesinti, drain sırasında 503, PID 1'in 10 saniyelik bekleyişi ve 4 worker'lı cluster.
Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS), Express 5.3.0, pino 10.4.0, zod 4.6.7, mssql 12.7.14, TypeScript 7.0.2 ve Docker 29 ile çalıştırılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) 13. yazının sonunda API SQL Server'a bağlandı. Gül müşteri hizmetlerinden 98 MB'lık sipariş export'unu indiriyor; tam o sırada yeni sürüm deploy ediliyor ve orchestrator eski process'e SIGTERM gönderiyor. 13'teki server.ts'in sonu şöyle:
const server = app.listen(port, host, (err?: Error) => {
if (err) {
console.error(`cannot listen on ${host}:${port}: ${err.message}`);
process.exit(1);
}
console.log(`Bookstore API listening on http://${host}:${port} (${app.get("env")})`);
});
Sinyal için hiçbir kod yok. İndirme başladıktan bir saniye sonra process'e SIGTERM gönderiyoruz (curl hızı saniyede 20 MB ile sınırlı, tam indirme yaklaşık 5 saniye sürer). Gül'ün dosyasına ne olur?
curl -s -o /dev/null --limit-rate 20M -w "export: %{http_code} %{size_download} bytes %{time_total}s curl-exit=%{exitcode}\n" http://127.0.0.1:3001/api/orders/export
kill -TERM <api-pid>
export: 200 21842287 bytes 1.040896s curl-exit=18
Status 200, ama 98 MB yerine 22 MB geldi; curl'ün 18 numaralı hatası "transfer yarıda kaldı" demek. SIGTERM için listener yoksa Node varsayılan davranışı uygular ve process anında ölür: açık response'lar, yazılmamış log satırları, SQL bağlantıları, hepsi olduğu yerde kalır. Status satırı çoktan gittiği için client 500 bile görmez; elinde yarım bir CSV (virgülle ayrılmış değerler) vardır. Bu yazıda API'yi production'a hazırlıyoruz: şemayla doğrulanan configuration, pino ile structured logging, liveness ve readiness endpoint'leri, graceful shutdown, container'da PID'si (Process ID; işletim sisteminin process'e verdiği numara) 1 olan process ve cluster ile replika arasındaki seçim. Kapanış örneklerini 3001 portunda çalıştırıyorum.
Configuration: Şemayla Doğrulanan Tek Object
13'e kadar process.env.PORT, process.env.HOST ve process.env.DB_CONNECTION_STRING kodun farklı yerlerinde okunuyordu. ASP.NET Core'da bunun karşılığı appsettings.json + environment variable'lar + Options pattern: configuration bir kez okunur, tipli bir object'e bağlanır ve ValidateOnStart ile açılışta doğrulanır. Node'da aynı işi 12'de kullandığımız zod ile yapıyoruz:
import { z } from "zod";
// Process'in environment'tan okuduğu her şey, açılışta bir kez doğrulanır (appsettings + Options pattern)
const environmentSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
HOST: z.string().min(1).default("127.0.0.1"),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
DB_CONNECTION_STRING: z.string().min(1),
SHUTDOWN_DRAIN_MS: z.coerce.number().int().min(0).max(60_000).default(0),
SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().min(1_000).max(120_000).default(10_000),
});
// process.env'i (ya da bir testin kendi object'ini) okur; her sorun tek seferde bildirilir, her restart'ta bir tane değil
export function loadConfig(environment: Record<string, string | undefined> = process.env): AppConfig {
const result = environmentSchema.safeParse(environment);
if (!result.success) {
throw new ConfigError(z.prettifyError(result.error));
}
const e = result.data;
return Object.freeze({
env: e.NODE_ENV,
host: e.HOST,
port: e.PORT,
logLevel: e.LOG_LEVEL,
db: Object.freeze({ connectionString: e.DB_CONNECTION_STRING }),
shutdown: Object.freeze({ drainMs: e.SHUTDOWN_DRAIN_MS, timeoutMs: e.SHUTDOWN_TIMEOUT_MS }),
});
}
Environment variable'lar her zaman string; z.coerce.number() "3001"'i sayıya çeviriyor, "abc"'yi reddediyor. Sonuç dondurulmuş (Object.freeze) ve AppConfig tipinde bir object; kodun geri kalanı process.env'e hiç dokunmuyor. Üç hatalı değerle açalım:
PORT=abc LOG_LEVEL=verbose node src/server.ts; echo "exit $?"
invalid configuration:
✖ Invalid input: expected number, received NaN
→ at PORT
✖ Invalid option: expected one of "fatal"|"error"|"warn"|"info"|"debug"|"trace"
→ at LOG_LEVEL
✖ Invalid input: expected string, received undefined
→ at DB_CONNECTION_STRING
exit 1
Üç sorun tek seferde, process daha hiçbir şey başlatmadan exit code 1 ile kapanıyor. Bu, bir deploy'da en ucuz hata türü: yeni pod hiç ready olmaz, eski sürüm çalışmaya devam eder. loadConfig parametre olarak bir object aldığı için testlerde process.env'i değiştirmeden çağrılabiliyor.
02'de --env-file'ı tanıtmıştık; o yalnız geliştirme içindir (npm run dev onu kullanıyor). npm start dosya okumuyor: production'da değerleri platform verir (container environment'ı, Kubernetes Secret'ı, bir secret store). Configuration'a iki değer daha dahil: HOST geliştirmede 127.0.0.1, container'da 0.0.0.0 olmalı, yoksa container dışından kimse bağlanamaz (10). NODE_ENV'i de buradan okuyup Express'e app.set("env", config.env) ile veriyoruz; 11'de gördüğümüz gibi Express'in hata sayfası ona göre stack trace gösterip göstermemeye karar veriyor, hızda ise ölçülebilir bir fark yaratmadı.
pino ile Structured Logging
07'den beri kendi küçük JSON logger'ımızı kullanıyorduk. Production'da yerine pino koyuyoruz: Node ekosisteminin en yaygın structured logger'larından biri, JSON satırlarını düşük maliyetle yazıyor ve log seviyesi, child logger, redaction gibi ihtiyaçları hazır getiriyor. Serilog'un Node'daki karşılığı gibi düşünebilirsin.
import { pino } from "pino";
import { currentRequestContext } from "../context/requestContext.ts";
// stdout'a kayıt başına bir JSON satırı; log'ları toplayıp taşımak platformun işi
export const logger = pino({
level: "info",
base: { service: "bookstore-api" },
timestamp: pino.stdTimeFunctions.isoTime,
// "level":30 yerine "level":"info"; log platformlarının çoğu bunu bekler
formatters: { level: (label) => ({ level: label }) },
// Her kayda eklenir: request id parametre olarak değil AsyncLocalStorage ile taşınır
mixin() {
const context = currentRequestContext();
return context ? { requestId: context.requestId } : {};
},
// Biri object'in tamamını log'lasa bile gizli değerler log'a ulaşmaz
redact: {
paths: ["config.db.connectionString", "*.password", "req.headers.authorization", "req.headers.cookie"],
censor: "[redacted]",
},
});
Çağrılar pino'nun sırasına geçiyor: önce alanlar, sonra mesaj (logger.info({ orderId: order.id, items: items.length }, "order placed")). mixin, 07'de kurduğumuz AsyncLocalStorage'dan request id'yi okuyup her satıra ekliyor; kodun hiçbir yerinde id parametre olarak dolaşmıyor. base'i kendimiz verdiğimiz için pino'nun varsayılan olarak eklediği pid ve hostname alanları da yok; container'da hostname pod adıdır ve işe yarar, o zaman base'e eklenir. Bir sipariş verip log'a bakalım:
{"level":"info","time":"2026-11-14T19:53:16.404Z","service":"bookstore-api","config":{"env":"development","host":"127.0.0.1","port":3000,"logLevel":"info","db":{"connectionString":"[redacted]"},"shutdown":{"drainMs":0,"timeoutMs":10000}},"msg":"configuration loaded"}
{"level":"info","time":"2026-11-14T19:53:16.537Z","service":"bookstore-api","url":"http://127.0.0.1:3000","env":"development","pid":92223,"msg":"Bookstore API listening"}
{"level":"info","time":"2026-11-14T19:53:18.404Z","service":"bookstore-api","requestId":"req-14a","orderId":"ORD-501005","items":1,"msg":"order placed"}
{"level":"info","time":"2026-11-14T19:53:18.408Z","service":"bookstore-api","requestId":"req-14a","method":"POST","path":"/api/orders","status":201,"durationMs":162,"msg":"request finished"}
{"level":"info","time":"2026-11-14T19:53:18.435Z","service":"bookstore-api","requestId":"req-14a","to":"ada@example.com","subject":"Order ORD-501005 received","msg":"e-mail sent"}
Üç şeye dikkat. Birincisi, açılışta configuration'ın tamamını log'luyoruz ve connection string [redacted] olarak görünüyor; redaction olmasaydı SQL Server şifresi log platformuna, oradan da log'lara erişimi olan herkese giderdi. İkincisi, "e-mail sent" satırı response gittikten 27 ms sonra yazıldı ama yine req-14a taşıyor: event listener'daki async iş de aynı request bağlamında. Üçüncüsü, sipariş numarası 13'teki test verisinin arkasından devam ediyor (ORD-501005). Hataları logger.error({ err }, "unhandled error") ile yazıyoruz; pino err anahtarındaki Error object'ini type, message ve stack alanlarına açıyor.
Geliştirirken JSON okumak yorucu; pino-pretty devDependency olarak kurulu ve çıktıyı okunur hâle getiriyor:
PORT=3001 node src/server.ts | npx pino-pretty
[22:55:45.849] INFO: request finished
service: "bookstore-api"
requestId: "req-14p"
method: "GET"
path: "/api/books/9780441172719"
status: 200
durationMs: 143
pino-pretty saati yerel saate çeviriyor; JSON'daki time ise UTC (saat dilimi farkı sıfır olan referans saat). Production'da pretty print kullanılmaz: log toplayıcı JSON bekler. Seviyeyi LOG_LEVEL belirliyor; server.ts configuration yüklendikten sonra logger.level = config.logLevel diyor. Her HTTP isteğini otomatik log'layan bir middleware isteyen pino-http'yi kullanabilir; biz 07'deki requestContext middleware'inin "request finished" satırını koruduk. Bu log'ları OpenTelemetry ile trace'lere bağlamak ve bir platforma taşımak Observability serisinin konusu.
Health Check: Liveness ve Readiness
Orchestrator'ın bir process hakkında sorduğu iki soru farklı: "Bu process yaşıyor mu, yoksa yeniden mi başlatayım?" (liveness) ve "Buna trafik gönderebilir miyim?" (readiness). Kubernetes serisindeki probe'lar tam olarak bu iki endpoint'i çağırır. 02'den beri olan /health liveness olarak kalıyor; yanına readiness ekliyoruz:
// GET /health/ready (readiness): buraya trafik gönderilmeli mi? 503, instance'ı load balancer'dan çıkarır
export function readiness(check: ReadinessCheck): RequestHandler {
return async (req, res) => {
const report = await check();
res.status(report.status === "ready" ? 200 : 503).json(report);
};
}
// Ready = shutdown başlamadı ve SQL Server bir saniye içinde cevap veriyor
export function createReadinessCheck(pool: sql.ConnectionPool, lifecycle: Lifecycle): ReadinessCheck {
return async (): Promise<ReadinessReport> => {
if (lifecycle.shuttingDown) {
return { status: "not-ready", checks: { shutdown: "in progress" } };
}
try {
const request = pool.request();
const timer = setTimeout(() => request.cancel(), 1_000);
await request.query("SELECT 1 AS ok").finally(() => clearTimeout(timer));
return { status: "ready", checks: { sql: "ok" } };
} catch (err) {
return { status: "not-ready", checks: { sql: (err as Error).message } };
}
};
}
Liveness bilerek hiçbir bağımlılığa bakmıyor. SQL Server yavaşladı diye liveness başarısız olursa orchestrator bütün API pod'larını aynı anda yeniden başlatır ve sorun büyür. Veritabanı sorunu readiness'i 503'e çeker; instance yaşamaya devam eder ama trafik almaz. Readiness kontrolünün kendisi de sınırlı: SELECT 1 bir saniyede dönmezse request iptal ediliyor. createApp'e giden AppServices'e readiness eklendi; 16'da testler bunu sahte bir fonksiyonla verecek.
Graceful Shutdown
ASP.NET Core'da host SIGTERM'i kendisi yakalar, Kestrel'e yeni bağlantı almamasını söyler, açık request'lerin bitmesini ShutdownTimeout kadar bekler ve IHostedService'leri sırayla durdurur. Node'da bu sırayı biz yazıyoruz:
// SIGTERM (orchestrator'lar, docker stop) ve SIGINT (Ctrl+C) aynı graceful shutdown'ı başlatır
export function registerGracefulShutdown({ server, pool, lifecycle, config }: ShutdownTargets): void {
const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
if (lifecycle.shuttingDown) {
return;
}
lifecycle.shuttingDown = true;
const started = performance.now();
logger.info({ signal }, "shutdown started: readiness now reports 503");
// 1) Load balancer'a 503'ü görüp trafiği kesmesi için zaman tanı
await delay(config.drainMs);
// 2) Yeni bağlantı yok; boştaki keep-alive bağlantıları hemen, meşgul olanlar response'tan sonra kapanır
const closed = new Promise<void>((resolve) => server.close(() => resolve()));
const forced = delay(config.timeoutMs, "timeout" as const, { ref: false });
if ((await Promise.race([closed.then(() => "closed" as const), forced])) === "timeout") {
logger.warn({ timeoutMs: config.timeoutMs }, "requests still running at the deadline, closing their connections");
server.closeAllConnections();
await closed;
}
// 3) Database pool'unu ancak şimdi kapat: artık hiçbir request ona ihtiyaç duyamaz
await pool.close();
logger.info({ durationMs: Math.round(performance.now() - started) }, "shutdown complete");
// Event loop'u artık hiçbir şey canlı tutmamalı; tutuyorsa sonsuza kadar asılı kalma
setTimeout(() => {
logger.error("event loop still busy after shutdown, forcing exit");
process.exit(1);
}, 2_000).unref();
};
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);
}
Sıra önemli. server.close() Node 19'dan beri boştaki keep-alive bağlantılarını da kapatıyor; bir request işleyen bağlantılar response bitince kapanıyor ve callback hepsi kapanınca çağrılıyor. Pool'u ondan önce kapatsaydık yarıda kalan export SQL bağlantısını kaybederdi. Bitmeyen bir request sonsuza kadar beklemesin diye SHUTDOWN_TIMEOUT_MS sonunda closeAllConnections() devreye giriyor; Node dokümanı da bunun close()'dan sonra çağrılmasını öneriyor. Sondaki unref()'lu timer kendi başına process'i canlı tutmaz; yalnız kapatmayı unuttuğumuz bir şey event loop'u canlı tutuyorsa 2 saniye sonra çalışır. Process'i normalde process.exit() değil, boşalan event loop sonlandırıyor; pino da bu sırada tamponunu yazıyor. Önce Tahmin Et'teki denemeyi tekrarlayalım:
new request after SIGTERM: http=000 curl-exit=7
export: 200 97734499 bytes 4.655518s curl-exit=0
{"level":"info","time":"2026-11-14T19:53:58.308Z","service":"bookstore-api","signal":"SIGTERM","msg":"shutdown started: readiness now reports 503"}
{"level":"info","time":"2026-11-14T19:54:01.807Z","service":"bookstore-api","requestId":"req-14-export","method":"GET","path":"/api/orders/export","status":200,"durationMs":4573,"msg":"request finished"}
{"level":"info","time":"2026-11-14T19:54:01.807Z","service":"bookstore-api","requestId":"req-14-export","gzip":false,"durationMs":4573,"msg":"order export finished"}
{"level":"info","time":"2026-11-14T19:54:01.889Z","service":"bookstore-api","durationMs":3582,"msg":"shutdown complete"}
Gül'ün dosyası tam geldi (97.734.499 byte; 13'teki ölçümden biraz büyük, çünkü aradan siparişler eklendi). SIGTERM'den 300 ms sonra açılan yeni bir bağlantı reddedildi (curl'ün 7 numaralı hatası: bağlanılamadı), export bitince process exit code 0 ile kapandı. Şimdi aynı denemeyi 2 saniyelik bir timeout ile yapalım (SHUTDOWN_TIMEOUT_MS=2000):
export: 200 66194028 bytes 3.345505s curl-exit=18
{"level":"warn","time":"2026-11-14T19:54:10.279Z","service":"bookstore-api","timeoutMs":2000,"msg":"requests still running at the deadline, closing their connections"}
{"level":"warn","time":"2026-11-14T19:54:10.288Z","service":"bookstore-api","requestId":"req-14-export","error":"ERR_STREAM_PREMATURE_CLOSE","durationMs":3350,"msg":"order export aborted"}
{"level":"info","time":"2026-11-14T19:54:10.371Z","service":"bookstore-api","durationMs":2219,"msg":"shutdown complete"}
Bu sefer export yarıda kesildi, ama kontrollü biçimde: log neyin neden kesildiğini söylüyor, 13'te yazdığımız request.cancel() SQL sorgusunu durdurdu ve pool temiz kapandı. Timeout'u platformun süresinden kısa tut: Kubernetes'in varsayılan terminationGracePeriodSeconds'ı 30 saniye, docker stop'un varsayılanı 10 saniye; bu süre dolunca process SIGKILL ile öldürülür ve hiçbir kod çalışmaz.
SHUTDOWN_DRAIN_MS neden var? Kubernetes bir pod'a SIGTERM gönderirken pod'u servis endpoint'lerinden de çıkarıyor, ama bu iki iş paralel ve endpoint güncellemesi bir süre gecikebiliyor. Hemen server.close() dersek o arada gelen bağlantılar reddedilir. Drain süresince server dinlemeye devam ediyor, readiness 503 dönüyor:
PORT=3001 SHUTDOWN_DRAIN_MS=2000 node src/server.ts
{"status":"ready","checks":{"sql":"ok"}} 200
{"status":"not-ready","checks":{"shutdown":"in progress"}} 503
/health during drain: 200
exit code 0
Geliştirmede 0, Kubernetes'te birkaç saniye mantıklı; değeri Kubernetes serisindeki probe aralıklarıyla birlikte seç. Windows'ta okurların çoğu API'yi Ctrl+C ile durduracak: bu SIGINT gönderir ve aynı kapanışı başlatır. Windows'ta gerçek bir SIGTERM yok; SIGTERM'ü container'lar ve Linux orchestrator'ları gönderir.
Container'da PID 1 ve npm start
Container'da ilk process PID 1 olur ve Linux çekirdeği PID 1'e özel davranır: bir sinyal için handler yoksa varsayılan eylemi (process'i sonlandırmak) uygulamaz. Sinyal handler'ı olmayan bir Node script'iyle denedim:
docker run -d --name nodeseries-pid1 node:26-alpine node -e "setInterval(() => {}, 1000)"
docker stop -t 10 nodeseries-pid1
| Container | docker stop süresi | Exit code |
|---|---|---|
Handler yok, --init yok | 10,09 s | 137 |
--init ile | 0,10 s | 143 |
Script'te SIGTERM handler'ı var | 0,08 s | 0 |
Handler'sız PID 1 SIGTERM'ü görmezden geldi; Docker 10 saniye bekleyip SIGKILL gönderdi (137 = 128 + 9). --init araya küçük bir init process'i koyuyor ve Node artık PID 1 değil (143 = 128 + 15). Bizim API'nin handler'ı var, yani PID 1 olması kapanışı bozmuyor; yine de zombie process'leri toplayan bir init (--init ya da tini) iyi bir alışkanlık. npm start'ı da denedim: npm 11, kendisine gönderilen SIGTERM'ü node child'ına iletti ve log'da kapanış satırları göründü. Yine de container'da CMD ["node", "src/server.ts"] tercih edilir: arada fazladan bir process olmaz, exit code doğrudan platforma gider. Dockerfile'ın kendisi ve image ayrıntıları Docker serisinde.
Ölçek: cluster mı, Replika mı?
Bir Node process'i JavaScript'i tek thread'de çalıştırır (01). Bir makinedeki bütün CPU çekirdeklerini kullanmanın Node'daki yerleşik yolu node:cluster: bir primary process aynı portu dinleyen birkaç worker process açar.
import cluster from "node:cluster";
import http from "node:http";
const workers = 4;
if (cluster.isPrimary) {
// Primary yalnız worker'ları yönetir; bağlantıları kabul edip dağıtır (Windows dışında round-robin)
console.log(`primary ${process.pid}, scheduling ${cluster.schedulingPolicy === cluster.SCHED_RR ? "round-robin" : "by the OS"}`);
for (let i = 0; i < workers; i++) {
cluster.fork();
}
let listening = 0;
cluster.on("listening", async () => {
if (++listening < workers) {
return;
}
const answeredBy = new Map<string, number>();
for (let i = 0; i < 8; i++) {
// agent: false -> her request için yeni bağlantı, böylece her request yeniden dağıtılır
const pid = await new Promise<string>((resolve) => http.get({ host: "127.0.0.1", port: 3002, agent: false }, (res) => res.setEncoding("utf8").on("data", resolve)));
answeredBy.set(pid, (answeredBy.get(pid) ?? 0) + 1);
}
console.log("requests per worker:", Object.fromEntries(answeredBy));
cluster.disconnect();
});
} else {
// Her worker kendi event loop'u, heap'i ve SQL pool'u olan ayrı bir process
http.createServer((req, res) => res.end(String(process.pid))).listen(3002, "127.0.0.1");
}
primary 93238, scheduling round-robin
requests per worker: { '93239': 2, '93240': 2, '93241': 2, '93242': 2 }
Sekiz request dört worker'a ikişer ikişer dağıldı. Node dokümanına göre round-robin Windows dışındaki bütün işletim sistemlerinde varsayılan; Windows'ta dağıtımı işletim sistemi yapıyor. Worker'lar bellek paylaşmıyor: 12'deki in-memory repository'ler gibi bellekte tutulan bir state her worker'da ayrı olurdu. SQL Server'a geçtiğimiz için bu sorun kalmadı, ama her worker kendi SQL pool'unu açıyor, yani 4 worker × 10 bağlantı.
Container ve Kubernetes dünyasında genel tercih başka: container başına tek Node process'i, ölçek için daha çok replika. Her replika ayrı izlenir, ayrı yeniden başlatılır, CPU ve bellek limiti ayrı konur; graceful shutdown, health check ve log tek process için yazıldığı gibi çalışır. cluster'ı ya da onu saran PM2'yi (Node için yaygın bir process manager), orchestrator'ı olmayan tek bir sunucuda (bir VM'de systemd altında gibi) düşün. PM2 bir process manager: yeniden başlatma, log dosyaları ve cluster modu sunuyor, ama container'da bu işleri zaten platform yapıyor.
Ölçeklemeden önce de ölçmek gerekir: 06'da yazdığımız /diagnostics/event-loop endpoint'i event loop gecikmesinin p50/p99 değerlerini veriyor. p99 yükseliyorsa önce bloklayan işi bul (06), sonra replika ekle. Bu metriği Prometheus ya da OpenTelemetry ile dışarı aktarmak Observability serisinde.
Üretimde Dikkat
Configuration açılışta doğrulansın ve hata varsa process başlamasın. Bir değeri ilk kullanıldığı yerde, belki saatler sonra fark etmek, en pahalı hata türü.
Log satırına secret girmesin. Redaction yolları configuration'ın ve header'ların şekliyle birlikte güncel kalmalı; yeni bir secret alanı eklerken redaction'ı da güncelle.
Liveness'i sade tut. Bağımlılık kontrolü readiness'te; liveness yalnız process'in cevap verip vermediğini söylesin.
Kapanış süreleri iç içe olsun: drain + SHUTDOWN_TIMEOUT_MS platformun süresinden (Kubernetes'te terminationGracePeriodSeconds) kısa olmalı. Aksi hâlde SIGKILL son log satırını bile yazdırmaz.
Bağlantı sayısını hesapla. Replika × worker × pool boyutu, SQL Server'ın kaldırabileceği bağlantı sayısının altında kalmalı.
Sık Düşülen Tuzaklar
console.log ile production log'u. Seviye yok, alan yok, redaction yok; stdout bir pipe'a bağlıyken ve log çok olduğunda yazma maliyeti de request'lerin önüne geçebilir.
SIGTERM'ü yakalamamak. Önce Tahmin Et'teki gibi: yarım dosya, yazılmamış log, kapanmamış bağlantılar.
Shutdown'da pool'u önce kapatmak. Hâlâ çalışan request'ler veritabanına ulaşamaz ve 500 döner; sıra her zaman HTTP server, sonra bağımlılıklar.
Handler'da process.exit()'i hemen çağırmak. Açık response'ları ve pino'nun tamponunu keser; event loop'un kendiliğinden boşalmasını bekle, yalnız son çare olarak zorla.
Container'da HOST=127.0.0.1. API container içinde çalışır ama port yönlendirmesi ona ulaşamaz; container'da 0.0.0.0.
Readiness'i liveness'e bağlamak. Veritabanı kesintisi bütün pod'ları yeniden başlatma döngüsüne sokar.
Küçük Bir Deneme
★☆☆ Log'lardaki requestId'nin yanına method ve path'i de mixin'den ekle. Hangi satırlarda bu artık tekrar oluyor, bunu nasıl çözersin?
★★☆ loadConfig'e NODE_ENV=production iken TrustServerCertificate=true içeren bir connection string'i reddeden bir kural ekle (superRefine). Hata mesajında connection string'in kendisinin görünmediğinden emin ol.
★★★ Shutdown'a "en uzun süren request" bilgisini ekle: requestContext middleware'inde açık request'leri bir Set'te tut, timeout dolduğunda hangi path'lerin ne kadar süredir açık olduğunu tek bir warn satırında log'la.
API artık ölçülebiliyor, düzgün kapanıyor ve hatalı configuration ile açılmıyor. Ama hâlâ herkes sipariş verebiliyor, herkes her siparişi okuyabiliyor ve bir script saniyede binlerce request gönderebilir. Bir sonraki yazıda Express'te güvenliğe bakıyoruz: helmet, CORS (Cross-Origin Resource Sharing; tarayıcının başka bir origin'e yapılan isteklere izin kuralları), rate limiting, JWT (JSON Web Token; imzalı, kimlik bilgisi taşıyan token) doğrulama ve input sınırları.