¿Cómo se implementa un apagado ordenado (graceful shutdown) que drena las solicitudes en curso en lugar de descartarlas?
Un proceso que muere en el instante en que recibe SIGTERM convierte cada despliegue continuo en una pequeña ola de conexiones perdidas y 502 para quien estaba a mitad de una solicitud.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo se implementa un apagado ordenado (graceful shutdown) que drena las solicitudes en curso en lugar de descartarlas?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Por qué esto importa en cada despliegue
Cada despliegue continuo, cada reducción de un autoscaler y cada desalojo de pod terminan igual para una instancia individual: el orquestador envía una señal de terminación (SIGTERM en cualquier sistema POSIX, que es lo que usan Kubernetes, ECS y systemd) y le da al proceso un período de gracia antes de seguir con un SIGKILL incondicional e inmanejable. Kubernetes fija ese período de gracia por defecto en 30 segundos vía terminationGracePeriodSeconds. Lo que el proceso haga en esa ventana determina si las solicitudes que estaba atendiendo activamente terminan con éxito o se cortan abruptamente a mitad de la respuesta.
Un proceso sin manejo de apagado, o bien muere en el instante en que se dispara la acción por defecto de la señal (muchos runtimes terminan inmediatamente ante SIGTERM si nada la intercepta), o, si la intercepta pero no hace nada útil, sigue aceptando conexiones completamente nuevas hasta el SIGKILL, momento en el cual todo lo que está en curso, nuevo y viejo, se descarta sin oportunidad de terminar ni siquiera de volcar una línea de log. Ambos casos son malos; un apagado ordenado significa que no ocurra ninguno de los dos.
#La secuencia que sigue un apagado correcto
- Capturar la señal. Registrar un manejador para
SIGTERM(y típicamenteSIGINTpara el Ctrl+C local durante el desarrollo). - Dejar de aceptar trabajo nuevo de inmediato, mientras se sigue atendiendo lo que ya está en curso. En concreto: marcar el readiness probe como fallido enseguida, y hacer que el socket que escucha deje de aceptar conexiones nuevas; la mayoría de las librerías de servidor HTTP exponen una llamada de "close" o "shutdown" que hace exactamente esto (dejar de escuchar, dejar que las conexiones existentes terminen).
- Esperar a que las solicitudes en curso realmente terminen, no solo a que el listener se cierre. Este es el paso que la gente se salta, y es el que de verdad evita respuestas descartadas.
- Imponer un plazo máximo. Si las solicitudes no han drenado con cierto margen de seguridad antes de que expire el período de gracia del orquestador, se registra y se sale de todos modos: colgarse para siempre solo significa que te van a matar con
SIGKILLcon aún menos aviso y sin oportunidad de volcar logs o métricas finales. - Cerrar los recursos downstream en orden después de que las solicitudes hayan drenado: detener workers/consumidores en segundo plano, cerrar los pools de conexión a la base de datos, volcar cualquier telemetría en buffer, y luego salir con estado 0.
Vale la pena nombrar explícitamente una carrera sutil pero importante: en Kubernetes, el momento en que se envía SIGTERM al pod no está perfectamente sincronizado con el momento en que el pod se elimina de la lista de endpoints de cada Service en todo el clúster (esa actualización tiene que propagarse a través de kube-proxy o del plano de datos del mesh). Si la app deja de aceptar conexiones en el instante en que recibe SIGTERM, existe una pequeña ventana en la que el balanceador de carga todavía puede enrutarle una solicitud y obtener una conexión rechazada. La mitigación habitual es un hook preStop breve que simplemente duerme unos segundos antes de que el contenedor reciba realmente SIGTERM (o antes de que la app actúe sobre ella), dándole tiempo a la eliminación del endpoint para propagarse mientras el pod todavía acepta tráfico con normalidad.
#Una implementación real
La librería estándar de Go modela esto casi exactamente como se describió arriba, a través de http.Server.Shutdown, que detiene el listener y bloquea hasta que las conexiones activas terminan (o se cancela el contexto pasado):
package mainimport ("context""log""net/http""os""os/signal""syscall""time")func main() {mux := http.NewServeMux()mux.HandleFunc("/healthz", healthzHandler)mux.HandleFunc("/orders", ordersHandler)srv := &http.Server{Addr: ":8080", Handler: mux}go func() {if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {log.Fatalf("error al escuchar: %v", err)}}()// Bloquear hasta recibir una señal de terminación.sigCh := make(chan os.Signal, 1)signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)<-sigChlog.Println("señal de apagado recibida: ya no se aceptan conexiones nuevas")// Aquí se marcaría el readiness como fallido (omitido: un booleano// atómico compartido leído por el handler /healthz) para que el// balanceador deje de enrutar tráfico nuevo incluso antes de que// el listener termine de cerrarse.// Dar hasta 25s a las solicitudes en curso para terminar (dejando// margen bajo los 30s por defecto de terminationGracePeriodSeconds).ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)defer cancel()if err := srv.Shutdown(ctx); err != nil {log.Printf("apagado forzado tras el timeout, es posible que algunas solicitudes se cortaran: %v", err)} else {log.Println("todas las solicitudes en curso drenaron correctamente")}closeDatabasePool()closeBackgroundWorkers()log.Println("apagado completo")}
srv.Shutdown(ctx) cierra el listener de inmediato (no se aceptan conexiones nuevas), y luego espera a que cada conexión que actualmente atiende una solicitud quede inactiva, retornando en cuanto eso ocurre o cuando se alcanza el plazo del contexto, lo que pase primero. Ese es todo el paso de "drenado" en una sola llamada, por lo que apoyarse en la primitiva de apagado de la librería estándar (en lugar de rastrear conexiones a mano) es casi siempre la decisión de implementación correcta cuando el runtime la ofrece.
🧠 Check your understanding
0/2 · 0/2 answered1. Un pod de Kubernetes está siendo terminado durante un despliegue continuo. ¿Cuál es el orden correcto de pasos para un apagado ordenado dentro de la app?
2. ¿Por qué es necesario un plazo máximo en el paso de drenado aunque el objetivo sea dejar terminar las solicitudes en curso?