Modèles pratiques pour implémenter un Graceful Shutdown en Go
(victoriametrics.com)- Dans une application Go, le Graceful Shutdown est une procédure d’arrêt qui bloque les nouvelles requêtes, attend la fin des tâches en cours, puis nettoie les ressources comme les connexions à la base de données, les verrous de fichiers et les listeners réseau
- La gestion de l’arrêt commence par l’interception des signaux de terminaison comme
SIGTERMetSIGINTavecos/signalou, à partir de Go 1.16,signal.NotifyContext, afin de remplacer le comportement par défaut d’arrêt immédiat - Dans Kubernetes, l’arrêt doit se terminer dans le grace period de 30 secondes par défaut, et il faut laisser le temps, via un délai
preStopou l’échec de la readiness probe, pour que l’arrêt du trafic se propage jusqu’au load balancer externe http.Server.Shutdownempêche les nouvelles connexions et attend la fin des requêtes actives, mais si les handlers ne respectent pas la context cancellation, cela peut entraîner des écritures partielles, des pertes de données ou des transactions restées ouvertes- Les ressources critiques doivent être nettoyées non pas juste après le signal de terminaison, mais après la fin des requêtes ou l’expiration du délai limite ; les arrêter dans l’ordre inverse de l’initialisation facilite le respect des dépendances entre composants
Conditions minimales d’un Graceful Shutdown
- Un Graceful Shutdown doit généralement remplir trois conditions
- Ne plus accepter de nouvelles requêtes ou de nouveaux messages aux points d’entrée comme HTTP ou pub/sub
- Attendre la fin des requêtes déjà en cours et, si elles prennent trop longtemps, répondre avec une erreur graceful
- Libérer les ressources critiques comme les connexions à la base de données, les verrous de fichiers et les listeners réseau, puis effectuer le nettoyage final
- Les connexions sortantes vers des services externes, comme les bases de données ou les caches, ne sont pas coupées immédiatement lors de l’étape de blocage des nouvelles requêtes
- L’accent est mis sur les serveurs HTTP et les applications conteneurisées, mais les principes clés s’appliquent aussi à d’autres applications
Gestion des signaux de terminaison
- Sur les systèmes de type Unix, un signal est une interruption logicielle qui informe un processus qu’une situation particulière s’est produite
- Un processus peut enregistrer un handler pour certains signaux ; en l’absence de handler, le comportement par défaut s’applique
- Le comportement par défaut peut être l’arrêt, la suspension, la reprise de l’exécution ou l’ignorance
- Certains signaux comme
SIGKILLne peuvent pas être interceptés ni ignorés, et terminent le processus
- Le runtime Go enregistre automatiquement plusieurs handlers de signaux, dont
SIGTERM,SIGQUIT,SIGILLetSIGTRAP, avant même l’exécution de la fonctionmain - Trois signaux de terminaison sont principalement importants pour le Graceful Shutdown
SIGTERM: la manière standard et polie de demander l’arrêt d’un processus ; c’est le signal que Kubernetes envoie à l’application avant une terminaison forcéeSIGINT: envoyé lorsque l’utilisateur tente d’arrêter le processus depuis le terminal avecCtrl+CSIGHUP: utilisé à l’origine pour signaler la déconnexion d’un terminal, et aujourd’hui souvent exploité comme signal de rechargement de configuration
- Sans traitement spécifique, lorsqu’il reçoit
SIGTERM,SIGINTouSIGHUP, le runtime Go termine l’application
os/signal et NotifyContext
signal.Notifyindique au runtime Go de transmettre les signaux indiqués dans un channel plutôt que d’appliquer le comportement par défaut- Il est plus fiable de créer le channel de signaux avec une taille de buffer de 1
- En interne, Go utilise
selectetdefaultpour envoyer sur le channel - S’il y a de la place dans le buffer, le signal est transmis ; si le buffer est plein, le signal est ignoré
- Avec un channel sans buffer, s’il n’y a pas de goroutine en train de recevoir, le signal peut être manqué
- En interne, Go utilise
signal.Notifypeut être appelé plusieurs fois pour un même signal, et Go envoie ce signal à tous les channels enregistrés- Même si l’on appuie plusieurs fois sur
Ctrl+C, la deuxième saisie n’est généralement pas automatiquement promue enSIGKILL- La plupart des shells bash ou Linux n’effectuent pas de promotion automatique
- Pour forcer l’arrêt, il faut envoyer directement
SIGKILLaveckill -9
- En développement local, pour qu’un deuxième
Ctrl+Cforce l’arrêt, on peut appelersignal.Stopjuste après avoir reçu le premier signal afin d’arrêter la réception des signaux supplémentaires - Depuis Go 1.16,
signal.NotifyContextpermet de relier la gestion des signaux à la context cancellation- Même après
ctx.Done(), il faut appelerstop()pour qu’un deuxièmeCtrl+Cpuisse forcer l’arrêt de l’application
- Même après
Délai limite d’arrêt et comportement de Kubernetes
- Après réception d’un signal de terminaison, il faut d’abord connaître le temps d’arrêt réellement disponible pour l’application
- Le grace period par défaut de Kubernetes est de 30 secondes si
terminationGracePeriodSecondsn’est pas spécifié - Une fois ce délai écoulé, Kubernetes envoie
SIGKILLpour interrompre l’application de forceSIGKILLne peut pas être intercepté ni traité
- Toute la logique d’arrêt, y compris le traitement des requêtes restantes et la libération des ressources, doit se terminer dans ce délai
- Avec les 30 secondes par défaut, en gardant environ 20 % de marge de sécurité, il vaut mieux terminer l’ensemble de l’arrêt en moins de 25 secondes
Blocage des nouvelles requêtes et gestion de la readiness
- Dans
net/httpde Go, on peut effectuer un Graceful Shutdown avechttp.Server.Shutdown- Il cesse d’accepter de nouvelles connexions
- Il attend la fin des requêtes actives
- Il ferme ensuite les idle connections
- Les requêtes déjà en cours peuvent se terminer et, après leur achèvement, la connexion correspondante passe à l’état idle puis est fermée
- Les clients qui tentent d’ouvrir une nouvelle connexion pendant l’arrêt reçoivent généralement une erreur
connection refused, car le listener est déjà fermé - Dans les environnements conteneurisés ou orchestrés avec un load balancer externe, il est important de ne pas arrêter immédiatement l’acceptation des nouvelles requêtes
- Même après qu’un pod a été marqué comme destiné à être terminé, il peut encore recevoir du trafic pendant un court moment
kube-proxy, composant interne de Kubernetes, détecte rapidement le passage de l’état du pod àTerminating- Les load balancers externes utilisent leurs propres health checks indépendamment de Kubernetes ; la propagation de l’état prend donc du temps
- Il existe deux façons d’attendre la propagation du blocage du trafic
- Faire un
sleepbref dans le hookpreStoppour laisser au load balancer externe le temps de détecter l’état d’arrêt du pod- Le temps passé dans
preStopest inclus dansterminationGracePeriodSeconds
- Le temps passé dans
- Au niveau du code, faire échouer la readiness probe puis attendre un court délai
- Cette approche s’applique aussi à d’autres environnements où le load balancer doit connaître l’état de disponibilité, pas seulement Kubernetes
- Faire un
- La readiness probe vérifie périodiquement si le conteneur est prêt à recevoir du trafic
- Elle peut effectuer le health check via une requête HTTP, une connexion TCP ou l’exécution d’une commande
- Si la probe échoue, Kubernetes retire le pod des service endpoints afin qu’il ne reçoive plus de trafic
- Lors de la préparation de l’arrêt, on peut utiliser un
atomic.BoolcommeisShuttingDownpour faire en sorte que/healthzretourne HTTP 503 - Après avoir fait passer la readiness à l’état d’échec, il faut attendre quelques secondes pour que le changement se propage
- L’exemple de configuration utilise
periodSeconds: 5, et l’exemple du texte utilise une attente de 5 secondes - Le temps d’attente exact dépend de la configuration de la readiness probe
- L’exemple de configuration utilise
Traitement des requêtes en cours
- Créer un délai limite adapté au shutdown budget avec
context.WithTimeoutet le passer àserver.Shutdown(ctx) server.Shutdownpeut retourner dans deux cas- Toutes les connexions actives sont fermées et tous les handlers ont terminé leur traitement
- Le context transmis expire avant la fin des handlers, et le serveur abandonne l’attente
- Dans les deux cas,
Shutdownretourne après que le serveur a complètement arrêté de traiter les requêtes - Les handlers doivent être rapides et context-aware
- Sinon, ils peuvent être interrompus au milieu d’une opération lorsque le délai limite expire
- Cela peut provoquer des écritures partielles, des pertes de données, un état incohérent, des transactions restées ouvertes ou des données corrompues
- Il existe deux grandes façons de transmettre le signal d’arrêt aux handlers
- Injecter une logique d’annulation dans le context de chaque requête via un middleware
- Fournir un context global partagé par toutes les connexions via
BaseContextdehttp.Server
- Dans un serveur HTTP, les contexts personnalisables sont
BaseContextetConnContext- Pour un Graceful Shutdown,
BaseContextest plus adapté, car il permet de créer un context global annulable s’appliquant à tout le serveur
- Pour un Graceful Shutdown,
- Le Graceful Shutdown est efficace lorsque les fonctions respectent l’annulation du context
- Il faut éviter les usages qui ignorent l’annulation, comme
context.Background()outime.Sleep() time.Sleep(duration)peut être remplacé par une attente conjointe detime.After(duration)etctx.Done()dans unselect
- Il faut éviter les usages qui ignorent l’annulation, comme
- Dans les anciennes versions de Go,
time.Afterpouvait fuir de la mémoire jusqu’à l’exécution du timer- Ce problème est corrigé à partir de Go 1.23
- Si la version n’est pas certaine, on peut utiliser
time.NewTimeravecStop, puis vérifier<-t.Csi nécessaire - Issue associée : time: stop requiring Timer/Ticker.Stop for prompt GC
Différence entre Shutdown et Close
- Le même principe s’applique non seulement aux serveurs HTTP, mais aussi aux services tiers
DB.Closededatabase/sqlferme les connexions à la base de données, empêche le lancement de nouvelles requêtes et attend la fin des requêtes en cours- L’essentiel est de ne plus accepter de nouvelles requêtes ou de nouveaux messages, tout en laissant aux tâches existantes le temps de se terminer dans le grace period défini
server.Close()termine immédiatement sans attendre les connexions en cours- Les handlers qui utilisent le réseau reçoivent des erreurs lors des lectures ou écritures
- Les clients peuvent recevoir immédiatement des erreurs de connexion comme
ECONNRESETousocket hang up - Les handlers de longue durée qui n’interagissent pas avec le réseau peuvent continuer à s’exécuter en arrière-plan
- Il est possible d’utiliser
server.Close()après queserver.Shutdown()a retourné une erreur, mais cela dépend de la stratégie d’arrêt - Propager le signal d’arrêt via un context est une approche plus fiable et plus graceful
Ordre de libération des ressources critiques
- Une erreur fréquente consiste à libérer les ressources critiques dès la réception du signal de terminaison
- À ce moment-là, les handlers et les requêtes in-flight peuvent encore utiliser ces ressources ; le nettoyage doit donc être reporté après l’expiration du shutdown timeout ou après la fin de toutes les requêtes
- Dans de nombreux cas, la seule terminaison du processus suffit pour que le système d’exploitation récupère les ressources
- La mémoire allouée par Go est libérée à l’arrêt du processus
- Les descripteurs de fichiers sont fermés par le système d’exploitation
- Les ressources de niveau OS comme les handles de processus sont également récupérées
- Dans certains cas, un nettoyage explicite est nécessaire
- Les connexions à la base de données doivent être fermées correctement, et les transactions ouvertes doivent être commit ou rollback
- Les files de messages et les brokers peuvent nécessiter un flush des messages, un commit des offsets ou une notification de fermeture du client
- Les services externes peuvent ne pas détecter immédiatement la déconnexion ; fermer manuellement la connexion permet de nettoyer plus vite que d’attendre un TCP timeout
- Une bonne règle consiste à arrêter les composants dans l’ordre inverse de l’initialisation
- Le
deferde Go convient bien à ce modèle, car la dernière fonction enregistrée s’exécute en premier
- Le
- Certains composants doivent disposer d’une routine de shutdown dédiée, par exemple lorsqu’il faut écrire sur disque des données présentes dans un cache mémoire
Déroulé de l’exemple complet
- L’exemple complet construit un root context qui reçoit
SIGINTetSIGTERMavecsignal.NotifyContext - L’endpoint
/healthzretourne HTTP 503 etShutting downsiisShuttingDownvaut true, sinonOK - Le handler de requête d’exemple retourne
Hello, world!après 2 secondes, ou répond avec un HTTP request timeout si le context de la requête est annulé BaseContextest relié àongoingCtxafin que les requêtes in-flight ne soient pas annulées immédiatement juste aprèsSIGTERM- À la réception d’un signal de terminaison, le déroulé est le suivant
- Appeler
stop()pour autoriser le traitement par défaut des signaux supplémentaires - Créer un état d’échec de readiness avec
isShuttingDown.Store(true) - Attendre 5 secondes, soit
_readinessDrainDelay, pour laisser se propager le readiness check - Appeler
server.Shutdownavec un délai limite de 15 secondes, soit_shutdownPeriod - Annuler le context en cours avec
stopOngoingGracefully() - Si
Shutdownéchoue, prévoir un délai d’attente d’annulation forcée de 3 secondes, soit_shutdownHardPeriod
- Appeler
1 commentaires
Avis sur Hacker News
Il m’est arrivé de me faire piéger parce que, dans certaines configurations, Kubernetes mettait plus longtemps que prévu à mettre à jour les IP cibles du load balancer. Dans mon cas, 90 % du graceful shutdown consistait à garantir que le trafic était effectivement drainé avant l’arrêt du pod.
Ajouter un sleep de 15 secondes dans le hook global
preStopa fortement réduit le taux de HTTP 503, et a laissé le temps, après le début du désenregistrement auprès du load balancer, avant queSIGTERMsoit transmis à l’application, ce qui a beaucoup simplifié le traitement côté application.preStop, c’est une sorte de solution magique pour respecter les SLO lors de rolling deployments de qualité.À mon avis, Kubernetes pourrait s’améliorer sur deux points. Les pods devraient d’abord être retirés des Endpoints avant de démarrer la séquence d’arrêt, et il devrait exister une option de termination delay, comme la termination grace. De plus, les PDB devraient avoir une option permettant la recréation avant l’éviction.
Si vous scrapez un endpoint Prometheus
/metricsclassique toutes les N secondes, il existe une fenêtre pendant laquelle les métriques enregistrées entre le dernier scrape et l’arrêt réel du processus ne sont pas propagées. On peut donc avoir une fausse impression sur la présence d’erreurs pendant la séquence d’arrêt.Si l’on ne fait pas attention, on peut aussi perdre les logs des dernières secondes juste avant l’arrêt du service. Par exemple, si un sidecar comme Promtail ou Vector surveille un fichier de logs, et que le service tronque le même chemin au démarrage avant de réécrire dedans, cela crée une condition de concurrence où les logs produits pendant l’arrêt peuvent disparaître.
Même avec tous ces efforts, la plupart des données sont complètement ignorées, et les insights business sont rarement beaucoup meilleurs que la version de fortune consistant à entrer en
sshsur un serveur et à faire ungrepdans les fichiers de logs. Je ne suis pas sûr que tous les efforts investis dans cet écosystème aient vraiment amélioré de manière significative la disponibilité, les performances ou l’utilisabilité.Nous prévoyons de gérer des choses comme la « synchronisation des logs » et « attendre que l’ingress rattrape le liveness handler ».
https://github.com/utrack/caisson-go/blob/main/caiapp/caiapp...
https://github.com/utrack/caisson-go/tree/main/closer
La documentation est encore insuffisante et il manque des éléments, mais je prévois de faire une première release à mon retour de vacances. À terme, cela doit devenir une méta-plateforme et une bibliothèque de plateforme de référence pour gérer une infrastructure k8s/otel/grpc+http courante.
À cause de ce comportement, nos services utilisent encore statsd. Le modèle basé sur le push n’a pas ce problème.
Un petit piège que je vois souvent : certains pensent qu’appeler
log.Fatalexécute quand même lesdefer. En réalité, ce n’est pas le cas.log.Fatal("fatal")appelleos.Exiten interne, donc le programme se termine immédiatement et lesdeferne s’exécutent pas. En revanche,panic("fatal")affiche à la foisfataletin defer.Si, pour qu’un système distribué fonctionne correctement, il repose sur l’hypothèse que les clients doivent s’arrêter proprement, il finira forcément par casser violemment un jour.
La seule façon de vérifier qu’un système tolère les crashs brutaux de ses composants est de faire en sorte que ces crashs soient un événement normal et permanent. Gloire au chaos monkey.
Il y a une grande différence entre une application arrêtée avec
sig intet une application tuée aveckill. Par exemple, une migration blue-green nécessite un comportement d’arrêt propre.À bien y réfléchir, peut-être que si. C’est peut-être le seul moyen de garantir que cette hypothèse est vraie. Une approche comme le chaos monkey de Netflix il y a quelques années.
Je pensais que l’article allait expliquer comment une nouvelle instance de service peut reprendre le socket d’écoute de l’ancienne instance afin de redémarrer l’application sans couper une seule connexion entrante.
Avec systemd, c’est relativement simple à mettre en œuvre, et nginx le prend en charge depuis plus de 20 ans. Malheureusement, Kubernetes et Docker ne le prennent pas en charge, car ils supposent que cela est géré par le load balancer ou le reverse proxy.
Mon collègue disait toujours que si un programme ne gérait pas proprement
ctrl cet quelques commandes d’arrêt, c’était un programme mal écrit.Je trouve qu’Elixir gère ce genre de choses de façon vraiment intelligente. Je n’ai pas une énorme expérience, mais comme il est conçu pour que de petits processus de VM puissent paniquer, s’arrêter puis être recréés, il semble moins nécessaire de mettre en place volontairement une routine de graceful shutdown
C’est parce que cette propriété est déjà intégrée à l’architecture de l’application
J’ai créé une petite bibliothèque pour gérer le graceful shutdown dans mon projet : https://github.com/eberkund/graceful
En général, on a plusieurs services à démarrer, et chacun a souvent sa propre façon de démarrer et de s’arrêter. Parfois il faut d’abord instancier un objet, parfois il y a un contexte qu’on veut annuler, parfois une méthode
Stopà appeler. Je l’ai conçue pour rassembler tout cela au même endroit derrière une API unifiéehttps://pkg.go.dev/git.sr.ht/~mariusor/wrapper#example-Regis...
Un pod en cours d’arrêt n’est, par définition, pas prêt. Le service marque aussi l’endpoint comme terminating et not ready. Cela se produit au moment du passage à l’état Terminating, donc il n’est pas nécessaire de faire échouer explicitement le readiness check
Je ne connais pas l’ordre exact entre
SIGTERMet la mise à jour d’objets commePod.statusou les endpoint slices. Il peut y avoir une toute petite fenêtre pendant laquelle des connexions arrivent encore aprèsSIGTERM, mais ce n’est pas la grande période « jusqu’à ce que le readiness check échoue » que l’article semble suggérer. Du point de vue de l’exploitation d’un cluster, cette fenêtre minuscule n’a pas vraiment d’importance. Il suffit de ne plus accepter de nouvelles connexions, de fermer proprement les connexions existantes et de s’arrêter dans un délai raisonnablement court. Cela dit, la moitié des applis dont je m’occupe traitentSIGTERMmais mettent longtemps à s’arrêter, ou ne traitent pas du toutSIGTERMtout en mettant longtemps à s’arrêterNous avons adopté Google Wire dans certains projets de JustWatch, et ça a changé la donne. C’est étonnamment peu connu, mais cela aide à éliminer la logique d’arrêt brouillonne dans Kubernetes
Wire impose une injection de dépendances propre, si bien que désormais tout s’arrête dans un ordre défini, et non plus dans un ordre inconnu
https://go.dev/blog/wire
https://github.com/google/wire