¿Alguna vez ha desenchufado el cable de alimentación de su ordenador por frustración? Aunque parezca una solución rápida, puede provocar la pérdida de datos y la inestabilidad del sistema. En el mundo del software, existe un concepto similar: el apagado brusco. Esta terminación abrupta puede causar problemas al igual que su contraparte física. Afortunadamente, existe una forma mejor: el apagado elegante.
Al integrar el apagado ordenado, proporcionamos una notificación anticipada al servicio, lo que le permite completar solicitudes en curso, potencialmente guardar información de estado en el disco y, en última instancia, evitar la corrupción de datos durante el apagado.
Esta guía profundizará en el mundo de los apagados elegantes, centrándose específicamente en su implementación en aplicaciones Go que se ejecutan en Kubernetes.
Una de las herramientas clave para lograr apagados elegantes en sistemas basados en Unix es el concepto de señales, que son, en términos simples, una forma sencilla de comunicar una cosa específica a un proceso desde otro proceso. Al comprender cómo funcionan las señales, podemos aprovecharlas para implementar procedimientos de finalización controlados dentro de nuestras aplicaciones, lo que garantiza un proceso de apagado sin problemas y seguro para los datos.
Hay muchas señales, y puedes encontrarlas aquí , pero nos interesan solo las señales de apagado:
Estas señales pueden ser enviadas desde el usuario (Ctrl+C / Ctrl+D), desde otro programa/proceso o desde el propio sistema (kernel / SO), por ejemplo, un SIGSEGV , también conocido como fallo de segmentación, es enviado por el SO.
Para explorar el mundo de los apagados elegantes en un entorno práctico, creemos un servicio simple con el que podamos experimentar. Este servicio "conejillo de indias" tendrá un único punto final que simula un trabajo del mundo real (agregaremos un pequeño retraso) al llamar al comando INCR de Redis. También proporcionaremos una configuración básica de Kubernetes para probar cómo la plataforma maneja las señales de finalización.
El objetivo final: garantizar que nuestro servicio gestione los apagados correctamente sin perder ninguna solicitud o dato. Al comparar la cantidad de solicitudes enviadas en paralelo con el valor del contador final en Redis, podremos verificar si nuestra implementación de apagado correcto es exitosa.
No entraremos en detalles sobre la configuración del clúster de Kubernetes y Redis, pero puedes encontrar la configuración completa en nuestro repositorio de Github .
El proceso de verificación es el siguiente:
Comencemos con nuestro servidor HTTP Go base.
apagado forzado/main.go
package main import ( "net/http" "os" "time" "github.com/go-redis/redis" ) func main() { redisdb := redis.NewClient(&redis.Options{ Addr: os.Getenv("REDIS_ADDR"), }) server := http.Server{ Addr: ":8080", } http.HandleFunc("/incr", func(w http.ResponseWriter, r *http.Request) { go processRequest(redisdb) w.WriteHeader(http.StatusOK) }) server.ListenAndServe() } func processRequest(redisdb *redis.Client) { // simulate some business logic here time.Sleep(time.Second * 5) redisdb.Incr("counter") }
Cuando ejecutamos nuestro procedimiento de verificación usando este código, veremos que algunas solicitudes fallan y el contador es menor que 1000 (el número puede variar en cada ejecución).
Lo que claramente significa que perdimos algunos datos durante la actualización continua. 😢
Go proporciona un paquete de señales que permite manejar señales Unix. Es importante tener en cuenta que, de forma predeterminada, las señales SIGINT y SIGTERM hacen que el programa Go salga. Y para que nuestra aplicación Go no salga tan abruptamente, necesitamos manejar las señales entrantes.
Hay dos opciones para hacerlo.
Usando el canal:
c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGTERM)
Utilizando el contexto (el enfoque preferido hoy en día):
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) defer stop()
NotifyContext devuelve una copia del contexto padre que está marcado como terminado (su canal Done está cerrado) cuando llega una de las señales enumeradas, cuando se llama a la función stop() devuelta o cuando se cierra el canal Done del contexto padre, lo que ocurra primero.
Hay algunos problemas con nuestra implementación actual del servidor HTTP:
Vamos a reescribirlo.
apagado elegante/main.go
package main // imports var wg sync.WaitGroup func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) defer stop() // redisdb, server http.HandleFunc("/incr", func(w http.ResponseWriter, r *http.Request) { wg.Add(1) go processRequest(redisdb) w.WriteHeader(http.StatusOK) }) // make it a goroutine go server.ListenAndServe() // listen for the interrupt signal <-ctx.Done() // stop the server if err := server.Shutdown(context.Background()); err != nil { log.Fatalf("could not shutdown: %v\n", err) } // wait for all goroutines to finish wg.Wait() // close redis connection redisdb.Close() os.Exit(0) } func processRequest(redisdb *redis.Client) { defer wg.Done() // simulate some business logic here time.Sleep(time.Second * 5) redisdb.Incr("counter") }
Aquí está el resumen de las actualizaciones:
Ahora, si repetimos nuestro proceso de verificación veremos que las 1000 solicitudes se procesan correctamente. 🎉
Los frameworks como Echo, Gin, Fiber y otros generarán una goroutine para cada solicitud entrante, dándole un contexto y luego llamarán a su función/controlador según el enrutamiento que haya decidido. En nuestro caso, sería la función anónima dada a HandleFunc para la ruta “/incr”.
Cuando interceptas una señal SIGTERM y le pides a tu marco que se apague correctamente, suceden dos cosas importantes (para simplificar):
Nota: Kubernetes también deja de dirigir el tráfico entrante desde el balanceador de carga a su pod una vez que lo ha etiquetado como Terminando.
Terminar un proceso puede ser complejo, especialmente si implica muchos pasos, como cerrar conexiones. Para garantizar que todo funcione sin problemas, puede establecer un tiempo de espera. Este tiempo de espera actúa como una red de seguridad, ya que permite salir del proceso sin problemas si tarda más de lo esperado.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() go func() { if err := server.Shutdown(shutdownCtx); err != nil { log.Fatalf("could not shutdown: %v\n", err) } }() select { case <-shutdownCtx.Done(): if shutdownCtx.Err() == context.DeadlineExceeded { log.Fatalln("timeout exceeded, forcing shutdown") } os.Exit(0) }
Dado que usamos Kubernetes para implementar nuestro servicio, analicemos en profundidad cómo finaliza los pods. Una vez que Kubernetes decide finalizar el pod, se producirán los siguientes eventos:
Como puede ver, si tiene un proceso de finalización de larga duración, puede ser necesario aumentar la configuración endingGracePeriodSeconds , lo que le permitirá a su aplicación tener tiempo suficiente para cerrarse correctamente.
Los apagados elegantes protegen la integridad de los datos, mantienen una experiencia de usuario fluida y optimizan la gestión de recursos. Con su rica biblioteca estándar y el énfasis en la concurrencia, Go permite a los desarrolladores integrar sin esfuerzo prácticas de apagado elegante, una necesidad para las aplicaciones implementadas en entornos orquestados o en contenedores como Kubernetes.
Puede encontrar el código Go y los manifiestos de Kubernetes en nuestro repositorio de Github .