Avez-vous déjà débranché le cordon d'alimentation de votre ordinateur par frustration ? Bien que cela puisse sembler être une solution rapide, cela peut entraîner une perte de données et une instabilité du système. Dans le monde des logiciels, un concept similaire existe : l'arrêt brutal. Cet arrêt brutal peut causer des problèmes tout comme son homologue physique. Heureusement, il existe une meilleure solution : l'arrêt progressif.
En intégrant un arrêt progressif, nous envoyons une notification préalable au service. Cela lui permet de répondre aux demandes en cours, d'enregistrer potentiellement les informations d'état sur le disque et, en fin de compte, d'éviter la corruption des données lors de l'arrêt.
Ce guide se plongera dans le monde des arrêts gracieux, en se concentrant spécifiquement sur leur implémentation dans les applications Go exécutées sur Kubernetes.
L'un des outils clés pour réaliser des arrêts en douceur dans les systèmes basés sur Unix est le concept de signaux, qui sont, en termes simples, un moyen simple de communiquer une chose spécifique à un processus, à partir d'un autre processus. En comprenant le fonctionnement des signaux, nous pouvons les exploiter pour mettre en œuvre des procédures d'arrêt contrôlées dans nos applications, garantissant un processus d'arrêt fluide et sécurisé pour les données.
Il existe de nombreux signaux, et vous pouvez les trouver ici , mais nous nous intéressons uniquement aux signaux d'arrêt :
Ces signaux peuvent être envoyés par l'utilisateur (Ctrl+C / Ctrl+D), par un autre programme/processus, ou par le système lui-même (noyau / OS), par exemple, un SIGSEGV alias erreur de segmentation est envoyé par le système d'exploitation.
Pour explorer le monde des arrêts progressifs dans un cadre pratique, créons un service simple avec lequel nous pouvons expérimenter. Ce service « cobaye » aura un point de terminaison unique qui simule un travail réel (nous ajouterons un léger délai) en appelant la commande INCR de Redis. Nous fournirons également une configuration Kubernetes de base pour tester la manière dont la plateforme gère les signaux d'arrêt.
L'objectif ultime : garantir que notre service gère correctement les arrêts sans perdre de requêtes/données. En comparant le nombre de requêtes envoyées en parallèle avec la valeur finale du compteur dans Redis, nous pourrons vérifier si notre implémentation d'arrêt gracieux est réussie.
Nous n'entrerons pas dans les détails de la configuration du cluster Kubernetes et de Redis, mais vous pouvez trouver la configuration complète dans notre référentiel Github .
Le processus de vérification est le suivant :
Commençons par notre serveur HTTP Go de base.
arrêt-matériel/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") }
Lorsque nous exécutons notre procédure de vérification à l'aide de ce code, nous verrons que certaines requêtes échouent et que le compteur est inférieur à 1000 (le nombre peut varier à chaque exécution).
Ce qui signifie clairement que nous avons perdu certaines données lors de la mise à jour continue. 😢
Go fournit un package de signaux qui vous permet de gérer les signaux Unix. Il est important de noter que par défaut, les signaux SIGINT et SIGTERM provoquent la fermeture du programme Go. Et pour que notre application Go ne se ferme pas aussi brusquement, nous devons gérer les signaux entrants.
Il existe deux options pour le faire.
Utilisation du canal :
c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGTERM)
En utilisant le contexte (approche privilégiée de nos jours) :
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM) defer stop()
NotifyContext renvoie une copie du contexte parent marqué comme terminé (son canal Terminé est fermé) lorsqu'un des signaux répertoriés arrive, lorsque la fonction stop() renvoyée est appelée ou lorsque le canal Terminé du contexte parent est fermé, selon la première éventualité.
Il y a quelques problèmes avec notre implémentation actuelle du serveur HTTP :
Réécrivons-le.
arrêt-gracieux/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") }
Voici le résumé des mises à jour :
Maintenant, si nous répétons notre processus de vérification, nous verrons que les 1000 demandes sont traitées correctement. 🎉
Les frameworks comme Echo, Gin, Fiber et autres génèrent une goroutine pour chaque requête entrante, lui donnant un contexte, puis appellent votre fonction/gestionnaire en fonction du routage que vous avez décidé. Dans notre cas, il s'agirait de la fonction anonyme donnée à HandleFunc pour le chemin « /incr ».
Lorsque vous interceptez un signal SIGTERM et demandez à votre infrastructure de s'arrêter correctement, deux choses importantes se produisent (pour simplifier à l'extrême) :
Remarque : Kubernetes arrête également de diriger le trafic entrant de l'équilibreur de charge vers votre pod une fois qu'il l'a étiqueté comme en cours de terminaison.
Mettre fin à un processus peut être complexe, surtout s'il implique de nombreuses étapes, comme la fermeture de connexions. Pour garantir le bon déroulement des opérations, vous pouvez définir un délai d'expiration. Ce délai d'expiration agit comme un filet de sécurité, quittant le processus en douceur s'il prend plus de temps que prévu.
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) }
Puisque nous avons utilisé Kubernetes pour déployer notre service, examinons plus en détail la manière dont il met fin aux pods. Une fois que Kubernetes décide de mettre fin au pod, les événements suivants se produisent :
Comme vous pouvez le constater, si vous disposez d’un processus de terminaison de longue durée, il peut être nécessaire d’augmenter le paramètre terminateGracePeriodSeconds , ce qui laisse à votre application suffisamment de temps pour s’arrêter correctement.
Les arrêts progressifs préservent l'intégrité des données, assurent une expérience utilisateur fluide et optimisent la gestion des ressources. Grâce à sa riche bibliothèque standard et à l'accent mis sur la concurrence, Go permet aux développeurs d'intégrer sans effort des pratiques d'arrêt progressif, une nécessité pour les applications déployées dans des environnements conteneurisés ou orchestrés comme Kubernetes.
Vous pouvez trouver le code Go et les manifestes Kubernetes dans notre référentiel Github .