Kubernetes : mises à jour en continu sans temps d'arrêt

6 octobre 2017

Rolling Update s'assure seulement que Kubernetes arrête les pods de manière roulante - un par un, en s'assurant toujours qu'il y a la quantité minimale souhaitée de pods en cours d'exécution. Cela peut sembler suffisant pour des déploiements sans temps d'arrêt. Mais comme d'habitude, ce n'est pas si simple.

Lors d'une mise à jour, Kubernetes doit mettre fin aux anciennes versions des pods - après tout, c'est ce que vous voulez. Mais c'est un problème lorsque votre pod est interrompu au milieu du traitement d'une requête HTTP. Et cela peut arriver lorsque votre application ne coopère pas avec Kubernetes. Dans cet article, je vais voir pourquoi cela se produit et comment y remédier.

Le problème

Le problème nous est apparu lorsque nous avons récemment déplacé l'un de nos services les plus connectés vers notre cluster Kubernetes. Le transfert s'est déroulé sans problème, mais après un certain temps, nous avons commencé à voir des erreurs de connexion apparemment aléatoires à ce service à travers notre plateforme.

Après quelques recherches, nous nous sommes rendu compte que lorsque nous déployions une nouvelle mise à jour du service, il y avait un risque que certains des autres services échouent à certaines requêtes pendant le déploiement.

Pour prouver cette hypothèse, j'ai fait un test synthétique en utilisant wrk et en déclenchant la mise à jour continue en même temps :

wrk https://www.server.com/api &
sleep 1 && kubectl set image deployment/api api=driftrock/api:2

Le résultat a confirmé le problème :

Test de 10s à https://www.server.com/api
2 threads et 10 connexions
Thread Stats Avg Stdev Max +/- Stdev
Latence 290.50ms 176.36ms 1.19s 87.78%
Req/Sec 19.39 9.31 49.00 47.06%
368 requêtes en 10.10s, 319.48KB lus
Réponses non-2xx ou 3xx : 19
Requêtes/sec : 36.44
Transferts/sec : 31.63KB

Examinons la question plus en détail dans la suite de cet article.

Processus de terminaison des pods Kubernetes

Voyons d'abord comment Kubernetes met fin aux pods. Il sera crucial de comprendre comment l'application doit gérer la terminaison.

D'après la documentation https://kubernetes.io/docs/concepts/workloads/pods/pod/#termination-of-pods, la procédure suit plus ou moins les étapes suivantes :

  1. Si le pod dispose de services réseau, kubernetes arrête d'acheminer les nouvelles connexions vers le pod de terminaison. Les connexions déjà établies restent intactes et ouvertes.
  2. Kubernetes envoie le signal TERM au processus racine de chaque conteneur dans le pod, en supposant que les conteneurs commencent à s'arrêter. Le signal envoyé ne peut pas être configuré.
  3. Il attend la période de temps spécifiée dans terminationGracePeriodSeconds du pod (30 secondes par défaut). Si les conteneurs sont toujours en cours d'exécution à ce stade, il envoie KILL qui met fin aux conteneurs sans leur donner une chance de respirer encore une fois.

Cela a l'air bien. Quel est le problème ?

Il semble que Kubernetes fasse tout ce qu'il faut - arrête d'envoyer de nouvelles connexions et donne à l'application 30 secondes pour arrêter le travail en cours de traitement.

Cependant, il y a deux problèmes importants. Le premier concerne les connexions HTTP keep-alive et le second la manière dont les applications réagissent au signal TERM.

Connexions permanentes (Keep-alive)

Comme nous avons pu le voir ci-dessus, Kubernetes maintient toutes les connexions réseau établies intactes pendant la terminaison. C'est logique - Kubernetes donne 30 secondes à l'application pour dire à ses clients de se déconnecter ou d'envoyer la réponse.

Mais si l'application ne fait rien, elle recevra de nouvelles requêtes via la connexion ouverte jusqu'à ce que les 30 secondes soient écoulées. Ainsi, lorsque le client envoie une nouvelle requête juste une milliseconde avant que Kubernetes ne mette fin au conteneur à l'aide du signal KILL, le client verra la connexion s'interrompre au lieu de recevoir une réponse.

Dans le monde HTTP, cela n'affecte pas seulement les clients du navigateur, pour lesquels quelques connexions interrompues peuvent ne pas être catastrophiques. La plupart des applications fonctionnent derrière un équilibreur de charge. Dans la plupart des cas, l'équilibreur de charge se connecte au backend à l'aide d'une connexion de type "keep-alive". Cela affecte également les appels API, même si ces appels sont généralement effectués en tant que connexions séparées du client vers le côté public de l'équilibreur de charge.

Mauvaise manipulation du signal TERM

Vous vous dites peut-être : "Attendez un peu, pourquoi l'application ne s'arrête-t-elle pas ? Pourquoi continue-t-elle à recevoir de nouvelles demandes après avoir reçu le signal TERM jusqu'à la dernière instruction du CPU ?

Le problème réside dans le fait que différentes applications au sein de différentes configurations d'images Docker traiteront le signal de différentes manières.

Au cours de cette enquête, j'ai relevé trois cas différents de traitement du signal TERM par les applications :

  • il l'ignore - par exemple, le framework Phoenix d'Elixir n'a pas de terminaison gracieuse intégrée(voir ici).
  • il fait quelque chose d'autre - par exemple nginx se termine immédiatement (c'est-à-dire de façon non gracieuse)(voir ici)
  • soit il est exécuté avec un shell dans un conteneur Docker (par exemple /bin/sh -c "rails s"), de sorte que l'application ne reçoit pas du tout le signal.

L'un ou l'autre de ces cas conduit à ce que le problème décrit à propos des connexions keep-alive prenne effet. Voyons comment l'éviter et faire en sorte que les applications soient prêtes pour la mise à jour continue de Kubernetes.

Pod™ prêt à l'emploi sans temps d'arrêt

D'après ce qui précède, nous pouvons avoir une idée de ce qui doit être fait pour que les pods soient vraiment prêts pour les mises à jour sur Kubernetes. Permettez-moi d'essayer de l'expliquer en quelques points :

  • Si le pod dispose d'un service réseau, il doit drainer les connexions de manière gracieuse après avoir reçu le signal TERM.
  • Si votre application dispose d'une gestion intégrée du signal TERM, assurez-vous qu'elle le reçoit lorsqu'elle s'exécute dans un conteneur Docker.
  • Définissez le paramètre terminationGracePeriodSeconds en fonction de la durée maximale prévue pour l'achèvement d'un travail en cours de traitement. Cela dépend de la charge de travail de votre application.
  • Faites fonctionner au moins 2 (mais idéalement 3) répliques et configurez le déploiement de manière à ce qu'au moins 1 pod fonctionne pendant les mises à jour itinérantes.

Lorsque toutes les conditions sont remplies, Kubernetes s'occupe du reste et veille à ce que les mises à jour en continu se fassent sans temps d'arrêt, tant pour les applications réseau que pour les charges de travail en arrière-plan de longue durée.

Voyons maintenant comment remplir ces conditions.

Vider les connexions (en utilisant le proxy inverse de nginx)

Malheureusement, toutes les applications ne peuvent pas être (facilement) mises à jour pour drainer gracieusement les connexions. Comme mentionné ci-dessus, le framework Phoenix n'a pas de support intégré. Pour éviter d'avoir à gérer de telles divergences entre les différentes technologies, nous pouvons créer une simple image nginx, qui gérera cela de manière universelle.

La lecture de la documentation sur la manière de vidanger les connexions m'indique que

nginx peut être contrôlé par des signaux envoyés au processus principal :
TERM, INT fast shutdown
QUIT graceful shutdown

Très bien ! Il dispose d'un signal d'arrêt progressif. Mais attendez... Nous devons lui envoyer le signal QUIT pour le faire, mais Kubernetes n'enverra que TERM, qui sera également reçu par Nginx, mais avec un résultat différent - terminaison immédiate.

Pour résoudre ce problème, j'ai écrit un petit script shell qui entoure le processus nginx et traduit le signal TERM en signal QUIT :

#!/bin/sh

# Configurer le piège pour le signal TERM (= 15), et envoyer QUIT à nginx à la place
trap "echo SIGTERM trapped. Signalling nginx with QUIT ; kill -s QUIT \$(cat /var/run/nginx.pid)" 15

# Démarrer la commande en arrière-plan - ainsi le shell est au premier plan et reçoit des signaux (sinon il ignore les signaux)
nginx "$@" &

CHILD_PID=$ !
echo "Nginx started with PID $CHILD_PID"

# Attendre l'enfant en boucle - apparemment le signal QUIT à nginx entraîne la sortie de `wait` à
#, même si le processus est toujours en cours d'exécution. Ajoutons donc une boucle jusqu'à ce que le processus
# existe
while kill -s 0 $CHILD_PID ; do wait $CHILD_PID ; done

echo "Process $CHILD_PID exited. Exit too..."

En ajoutant un simple default.conf avec la configuration du proxy inverse, nous pouvons créer le fichier Dockerfile pour notre image de proxy :

FROM nginx:mainline-alpine

COPY default.conf /etc/nginx/conf.d/
ADD trap_term_nginx.sh /usr/local/bin/

CMD [ "/usr/local/bin/trap_term_nginx.sh", "-g", "daemon off ;" ]

En envoyant le signal TERM au nouveau conteneur, nginx quittera le conteneur de manière élégante, c'est-à-dire qu'il attendra que les requêtes actives soient répondues et n'acceptera pas de nouvelles requêtes sur les connexions existantes. Il s'arrêtera alors de lui-même.

Nous avons ouvert l'image et elle peut être trouvée dans Docker Hub : https://hub.docker.com/r/driftrock/https-redirect-proxy/

Veiller à ce que le processus reçoive le signal

Une autre condition que j'ai décrite ci-dessus est de s'assurer que le processus reçoit le signal lorsque nous savons que l'application est prête à le traiter.

Par exemple, sidekiq gère déjà TERM comme nous le souhaitons. On pourrait croire qu'il n'y a rien de plus à faire. Malheureusement, dans un environnement Docker comme Kubernetes, il faut être très prudent pour ne pas faire d'erreur involontaire.

Le problème se pose lorsque le conteneur est configuré pour exécuter des commandes à l'aide d'un shell. Par exemple, le conteneur est configuré pour exécuter des commandes à l'aide d'un shell :

commande : ["/bin/sh", "-c", "rake db:migrate && sidekiq -t 30"]

Dans ce cas, le processus racine du conteneur sera /bin/sh. Comme décrit ci-dessus, Kubernetes lui envoie le signal. Ce qui n'est pas clair à première vue, c'est que le shell UNIX ignore les signaux lorsqu'un processus enfant est en cours d'exécution. Il ne le transmet pas à l'enfant et ne fait rien d'autre. Cela signifie que le signal n'est pas envoyé à notre application - sidekiq dans l'exemple ci-dessus.

Il y a deux façons de résoudre ce problème. La plus simple consiste à demander à l'interpréteur de commandes de se remplacer par la dernière commande en utilisant la commande exec :

commande : ["/bin/sh", "-c", "rake db:migrate && exec sidekiq -t 30"]

Mais si vous le pouvez, le mieux est d'éviter d'utiliser un wrapper shell. Exécutez la commande directement en tant que premier processus et utilisez les conteneurs Kubernetes Init pour les commandes que vous souhaitez exécuter avant le démarrage de l'application.

Mise en place d'un pod™ prêt à l'emploi

Lorsque nous avons un proxy pour gérer les connexions keep-alive dans les applications HTTP et que nous savons comment nous assurer que les autres applications recevront le signal TERM pour s'arrêter gracieusement, nous pouvons configurer notre Zero-downtime ready pod™.

La première chose à faire est d'installer le proxy nginx. Ajoutez-le comme un autre conteneur à votre pod. Le proxy suppose que votre application écoute sur le port 8080, et lui-même écoutera sur le port 80. Si vos services sont déjà configurés pour le port 80, alors vous n'avez rien d'autre à faire, ajoutez simplement le conteneur (Note : nous trouvons utile de nommer le conteneur de la même manière que le conteneur de l'application, avec le suffixe -proxy).

...
containers :
...
- name : APP-NAME-proxy
image : driftrock/https-redirect-proxy
ports :
- containerPort : 80
...
...

La deuxième chose à faire est de s'assurer que l'application est prête à recevoir les signaux TERM. Comme je l'ai décrit ci-dessus, nous pouvons le faire :

...
conteneurs :
- name : APP-NAME
command : ['/bin/sh', '-c', 'rake db:migrate && exec puma -p 8080']
...
...
...

Et c'est tout. Bien sûr, compte tenu de vos spécificités, il se peut que vous deviez faire quelque chose de légèrement différent ici et là. J'espère que vous avez compris l'idée.

Résultat

Pour tester cela, nous avons mis à jour les pods avec la nouvelle image de proxy, puis nous avons lancé le benchmark et fait en sorte que Kubernetes exécute à nouveau la mise à jour en continu :

Test de 10s à https://www.server.com/api
2 threads et 10 connexions
Thread Stats Avg Stdev Max +/- Stdev
Latence 249.36ms 95.76ms 842.78ms 90.19%
Req/Sec 20.04 9.21 40.00 54.05%
403 requêtes en 10.10s, 349.87KB lus
Requêtes/sec : 39.91
Transferts/sec : 34.65KB

Désormais, toutes les requêtes de benchmark se terminent avec succès, même lorsque les pods sont terminés et redémarrés pendant le test de mise à jour en continu.

Libérer le singe du chaos

Après cela, j'ai réfléchi à la manière de réaliser un test de stress. En fait, lorsque le déploiement Kubernetes effectue la mise à jour en continu, il termine simplement le pod sur le dos. La même chose que vous pouvez faire avec kubectl delete pod [POD_NAME]. En fait, les étapes de terminaison décrites ci-dessus sont tirées de l'article intitulé "Pod termination process", et non "Deployment rolling update process".

J'étais donc curieux de savoir si la nouvelle configuration permettrait de tuer les pods en boucle (en s'assurant qu'il y a au moins un pod en marche en permanence), en leur donnant très peu de temps pour vivre. En théorie, cela devrait fonctionner. Le pod démarre, il reçoit peut-être une requête et commence à la traiter. Au même moment, il reçoit le signal TERM, car mon singe du chaos va essayer de le tuer. La requête sera terminée et aucune nouvelle requête ne sera acheminée vers lui.

Voyons ce qu'il en est :

wrk -t 120 https://www.server.com/api &

while true ; do
sleep 5
READY_PODS=$(kubectl get pods -l app=api-server -o json | jq -r ".items | map(select(.metadata | has(\"deletionTimestamp\") | not)) | map(select(.status.containerStatuses | map(.ready) | all)) | .[].metadata.name")
EXTRA_READY_PODS=$(echo $READY_PODS | ruby -e 'puts STDIN.readlines.shuffle[1..-1]' | tr '\n' ' ' )
/bin/sh -c "kubectl delete pods $EXTRA_READY_PODS"
kubectl get pods -l app=api-server
done

Donne le résultat :

Test de 120s à https://www.server.com/api
2 threads et 10 connexions
Thread Stats Avg Stdev Max +/- Stdev
Latence 953.28ms 513.90ms 3938.00ms 90.19%
Req/Sec 10.49 9.21 40.00 54.05%
1261 requêtes en 120.209s, 21.022MB read
Requêtes/sec : 10.49
Transfer/sec : 175.48KB

Cela montre que l'installation n'interrompt pas une seule connexion, même lorsque les pods sont terminés dès qu'ils sont prêts et commencent à recevoir des requêtes. SUCCESS !

Conclusion

En résumé, nous avons trouvé quelques principes simples qui doivent être respectés afin de faire fonctionner Kubernetes pour nous et de maintenir des déploiements sans temps d'arrêt. La solution que nous avons trouvée fonctionne même en cas de stress.

Merci de nous avoir lu et de nous avoir fait part de vos commentaires. Si vous avez un autre moyen de résoudre ce problème, nous serions ravis de le connaître aussi !