Node.js Uygulamalarında Graceful Shutdown ve Sinyal Yönetimi

Production ortamında Node.js uygulaması çalıştırıyorsun ve bir deployment geldi. Uygulamayı yeniden başlatman gerekiyor ama tam o sırada 50 kullanıcı aktif istek yapıyor. Ne yaparsın? Uygulamayı pat diye öldürürsen bu kullanıcıların istekleri yarım kalır, veritabanı bağlantıları kirli kapanır, dosya yazma işlemleri bozulur. İşte tam bu noktada graceful shutdown devreye giriyor. Bu yazıda Node.js uygulamalarında sinyal yönetimini, temiz kapanışı ve production ortamında karşılaşacağın gerçek senaryoları ele alacağız.

Sinyal Nedir ve Linux’ta Nasıl Çalışır

Linux’ta bir prosese mesaj göndermek için sinyaller kullanılır. Kernel veya başka bir proses, çalışan bir prosese çeşitli sinyaller iletebilir. Node.js uygulamaları da bu sinyalleri dinleyip uygun şekilde tepki verebilir.

Production’da en sık karşılaşacağın sinyaller şunlardır:

  • SIGTERM: Nazik sonlandırma isteği. Systemd, Kubernetes ve Docker’ın varsayılan olarak gönderdiği sinyal budur.
  • SIGINT: Terminalden Ctrl+C bastığında gönderilir.
  • SIGHUP: Terminal bağlantısı koptuğunda veya yapılandırmayı yeniden yüklemek için kullanılır.
  • SIGKILL: Anında öldürme. Bu sinyali uygulamanız yakalayamaz, kernel direkt devreye girer.
  • SIGUSR1: Kullanıcı tanımlı sinyal. Node.js bunu debugger başlatmak için kullanır.
  • SIGUSR2: İkinci kullanıcı tanımlı sinyal. Nodemon bu sinyali yeniden başlatma için kullanır.

Bir sinyalin prosese nasıl gönderildiğini görmek için:

# Proses ID'sini bul
ps aux | grep node

# SIGTERM gönder
kill -15 <PID>
# ya da
kill -SIGTERM <PID>

# SIGINT gönder
kill -2 <PID>

# Sinyalleri listele
kill -l

Kritik nokta şu: SIGTERM aldığında uygulamanın “tamam, kapanıyorum ama önce işlerimi tamamlayayım” demesi gerekir. SIGKILL aldığında ise zaten yapabileceğin hiçbir şey yoktur.

Node.js’te Sinyal Dinleme Temelleri

Node.js’te process objesi üzerinden sinyalleri dinleyebilirsin. En basit haliyle:

// signals-basic.js
process.on('SIGTERM', () => {
  console.log('SIGTERM alindi, uygulama kapaniyor...');
  process.exit(0);
});

process.on('SIGINT', () => {
  console.log('SIGINT alindi (Ctrl+C), uygulama kapaniyor...');
  process.exit(0);
});

// Uygulamanin calismaya devam etmesi icin bir sey yapmali
const http = require('http');
const server = http.createServer((req, res) => {
  res.end('Merhaba Dunyan');
});

server.listen(3000, () => {
  console.log('Sunucu 3000 portunda calisiyor, PID:', process.pid);
});

Bu temel yapı çalışır ama production için yeterli değildir. Gerçek dünyada aktif HTTP bağlantıları, veritabanı transaction’ları ve kuyruk işlemleri vardır. Bunların hepsini düzgün kapatman gerekir.

Gerçek Bir HTTP Sunucusunda Graceful Shutdown

Production uygulamalarında graceful shutdown’ın nasıl uygulanacağını gösteren kapsamlı bir örnek:

// graceful-server.js
const http = require('http');

const server = http.createServer((req, res) => {
  // Uzun suren bir islem simule ediyoruz
  setTimeout(() => {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ status: 'ok', pid: process.pid }));
  }, 2000);
});

let isShuttingDown = false;

// Graceful shutdown fonksiyonu
async function gracefulShutdown(signal) {
  if (isShuttingDown) {
    console.log('Zaten kapanma sureci baslamis, bekleniyor...');
    return;
  }

  isShuttingDown = true;
  console.log(`${signal} sinyali alindi. Graceful shutdown basliyor...`);

  // Yeni baglantilari reddet
  server.close(async (err) => {
    if (err) {
      console.error('Sunucu kapatilirken hata:', err);
      process.exit(1);
    }

    console.log('HTTP sunucusu kapatildi.');

    try {
      // Veritabani baglantisini kapat
      await closeDatabase();
      console.log('Veritabani baglantisi kapatildi.');

      // Redis baglantisini kapat
      await closeRedis();
      console.log('Redis baglantisi kapatildi.');

      console.log('Temiz kapanis tamamlandi.');
      process.exit(0);
    } catch (cleanupError) {
      console.error('Temizlik sirasinda hata:', cleanupError);
      process.exit(1);
    }
  });

  // Zorla kapanma icin timeout (30 saniye)
  setTimeout(() => {
    console.error('Graceful shutdown zaman asimina ugradi, zorla kapatiliyor.');
    process.exit(1);
  }, 30000);
}

// Sinyal dinleyicileri
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

server.listen(3000, () => {
  console.log(`Sunucu baslatildi. PID: ${process.pid}`);
});

Burada dikkat etmen gereken birkaç şey var. isShuttingDown flag’i, aynı anda birden fazla sinyal geldiğinde çift kapanma işlemini önler. server.close() yeni bağlantı kabul etmeyi durdurur ama mevcut bağlantılar tamamlanana kadar bekler. 30 saniyelik timeout ise sonsuz beklemeyi engeller.

Express.js ile Graceful Shutdown

Gerçek dünya projelerinin büyük çoğunluğu Express kullanıyor. İşte production’a hazır bir Express yapılandırması:

// express-graceful.js
const express = require('express');
const app = express();

app.use(express.json());

// Middleware: Kapanma sirasinda yeni istekleri reddet
app.use((req, res, next) => {
  if (isShuttingDown) {
    res.set('Connection', 'close');
    return res.status(503).json({
      error: 'Sunucu bakimda, lutfen biraz sonra tekrar deneyin.'
    });
  }
  next();
});

app.get('/health', (req, res) => {
  res.json({
    status: isShuttingDown ? 'shutting_down' : 'healthy',
    pid: process.pid,
    uptime: process.uptime()
  });
});

app.get('/api/data', async (req, res) => {
  try {
    const data = await fetchDataFromDatabase();
    res.json(data);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

let isShuttingDown = false;
const server = app.listen(3000);

async function shutdown(signal) {
  if (isShuttingDown) return;
  isShuttingDown = true;

  console.log(`[${new Date().toISOString()}] ${signal} alindi`);

  const shutdownTimeout = setTimeout(() => {
    console.error('Shutdown timeout! Zorla cikiliyor.');
    process.exit(1);
  }, 30000);

  shutdownTimeout.unref(); // Bu timeout Node'un kapanmasini engellemesin

  server.close(async () => {
    try {
      await Promise.all([
        db.end(),           // PostgreSQL pool
        redisClient.quit(), // Redis
        messageQueue.close() // RabbitMQ veya benzeri
      ]);
      clearTimeout(shutdownTimeout);
      process.exit(0);
    } catch (err) {
      console.error('Temizlik hatasi:', err);
      process.exit(1);
    }
  });
}

['SIGTERM', 'SIGINT', 'SIGHUP'].forEach(signal => {
  process.on(signal, () => shutdown(signal));
});

Keep-Alive Bağlantıları ile Başa Çıkmak

HTTP/1.1 keep-alive bağlantıları graceful shutdown’ı zorlaştırır. server.close() çağrıldığında aktif keep-alive bağlantıları hemen kapanmaz ve bu süreç uzayabilir. Bunu çözmek için bağlantıları takip etmek gerekir:

// connection-tracker.js
const http = require('http');

const connections = new Map();
let connectionId = 0;

const server = http.createServer((req, res) => {
  res.end('OKn');
});

server.on('connection', (socket) => {
  const id = connectionId++;
  connections.set(id, socket);

  socket.on('close', () => {
    connections.delete(id);
  });
});

function destroyConnections() {
  console.log(`${connections.size} baglanti kapatiliyor...`);
  for (const [id, socket] of connections) {
    socket.destroy();
    connections.delete(id);
  }
}

async function gracefulShutdown() {
  console.log('Graceful shutdown basliyor...');

  // Once server.close() ile yeni baglantilari engelle
  server.close(() => {
    console.log('Sunucu kapatildi.');
    process.exit(0);
  });

  // Keep-alive baglantilari icin 10 saniye bekle, sonra zorla kes
  setTimeout(() => {
    console.log('Keep-alive baglantilari zorla kesiliyor...');
    destroyConnections();
  }, 10000);
}

process.on('SIGTERM', gracefulShutdown);
server.listen(3000);

Kubernetes ve Docker Ortamında Sinyal Yönetimi

Kubernetes ve Docker’da bir container durdurulduğunda şu süreç yaşanır: önce SIGTERM gönderilir, ardından terminationGracePeriodSeconds (varsayılan 30 saniye) beklenir, süre dolunca SIGKILL gelir. Bunu doğru yönetmek çok önemlidir.

Docker’da PID 1 problemi yaygın bir tuzaktır. node app.js ile başlatılan uygulama PID 1 olur ve bazı Linux sinyalleri PID 1 tarafından farklı işlenir. Bunun için iki çözüm vardır:

# Dockerfile - doğru yöntem 1: exec form kullan
CMD ["node", "app.js"]

# Dockerfile - doğru yöntem 2: tini kullan
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "app.js"]

# docker-compose ile
services:
  app:
    image: myapp:latest
    init: true  # Docker'in dahili init'ini kullan
    stop_grace_period: 30s

Kubernetes için deployment yapılandırması:

# kubernetes deployment ornegi (yaml formatinda gosteriyoruz)
# terminationGracePeriodSeconds: 60
# lifecycle preStop hook ekle:
#   exec:
#     command: ["/bin/sh", "-c", "sleep 5"]
# Bu sayede Kubernetes endpoint'i guncelleme sansı bulur

# Konteyner durdurma sirasini test etmek icin:
kubectl delete pod <pod-name> --grace-period=0 --force
# NOT: Bu production'da kullanma, sadece test icin

# Grace period ile normal kapanma:
kubectl delete pod <pod-name>

# Deployment guncelleme sirasinda graceful shutdown:
kubectl rollout restart deployment/myapp

Hata Yönetimi ve Uncaught Exception’lar

Graceful shutdown sadece sinyal yönetimi değildir. Yakalanmamış hatalar da uygulamayı düzgünce kapatmalıdır:

// error-handling.js

// Yakalanmamis exception
process.on('uncaughtException', (error) => {
  console.error('Yakalanmamis exception:', error);
  console.error('Stack:', error.stack);

  // Burada loglama yap, alert gonder
  // Ama dikkat: Bu noktada uygulama tutarsiz durumda olabilir
  // Temiz kapanma yapmaya calis ama garanti yok

  gracefulShutdown('uncaughtException').finally(() => {
    process.exit(1);
  });
});

// Yakalanmamis Promise reddi
process.on('unhandledRejection', (reason, promise) => {
  console.error('Yakalanmamis Promise reddi:', promise);
  console.error('Sebep:', reason);

  // Node.js 15+ surumlerinde bu otomatik olarak uygulamayi cokertir
  // Onceki surumlerde sadece uyari verirdi
  // Explicit olarak handle etmek en iyisi:
  gracefulShutdown('unhandledRejection').finally(() => {
    process.exit(1);
  });
});

// Memory leak uyarisi
process.on('warning', (warning) => {
  console.warn('Node.js uyarisi:', warning.name, warning.message);
  if (warning.name === 'MaxListenersExceededWarning') {
    console.error('Olasilikla event listener leak var!');
  }
});

PM2 ile Graceful Shutdown

PM2 ile çalışıyorsan, PM2 kendi sinyal mekanizmasını kullanır:

# pm2 ecosystem.config.js ornegi olustur
cat > ecosystem.config.js << 'EOF'
module.exports = {
  apps: [{
    name: 'myapp',
    script: 'app.js',
    instances: 'max',
    exec_mode: 'cluster',
    kill_timeout: 5000,     // SIGKILL gondermeden once bekle (ms)
    wait_ready: true,        // process.send('ready') bekle
    listen_timeout: 10000,   // ready sinyali icin max bekleme
    shutdown_with_message: false
  }]
};
EOF

# Uygulamayi baslat
pm2 start ecosystem.config.js

# Graceful reload (sifir downtime)
pm2 reload myapp

# Graceful stop
pm2 stop myapp

# Proses durumunu kontrol et
pm2 list
pm2 logs myapp --lines 50

PM2 cluster modu ile çalışırken uygulamanın hazır olduğunu bildirmesi önemlidir:

// pm2-ready.js
const server = app.listen(3000, () => {
  console.log('Sunucu hazir');

  // PM2'ye hazir oldugunu bildir
  if (process.send) {
    process.send('ready');
  }
});

// PM2'nin gonderdigi shutdown mesajini dinle
process.on('message', (msg) => {
  if (msg === 'shutdown') {
    console.log('PM2 shutdown mesaji alindi');
    gracefulShutdown('PM2_SHUTDOWN');
  }
});

Gerçek Dünya Senaryosu: Veritabanı Transaction’ları

En kritik senaryo, shutdown sırasında aktif veritabanı transaction’ının bulunmasıdır:

// db-transaction-shutdown.js
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

const activeTransactions = new Set();

async function withTransaction(callback) {
  const client = await pool.connect();
  const transactionId = Date.now() + Math.random();

  try {
    await client.query('BEGIN');
    activeTransactions.add(transactionId);

    const result = await callback(client);

    await client.query('COMMIT');
    return result;
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    activeTransactions.delete(transactionId);
    client.release();
  }
}

async function waitForActiveTransactions(timeoutMs = 10000) {
  const start = Date.now();

  while (activeTransactions.size > 0) {
    if (Date.now() - start > timeoutMs) {
      console.warn(`${activeTransactions.size} transaction hala aktif, devam ediliyor...`);
      break;
    }
    console.log(`${activeTransactions.size} aktif transaction bekleniyor...`);
    await new Promise(resolve => setTimeout(resolve, 500));
  }
}

async function gracefulShutdown() {
  console.log('Shutdown basliyor...');
  isShuttingDown = true;

  // Aktif transaction'larin bitmesini bekle
  await waitForActiveTransactions();

  // Veritabani pool'unu kapat
  await pool.end();
  console.log('Veritabani pool kapatildi.');

  process.exit(0);
}

Monitoring ve Logging

Graceful shutdown sürecini izlemek production’da hayat kurtarır:

# Systemd ile shutdown loglarini izle
journalctl -u myapp -f

# Son 100 satir log
journalctl -u myapp -n 100

# Belirli bir zaman araliginda
journalctl -u myapp --since "2024-01-15 10:00:00" --until "2024-01-15 11:00:00"

# Prosesin ne kadar surdugunu gormek icin
systemctl status myapp

# Systemd service dosyasinda graceful shutdown ayarlari
cat > /etc/systemd/system/myapp.service << 'EOF'
[Unit]
Description=My Node.js Application
After=network.target

[Service]
Type=simple
User=nodejs
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/node app.js
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
KillMode=mixed
KillSignal=SIGTERM

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable myapp
systemctl start myapp

TimeoutStopSec=30 ile systemd’ye 30 saniye beklemesini söylüyoruz. KillMode=mixed ise SIGTERM’den sonra SIGKILL gönderir.

Sonuç

Graceful shutdown, “isteğe bağlı güzel bir özellik” değil, production uygulamalarının olmazsa olmazıdır. Özellikle Kubernetes, Docker Swarm veya PM2 cluster modu kullandığında, deployment sırasında yaşanan her düzgün olmayan kapanış kullanıcıya hata olarak yansır.

Özetlemek gerekirse dikkat etmen gereken başlıca noktalar şunlardır:

  • Her zaman SIGTERM dinle: Systemd, Kubernetes ve Docker bu sinyali gönderir.
  • Timeout koy: Graceful shutdown sonsuza kadar beklememeli, 30 saniye genellikle yeterlidir.
  • Keep-alive bağlantılarını takip et: server.close() tek başına yetmeyebilir.
  • Uncaught exception’ları handle et: Uygulama çökse bile temiz kapanmaya çalış.
  • PID 1 problemine dikkat et: Docker’da tini kullan veya exec form ile başlat.
  • Aktif işlemleri bekle: Transaction’lar, dosya yazmaları ve mesaj işlemleri tamamlanmalı.

Uyguladıktan sonra mutlaka test et. kill -SIGTERM ile uygulamana sinyal gönder, logları izle ve davranışın beklediğin gibi olduğunu doğrula. Production’da sürpriz yaşamamanın en iyi yolu, bu senaryoları önceden test etmektir.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir