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:
- Cierra todos los listeners, por lo que el servidor no acepta nuevas conexiones.
- Cierra las conexiones inactivas.
- Espera a que las conexiones activas vuelvan a inactivas y luego las cierra. Si el
ctxexpira 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.NotifyContextdevuelve un contexto. Este contexto se cancela cuando el proceso recibeSIGINT(Ctrl+C) oSIGTERM(la señal que Docker y Kubernetes envían).ListenAndServese ejecuta en su propia gorrutina. Después de que llamas aShutdown, devuelve inmediatamentehttp.ErrServerClosed. Este es un error normal, por lo que lo filtramos.- El canal
serverErrtiene buffer 1. Ayuda a capturar errores de inicio, como cuando el puerto ya está en uso. Asímainno 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.shutdownCtxusacontext.Background()como base, noctx. La razón es quectxya se ha cancelado; si lo usamos,Shutdowndevolverá 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
ListenAndServeenmainy aShutdownen otra gorrutina. Dado queListenAndServedevuelve inmediatamente cuandoShutdowncomienza,maintermina mientrasShutdowntodavía está esperando solicitudes. El proceso se cierra y las solicitudes siguen siendo interrumpidas. La regla es quemaindebe esperar a queShutdowndevuelva.
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
BaseContextde 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 reenviarSIGTERMal proceso Go. Usa la forma execCMD ["/app/server"]o llamaexec /app/serveral 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.Listenen puerto0para que el sistema operativo elija un puerto libre. Esto permite que las pruebas se ejecuten en paralelo sin conflictos.- El canal
startedasegura queShutdownsolo se llama cuando la solicitud realmente está en el handler. - La prueba verifica tres cosas:
Shutdowndevuelvenil, 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 elctxdesignal.NotifyContexthace queShutdowndevuelvacontext canceledinmediatamente. - Tratar
http.ErrServerClosedcomo un error. Llamar alog.Fatalcon 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íeSIGKILL. - Olvidar
ReadHeaderTimeout. Un cliente lento puede mantener la conexión en estado activo, y shutdown siempre se ejecuta hasta agotar el timeout. - Usar
os.Exitolog.Fataldespués de haber iniciado el shutdown. Ambos ignoran todos losdefer, 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.