Estrategias de Graceful Shutdown en servicios complejos

El Graceful Shutdown (cierre controlado) no es simplemente llamar a server.Shutdown. En sistemas distribuidos, es una coreografía de desmantelamiento que debe seguir un orden estrictamente inverso al de la inicialización. Consiste en orquestar la terminación de múltiples subsistemas para asegurar que no se pierdan mensajes de una cola, no se corten peticiones HTTP en curso y no se corrompan datos por cerrar una conexión de base de datos mientras un worker aún la utiliza.

Para que esto funcione, el proceso debe seguir una lógica de capas: primero se detiene la entrada de tráfico (ingress), luego se permite que el trabajo en curso (in-flight) termine, y finalmente se cierran las dependencias de infraestructura (DB, brokers, loggers). Si inviertes este orden, por ejemplo, cerrando la base de datos antes de detener los workers, provocarás errores de “connection closed” en los últimos procesos que intenten completar su tarea.

Este patrón es indispensable cuando despliegas en Kubernetes o entornos similares. En estos entornos, cuando un Pod recibe un SIGTERM, el sistema de orquestación lo marca como Terminating, pero hay un periodo de propagación donde el Load Balancer o el Ingress todavía puede intentar enviarle tráfico. Si tu servicio responde al SIGTERM cerrando inmediatamente todos sus sockets, el Ingress recibirá errores 502 o de conexión rechazada durante ese breve lapso.

Si implementas mal el cierre, lo que rompes es la consistencia: terminas con procesos “zombie” que bloquean el despliegue, peticiones HTTP cortadas a la mitad y, lo más grave, estados inconsistentes en tus bases de datos o colas de mensajería debido a procesos que murieron sin terminar su unidad de trabajo.

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"sync"
	"syscall"
	"time"
)

// Database simula una conexión a una base de datos.
type Database struct {
	closed bool
}

func (db *Database) Close() error {
	db.closed = true
	fmt.Println("--- [DB] Conexión cerrada.")
	return nil
}

// Worker representa un proceso en segundo plano (ej. consumidor de Kafka).
type Worker struct {
	id int
}

func (w *Worker) Run(ctx context.Context, wg *sync.WaitGroup) {
	defer wg.Done()
	fmt.Printf("--- [Worker %d] Iniciado\n", w.id)
	for {
		select {
		case <-ctx.Done():
			fmt.Printf("--- [Worker %d] Deteniendo... completando última tarea\n", w.id)
			// Simulamos una última tarea que no puede interrumpirse
			time.Sleep(2 * time.Second)
			return
		case <-time.After(1 * time.Second):
			fmt.Printf("--- [Worker %d] Procesando tarea...\n", w.id)
		}
	}
}

func main() {
	// 1. Configuración de señales de sistema.
	// Capturamos SIGINT (Ctrl+C) y SIGTERM (Kubernetes/Docker).
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

	// 2. Contexto principal de la aplicación y su cancelación.
	appCtx, cancelApp := context.WithCancel(context.Background())
	defer cancelApp()

	// 3. Dependencias.
	db := &Database{}
	wg := &sync.WaitGroup{}

	// 4. Servidor HTTP.
	srv := &http.Server{
		Addr: ":8080",
		Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			fmt.Println("--- [HTTP] Petición recibida")
			time.Sleep(2 * time.Second) // Simular trabajo
			w.Write([]byte("OK"))
		}),
	}

	// Iniciar servidor en una goroutine.
	go func() {
		fmt.Println("--- [Server] Iniciando en :8080")
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("error en srv: %v", err)
		}
	}()

	// 5. Iniciar workers de fondo.
	// Usamos un context derivado para que el shutdown de workers sea independiente del de la app.
	workerCtx, cancelWorkers := context.WithCancel(appCtx)
	for i := 1; i <= 2; i++ {
		wg.Add(1)
		w := &Worker{id: i}
		go w.Run(workerCtx, wg)
	}

	// --- BLOQUEO HASTA RECIBIR SEÑAL ---
	sig := <-sigChan
	fmt.Printf("\n--- [Main] Señal recibida: %v. Iniciando Graceful Shutdown...\n", sig)

	// --- FASE 1: El "K8s Delay" ---
	// Esperamos un breve momento para permitir que el Service/Ingress actualice sus endpoints
	// y deje de enviar tráfico a este Pod antes de cerrar los sockets.
	time.Sleep(5 * time.Second)

	// 6. Shutdown con Timeout.
	// Creamos un contexto con un límite de tiempo estricto para todo el proceso de cierre.
	// Si el shutdown tarda más de esto, el proceso morirá por la fuerza.
	shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancelShutdown()

	// --- ORQUESTACIÓN DEL CIERRE (EL ORDEN IMPORTA) ---

	// PASO A: Detener el ingreso de nuevas peticiones HTTP.
	// Esto hace que el servidor deje de aceptar nuevas conexiones pero espere 
	// a que las actuales terminen.
	fmt.Println("--- [Step 1] Deteniendo servidor HTTP...")
	if err := srv.Shutdown(shutdownCtx); err != nil {
		fmt.Printf("Error en shutdown de HTTP: %v\n", err)
	}

	// PASO B: Detener los workers.
	// Cancelamos el contexto de los workers para que sepan que deben dejar de procesar.
	fmt.Println("--- [Step 2] Cancelando contexto de workers...")
	cancelWorkers()

	// Esperamos a que los workers terminen su última tarea gracias al WaitGroup.
	workerDone := make(chan struct{})
	go func() {
		wg.Wait()
		close(workerDone)
	}()

	select {
	case <-workerDone:
		fmt.Println("--- [Step 2] Todos los workers finalizaron limpiamente.")
	case <-shutdownCtx.Done():
		fmt.Println("--- [Step 2] Error: Timeout esperando a los workers.")
	}

	// PASO C: Cerrar dependencias pesadas (Base de Datos, Brokers).
	// Solo las cerramos cuando ya no hay nada que las necesite.
	fmt.Println("--- [Step 3] Cerrando base de datos...")
	if err := db.Close(); err != nil {
		fmt.Printf("Error cerrando DB: %v\n", err)
	}

	fmt.Println("--- [Main] Shutdown completado con éxito.")
}

Desglose del patrón

El código implementa un flujo de desmantelamiento por capas para evitar fugas de recursos y errores de ejecución.

  1. Captura de señales y el “K8s Delay”: La instrucción signal.Notify intercepta el SIGTERM. En entornos de orquestación, hemos añadido un time.Sleep(5 * time.Second) inmediatamente después de recibir la señal. Esto es una práctica defensiva para dar tiempo a que el plano de control (como el Ingress de Kubernetes) actualice las listas de IPs activas, evitando que el tráfico llegue a un pod que ya ha empezado a cerrarse.
  2. Diferenciación de Contextos: Observa que usamos appCtx para la vida útil general, pero creamos workerCtx derivado para los workers. Esto nos permite controlar el ciclo de vida de los procesos en segundo plano de forma independiente.
  3. El cierre del servidor HTTP: srv.Shutdown(shutdownCtx) es crítico. No mata el proceso; simplemente deja de escuchar nuevas conexiones y espera a que las peticiones “in-flight” (las que ya estaban en curso) terminen o que el shutdownCtx expire.
  4. Coordinación de Workers con sync.WaitGroup: A diferencia del servidor HTTP, los workers suelen ser bucles for { select { ... } }. No basta con cancelar el contexto; necesitamos asegurarnos de que el worker haya terminado su última iteración de trabajo antes de seguir con la base de datos. Para ello, el wg.Wait() es la única forma segura de garantizar que la goroutine ha salido completamente.
  5. Jerarquía de Dependencias: El db.Close() es el último paso. Al colocarlo después de wg.Wait(), garantizamos que ningún worker esté ejecutando una sentencia SQL cuando la conexión se rompa.

El error frecuente

Un error clásico es intentar simplificar el cierre cerrando la base de datos inmediatamente después de recibir la señal, sin esperar a los workers.

// ERROR COMÚN: RACE CONDITION DURANTE SHUTDOWN
sig := <-sigChan
db.Close() // <--- Error: Se cierra la DB aquí
cancelWorkers() // <--- El worker intenta usar la DB y falla
wg.Wait()

Cuando ejecutas esto, el worker que está justo en medio de una transacción SQL recibirá un error de sql: database is closed antes de que pueda terminar su tarea. Esto puede dejar datos inconsistentes o dejar procesos en un estado de error que complica el monitoreo. Siempre, siempre, cierra las dependencias en el orden inverso a como las abriste.

249

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio