Patrones prácticos para implementar Graceful Shutdown en Go
(victoriametrics.com)- 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
SIGTERMySIGINTmedianteos/signalosignal.NotifyContexten 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
preStopo una falla en la readiness probe propaguen el estado de corte de tráfico hasta los balanceadores de carga externos. http.Server.Shutdownbloquea 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,SIGILLySIGTRAP, incluso antes de ejecutar la funciónmain - 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ónSIGINT: se envía cuando el usuario intenta detener el proceso desde la terminal conCtrl+CSIGHUP: 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,SIGINToSIGHUPsin manejo especial, el runtime de Go termina la aplicación
os/signal y NotifyContext
signal.Notifyindica 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
selectydefaultpara 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
- Internamente, Go usa
signal.Notifypuede llamarse varias veces para la misma señal, y Go envía esa señal a todos los canales registrados- Aunque se presione
Ctrl+Cvarias veces, normalmente la segunda entrada no se escala automáticamente aSIGKILL- 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
SIGKILLconkill -9
- En desarrollo local, si se quiere que el segundo
Ctrl+Cfuerce la terminación, se puede detener la recepción de señales adicionales consignal.Stopjusto después de recibir la primera señal - Desde Go 1.16,
signal.NotifyContextpermite conectar el manejo de señales con la cancelación de context- Incluso después de
ctx.Done(), se debe llamar astop()para que un segundoCtrl+Cpueda terminar la aplicación por la fuerza
- Incluso después de
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
SIGKILLy detiene la aplicación por la fuerzaSIGKILLno 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/httpde Go, se puede realizar un Graceful Shutdown conhttp.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ó aTerminating- 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
sleepbreve en el hookpreStoppara dar tiempo a que el balanceador de carga externo reconozca el estado de terminación del pod- El tiempo usado por
preStopcuenta dentro determinationGracePeriodSeconds
- El tiempo usado por
- 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
- Hacer un
- 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.BoolcomoisShuttingDownpara que/healthzdevuelva 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
- La configuración de ejemplo es
Procesamiento de solicitudes en curso
- Se crea un límite de tiempo con
context.WithTimeoutde acuerdo con el shutdown budget y se pasa aserver.Shutdown(ctx) - Hay dos casos en los que
server.Shutdownretorna- 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,
Shutdownretorna 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
BaseContextdehttp.Server
- En un servidor HTTP, los contexts que se pueden personalizar son
BaseContextyConnContext- Para Graceful Shutdown,
BaseContextes más adecuado porque permite crear un context global cancelable que se aplica a todo el servidor
- Para Graceful Shutdown,
- 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()ytime.Sleep() time.Sleep(duration)puede reemplazarse por unselectque espere al mismo tiempo portime.After(duration)yctx.Done()
- Hay que evitar usos que ignoren la cancelación, como
- En versiones antiguas de Go,
time.Afterpodía filtrar memoria hasta que se ejecutara el timer- Este problema se corrigió en Go 1.23 o superior
- Si no se tiene certeza de la versión, se puede usar
time.NewTimerconStopy, si es necesario, verificar<-t.C - Issue relacionado: time: stop requiring Timer/Ticker.Stop for prompt GC
Diferencia entre Shutdown y Close
- El mismo principio se aplica no solo a servidores HTTP, sino también a servicios de terceros
DB.Closededatabase/sqlcierra 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
ECONNRESETosocket 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 queserver.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
deferde 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
SIGINTySIGTERMconsignal.NotifyContext - El endpoint
/healthzdevuelve HTTP 503 yShutting downsiisShuttingDownes true; si no, devuelveOK - 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
BaseContextse conectaongoingCtxpara que las solicitudes in-flight no se cancelen de inmediato justo después deSIGTERM - 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.Shutdowncon un límite de tiempo de 15 segundos, el valor de_shutdownPeriod - Se cancela el context en curso con
stopOngoingGracefully() - Si
Shutdownfalla, se deja un tiempo de espera para cancelación forzada de 3 segundos, el valor de_shutdownHardPeriod
- Se llama a
1 comentarios
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 entregabaSIGTERMa la aplicación, el procesamiento del lado de la aplicación se volvió mucho más simple.preStopes 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
/metricscada 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.
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
sshal servidor y hacergrepsobre 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.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.
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.Fatalse ejecutadefer. En realidad no se ejecuta.log.Fatal("fatal")llama internamente aos.Exit, así que termina de inmediato ydeferno corre. En cambio,panic("fatal")muestra tantofatalcomoin 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.
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 que una aplicación baje con
sig inty que muera conkill. Por ejemplo, una migración blue-green necesita un comportamiento de apagado elegante.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 cy algunos comandos de cierre, es un programa mal escrito.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
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
Stopque debes llamar. La diseñé para reunir todo eso en un solo lugar con una API unificadahttps://pkg.go.dev/git.sr.ht/~mariusor/wrapper#example-Regis...
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
SIGTERMy las actualizaciones de objetos comoPod.statuso endpoint slices. Puede haber una pequeña ventana en la que sigan entrando conexiones después deSIGTERM, 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í manejanSIGTERMpero tardan mucho en terminar, o directamente no manejanSIGTERMy aun así tardan mucho en cerrarseEn 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