3 puntos por GN⁺ 2025-05-06 | 1 comentarios | Compartir por WhatsApp
  • El Graceful Shutdown de una aplicación Go es un procedimiento de cierre que bloquea nuevas solicitudes, espera a que terminen las tareas en curso y luego limpia recursos como conexiones a bases de datos, locks de archivos y listeners de red.
  • El manejo del cierre empieza por recibir señales de terminación como SIGTERM y SIGINT mediante os/signal o signal.NotifyContext en Go 1.16 o superior, para reemplazar el comportamiento predeterminado de terminación inmediata.
  • En Kubernetes, el cierre debe completarse dentro del grace period predeterminado de 30 segundos, y se debe dar tiempo para que una demora preStop o una falla en la readiness probe propaguen el estado de corte de tráfico hasta los balanceadores de carga externos.
  • http.Server.Shutdown bloquea nuevas conexiones y espera a que terminen las solicitudes activas, pero si los handlers no respetan la cancelación de context, pueden producirse problemas como escrituras parciales, pérdida de datos y transacciones abiertas.
  • Los recursos importantes no deben limpiarse justo después de recibir la señal de terminación, sino después de que terminen las solicitudes o expire el límite de tiempo; cerrar en el orden inverso al de inicialización facilita respetar las dependencias entre componentes.

Condiciones mínimas para Graceful Shutdown

  • Un Graceful Shutdown normalmente debe cumplir tres condiciones
    • Dejar de aceptar nuevas solicitudes o mensajes en puntos de entrada como HTTP o pub/sub
    • Esperar a que terminen las solicitudes ya en curso y, si tardan demasiado, responder con un error graceful
    • Liberar recursos importantes como conexiones a bases de datos, locks de archivos y listeners de red, y realizar la limpieza final
  • Las conexiones salientes a servicios externos, como bases de datos o cachés, no se cierran de inmediato en la etapa de bloqueo de nuevas solicitudes
  • Aunque el foco está en servidores HTTP y aplicaciones en contenedores, los principios centrales también se aplican a otras aplicaciones

Manejo de señales de terminación

  • En sistemas tipo Unix, una señal es una interrupción de software que avisa a un proceso que ocurrió una situación específica
  • Un proceso puede registrar handlers para señales específicas y, si no hay handler, sigue el comportamiento predeterminado
    • El comportamiento predeterminado puede ser terminar, detenerse, continuar ejecutándose o ignorar la señal
    • Algunas señales, como SIGKILL, no se pueden capturar ni ignorar, y terminan el proceso
  • El runtime de Go registra automáticamente varios handlers de señales, como SIGTERM, SIGQUIT, SIGILL y SIGTRAP, incluso antes de ejecutar la función main
  • En Graceful Shutdown, las principales señales de terminación importantes son tres
    • SIGTERM: es la forma estándar y cortés de solicitar la terminación de un proceso, y es la señal que Kubernetes envía a la aplicación antes de forzar su terminación
    • SIGINT: se envía cuando el usuario intenta detener el proceso desde la terminal con Ctrl+C
    • SIGHUP: originalmente se usaba para la desconexión de terminales y hoy también se utiliza con frecuencia como señal para recargar configuración
  • Si recibe SIGTERM, SIGINT o SIGHUP sin manejo especial, el runtime de Go termina la aplicación

os/signal y NotifyContext

  • signal.Notify indica al runtime de Go que entregue las señales especificadas a un canal en lugar de aplicar el comportamiento predeterminado
  • Es más estable crear el canal de señales con tamaño de buffer 1
    • Internamente, Go usa select y default para enviar al canal
    • Si hay espacio en el buffer, la señal se entrega; si el buffer está lleno, la señal se descarta
    • En un canal sin buffer, si no hay una goroutine recibiendo, la señal puede perderse
  • signal.Notify puede llamarse varias veces para la misma señal, y Go envía esa señal a todos los canales registrados
  • Aunque se presione Ctrl+C varias veces, normalmente la segunda entrada no se escala automáticamente a SIGKILL
    • La mayoría de los shells bash o Linux no hacen esa escalación automática
    • Para forzar la terminación, hay que enviar directamente SIGKILL con kill -9
  • En desarrollo local, si se quiere que el segundo Ctrl+C fuerce la terminación, se puede detener la recepción de señales adicionales con signal.Stop justo después de recibir la primera señal
  • Desde Go 1.16, signal.NotifyContext permite conectar el manejo de señales con la cancelación de context
    • Incluso después de ctx.Done(), se debe llamar a stop() para que un segundo Ctrl+C pueda terminar la aplicación por la fuerza

Límite de tiempo de cierre y comportamiento de Kubernetes

  • Después de recibir una señal de terminación, primero hay que conocer el tiempo de cierre que la aplicación realmente puede usar
  • El grace period predeterminado de Kubernetes es de 30 segundos si no se especifica terminationGracePeriodSeconds
  • Cuando pasa ese tiempo, Kubernetes envía SIGKILL y detiene la aplicación por la fuerza
    • SIGKILL no se puede capturar ni procesar
  • Toda la lógica de cierre, incluido el procesamiento de solicitudes restantes y la liberación de recursos, debe terminar dentro de ese tiempo
  • Tomando como base los 30 segundos predeterminados, conviene dejar alrededor de un 20% como margen de seguridad, por lo que es mejor completar todo el cierre en menos de 25 segundos

Bloqueo de nuevas solicitudes y manejo de readiness

  • En net/http de Go, se puede realizar un Graceful Shutdown con http.Server.Shutdown
    • Deja de aceptar nuevas conexiones
    • Espera a que las solicitudes activas terminen
    • Después cierra las conexiones idle
  • Las solicitudes ya en curso pueden completarse y, después de completarse, esa conexión pasa a estado idle y se cierra
  • Los clientes que intentan abrir una conexión nueva durante el cierre normalmente reciben un error connection refused, porque el listener ya está cerrado
  • En entornos de contenedores u orquestación con balanceadores de carga externos, es importante no dejar de aceptar nuevas solicitudes de inmediato
    • Incluso después de que un pod se marca para terminación, puede seguir recibiendo tráfico por un momento
    • kube-proxy, un componente interno de Kubernetes, detecta rápidamente que el estado del pod cambió a Terminating
    • Los balanceadores de carga externos usan sus propios health checks de forma independiente de Kubernetes, por lo que la propagación del estado requiere tiempo
  • Hay dos formas de esperar la propagación del corte de tráfico
    • Hacer un sleep breve en el hook preStop para dar tiempo a que el balanceador de carga externo reconozca el estado de terminación del pod
      • El tiempo usado por preStop cuenta dentro de terminationGracePeriodSeconds
    • A nivel de código, hacer que la readiness probe falle y esperar un momento
      • Esto también se aplica a otros entornos donde no solo Kubernetes, sino también el balanceador de carga, debe conocer el estado de readiness
  • La readiness probe verifica periódicamente si el contenedor está listo para recibir tráfico
    • Puede realizar health checks mediante solicitudes HTTP, conexiones TCP o ejecución de comandos
    • Si la probe falla, Kubernetes elimina el pod de los service endpoints para que deje de recibir tráfico
  • Al prepararse para el cierre, se puede usar un atomic.Bool como isShuttingDown para que /healthz devuelva HTTP 503
  • Después de cambiar el estado de readiness a fallo, hay que esperar unos segundos para que el cambio se propague
    • La configuración de ejemplo es periodSeconds: 5, y el ejemplo del texto usa una espera de 5 segundos
    • El tiempo exacto de espera depende de la configuración de la readiness probe

Procesamiento de solicitudes en curso

  • Se crea un límite de tiempo con context.WithTimeout de acuerdo con el shutdown budget y se pasa a server.Shutdown(ctx)
  • Hay dos casos en los que server.Shutdown retorna
    • Todas las conexiones activas se cerraron y terminó el procesamiento de todos los handlers
    • El context entregado expiró antes de que terminaran los handlers, por lo que el servidor dejó de esperar
  • En cualquier caso, Shutdown retorna después de que el servidor dejó de procesar solicitudes por completo
  • Los handlers deben funcionar rápido y de forma context-aware
    • De lo contrario, cuando expire el límite de tiempo, pueden cortarse en medio del trabajo
    • Esto puede causar problemas como escrituras parciales, pérdida de datos, estados inconsistentes, transacciones abiertas o datos dañados
  • Hay dos formas representativas de transmitir la señal de cierre a los handlers
    • Inyectar lógica de cancelación en cada request context mediante middleware
    • Proporcionar un context global compartido por todas las conexiones con BaseContext de http.Server
  • En un servidor HTTP, los contexts que se pueden personalizar son BaseContext y ConnContext
    • Para Graceful Shutdown, BaseContext es más adecuado porque permite crear un context global cancelable que se aplica a todo el servidor
  • Graceful Shutdown es efectivo cuando las funciones respetan la cancelación del context
    • Hay que evitar usos que ignoren la cancelación, como context.Background() y time.Sleep()
    • time.Sleep(duration) puede reemplazarse por un select que espere al mismo tiempo por time.After(duration) y ctx.Done()
  • En versiones antiguas de Go, time.After podía filtrar memoria hasta que se ejecutara el timer

Diferencia entre Shutdown y Close

  • El mismo principio se aplica no solo a servidores HTTP, sino también a servicios de terceros
  • DB.Close de database/sql cierra las conexiones a la base de datos, bloquea el inicio de nuevas queries y espera a que terminen las queries en curso
  • Lo central es dejar de recibir nuevas solicitudes o mensajes y dar tiempo para que el trabajo existente termine dentro del grace period definido
  • server.Close() termina de inmediato sin esperar las conexiones en curso
    • Los handlers que están usando la red reciben errores al leer o escribir
    • Los clientes pueden recibir de inmediato errores de conexión como ECONNRESET o socket hang up
    • Los handlers de larga ejecución que no interactúan con la red pueden seguir ejecutándose en segundo plano
  • Se puede usar server.Close() después de que server.Shutdown() devuelva un error, pero depende de la estrategia de cierre
  • Propagar la señal de cierre mediante context es un enfoque más confiable y graceful

Orden de liberación de recursos importantes

  • Un error común es liberar recursos importantes apenas se recibe la señal de terminación
  • En ese momento, los handlers y las solicitudes in-flight todavía pueden estar usando esos recursos, por lo que la limpieza debe posponerse hasta después de que pase el shutdown timeout o hasta que todas las solicitudes terminen
  • En muchos casos, con solo terminar el proceso el sistema operativo recupera los recursos
    • La memoria asignada por Go se libera cuando termina el proceso
    • El sistema operativo cierra los file descriptors
    • También se recuperan recursos a nivel del sistema operativo, como handles de procesos
  • También hay casos que requieren limpieza explícita
    • Las conexiones a bases de datos deben cerrarse correctamente, y las transacciones abiertas necesitan commit o rollback
    • Las colas de mensajes y brokers pueden requerir flush de mensajes, commit de offsets y notificación de cierre del cliente
    • Los servicios externos pueden no detectar de inmediato la desconexión, por lo que cerrar manualmente la conexión puede limpiar más rápido que esperar el timeout de TCP
  • Una buena regla es cerrar los componentes en orden inverso al de inicialización
    • defer de Go se ajusta bien a este patrón, porque la última función registrada se ejecuta primero
  • Algunos componentes deben diseñar una shutdown routine aparte, por ejemplo cuando los datos de una caché en memoria deben escribirse en disco

Flujo del ejemplo completo

  • El ejemplo completo configura un root context que recibe SIGINT y SIGTERM con signal.NotifyContext
  • El endpoint /healthz devuelve HTTP 503 y Shutting down si isShuttingDown es true; si no, devuelve OK
  • El handler de solicitudes de ejemplo devuelve Hello, world! después de 2 segundos o, si se cancela el request context, responde con HTTP request timeout
  • En BaseContext se conecta ongoingCtx para que las solicitudes in-flight no se cancelen de inmediato justo después de SIGTERM
  • Al recibir la señal de terminación, se procede en este orden
    • Se llama a stop() para permitir el procesamiento predeterminado adicional
    • Se crea un estado de fallo de readiness con isShuttingDown.Store(true)
    • Se espera durante 5 segundos, el valor de _readinessDrainDelay, a que se propague el readiness check
    • Se llama a server.Shutdown con un límite de tiempo de 15 segundos, el valor de _shutdownPeriod
    • Se cancela el context en curso con stopOngoingGracefully()
    • Si Shutdown falla, se deja un tiempo de espera para cancelación forzada de 3 segundos, el valor de _shutdownHardPeriod

1 comentarios

 
GN⁺ 2025-05-06
Opiniones de Hacker News
  • Me pasó que, en algunas configuraciones, Kubernetes tardaba más de lo esperado en actualizar las IP de destino del balanceador de carga. En mi caso, el 90% del graceful shutdown consistía en garantizar que el tráfico realmente se drenara antes de terminar el pod.
    Al poner un sleep de 15 segundos en el hook global preStop, la proporción de HTTP 503 bajó mucho, y al ganar tiempo entre que empezaba la baja del registro en el balanceador de carga y que se entregaba SIGTERM a la aplicación, el procesamiento del lado de la aplicación se volvió mucho más simple.

    • Exacto. Un sleep en preStop es una solución casi mágica para cumplir los SLO en despliegues rolling de alta calidad.
      Creo que Kubernetes podría mejorar dos cosas: los pods deberían quitarse primero de Endpoints antes de iniciar la secuencia de terminación, y debería existir una opción de termination delay, similar al termination grace. Además, PDB debería tener una opción para permitir la recreación antes del desalojo.
  • Si se hace scraping de un endpoint típico de Prometheus /metrics cada N segundos, hay un intervalo en el que las métricas registradas entre el último scrape y la terminación real del proceso no se propagan. Por eso puedes llevarte una impresión equivocada sobre si hubo errores durante la secuencia de apagado.
    Si no tienes cuidado, también puedes perder los logs de los últimos segundos antes de que el servicio se cierre. Por ejemplo, si un sidecar como Promtail o Vector vigila el archivo de logs, y el servicio al iniciar trunca la misma ruta y vuelve a escribir ahí, se crea una condición de carrera en la que los logs desaparecen durante el apagado.

    • El stack de observabilidad parece un poco absurdo. Logs, métricas y trazas tienen cada uno su propia base de datos, sidecar y stack de visualización; las bibliotecas de integración por lenguaje son todas distintas, y los costos de nube también son enormes.
      Incluso con tanto esfuerzo, la mayor parte de los datos se ignora por completo, y las ideas de negocio rara vez son mucho mejores que la versión precaria de entrar por ssh al servidor y hacer grep sobre los archivos de log. No tengo claro que todo el esfuerzo invertido en este ecosistema haya mejorado de forma significativa el uptime, el rendimiento o la usabilidad.
    • En las bibliotecas de plataforma estamos manejando exactamente así los problemas que he vivido durante más de 8 años trabajando con aplicaciones Go de alta carga. Desarrollar y mejorar plataformas y despliegues rolling en cada empresa era casi un hobby.
      Planeo cubrir cosas como “sincronización de logs” y “esperar a que ingress alcance al liveness handler”.
      https://github.com/utrack/caisson-go/blob/main/caiapp/caiapp...
      https://github.com/utrack/caisson-go/tree/main/closer
      La documentación todavía es escasa y faltan cosas, pero cuando vuelva de vacaciones planeo hacer el primer release. Al final, será una metaplataforma y una biblioteca de plataforma de referencia para manejar infraestructura común k8s/otel/grpc+http.
    • Nunca entendí por qué Prometheus y sus herramientas relacionadas usan un modelo pull. La mayoría usa un modelo push.
    • Me pregunto si alguien ha visto una solución cómoda para este problema. Si el intervalo de scraping es de 15 segundos, no puedes esperar 30 segundos solo para registrar las métricas dos veces.
      Por este comportamiento, nuestros servicios todavía usan statsd. Un modelo basado en push no tiene este problema.
  • Hay una pequeña trampa que veo seguido: pensar que al llamar a log.Fatal se ejecuta defer. En realidad no se ejecuta.
    log.Fatal("fatal") llama internamente a os.Exit, así que termina de inmediato y defer no corre. En cambio, panic("fatal") muestra tanto fatal como in defer.

  • Si un sistema distribuido depende de la premisa de que los clientes deben apagarse con elegancia para funcionar correctamente, tarde o temprano va a romperse en grande.

    • Creo tanto en eso que al diseñar ni siquiera considero el graceful shutdown. Los componentes deben poder sufrir hard crashes de forma segura, incluso con frecuencia, y si una proporción importante del sistema está funcionando como se espera, no debería haber un impacto significativo en el sistema completo.
      La única forma de comprobar que el sistema puede tolerar hard crashes de componentes es hacer que los hard crashes sean algo normal que ocurra todo el tiempo. Gloria al chaos monkey.
    • Hay una gran diferencia entre hacer graceful shutdown para ser amable con los clientes o los workflows, y que los clientes tengan que depender de eso para que el sistema funcione.
    • En la época de los servidores físicos usábamos STONITH para eso: https://smcleod.net/2015/07/delayed-serial-stonith/
    • Incluso si la situación es recuperable, hay razones válidas para que un apagado normal no parezca un apagado catastrófico.
      Hay una gran diferencia entre que una aplicación baje con sig int y que muera con kill. Por ejemplo, una migración blue-green necesita un comportamiento de apagado elegante.
    • Exacto. Aun así, que el software esté diseñado para soportar que le desconecten el enchufe no significa que al apagarlo tengas que desconectarlo de verdad.
      Pensándolo de nuevo, quizá sí sea necesario. Puede ser la única forma de garantizar que esa suposición sea cierta. Algo como el chaos monkey de Netflix de hace unos años.
  • Pensé que iba a tratar sobre cómo una nueva instancia del servicio recibe el socket de escucha desde la instancia existente y reinicia la aplicación sin cortar ni una sola conexión entrante.
    En systemd es relativamente sencillo de implementar, y nginx lo soporta desde hace más de 20 años. Lamentablemente, Kubernetes y Docker no lo soportan porque asumen que eso lo maneja el balanceador de carga o el proxy inverso.

  • Mi compañero siempre decía que si un programa no puede manejar limpiamente ctrl c y algunos comandos de cierre, es un programa mal escrito.

    • Ctrl-C está reservado para copiar al portapapeles. Usarlo como acción para detener un programa es muy poco intuitivo y hará enojar a los usuarios.
  • Creo que Elixir maneja este tipo de cosas de forma muy inteligente. No tengo muchísima experiencia, pero como está diseñado para que pequeños procesos de la VM fallen, terminen y se vuelvan a crear, parece que reduce la necesidad de crear deliberadamente una rutina de graceful shutdown
    Es porque esta propiedad ya viene incorporada en la arquitectura de la aplicación

    • Me pregunto cómo eso elimina la necesidad del graceful shutdown que trata el autor
  • Hice una pequeña biblioteca para manejar el graceful shutdown en mi proyecto: https://github.com/eberkund/graceful
    Normalmente hay varios servicios que hay que iniciar, y muchas veces cada uno tiene una forma distinta de arrancar y terminar. A veces primero hay que instanciar un objeto, a veces hay un contexto que quieres cancelar, y a veces hay un método Stop que debes llamar. La diseñé para reunir todo eso en un solo lugar con una API unificada

  • Un pod que se está terminando, por definición, no está en estado listo. El servicio también marca el endpoint como terminating y not ready. Esto ocurre cuando pasa al estado Terminating, así que no hace falta forzar que falle el readiness check
    No sé con exactitud el orden entre SIGTERM y las actualizaciones de objetos como Pod.status o endpoint slices. Puede haber una pequeña ventana en la que sigan entrando conexiones después de SIGTERM, pero no es un tramo grande “hasta que falle el readiness check”, como sugiere el artículo. Desde el punto de vista de quien administra un clúster, esa ventana diminuta no me parece muy importante. Basta con no aceptar conexiones nuevas, cerrar con gracia las conexiones existentes y terminar razonablemente rápido. Dicho eso, la mitad de las apps con las que trato sí manejan SIGTERM pero tardan mucho en terminar, o directamente no manejan SIGTERM y aun así tardan mucho en cerrarse

  • En algunos proyectos de JustWatch adoptamos Google Wire y cambió las reglas del juego. Curiosamente no es tan conocido, pero ayuda a eliminar la lógica de cierre desordenada en Kubernetes
    Wire fuerza una inyección de dependencias limpia, así que ahora todo se cierra en un orden definido, no en un orden desconocido
    https://go.dev/blog/wire
    https://github.com/google/wire