Graceful shutdown para servidor HTTP Go

1. Descripción general

Cuando despliegas una versión nueva o reduces recursos, tu proceso recibirá SIGTERM. Si el servidor cierra inmediatamente, las solicitudes en proceso se interrumpen a mitad de camino. El cliente recibe un error connection reset, y los pedidos pueden quedar grabados solo a la mitad.

Este artículo te guía sobre cómo apagar correctamente un servidor HTTP Go. El servidor detendrá la aceptación de nuevas conexiones, esperará a que las solicitudes en curso se completen dentro de un período de tiempo limitado, y luego cerrará los recursos en el orden correcto. Todos los ejemplos usan solo la biblioteca estándar y requieren Go 1.22 o superior.

2. Problema al cerrar el servidor abruptamente

Comencemos con el código más familiar:

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

func main() {
	http.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second) // simula una consulta DB lenta
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Ejecuta el servidor y llama a curl localhost:8080/orders/42. Mientras la solicitud está en espera, presiona Ctrl+C en la terminal del servidor. El resultado en el cliente:

curl: (52) Empty reply from server

Go no instala un handler para SIGINT o SIGTERM automáticamente. Cuando recibe la señal, el runtime termina el proceso inmediatamente. Ningún defer se ejecuta y ninguna solicitud es esperada.

3. Graceful shutdown con http.Server.Shutdown

http.Server tiene el método Shutdown(ctx). Este método realiza tres cosas en orden:

  1. Cierra todos los listeners, por lo que el servidor no acepta nuevas conexiones.
  2. Cierra las conexiones inactivas.
  3. Espera a que las conexiones activas vuelvan a inactivas y luego las cierra. Si el ctx expira primero, el método devuelve el error del contexto.

Para usar este método, tienes que capturar las señales del sistema operativo por tu cuenta.

3.1. Captura de señales con signal.NotifyContext

package main

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

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second)
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	serverErr := make(chan error, 1)
	go func() {
		log.Println("listening on", srv.Addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			serverErr <- err
		}
	}()

	select {
	case err := <-serverErr:
		log.Fatalf("server error: %v", err)
	case <-ctx.Done():
		log.Println("shutdown signal received")
	}
	stop() // recibir Ctrl+C una segunda vez matará el proceso inmediatamente

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("graceful shutdown failed: %v, forcing close", err)
		_ = srv.Close()
	}
	log.Println("server stopped")
}

Los puntos clave en el código:

  • signal.NotifyContext devuelve un contexto. Este contexto se cancela cuando el proceso recibe SIGINT (Ctrl+C) o SIGTERM (la señal que Docker y Kubernetes envían).
  • ListenAndServe se ejecuta en su propia gorrutina. Después de que llamas a Shutdown, devuelve inmediatamente http.ErrServerClosed. Este es un error normal, por lo que lo filtramos.
  • El canal serverErr tiene buffer 1. Ayuda a capturar errores de inicio, como cuando el puerto ya está en uso. Así main no se queda esperando una señal que nunca llega.
  • stop() se llama inmediatamente después de recibir la señal. Esta función restaura el comportamiento predeterminado, así que si presionas Ctrl+C de nuevo, el proceso se cierra inmediatamente. Esto es muy útil cuando el shutdown se congela.
  • shutdownCtx usa context.Background() como base, no ctx. La razón es que ctx ya se ha cancelado; si lo usamos, Shutdown devolverá un error inmediatamente.
  • Si después de 10 segundos todavía hay solicitudes, srv.Close() fuerza el cierre de todas las conexiones.

Repite el experimento de la sección 2: llama a curl, luego presiona Ctrl+C mientras la solicitud está en espera. El registro del servidor:

2026/10/24 10:00:00 listening on :8080
^C2026/10/24 10:00:01 shutdown signal received
2026/10/24 10:00:03 server stopped

El cliente recibe la respuesta completa:

order 42

El servidor espera los 2 segundos restantes de la solicitud antes de cerrarse.

Nota: Un error común es invertir los roles de las dos gorrutinas. Algunas personas llaman a ListenAndServe en main y a Shutdown en otra gorrutina. Dado que ListenAndServe devuelve inmediatamente cuando Shutdown comienza, main termina mientras Shutdown todavía está esperando solicitudes. El proceso se cierra y las solicitudes siguen siendo interrumpidas. La regla es que main debe esperar a que Shutdown devuelva.

4. Lo que Shutdown no maneja automáticamente

4.1. Conexiones secuestradas: WebSocket, actualización HTTP/1.1

Shutdown no rastrea las conexiones secuestradas, como WebSocket. El servidor tampoco las espera. Para cerrar estas conexiones, debes registrar una devolución de llamada con RegisterOnShutdown:

type Hub struct {
	mu    sync.Mutex
	conns map[*websocketConn]struct{}
}

func (h *Hub) CloseAll() {
	h.mu.Lock()
	defer h.mu.Unlock()
	for c := range h.conns {
		c.SendCloseFrame("server shutting down")
		c.Close()
	}
}

// en main:
hub := &Hub{conns: make(map[*websocketConn]struct{})}
srv.RegisterOnShutdown(hub.CloseAll)

Aquí websocketConn representa el tipo de conexión de la biblioteca WebSocket que usas. La devolución de llamada se ejecuta en su propia gorrutina cuando Shutdown comienza. Shutdown no espera a que esta devolución de llamada termine, así que si necesitas esperar, debes sincronizar manualmente usando sync.WaitGroup.

4.2. Handlers de larga ejecución: streaming, long polling

Un handler que transmite datos infinitamente, como Server-Sent Events, nunca volverá a estar inactivo. En este caso, Shutdown siempre espera hasta que se agota el tiempo. Puedes usar BaseContext para que todo contexto de solicitud se cancele cuando el servidor comience a cerrarse:

appCtx, cancelApp := context.WithCancel(context.Background())

srv := &http.Server{
	Addr:    ":8080",
	Handler: mux,
	BaseContext: func(net.Listener) context.Context {
		return appCtx
	},
}
srv.RegisterOnShutdown(cancelApp)

mux.HandleFunc("GET /orders/stream", func(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/event-stream")
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-r.Context().Done():
			return // el servidor se está cerrando o el cliente desconectó
		case t := <-ticker.C:
			fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
			w.(http.Flusher).Flush()
		}
	}
})

Cada contexto de solicitud se crea a partir de appCtx. Cuando Shutdown se ejecuta, se llama a cancelApp y todos los r.Context() se cancelan. El handler de streaming ve Done() y se devuelve. Entonces Shutdown se completa temprano.

Nota: No debes aplicar esta técnica a todos los handlers. Si cancelas el contexto de una solicitud que está escribiendo un pedido en la DB, la escritura misma se cancelará. El objetivo de “esperar a que la solicitud termine” se pierde. Solo usa BaseContext de esta manera cuando todos los handlers puedan detenerse a mitad de camino de forma segura.

4.3. Gorrutinas de fondo y recursos secundarios

Shutdown solo gestiona conexiones HTTP. Los workers de correo electrónico, los consumidores de Kafka y el pool de conexiones de la DB son tu responsabilidad. El orden de cierre debe ser inverso al orden de inicialización:

if err := srv.Shutdown(shutdownCtx); err != nil {
	log.Printf("http shutdown: %v", err)
}

cancelWorkers() // señala a las gorrutinas de fondo que se detengan
workersDone := make(chan struct{})
go func() { wg.Wait(); close(workersDone) }()

select {
case <-workersDone:
case <-shutdownCtx.Done():
	log.Println("workers did not stop in time")
}

if err := db.Close(); err != nil {
	log.Printf("db close: %v", err)
}

El servidor HTTP se apaga primero porque los handlers aún necesitan la DB. La DB se cierra al final. Todo el proceso comparte el mismo shutdownCtx, por lo que el tiempo total no excede el presupuesto de 10 segundos.

5. Ejecutar en Kubernetes

Cuando se elimina un pod, Kubernetes hace dos cosas en paralelo: envía SIGTERM al contenedor y elimina el pod de la lista de endpoints del Service. Eliminar el endpoint tarda unos segundos en propagarse a kube-proxy e ingress. Si el servidor cierra el listener inmediatamente después de recibir SIGTERM, las solicitudes que aún se enrutan al pod serán rechazadas.

La solución es hacer que el readiness probe reporte un error primero, esperar un poco, y luego llamar a Shutdown:

var shuttingDown atomic.Bool

mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
	if shuttingDown.Load() {
		http.Error(w, "shutting down", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
})

// después de recibir la señal:
<-ctx.Done()
shuttingDown.Store(true)
time.Sleep(5 * time.Second) // espera a que el balanceador de carga elimine el pod
srv.Shutdown(shutdownCtx)

También necesitas configurar terminationGracePeriodSeconds mayor que la suma del tiempo de espera más el timeout de shutdown. Para este ejemplo son 5 + 10 = 15 segundos:

spec:
  terminationGracePeriodSeconds: 30
  containers:
    - name: order-service
      image: order-service:1.4.0
      readinessProbe:
        httpGet:
          path: /readyz
          port: 8080
        periodSeconds: 2

Una vez agotado el período de gracia, Kubernetes envía SIGKILL. Esta señal no se puede capturar, y toda la lógica de shutdown se ignorará.

Nota: Si ejecutas el binario a través de un script de shell, como CMD ./start.sh, el shell es PID 1 y puede no reenviar SIGTERM al proceso Go. Usa la forma exec CMD ["/app/server"] o llama exec /app/server al final del script.

6. Prueba del comportamiento de shutdown

Puedes escribir una prueba que confirme que Shutdown espera a que se completen las solicitudes en curso. Esta prueba solo necesita el paquete testing:

package main

import (
	"context"
	"io"
	"net"
	"net/http"
	"testing"
	"time"
)

func TestShutdownWaitsForInFlightRequest(t *testing.T) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}

	started := make(chan struct{})
	srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		close(started)
		time.Sleep(500 * time.Millisecond)
		io.WriteString(w, "order 42")
	})}
	go srv.Serve(ln)

	type result struct {
		body string
		err  error
	}
	resCh := make(chan result, 1)
	go func() {
		resp, err := http.Get("http://" + ln.Addr().String() + "/orders/42")
		if err != nil {
			resCh <- result{err: err}
			return
		}
		defer resp.Body.Close()
		b, err := io.ReadAll(resp.Body)
		resCh <- result{body: string(b), err: err}
	}()

	<-started // asegúrate de que la solicitud ya entró al handler
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		t.Fatalf("Shutdown returned error: %v", err)
	}

	res := <-resCh
	if res.err != nil {
		t.Fatalf("request failed: %v", res.err)
	}
	if res.body != "order 42" {
		t.Fatalf("got body %q, want %q", res.body, "order 42")
	}

	if _, err := http.Get("http://" + ln.Addr().String()); err == nil {
		t.Fatal("expected new connections to be refused after shutdown")
	}
}

Los puntos clave en la prueba:

  • net.Listen en puerto 0 para que el sistema operativo elija un puerto libre. Esto permite que las pruebas se ejecuten en paralelo sin conflictos.
  • El canal started asegura que Shutdown solo se llama cuando la solicitud realmente está en el handler.
  • La prueba verifica tres cosas: Shutdown devuelve nil, el cliente recibe el cuerpo completo, y se rechaza la conexión nueva.
go test -v -run TestShutdown
=== RUN   TestShutdownWaitsForInFlightRequest
--- PASS: TestShutdownWaitsForInFlightRequest (0.50s)
PASS

El tiempo de ejecución es de aproximadamente 0,5 segundos, exactamente el tiempo que el handler duerme. Esto muestra que Shutdown esperó la solicitud.

7. Errores comunes

  • Usar un contexto ya cancelado para Shutdown. Pasar directamente el ctx de signal.NotifyContext hace que Shutdown devuelva context canceled inmediatamente.
  • Tratar http.ErrServerClosed como un error. Llamar a log.Fatal con este error cerrará el proceso antes de que se complete el shutdown.
  • No establecer un timeout. Usar context.Background() sin deadline permite que un handler congelado mantenga el proceso indefinidamente hasta que Kubernetes envíe SIGKILL.
  • Olvidar ReadHeaderTimeout. Un cliente lento puede mantener la conexión en estado activo, y shutdown siempre se ejecuta hasta agotar el timeout.
  • Usar os.Exit o log.Fatal después de haber iniciado el shutdown. Ambos ignoran todos los defer, por lo que la DB y los archivos no se cierran.

8. Conclusión

El graceful shutdown en Go requiere tres componentes: signal.NotifyContext para capturar SIGTERM, srv.Shutdown con un contexto con timeout, y main debe esperar a que Shutdown devuelva. A partir de esa base, añades las partes que Shutdown no maneja automáticamente: RegisterOnShutdown para WebSocket, BaseContext para handlers de streaming, y cerrar workers junto con la DB en orden inverso al de inicialización. Al ejecutar en Kubernetes, haz que readiness reporte un error y espera unos segundos antes de cerrar el listener, además de establecer terminationGracePeriodSeconds suficientemente grande. Finalmente, mantén una prueba como la de la sección 6 para detectar temprano si alguien accidentalmente rompe la lógica de shutdown.

golanghttpgraceful-shutdownkubernetesconcurrency