Cuando despliegas servicios en entornos modernos como Kubernetes, el apagado de un contenedor no es un evento instantáneo de “todo o nada”. Kubernetes envía una señal SIGTERM al proceso para indicarle que debe morir. Si tu servidor HTTP simplemente se apaga al recibir la señal, cortará todas las conexiones TCP activas en ese mismo instante, dejando peticiones a medias y provocando errores 502 Bad Gateway o Connection Reset en tus clientes.
El graceful shutdown es el proceso de detener el servidor de forma controlada. En lugar de un apagado abrupto, el servidor deja de escuchar nuevas conexiones (cierra el listener) pero permite que las peticiones que ya están en curso se completen con éxito. Para lograrlo, Go proporciona el método server.Shutdown(ctx) [disponible desde Go 1.8].
El mecanismo interno es elegante: Shutdown cierra el listener para que no entren más peticiones, pero mantiene los bucles de lectura y escritura de las conexiones existentes activos. El control de cuánto tiempo debe esperar el servidor para que esas conexiones terminen lo tiene el context.Context que le pases. Si las conexiones no se cierran antes de que el contexto expire (por un timeout o cancelación), Shutdown retorna un error.
Este patrón es esencial en sistemas de alta disponibilidad. Si no lo implementas, corres el riesgo de corromper procesos de escritura en bases de datos o estados internos si una petición se corta justo en medio de una operación crítica.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
// Configuramos un servidor con un handler que simula trabajo pesado
srv := &http.Server{
Addr: ":8080",
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulamos una tarea que tarda 10 segundos
time.Sleep(10 * time.Second)
fmt.Fprintln(w, "Proceso completado con éxito")
}),
}
// El servidor debe correr en una goroutine para no bloquear el hilo principal
// donde escucharemos las señales del sistema operativo.
go func() {
fmt.Println("Servidor escuchando en :8080...")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
// http.ErrServerClosed no es un error real, es el aviso de que el shutdown funcionó.
fmt.Printf("Error crítico en ListenAndServe: %v\n", err)
os.Exit(1)
}
}()
// Canal para capturar señales de interrupción (Ctrl+C) o terminación (Kubernetes)
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
// Bloqueamos aquí hasta que recibamos una señal
sig := <-stop
fmt.Printf("\nSeñal recibida (%v). Iniciando apagado gradual...\n", sig)
// Creamos un contexto con un timeout de 30 segundos para el shutdown.
// Si las peticiones no terminan en este tiempo, forzaremos la salida.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Shutdown bloquea hasta que las conexiones se cierran o el contexto expira.
if err := srv.Shutdown(ctx); err != nil {
fmt.Printf("Error durante el shutdown: %v\n", err)
os.Exit(1)
}
fmt.Println("Servidor detenido sin pérdida de datos.")
}
Análisis del flujo de ejecución
Fíjate en la estructura del main. No podemos llamar a srv.ListenAndServe() directamente en el hilo principal porque es una función bloqueante; si lo hiciéramos, el programa se quedaría ahí y nunca llegaría a la línea de signal.Notify. Por eso lo encapsulamos en una goroutine.
Cuando ejecutas el programa y envías una señal (por ejemplo, con SIGINT vía Ctrl+C), el canal stop recibe el valor y el programa continúa su ejecución hacia la lógica de cierre. Es crucial el uso de context.WithTimeout. Sin ese límite, si un cliente tiene una conexión TCP “colgada” o muy lenta, tu servidor se quedaría esperando eternamente, impidiendo que el proceso termine y bloqueando el despliegue de la nueva versión de tu microservicio.
Un detalle vital es la comprobación errors.Is(err, http.ErrServerClosed). Cuando llamas a srv.Shutdown(), el método ListenAndServe() que está corriendo en la otra goroutine devolverá este error específico para avisar que el servidor se ha cerrado intencionalmente. Si no ignoras este error, tu lógica de error de producción marcará un fallo de sistema cuando en realidad el apagado fue exitoso.
El error frecuente
Un error común al implementar este patrón es olvidar que Shutdown es una operación bloqueante y que debe ser llamada con un contexto que tenga un límite de tiempo.
// ❌ PATRÓN INCORRECTO
// Si las conexiones activas se quedan colgadas, el programa nunca termina.
if err := srv.Shutdown(context.Background()); err != nil {
log.Fatal(err)
}
// ❌ ERROR DE MANEJO DE ERRORES
// Esto imprimirá un error de "falso positivo" cada vez que el servidor se apague bien.
err := srv.ListenAndServe()
if err != nil {
log.Fatalf("Error: %v", err)
}
En el primer caso, si un cliente mantiene una conexión abierta pero no envía datos, tu proceso se volverá un “zombie” que no muere, lo que en Kubernetes causará que el Pod se quede en estado Terminating hasta que el terminationGracePeriodSeconds del pod lo mate violentamente con un SIGKILL. En el segundo caso, estarás llenando tus logs de errores de sistema cuando en realidad solo estás cerrando el servidor correctamente.
N° 193