3 points par GN⁺ 2025-05-06 | 1 commentaires | Partager sur WhatsApp
  • 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 SIGTERM et SIGINT avec os/signal ou, à 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 preStop ou l’échec de la readiness probe, pour que l’arrêt du trafic se propage jusqu’au load balancer externe
  • http.Server.Shutdown empê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 SIGKILL ne peuvent pas être interceptés ni ignorés, et terminent le processus
  • Le runtime Go enregistre automatiquement plusieurs handlers de signaux, dont SIGTERM, SIGQUIT, SIGILL et SIGTRAP, avant même l’exécution de la fonction main
  • 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ée
    • SIGINT : envoyé lorsque l’utilisateur tente d’arrêter le processus depuis le terminal avec Ctrl+C
    • SIGHUP : 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, SIGINT ou SIGHUP, le runtime Go termine l’application

os/signal et NotifyContext

  • signal.Notify indique 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 select et default pour 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é
  • signal.Notify peut ê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 en SIGKILL
    • La plupart des shells bash ou Linux n’effectuent pas de promotion automatique
    • Pour forcer l’arrêt, il faut envoyer directement SIGKILL avec kill -9
  • En développement local, pour qu’un deuxième Ctrl+C force l’arrêt, on peut appeler signal.Stop juste après avoir reçu le premier signal afin d’arrêter la réception des signaux supplémentaires
  • Depuis Go 1.16, signal.NotifyContext permet de relier la gestion des signaux à la context cancellation
    • Même après ctx.Done(), il faut appeler stop() pour qu’un deuxième Ctrl+C puisse forcer l’arrêt de l’application

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 terminationGracePeriodSeconds n’est pas spécifié
  • Une fois ce délai écoulé, Kubernetes envoie SIGKILL pour interrompre l’application de force
    • SIGKILL ne 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/http de Go, on peut effectuer un Graceful Shutdown avec http.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 sleep bref dans le hook preStop pour laisser au load balancer externe le temps de détecter l’état d’arrêt du pod
      • Le temps passé dans preStop est inclus dans terminationGracePeriodSeconds
    • 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
  • 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.Bool comme isShuttingDown pour faire en sorte que /healthz retourne 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

Traitement des requêtes en cours

  • Créer un délai limite adapté au shutdown budget avec context.WithTimeout et le passer à server.Shutdown(ctx)
  • server.Shutdown peut 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, Shutdown retourne 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 BaseContext de http.Server
  • Dans un serveur HTTP, les contexts personnalisables sont BaseContext et ConnContext
    • Pour un Graceful Shutdown, BaseContext est plus adapté, car il permet de créer un context global annulable s’appliquant à tout le serveur
  • 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() ou time.Sleep()
    • time.Sleep(duration) peut être remplacé par une attente conjointe de time.After(duration) et ctx.Done() dans un select
  • Dans les anciennes versions de Go, time.After pouvait fuir de la mémoire jusqu’à l’exécution du timer

Différence entre Shutdown et Close

  • Le même principe s’applique non seulement aux serveurs HTTP, mais aussi aux services tiers
  • DB.Close de database/sql ferme 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 ECONNRESET ou socket 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 que server.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 defer de Go convient bien à ce modèle, car la dernière fonction enregistrée s’exécute en premier
  • 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 SIGINT et SIGTERM avec signal.NotifyContext
  • L’endpoint /healthz retourne HTTP 503 et Shutting down si isShuttingDown vaut true, sinon OK
  • 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é
  • BaseContext est relié à ongoingCtx afin que les requêtes in-flight ne soient pas annulées immédiatement juste après SIGTERM
  • À 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.Shutdown avec 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

1 commentaires

 
GN⁺ 2025-05-06
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 preStop a 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 que SIGTERM soit transmis à l’application, ce qui a beaucoup simplifié le traitement côté application.

    • Exact. Un sleep dans 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 /metrics classique 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.

    • Les stacks d’observabilité paraissent assez absurdes. Logs, métriques et traces ont chacun leur base de données, leurs sidecars et leur stack de visualisation, les bibliothèques d’intégration varient selon les langages, et les coûts cloud sont énormes.
      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 ssh sur un serveur et à faire un grep dans 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é.
    • Après plus de 8 ans à gérer des applications Go à forte charge, nous traitons exactement ce genre de problèmes dans notre bibliothèque de plateforme. Développer et améliorer des plateformes et des rolling deployments d’une entreprise à l’autre est devenu un hobby.
      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.
    • Je n’ai jamais vraiment compris pourquoi Prometheus et les outils associés utilisent un modèle pull. La plupart des autres utilisent un modèle push.
    • Je me demande si quelqu’un a déjà vu une solution pratique à ce problème. Si l’intervalle de scrape est de 15 secondes, on ne peut pas attendre 30 secondes juste pour enregistrer les métriques deux fois.
      À 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.Fatal exécute quand même les defer. En réalité, ce n’est pas le cas.
    log.Fatal("fatal") appelle os.Exit en interne, donc le programme se termine immédiatement et les defer ne s’exécutent pas. En revanche, panic("fatal") affiche à la fois fatal et in 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.

    • Je crois tellement à cela que, lors de la conception, je ne tiens pas du tout compte du graceful shutdown. Les composants doivent pouvoir crasher brutalement, et même souvent, sans danger ; si une proportion importante du système fonctionne comme prévu, il ne devrait pas y avoir d’effet significatif sur l’ensemble du système.
      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 un graceful shutdown destiné à ménager les clients ou les workflows, et le fait que les clients doivent en dépendre pour que le système fonctionne.
    • À l’époque des anciens serveurs physiques, on utilisait STONITH pour ça : https://smcleod.net/2015/07/delayed-serial-stonith/
    • Même dans des situations récupérables, il y a de bonnes raisons de faire en sorte qu’un arrêt normal ne ressemble pas à un arrêt catastrophique.
      Il y a une grande différence entre une application arrêtée avec sig int et une application tuée avec kill. Par exemple, une migration blue-green nécessite un comportement d’arrêt propre.
    • Exact. Mais ce n’est pas parce qu’un logiciel est conçu pour résister au débranchement de la prise qu’il faut effectivement débrancher la prise lors de l’arrêt.
      À 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 c et quelques commandes d’arrêt, c’était un programme mal écrit.

    • Ctrl-C est réservé à la copie dans le presse-papiers. L’utiliser comme action pour arrêter un programme est très contre-intuitif et va énerver les utilisateurs.
  • 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

    • Je me demande en quoi cela supprime le besoin de graceful shutdown dont parle l’auteur
  • 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ée

  • 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 SIGTERM et la mise à jour d’objets comme Pod.status ou les endpoint slices. Il peut y avoir une toute petite fenêtre pendant laquelle des connexions arrivent encore après SIGTERM, 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 traitent SIGTERM mais mettent longtemps à s’arrêter, ou ne traitent pas du tout SIGTERM tout en mettant longtemps à s’arrêter

  • Nous 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