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:
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
preStopque 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 restartmientras 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
- Kubernetes — Terminación de Pods
— documentación oficial sobre el grace period de terminación, los hooks
preStopy la remoción de endpoints. - Spring Boot — Graceful Shutdown
— referencia oficial de Spring Boot sobre
server.shutdown=gracefulyspring.lifecycle.timeout-per-shutdown-phase.
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.
Recursos Relacionados
Patrón Circuit Breaker Half-Open
Cómo probar la recuperación de servicios con el estado half-open del circuit breaker. Cubre closed, open, half-open, requests de prueba y recuperación gradual.
PatternPatrón Bulkhead: Aísla Recursos y Limita el Radio de Impacto
Cómo aislar recursos por servicio para limitar el radio de impacto. Cubre aislamiento de thread pools, pools de conexión, bulkheads con semáforos y cuotas de recursos.
PatternPatrón Failover: Conmuta a Standby ante Fallo del Primario
Cómo desviar el tráfico a un sistema standby cuando el primario falla. Cubre active-passive, active-active, health checks, failover DNS y promoción de réplicas.
RecipeImplementar graceful shutdown y reinicios sin downtime
Cómo implementar graceful shutdown y reinicios sin downtime para servidores web, workers y contenedores