StackPractices
intermediate Por Mathias Paulenko

Patrón Graceful Shutdown: Drenar Peticiones antes de Salir

Cómo drenar las peticiones en curso antes de que el proceso salga: señales, health check, draining de conexiones y timeouts.

Visión General

Matá un servidor a mitad de una request y el cliente se come un 502 mientras la base de datos queda con trabajo a medio commitear. El graceful shutdown ordena la secuencia de salida: al recibir SIGTERM el proceso deja de aceptar trabajo nuevo, termina las peticiones que ya están en curso, cierra las conexiones de base de datos, vacía los buffers y recién ahí sale.

Kubernetes hace concretos los tiempos. Al borrar un pod se ejecuta primero el hook preStop, después llega el SIGTERM — y el reloj del grace period (30 segundos por defecto, vía terminationGracePeriodSeconds) ya está corriendo. Lo que siga vivo cuando se agota el presupuesto recibe SIGKILL a mitad de la respuesta.

Cuándo Usarlo

  • Cualquier servidor de larga duración (HTTP, gRPC, WebSocket)
  • Deployments de Kubernetes donde los pods se terminan con frecuencia
  • Aplicaciones con workers en background que procesan jobs
  • Servicios con conexiones de base de datos o file handles abiertos
  • Consumers de mensajes que necesitan terminar de procesar el mensaje actual

Cuándo NO Usarlo

  • Herramientas CLI y scripts que salen apenas terminan su trabajo
  • Funciones serverless, donde la plataforma maneja el ciclo de vida
  • Jobs batch pensados para ser matados y reiniciados

Solución

Graceful shutdown en Python con manejo de señales

# shutdown/graceful.py — Graceful shutdown para servidores HTTP en Python
import signal
import threading
import time
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler

class GracefulHTTPServer(HTTPServer):
    """Servidor HTTP con soporte para graceful shutdown."""

    def __init__(self, addr, handler):
        super().__init__(addr, handler)
        self._shutting_down = False
        self._active_requests = 0
        self._lock = threading.Lock()
        self._shutdown_event = threading.Event()

    @property
    def is_shutting_down(self):
        return self._shutting_down

    def request_started(self):
        with self._lock:
            self._active_requests += 1

    def request_finished(self):
        with self._lock:
            self._active_requests -= 1
            if self._shutting_down and self._active_requests == 0:
                self._shutdown_event.set()

    def graceful_shutdown(self, timeout=30):
        """Deja de aceptar requests nuevas y espera a que terminen las activas."""
        print("Starting graceful shutdown...")
        self._shutting_down = True

        # Deja de aceptar conexiones nuevas
        self.socket.close()

        # Espera a que las requests activas terminen
        with self._lock:
            if self._active_requests == 0:
                print("No active requests, shutting down immediately")
                return

            print(f"Waiting for {self._active_requests} active requests...")

        if self._shutdown_event.wait(timeout=timeout):
            print("All requests completed, shutting down")
        else:
            print(f"Timeout after {timeout}s, forcing shutdown with "
                  f"{self._active_requests} requests still active")

        # Limpieza de recursos
        self._cleanup()

    def _cleanup(self):
        """Cierra conexiones de base de datos, vacía buffers, etc."""
        print("Cleaning up resources...")
        # Close database connections
        # Flush log buffers
        # Close file handles
        print("Cleanup complete")


class GracefulRequestHandler(BaseHTTPRequestHandler):
    """Handler que trackea las requests activas para el graceful shutdown."""

    def handle_one_request(self):
        self.server.request_started()
        try:
            super().handle_one_request()
        finally:
            self.server.request_finished()

    def do_GET(self):
        if self.server.is_shutting_down:
            self.send_response(503)
            self.send_header("Connection", "close")
            self.end_headers()
            self.wfile.write(b'{"error": "Server shutting down"}')
            return

        # Simula trabajo
        time.sleep(0.5)
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b'{"status": "ok"}')


# Configura los handlers de señal
server = GracefulHTTPServer(("0.0.0.0", 8080), GracefulRequestHandler)

def signal_handler(signum, frame):
    server.graceful_shutdown(timeout=30)
    sys.exit(0)

signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)

print("Server running on port 8080")
server.serve_forever()

Graceful shutdown en Node.js

// shutdown/graceful.js — Graceful shutdown para Node.js Express
const express = require("express");
const http = require("http");

const app = express();
const server = http.createServer(app);

let isShuttingDown = false;
let activeRequests = 0;

// Trackea las requests activas
app.use((req, res, next) => {
    if (isShuttingDown) {
        res.setHeader("Connection", "close");
        return res.status(503).json({ error: "Server shutting down" });
    }
    activeRequests++;
    res.on("finish", () => activeRequests--);
    next();
});

app.get("/health", (req, res) => {
    if (isShuttingDown) {
        return res.status(503).json({ status: "shutting_down" });
    }
    res.json({ status: "healthy" });
});

app.get("/api/data", (req, res) => {
    setTimeout(() => {
        res.json({ data: "response", activeRequests });
    }, 500);
});

// Graceful shutdown
function gracefulShutdown(signal) {
    console.log(`Received ${signal}, starting graceful shutdown...`);
    isShuttingDown = true;

    // Deja de aceptar conexiones nuevas
    server.close(() => {
        console.log("HTTP server closed");
    });

    // Espera a las requests activas
    const checkInterval = setInterval(() => {
        if (activeRequests === 0) {
            clearInterval(checkInterval);
            console.log("All requests completed, exiting");
            cleanup();
        }
    }, 100);

    // Salida forzada después del timeout
    setTimeout(() => {
        console.log(`Timeout: ${activeRequests} requests still active, forcing exit`);
        cleanup();
    }, 30000);
}

function cleanup() {
    // Close database connections
    // Flush log buffers
    // Close message queue connections
    console.log("Cleanup complete, exiting");
    process.exit(0);
}

process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));

server.listen(8080, () => console.log("Server running on port 8080"));

Graceful shutdown en Java con Spring

// ShutdownConfig.java — Graceful shutdown en Spring Boot
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.boot.web.server.Shutdown;

@Configuration
public class GracefulShutdownConfig
    implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {

    @Override
    public void customize(TomcatServletWebServerFactory factory) {
        factory.setShutdown(Shutdown.GRACEFUL);
        // Spring espera a que las requests activas terminen
    }
}

// application.properties
// server.shutdown=graceful
// spring.lifecycle.timeout-per-shutdown-phase=30s

// Hook de shutdown propio para limpiar recursos
import org.springframework.context.SmartLifecycle;
import org.springframework.stereotype.Component;

@Component
public class ResourceCleanupHook implements SmartLifecycle {

    private volatile boolean running = false;

    @Override
    public void start() {
        running = true;
    }

    @Override
    public void stop() {
        running = false;
        // Cierra conexiones de base de datos
        closeDatabaseConnections();
        // Vacía los buffers de logs
        flushLogBuffers();
        // Cierra las conexiones de consumers de mensajes
        closeMessageConsumers();
        System.out.println("Resource cleanup complete");
    }

    @Override
    public boolean isRunning() {
        return running;
    }

    @Override
    public int getPhase() {
        // Corre la limpieza al inicio de la secuencia de shutdown
        return Integer.MIN_VALUE + 100;
    }

    private void closeDatabaseConnections() {
        System.out.println("Closing database connections...");
    }

    private void flushLogBuffers() {
        System.out.println("Flushing log buffers...");
    }

    private void closeMessageConsumers() {
        System.out.println("Closing message consumers...");
    }
}

Graceful shutdown en Kubernetes

# k8s-graceful-shutdown.yaml — Pod de Kubernetes con graceful shutdown
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api-server
  template:
    metadata:
      labels:
        app: api-server
    spec:
      terminationGracePeriodSeconds: 45  # Le da a la app 45s para drenar
      containers:
        - name: api
          image: shop/api-server:latest
          ports:
            - containerPort: 8080
          lifecycle:
            preStop:
              exec:
                # Pausa para que el load balancer quite este pod
                # antes de que llegue el SIGTERM
                command: ["sh", "-c", "sleep 5"]
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          env:
            - name: SHUTDOWN_TIMEOUT
              value: "30"
---
# Service con baja correcta del health check
apiVersion: v1
kind: Service
metadata:
  name: api-server
spec:
  selector:
    app: api-server
  ports:
    - port: 80
      targetPort: 8080
  type: ClusterIP

Graceful shutdown de un worker en background en Python

# shutdown/worker.py — Graceful shutdown para workers en background
import signal
import threading
import time
import queue

class GracefulWorker:
    """Worker en background que termina el job actual antes de salir."""

    def __init__(self, job_queue, max_workers=4):
        self._queue = job_queue
        self._max_workers = max_workers
        self._workers = []
        self._active_jobs = 0
        self._lock = threading.Lock()
        self._shutdown = threading.Event()
        self._jobs_done = threading.Event()

    def start(self):
        for i in range(self._max_workers):
            t = threading.Thread(target=self._worker_loop, args=(i,), daemon=True)
            t.start()
            self._workers.append(t)

    def _worker_loop(self, worker_id):
        while not self._shutdown.is_set():
            try:
                job = self._queue.get(timeout=1)
            except queue.Empty:
                continue

            with self._lock:
                self._active_jobs += 1

            try:
                self._process_job(job, worker_id)
            except Exception as e:
                print(f"Worker {worker_id} job failed: {e}")
            finally:
                with self._lock:
                    self._active_jobs -= 1
                    if self._shutdown.is_set() and self._active_jobs == 0:
                        self._jobs_done.set()
                self._queue.task_done()

    def _process_job(self, job, worker_id):
        print(f"Worker {worker_id} processing job {job['id']}")
        time.sleep(2)  # Simula trabajo
        print(f"Worker {worker_id} completed job {job['id']}")

    def shutdown(self, timeout=30):
        """Deja de aceptar jobs nuevos y termina los activos."""
        print("Worker shutdown initiated...")
        self._shutdown.set()

        with self._lock:
            if self._active_jobs == 0:
                print("No active jobs, exiting immediately")
                return

        if self._jobs_done.wait(timeout=timeout):
            print("All jobs completed, exiting")
        else:
            print(f"Timeout: {self._active_jobs} jobs still active")

# Uso
job_queue = queue.Queue()
worker = GracefulWorker(job_queue, max_workers=4)
worker.start()

# Encola jobs
for i in range(10):
    job_queue.put({"id": i, "data": f"job-{i}"})

# Maneja el shutdown
def handle_signal(signum, frame):
    worker.shutdown(timeout=30)

signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)

Baja del load balancer antes del shutdown

# shutdown/deregister.py — Baja del load balancer antes de drenar
import requests
import time
import signal
import sys

class GracefulService:
    """Se da de baja del load balancer y después drena las requests."""

    def __init__(self, lb_registry_url, health_port=8080):
        self.lb_url = lb_registry_url
        self.health_port = health_port
        self._healthy = True
        self._active_requests = 0

    def deregister(self):
        """Le avisa al load balancer que deje de mandar tráfico."""
        try:
            resp = requests.post(
                f"{self.lb_url}/deregister",
                json={"port": self.health_port},
                timeout=5
            )
            print(f"Deregistered from load balancer: {resp.status_code}")
        except Exception as e:
            print(f"Deregistration failed: {e}")

        # Espera a que el LB propague la baja
        time.sleep(5)

    def health_check(self):
        """Devuelve 200 si está sano, 503 si está saliendo."""
        if not self._healthy:
            return 503
        return 200

    def shutdown(self, timeout=30):
        # Paso 1: marcarse como unhealthy (el LB deja de mandar tráfico nuevo)
        self._healthy = False
        print("Marked as unhealthy")

        # Paso 2: baja del load balancer
        self.deregister()

        # Paso 3: esperar a las requests activas
        start = time.time()
        while self._active_requests > 0:
            if time.time() - start > timeout:
                print(f"Timeout with {self._active_requests} requests active")
                break
            time.sleep(0.5)

        # Paso 4: limpieza y salida
        print("Shutting down")
        sys.exit(0)

Cómo Funciona

El graceful shutdown es una secuencia ordenada, y el orden importa más que el código:

flowchart diagram: Pod marcado para borrado

Orden de las fases. Marcate como unhealthy primero para que el load balancer y kube-proxy dejen de enrutar tráfico, después cerrá el listener, después drená, y al final limpiá los recursos. Invertir el orden — cerrar conexiones antes de que el LB se entere — es como se genera una ráfaga de 502s en cada deploy.

El presupuesto de drenado. terminationGracePeriodSeconds debería superar tu request real más lenta más el delay de propagación del LB. Si tu p99 es de 20s y el LB tarda ~5s en quitarte, un presupuesto de 30s queda justo; 45s le da al drenado margen para terminar.

Trade-offs. Un grace period más largo da shutdowns más seguros pero rollouts y scale-downs más lentos. Saltearse el sleep del preStop ahorra segundos pero deja al LB enrutando requests a un pod que ya está drenando. Drenar para siempre tampoco es opción — una query colgada o un stream SSE abierto tiene que chocar con un deadline en algún momento.

Para una guía de implementación completa con ejemplos en Flask, Go y reinicios sin downtime, mirá la receta de graceful shutdown. Si la lógica de drenado vive fuera del proceso de tu app — un contenedor compañero que maneja la baja y la limpieza — eso es el patrón sidecar.

Código companion: el repo companion de graceful-shutdown-pattern tiene todos los ejemplos de esta página como archivos ejecutables, listos para probar con python o node.

Variantes

Shutdown por fases

# shutdown/phased.py — Shutdown multi-fase con limpieza ordenada
class PhasedShutdown:
    """Sale por fases: dejar de aceptar, drenar, limpiar, salir.
    Cada fase tiene su propio timeout."""

    def __init__(self):
        self._phases = [
            ("stop_accepting", self._stop_accepting, 5),
            ("drain_requests", self._drain_requests, 20),
            ("close_connections", self._close_connections, 5),
            ("flush_buffers", self._flush_buffers, 3),
            ("cleanup", self._cleanup, 2),
        ]

    def _stop_accepting(self):
        print("Phase 1: Stop accepting new requests")

    def _drain_requests(self):
        print("Phase 2: Drain active requests")

    def _close_connections(self):
        print("Phase 3: Close database and MQ connections")

    def _flush_buffers(self):
        print("Phase 4: Flush log and metric buffers")

    def _cleanup(self):
        print("Phase 5: Final cleanup")

    def execute(self):
        for name, handler, timeout in self._phases:
            print(f"Starting phase: {name} (timeout: {timeout}s)")
            handler()
        print("All phases complete, exiting")

Graceful shutdown en WebSocket

// shutdown/websocket.js — Graceful shutdown para un servidor WebSocket
const { WebSocketServer } = require("ws");

const wss = new WebSocketServer({ port: 8080 });
let isShuttingDown = false;

wss.on("connection", (ws) => {
    if (isShuttingDown) {
        ws.close(1001, "Server shutting down");
        return;
    }

    ws.on("message", (data) => {
        ws.send(`Echo: ${data}`);
    });
});

function gracefulShutdown() {
    console.log("WebSocket graceful shutdown...");
    isShuttingDown = true;

    // Cierra todas las conexiones con código "going away"
    wss.clients.forEach((ws) => {
        ws.close(1001, "Server shutting down");
    });

    // Cierra el servidor cuando todos los clientes se desconectan
    setTimeout(() => {
        wss.close(() => {
            console.log("WebSocket server closed");
            process.exit(0);
        });
    }, 5000);
}

process.on("SIGTERM", gracefulShutdown);

Mejores Prácticas

  • Marcá el health check como fallido antes de cerrar el listener — devolvé 503 para que el LB te saque de la rotación mientras todavía tenés conexiones que drenar
  • En Kubernetes, agregá un hook preStop que duerma 5–10s — kube-proxy y los LB externos tardan unos segundos en propagar la baja del endpoint
  • Configurá terminationGracePeriodSeconds ~10–15s por encima del timeout interno de drenado de tu app, para que la app decida cuándo salir y no el kubelet
  • Limitá el drenado — una request colgada o un WebSocket abierto tiene que chocar con un deadline, nunca bloquear la salida para siempre
  • Liberá los recursos en orden de dependencia: pará los consumers, terminá los jobs en curso, cerrá los pools de base de datos, vaciá logs y métricas al final
  • Re-armá SIGTERM en el handler para que una segunda señal fuerce la salida de un drenado colgado
  • Probá bajo carga — corré kubectl rollout restart mientras k6 o hey le pegan al servicio y confirmá que no haya 5xx

Errores Comunes

  • Sin graceful shutdown en absoluto: el proceso muere a mitad de la request y cada llamada en curso se convierte en un 502.
  • Falta el delay del preStop: kube-proxy y los LB externos siguen enrutando tráfico durante unos segundos después de que el pod empieza a terminar.
  • Drenado sin límite: una request colgada o una conexión SSE/WebSocket persistente traba todo el shutdown hasta el SIGKILL.
  • Conexiones filtradas: saltear la limpieza del pool deja sockets idle; los deploys repetidos agotan max_connections.
  • El health check sigue en verde: el LB sigue mandando tráfico a una instancia que ya está saliendo.

See Also

Preguntas frecuentes

¿Qué es graceful shutdown?

La secuencia de salida en la que un servidor deja de aceptar requests nuevas, termina las que ya están corriendo, libera sus recursos y recién ahí sale — para que los clientes nunca vean 502s por un deploy.

¿Cuánto debería durar el graceful shutdown?

Presupuestá para tu request real más lenta más un margen — típicamente 15–30 segundos para APIs HTTP. Configurá terminationGracePeriodSeconds al menos 5s por encima de tu timeout interno de drenado, y seguí sirviendo el trabajo en curso mientras devolvés 503 a las requests nuevas.

¿Qué es un hook preStop en Kubernetes?

Un hook de ciclo de vida que el kubelet ejecuta dentro del contenedor justo antes de mandar el SIGTERM. El patrón habitual es sleep 5-10, que le da a kube-proxy y a los load balancers externos tiempo de sacar el pod de sus endpoints antes de que empiece el drenado.

¿Debería devolver 503 durante el shutdown?

Sí — marcá los health checks como 503 apenas empieza el shutdown para que el LB deje de enrutar tráfico nuevo. Seguí sirviendo las requests en curso hasta que terminen o se cumpla el timeout de drenado.

¿Qué pasa si el graceful shutdown supera el grace period de Kubernetes?

Llega SIGKILL — sin limpieza, los sockets mueren a mitad de la respuesta y las transacciones abiertas y los buffers sin vaciar quedan abandonados. Es exactamente el modo de fallo que este patrón existe para evitar, así que dimensioná terminationGracePeriodSeconds por encima de tu peor tiempo de drenado.