Table des matières du cours
Leçon 9 — Mettre à l'échelle et mettre à jour en mesurant chaque coupure
Par Dorian Chávez · fondateur et PDG de Hábil ·
Lundi, 8 h 57 du matin. À 9 h 00, l'équipe d'exploitation tient sa réunion hebdomadaire et quarante personnes vont ouvrir, presque en même temps, le rapport du revisor. Lundi dernier, il s'est passé quelque chose de semblable. À 9 h 01, le tableau de bord est devenu lent ; quelqu'un a augmenté les répliques manuellement, de trois à huit, pour « tenir le coup » ; et il a oublié de les redescendre. À 9 h 04, une autre personne a appliqué un correctif urgent, et pendant quelques secondes plusieurs utilisateurs ont vu une erreur à l'écran. Personne ne sait combien. Aujourd'hui, le directeur de l'exploitation pose trois questions, et la réunion commence dans trois minutes : de combien de répliques le service a-t-il vraiment besoin, et qui décide quand il y en a trop ? combien de requêtes sont perdues pendant une mise à jour ? et que se passe-t-il si l'un des serveurs du cluster tombe en panne ? Il y a une semaine, ta réponse aux trois était la même : « ça devrait aller ».
Cette phrase est le problème. Dans Kubernetes, « ça devrait aller » peut se transformer en un nombre, et cette leçon est l'atelier où l'on fait cette transformation. Ce n'est pas une histoire inventée pour faire peur. En 2019, un ingénieur nommé Phil Pearl a raconté sur son blog que, en déplaçant une API vers un cluster Kubernetes de Google derrière un Ingress, ses tests recevaient de temps en temps des erreurs 502. En cherchant la cause, il a trouvé quelque chose que presque personne ne disait à l'époque : quand un Pod s'arrête, l'avis d'arrêt et le retrait de ce Pod de la liste des destinations du réseau se produisent en même temps, et non l'un après l'autre ; l'étape qui sort le Pod de la liste n'attend pas l'étape qui l'arrête. Ce qui varie, c'est le temps que met la nouvelle à parvenir à chaque répartiteur de charge : selon Pearl, beaucoup pour un Ingress et peu pour un Service. Avec cette situation de compétition (race condition), une requête peut arriver sur un Pod qui est déjà en train de se fermer (Phil Pearl, « Kubernetes' dirty endpoint secret and Ingress », 26 juillet 2019). Ce que tu apprends aujourd'hui, c'est comment mesurer cette situation de compétition sur ton propre cluster, comment la refermer avec quelques lignes, et comment ne pas accorder à une mesure plus de crédit qu'elle n'en mérite.
Cette leçon comporte trois parties. La première apprend au cluster à se mettre à l'échelle tout seul : un compteur de consommation (metrics-server), un autoscaler qui utilise une formule que tu pourras calculer manuellement, et une règle de répartition pour que les répliques ne s'entassent pas sur un seul nœud. La deuxième apprend à mettre à jour et évincer sans surprises : une mise à jour mesurée avec une interrogation périodique (polling) écrite par nous, avec et sans la pause qui referme la situation de compétition, l'historique pour revenir en arrière, et un budget d'interruptions face manuellementtenance d'un nœud. La troisième casse un serveur exprès et mesure combien dure la coupure. Chaque chiffre que tu verras est sorti de l'exécution de la procédure, et chaque chiffre vient avec ce qui le limite.
Durée : 2 × 45 min de lecture et de pratique ; l'exécution complète du script prend bien plus longtemps (c'est dit dans « À propos des exécutions »), parce que les attentes font partie de ce qu'on enseigne
Ce que tu construis : le revisor qui augmente et réduit ses répliques avec la charge, réparti entre les deux nœuds de travail, qui se met à jour avec une interrogation périodique comptant chaque requête perdue et qui survit à un nœud arrêté avec une perte mesurée et expliquée
Ce que tu apprends : pourquoi metrics-server tel quel ne fonctionne pas dans kind et ce que change le drapeau qui le corrige ; la formule du HorizontalPodAutoscaler, sa tolérance de 10 % et la fenêtre qui retarde la réduction ; pourquoi un Deployment géré par un autoscaler ne porte pas replicas ; la répartition entre nœuds avec topologySpreadConstraints et pourquoi le nœud de contrôle exige nodeTaintsPolicy: Honor ; RollingUpdate avec maxSurge et maxUnavailable ; la situation de compétition entre l'arrêt et les destinations du Service, et le preStop qui la referme ; rollout history et rollout undo, et pourquoi rollout status peut se terminer alors que les anciens Pods répondent encore ; le PodDisruptionBudget et un drain qui se bloque exprès ; et la fenêtre d'un nœud en panne, avec ce qui la raccourcit et ce qui ne la raccourcit pas
D'où tu viens. De la Leçon 8 : le revisor vit dans son propre espace de noms, revisor, avec le réseau fermé de la Leçon 7, un volume pour ses rapports et une tâche planifiée. De la Leçon 5, tu apportes ce qui devient aujourd'hui un calcul : les requests de CPU (50m), qui sont l'étalon avec lequel l'autoscaler mesure le pourcentage, et le délai d'arrêt, qu'on affine aujourd'hui avec une pause avant le signal de terminaison. De la Leçon 6, tu apportes la plateforme : MetalLB et Envoy Gateway déjà installés, avec l'ordre « plateforme, attente, projet » qui se répète aujourd'hui dans un seul script. Et de la Leçon 3, le Pod depurar et l'habitude de regarder ENDPOINTS avant d'accuser qui que ce soit.
Ce que cette leçon ne fait pas. Elle ne met pas à l'échelle les nœuds : dans kind, les trois nœuds sont des conteneurs fixes, et ajouter ou retirer des machines est le travail d'une autre pièce qu'un vrai cluster apporte et que ce laboratoire n'a pas. Elle ne met pas à l'échelle en vertical (donner plus de CPU à chaque réplique) ni avec des métriques personnalisées (requêtes par seconde, taille d'une file). Elle ne fait pas de mises à jour bleu-vert ni canari : la seule stratégie ici est la progressive qu'apporte Kubernetes. Elle ne met pas de chiffrement. Et ce n'est pas un vrai test de charge : la charge de la leçon est un Job de curl en boucle, suffisant pour voir l'autoscaler réagir, et rien de plus. La Leçon 10 rassemble tout en deux commandes et liste ce qui manque à ce montage pour être prêt pour la production.
À propos des exécutions. Toutes les sorties de cette leçon ont été obtenues le 21 octobre 2026 avec kind v0.33.0, kubectl v1.37.1, l'image de nœud kindest/node:v1.37.0 figée par son empreinte (Kubernetes v1.37.0), MetalLB v0.16.1, Envoy Gateway v1.9.2 et metrics-server v0.9.0, sur le cluster à trois nœuds de la Leçon 1, qu'on appelle ici curso-k8s-l9. La machine était un Mac à processeur ARM avec Docker Engine 29.2.1 dans une machine virtuelle Linux (Colima) dotée de 6 CPU et de 16 GiB de mémoire, et non Linux Mint, qui est le système de référence du cours. Et elle était partagée : pendant que cette leçon tournait, d'autres leçons du cours tournaient sur la même machine avec leurs propres clusters, et la charge du système (la moyenne qu'affiche uptime) est passée de moins de 5 à plus de 40 pendant le travail. Cela compte pour lire deux types de chiffres : les durées (télécharger les images de MetalLB a pris, lors d'essais précédents, plusieurs minutes par nœud, et preparar.sh comporte de longues attentes pour cette raison) et les comptes de requêtes de 9.9 et 9.12, qui dépendent de la quantité de CPU reçue par le client de l'interrogation périodique. Chaque section dit quel chiffre dépend de quoi. Ce qui change sur ton Mint : l'architecture (amd64 au lieu de arm64), les IP des Pods, des Services et des nœuds, les suffixes des noms, les âges, le nœud où atterrit chaque réplique et, surtout, les chiffres de requêtes : ce sont les tiens, ils ne se comparent pas à ceux d'ici. Il y a en outre une différence de réseau qu'il faut mentionner. L'exécution de cette leçon a utilisé un réseau Docker dédié, curso-k8s-red-l9, avec le sous-réseau 172.29.0.0/16, afin de pouvoir tourner en même temps que d'autres leçons qui installent elles aussi MetalLB sans se marcher dessus ; c'est pourquoi les IP des nœuds apparaissent en 172.29.0.x et le point d'entrée du Gateway en 172.29.255.200. Sur ta machine, avec le réseau kind normal, les IP sont en 172.18.0.x et le point d'entrée en 172.18.255.200, ce que preparar.sh fait par défaut. correr.sh fait la même chose si tu le lances avec RED=kind SUBRED=172.18.0.0/16 (ce sont ses valeurs par défaut) et s'arrête avant de rien créer si le réseau n'a pas le sous-réseau attendu. Le parcours complet se trouve dans programas/09-escalar-y-actualizar/ : correr.sh crée le cluster, y charge les trois images, le met dans l'état de la Leçon 8 avec preparar.sh, parcourt la leçon dans l'ordre et le supprime. Sa sortie est correr.salida.txt. L'exécution complète a duré 1 809 secondes (30 minutes et 9 secondes) : créer le cluster et attendre qu'il soit prêt, 37 secondes ; preparar.sh, 343 ; supprimer le cluster, 2 ; le reste, ce sont les attentes que la leçon apprend à faire (la charge et la réduction de l'autoscaler, les sept interrogations périodiques de mise à jour, le nœud arrêté). L'image qui n'existe pas de 9.10.4 s'exécute à part, avec fig09_02.imagen-rota.sh (sa sortie est fig09_02.imagen-rota.salida.txt), dans un cluster dédié et minimal qui prend environ quatre minutes. Il faut internet : les manifestes de metrics-server, MetalLB et Envoy Gateway sont téléchargés depuis leurs publications, figées à leur version, et leurs images sont téléchargées la première fois.
À la fin, tu seras capable de
- Expliquer pourquoi
metrics-serverne démarre pas tel quel dans kind, lire lex509de son journal, appliquer le drapeau qui le corrige et dire pourquoi ce drapeau n'est que pour le laboratoire. - Calculer manuellement combien de répliques demandera un
HorizontalPodAutoscalerà partir des répliques actuelles, du pourcentage de CPU mesuré et de la cible, en appliquant la tolérance de 10 %, et le vérifier avec la colonneTARGETS. - Écrire un
HorizontalPodAutoscaleravec plancher, plafond et fenêtre de réduction, et expliquer pourquoi le Deployment qu'il gouverne ne déclare plusreplicas. - Répartir les répliques entre les nœuds avec
topologySpreadConstraints, et expliquermaxSkew,DoNotSchedule,nodeTaintsPolicy: HonoretmatchLabelKeys. - Mesurer avec une interrogation périodique combien de requêtes échouent pendant une mise à jour, avec et sans
preStop, et dire ce que cette mesure prouve et ce qu'elle ne prouve pas. - Utiliser
rollout historyetrollout undo, regarderdeletionTimestampavant de conclure qu'un retour en arrière n'a pas fonctionné, et lire une mise à jour avec une image qui n'existe pas (ErrImagePull), en vérifiant que le service n'est pas interrompu. - Provoquer un
drainbloqué par unPodDisruptionBudgettrop strict, le lire et le corriger ; arrêter un nœud, mesurer la fenêtre de panne et expliquer ce qui la raccourcit et ce qui ne la raccourcit pas.
Le pourquoi avant le comment
Jusqu'ici, le nombre de répliques du revisor, c'est toi qui le décidais, écrit dans un fichier. C'est une bonne décision pour apprendre, et une mauvaise pour un lundi à 9 h 00. Si la charge triple, personne n'est en train d'écrire dans ce fichier. Et si la charge baisse, trois répliques continuent de consommer du CPU et de la mémoire que d'autres Pods pourraient utiliser. La première question de la leçon est donc : qui décide combien de répliques il faut, avec quelle information et avec quel frein ? La réponse a trois éléments. Un compteur, metrics-server, qui regarde combien de CPU et de mémoire utilise chaque Pod. Un contrôleur, le HorizontalPodAutoscaler (HPA), qui compare ce qui est mesuré à une cible et ajuste le nombre de répliques. Et une règle de répartition, pour que les nouvelles répliques ne tombent pas toutes sur la même machine, car trois répliques sur un seul nœud tombent en panne ensemble avec ce nœud.
La deuxième question est : que devient le trafic pendant que quelque chose change ? Beaucoup de choses changent dans un cluster vivant : une nouvelle version, un nœud qui part en maintenance, un nœud qui tombe en panne sans prévenir. Chacune a une réponse différente, et la différence compte. Une mise à jour se planifie : Kubernetes peut remplacer les répliques une par une et attendre que la nouvelle soit prête. Une maintenance se négocie : tu peux dire au cluster combien de répliques doivent rester debout, et le cluster respecte ce nombre quand il évince. Une panne surprend : personne n'a demandé la permission et le cluster met du temps à s'en apercevoir. La leçon utilise les trois cas comme prétexte pour mesurer. Mesure, ne devine pas, parce que chacune des trois réponses est un nombre qui change avec la configuration, et ce nombre est la réponse au directeur.
Et il y a une troisième question, plus inconfortable : comment mesure-t-on la coupure sans se tromper ? Une interrogation périodique qui envoie quelques requêtes et ne voit aucun échec ne démontre pas qu'il n'y a pas d'échecs ; elle démontre que cette fois-là, à ce rythme, on n'en a pas vu. La leçon le dit chaque fois qu'elle mesure quelque chose, et répète les mesures là où cela coûte peu. C'est l'habitude qui sépare un rapport d'une anecdote.
La leçon compte dix parties de concepts, puis les sections habituelles :
- Le cluster et le point de départ (9.1) : l'état dans lequel se termine la Leçon 8, démarré avec un script.
- Mesurer avant de mettre à l'échelle (9.2) :
metrics-server, le drapeau et pourquoi. - L'autoscaler et sa formule (9.3) :
HorizontalPodAutoscaler, tolérance, fenêtre et pourquoireplicassort du Deployment. - Répartir les répliques (9.4) :
topologySpreadConstraints. - Appliquer les changements (9.5) : ce que montre
kubectl diffet le désordre de répliques qui s'y cache. - Avec de la charge, l'autoscaler monte (9.6).
- Sans charge, il redescend, après la fenêtre (9.7).
- Qui commande les répliques (9.8).
- Mettre à jour et mesurer la coupure (9.9), avec l'interrogation périodique, le
preStopet le retour en arrière (9.10). - Maintenance et panne d'un nœud (9.11 et 9.12) :
PodDisruptionBudget,drainet un nœud arrêté.
Voici la destination. À la fin de 9.6, avec de la charge, l'autoscaler a déjà changé le nombre de répliques et l'affiche sur une seule ligne :
$ kubectl get hpa revisor -n revisor --no-headers
revisor Deployment/revisor cpu: 38%/50% 2 6 4 5m52s
C'est une ligne de l'exécution (la dernière de l'attente de 9.6) : le HPA revisor, qui gouverne le Deployment revisor, mesure 38 % face à une cible de 50 %, a un plancher de 2 répliques, un plafond de 6 et en compte aujourd'hui 4.
Si tu n'as pas l'image du cours de Docker
Cette leçon utilise trois images locales : curso-docker-revisor:1.6.0, celle du revisor de la Leçon 2 ; curso-docker-prueba:1.0.0, celle du service qui échoue exprès ; et une nouvelle, curso-docker-revisor:1.6.1, qui sert à ce que la mise à jour de la leçon ait quelque chose à mettre à jour. Les deux images du revisor sortent du même Dockerfile et du même code : la seule chose qui change est la version que le service annonce dans son /salud ("version":"1.6.0" ou "version":"1.6.1"), et c'est justement ce qui permet de voir, requête par requête, quelle réplique a répondu. Vérifie que tu les as :
$ docker image ls --format '{{.Repository}}:{{.Tag}} {{.ID}}' | grep -E '^curso-docker-(revisor|prueba):'
curso-docker-revisor:1.6.1 61c70431badb
curso-docker-prueba:1.0.0 400d3fd59edc
curso-docker-revisor:1.6.0 bd1b2e7d91b8
(Cette sortie vient de la machine de l'auteur et n'est pas dans correr.salida.txt. Les identifiants en proviennent ; sur la tienne ils peuvent être autres si tu as construit les images toi-même. Ce qui compte, c'est que les trois tags existent.) S'il te manque la 1.6.1, construis-la comme la 1.6.0, depuis le dépôt du cours de Docker, avec le Dockerfile de sa Leçon 10 sur le dossier programas/revisor et avec --build-arg VERSION=1.6.1, en la taguant exactement curso-docker-revisor:1.6.1. C'est une image de laboratoire : elle sert à voir une mise à jour, ce n'est pas une nouvelle version du service.
Les concepts
9.1 Le cluster et le point de départ
9.1.1 Une règle de cohabitation, encore
Cette leçon installe MetalLB (par l'intermédiaire de preparar.sh, 9.1.3), et la règle de la Leçon 6 reste en vigueur : deux clusters avec MetalLB et la même plage d'adresses, sur le même réseau Docker, se marchent dessus. Avant de créer le tien, vérifie le réseau kind et que personne ne répond déjà sur la première adresse de la plage :
# La red kind debe ser 172.18.0.0/16: de ahí sale el rango de MetalLB.
docker network inspect kind -f '{{range .IPAM.Config}}{{.Subnet}} {{end}}'
# Nadie debe contestar en 172.18.255.200. Si este curl responde, hay otro
# clúster con MetalLB vivo: espera a que termine.
# Si imprime un número (200, 404…), alguien contesta; si imprime 000, nadie.
docker run --rm --network kind curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777 -s -o /dev/null -w '%{http_code}\n' -m 3 http://172.18.255.200/
kind get clusters
Si le réseau kind n'existe pas encore parce que tu n'as jamais créé de cluster, crée le cluster et vérifie le réseau juste après. Il y a en outre un détail de laboratoire qu'il vaut mieux dire dès maintenant, parce qu'il change les adresses que tu verras dans les sorties : l'exécution de cette leçon a utilisé un réseau Docker dédié, curso-k8s-red-l9, avec le sous-réseau 172.29.0.0/16, afin de pouvoir tourner en même temps que d'autres leçons qui installent elles aussi MetalLB. Sur ta machine, avec le réseau kind normal, les adresses de la plage sont en 172.18.255.x, et non en 172.29.255.x ; le script correr.sh s'adapte avec deux variables, RED et SUBRED, et comporte le frein habituel : si le réseau n'a pas le sous-réseau attendu, il s'arrête avant de rien créer.
9.1.2 Créer le cluster et charger les images
Depuis la racine du dépôt du cours :
# Un kubeconfig temporal y propio: tu ~/.kube/config ni se lee ni se toca.
# La ruta queda guardada en la variable KUBECONFIG para borrarla al final.
export KUBECONFIG="$(mktemp -d /tmp/curso-k8s-l9.XXXX)/kubeconfig"
# curso-k8s-l9 NO debe aparecer aquí. Si aparece y no es tuyo, detente.
kind get clusters
kind create cluster --name curso-k8s-l9 --config programas/09-escalar-y-actualizar/kind-curso.yaml
# Debe decir kind-curso-k8s-l9. Si dice otra cosa, no sigas.
kubectl config current-context
# La guarda de siempre, con el nombre de este clúster. Va delante de cada
# comando que cambie algo: ctx && kubectl apply ...
ctx() { [ "$(kubectl config current-context)" = kind-curso-k8s-l9 ] || { echo "contexto equivocado: no sigo"; return 1; }; }
ctx && kind load docker-image curso-docker-revisor:1.6.0 curso-docker-revisor:1.6.1 curso-docker-prueba:1.0.0 --name curso-k8s-l9
cd programas/09-escalar-y-actualizar
# El token de mentira de la Lección 4: nuevo, legible solo para ti (0600), y no se versiona.
(umask 077; openssl rand -hex 16 > token.txt)
ls -l token.txt # -rw-------
La fonction ctx compare le contexte actif avec celui qui est attendu ; s'ils coïncident, elle n'affiche rien, et sinon elle écrit « contexto equivocado: no sigo » et renvoie une erreur qui coupe la chaîne &&. Dans cette leçon, elle va devant chaque commande qui change quelque chose (apply, patch, scale, drain, delete) ; dans les sorties qui suivent elle est omise pour qu'elles tiennent, mais dans ton terminal elle y est. Les commandes qui ne font que lire (get, top, describe) n'en ont pas besoin, même si elle ne gêne pas. correr.sh fait la vérification avant chaque commande d'une autre façon, avec une fonction qui s'arrête toute seule.
Le (umask 077; …) fait naître token.txt en -rw------- : le masque 077 retire toutes les permissions au groupe et aux autres, et les parenthèses font que le changement ne vaut que pour cette commande. C'est le soin des Leçons 4 à 6, et il se répète parce qu'une habitude qu'on omet une fois n'est plus une habitude.
$ kind create cluster --name curso-k8s-l9 --config kind-curso.yaml
(crear el clúster y esperar a que estuviera listo tardó 37 s)
$ kubectl config current-context
kind-curso-k8s-l9
$ kubectl get nodes -o wide | awk '{print $1, $2, $5, $6}' | column -t
NAME STATUS VERSION INTERNAL-IP
curso-k8s-l9-control-plane Ready v1.37.0 172.29.0.2
curso-k8s-l9-worker Ready v1.37.0 172.29.0.4
curso-k8s-l9-worker2 Ready v1.37.0 172.29.0.3
Créer le cluster et attendre que ses nœuds et les Pods de kube-system soient prêts a pris 37 secondes, avec l'image du nœud déjà téléchargée auparavant. Ce sont trois nœuds Ready en v1.37.0 : un nœud de contrôle et deux nœuds de travail. La colonne INTERNAL-IP est l'adresse de chaque nœud sur le réseau Docker ; ici elle tombe en 172.29.0.x à cause du réseau dédié de l'exécution, et sur ta machine elle tombera en 172.18.0.x. Charger les trois images avec kind load imprime ses lignes habituelles (not yet present on node … loading...) ; correr.sh les fait taire et note à la place qu'elles ont été chargées.
9.1.3 Le point de départ : preparar.sh
La Leçon 8 se termine avec un cluster qui porte déjà la plateforme de la Leçon 6 et le projet de la Leçon 8. Pour ne pas répéter manuellement quelques minutes d'installation, preparar.sh les refait avec les trois étapes de la Leçon 6, à la vue de tous et dans le même ordre : la plateforme (MetalLB et Envoy Gateway), l'attente avec kubectl wait jusqu'à ce qu'ils soient prêts, et enfin la plage d'adresses, la classe de passerelle et le projet du dossier punto-de-partida/. Si tu viens de la Leçon 8 avec ton cluster vivant, tu n'en as pas besoin ; si tu commences ici, ou si tu l'as déjà supprimé, tu le lances :
ctx && ./preparar.sh
C'est un programme : preparar.sh. Lis-le avant de le lancer : il ne cache rien. Chaque étape vérifie le contexte, imprime la commande qu'elle va lancer et s'arrête en cas d'échec. Il accepte une variable, POOL_RANGO, pour changer la plage de MetalLB si ton réseau n'est pas le 172.18.0.0/16. Une remarque sur la largeur : infra/metallb-pool.yaml et preparar.sh portent .200-.220, tandis que correr.sh calcule .200-.250, qui est celle qu'on voit ci-dessous. Cela n'a pas d'importance ici, parce qu'on n'utilise que la première adresse de la plage, celle du Gateway.
$ POOL_RANGO=172.29.255.200-172.29.255.250 ./preparar.sh
## Paso 1: la plataforma
$ kubectl apply -k infra/metallb >/dev/null
$ kubectl apply --server-side -k infra/envoy-gateway >/dev/null
## Paso 2: la espera
$ kubectl wait --for=condition=Established crd/ipaddresspools.metallb.io crd/l2advertisements.metallb.io --timeout=120s
customresourcedefinition.apiextensions.k8s.io/ipaddresspools.metallb.io condition met
customresourcedefinition.apiextensions.k8s.io/l2advertisements.metallb.io condition met
$ kubectl wait --for=condition=Available deployment/controller -n metallb-system --timeout=1200s
deployment.apps/controller condition met
$ kubectl rollout status daemonset/speaker -n metallb-system --timeout=1200s
Waiting for daemon set "speaker" rollout to finish: 0 of 3 updated pods are available...
Waiting for daemon set "speaker" rollout to finish: 1 of 3 updated pods are available...
Waiting for daemon set "speaker" rollout to finish: 2 of 3 updated pods are available...
daemon set "speaker" successfully rolled out
$ kubectl wait --for=condition=Established crd/gatewayclasses.gateway.networking.k8s.io crd/gateways.gateway.networking.k8s.io crd/httproutes.gateway.networking.k8s.io --timeout=120s
customresourcedefinition.apiextensions.k8s.io/gatewayclasses.gateway.networking.k8s.io condition met
customresourcedefinition.apiextensions.k8s.io/gateways.gateway.networking.k8s.io condition met
customresourcedefinition.apiextensions.k8s.io/httproutes.gateway.networking.k8s.io condition met
$ kubectl wait --for=condition=Available deployment/envoy-gateway -n envoy-gateway-system --timeout=1200s
deployment.apps/envoy-gateway condition met
## Paso 3: la configuración de la plataforma, y el proyecto
$ kubectl apply -f /tmp/curso-k8s-l9.wJDg/metallb-pool.Xbdv -f infra/gatewayclass.yaml
ipaddresspool.metallb.io/curso created
l2advertisement.metallb.io/curso created
gatewayclass.gateway.networking.k8s.io/eg created
$ kubectl wait --for=condition=Accepted gatewayclass/eg --timeout=120s
gatewayclass.gateway.networking.k8s.io/eg condition met
$ kubectl apply -k punto-de-partida >/dev/null
$ kubectl rollout status deployment/revisor deployment/prueba -n revisor --timeout=600s
Waiting for deployment "revisor" rollout to finish: 0 of 3 updated replicas are available...
Waiting for deployment "revisor" rollout to finish: 1 of 3 updated replicas are available...
Waiting for deployment "revisor" rollout to finish: 2 of 3 updated replicas are available...
deployment "revisor" successfully rolled out
deployment "prueba" successfully rolled out
$ kubectl wait --for=condition=Programmed gateway/revisor -n revisor --timeout=1200s
gateway.gateway.networking.k8s.io/revisor condition met
(preparar.sh tardó 343 s)
Cela se lit en trois temps, les mêmes que ceux de la Leçon 6. À l'étape 1, on applique la plateforme de MetalLB et celle d'Envoy Gateway ; leurs sorties sont envoyées vers /dev/null parce que ce sont des dizaines de lignes sans intérêt. À l'étape 2 viennent les attentes, toutes avec kubectl wait ou rollout status et toutes avec un délai : les nouveaux types de MetalLB (condition met), le contrôleur, un speaker prêt sur chacun des trois nœuds, les types de Gateway API, le contrôleur d'Envoy Gateway. À l'étape 3, on applique la plage d'adresses (un fichier temporaire que preparar.sh a généré à partir de infra/metallb-pool.yaml avec la plage du réseau dédié : c'est l'effet de POOL_RANGO), la classe de passerelle eg, et enfin le projet de punto-de-partida/, avec son rollout status et l'attente que le Gateway soit Programmed. Le tout a pris 343 secondes ; en majeure partie à attendre les téléchargements d'images. Voici le point de départ de la leçon :
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-67b97d67c9-fnpdj 1/1 Running 0 22s 10.244.2.6 curso-k8s-l9-worker2 <none> <none>
revisor-67b97d67c9-jfdw4 1/1 Running 0 22s 10.244.2.5 curso-k8s-l9-worker2 <none> <none>
revisor-67b97d67c9-tqffn 1/1 Running 0 22s 10.244.1.3 curso-k8s-l9-worker <none> <none>
$ kubectl get deployment,hpa,pdb -n revisor
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/prueba 1/1 1 1 22s
deployment.apps/revisor 3/3 3 3 22s
$ kubectl get gateway -n revisor
NAME CLASS ADDRESS PROGRAMMED AGE
revisor eg 172.29.255.200 True 22s
$ docker run --rm --network curso-k8s-red-l9 --name curso-k8s-l9-cliente curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777 -s -m 5 http://172.29.255.200/salud
{"estado":"vivo","version":"1.6.0"}
Trois répliques du revisor, celle du service de test, aucun HPA ni PDB pour l'instant (le dernier get ne les liste pas parce qu'ils n'existent pas), et un Gateway PROGRAMMED True sur la première adresse de la plage. La dernière commande est le client « de l'extérieur » de la Leçon 6, un conteneur de curl branché au réseau Docker, qui demande /salud par le point d'entrée : {"estado":"vivo","version":"1.6.0"}. Si cela répond, la plateforme et le projet sont debout.
9.2 Mesurer avant de mettre à l'échelle : metrics-server
9.2.1 Ce que le cluster ne sait pas encore
Avant de décider de combien de répliques on a besoin, il faut savoir combien de CPU utilisent celles qui existent. Le cluster ne le sait pas d'origine. Si tu le lui demandes avec kubectl top, la commande qui montre la consommation :
$ kubectl top nodes
error: Metrics API not available
[código de salida: 1]
error: Metrics API not available : « l'API de métriques n'est pas disponible ». La phrase a un sujet exact. Kubernetes définit une API pour la consommation de CPU et de mémoire des nœuds et des Pods, la Metrics API, et la documentation la décrit ainsi : elle offre « un ensemble basique de métriques pour prendre en charge l'autoscaling automatique et des cas similaires » (Resource metrics pipeline). Mais cette API n'est qu'une forme de poser la question : il faut que quelqu'un réponde. La même page le dit sans fard : il faut déployer metrics-server « ou un adaptateur alternatif » pour pouvoir l'utiliser, et son rôle principal est « d'alimenter en métriques d'utilisation des ressources les composants d'autoscaling ». Un cluster fraîchement créé ne l'a pas, et c'est pourquoi top n'a personne à qui demander.
Comment branche-t-on un répondeur sur l'API de Kubernetes ? Avec un objet que tu connais déjà par la Leçon 6, par parenté : une ressource qui enseigne quelque chose de nouveau au kube-apiserver. Ici ce n'est pas un CRD mais un APIService, qui dit au kube-apiserver : « les questions qui arrivent sur /apis/metrics.k8s.io/v1beta1, ne les traite pas toi-même ; transmets-les à ce Service ». Avant d'installer quoi que ce soit, cet objet n'existe pas :
$ kubectl get apiservice v1beta1.metrics.k8s.io
Error from server (NotFound): apiservices.apiregistration.k8s.io "v1beta1.metrics.k8s.io" not found
[código de salida: 1]
Avec NotFound : le kube-apiserver n'a personne à qui transmettre. C'est la même situation que dans la Leçon 6 avec Gateway, vue sous un autre angle : avant que la pièce existe, poser une question sur elle revient à interroger le vide.
9.2.2 Installer metrics-server tel quel
Le projet publie un manifeste par version. Celle qu'utilise cette leçon est la v0.9.0, et elle est figée dans l'URL, comme tout le reste a été figé dans le cours : sans version figée, le jour où la dernière version change, ton laboratoire change sans te prévenir. On l'applique tel quel, sans rien modifier, pour voir ce qui se passe :
$ kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.9.0/components.yaml | tail -3
service/metrics-server created
deployment.apps/metrics-server created
apiservice.apiregistration.k8s.io/v1beta1.metrics.k8s.io created
(espera visible: hasta 600 s a que metrics-server descargue su imagen, arranque e intente leer a los kubelet)
Les trois lignes visibles sont les dernières de plusieurs (| tail -3) : un Service, un Deployment et l'APIService qui relie les deux au kube-apiserver. À propos du moment où regarder : le Deployment n'est pas prêt instantanément. Il doit d'abord télécharger son image et démarrer, puis tenter sa première lecture. Le script attend, à la vue de tous, que le journal du Pod montre l'erreur qu'on veut lire (jusqu'à 600 secondes, parce que sur une machine lente le téléchargement prend du temps). Dans ton terminal, tu peux répéter kubectl get deployment metrics-server -n kube-system jusqu'à voir 0/1 avec quelques secondes d'âge. C'est une attente d'un autre type que kubectl wait : ici on n'attend pas qu'une condition arrive, on attend pour laisser le temps d'échouer et pouvoir lire comment.
$ kubectl get deployment metrics-server -n kube-system
NAME READY UP-TO-DATE AVAILABLE AGE
metrics-server 0/1 1 0 30s
READY 0/1 et AVAILABLE 0 : le Pod existe mais n'est pas prêt. On suit l'ordre de la Leçon 5, qui dit quoi regarder en premier quand quelque chose ne démarre pas : le journal du Pod.
$ kubectl logs -n kube-system deployment/metrics-server --tail=200 | grep -E 'x509|tls|certificate' | tail -1
E1021 23:14:05.707527 1 scraper.go:149] "Failed to scrape node" err="Get \"https://172.29.0.2:10250/metrics/resource\": tls: failed to verify certificate: x509: cannot validate certificate for 172.29.0.2 because it doesn't contain any IP SANs" node="curso-k8s-l9-control-plane"
Cela se lit de gauche à droite. E1021 est une ligne de niveau erreur du 21 octobre. scraper.go:149 est l'endroit du code qui a échoué, et Failed to scrape node dit ce qu'il essayait de faire : « racler » (collecter) les métriques du nœud, ce que fait metrics-server toutes les quelques secondes, en demandant à chaque kubelet le CPU et la mémoire qu'utilisent ses Pods. L'adresse est https://172.29.0.2:10250/metrics/resource : le kubelet de ce nœud (celui de contrôle ; sur ta machine ce sera 172.18.0.x), par son IP, sur le port 10250. Et la cause, à la fin : tls: failed to verify certificate: x509: cannot validate certificate for 172.29.0.2 because it doesn't contain any IP SANs.
Il faut démonter cette phrase. TLS est le chiffrement avec lequel metrics-server parle au kubelet (HTTPS). Pour que le chiffrement vaille quelque chose, metrics-server vérifie le certificat que le kubelet lui présente : il contrôle qu'il a été signé par une autorité en laquelle il a confiance et que le certificat dit appartenir à l'adresse à laquelle il s'est connecté. Un certificat porte une liste de noms et d'adresses valides, les Subject Alternative Names (SAN). Ici le certificat du kubelet ne contient aucune IP dans cette liste, et metrics-server s'est connecté par IP : c'est pourquoi il ne peut pas valider que « 172.29.0.2 » est bien qui il prétend être. Ce que le journal démontre se limite à cela : le certificat ne contient aucune IP. Le README de metrics-server pose comme prérequis que le certificat du kubelet soit signé par l'autorité du cluster (metrics-server), et dans kind les nœuds ne le respectent pas tels quels. Dans un vrai cluster, cela se règle sur les nœuds ; dans ce laboratoire, on n'essaie pas.
Le Pod n'étant pas prêt, le Service de metrics-server n'a pas de destinations, et l'APIService le reflète :
$ kubectl top nodes
error: Metrics API not available
[código de salida: 1]
$ kubectl get apiservice v1beta1.metrics.k8s.io
NAME SERVICE AVAILABLE AGE
v1beta1.metrics.k8s.io kube-system/metrics-server False (MissingEndpoints) 30s
AVAILABLE False (MissingEndpoints) : « les destinations manquent ». Le Service n'a personne à qui envoyer les questions parce que l'unique Pod n'est pas prêt. Maintenant l'erreur de kubectl top a sa chaîne complète : la commande interroge le kube-apiserver ; celui-ci transmet au Service ; le Service n'a pas de destinations ; l'unique destination n'est pas prête ; et elle n'est pas prête parce qu'elle ne peut pas vérifier le certificat du kubelet. Cinq maillons, et le message du dessus ne montrait que le dernier. C'est le même métier que dans la Leçon 3 : on descend d'étage en étage jusqu'à trouver celui qui est défaillant.
9.2.3 Le drapeau, et pourquoi il n'est que pour le laboratoire
metrics-server a une option pour ne pas vérifier le certificat des kubelet : --kubelet-insecure-tls. Son README la décrit en deux phrases courtes qu'il convient de lire lentement : « Do not verify the CA of serving certificates presented by Kubelets. For testing purposes only » (« Ne vérifie pas l'autorité des certificats présentés par les kubelet. Uniquement pour des tests »). Ce n'est pas une façon de parler pour se donner bonne conscience : c'est un avertissement, et la leçon le respecte. Ce drapeau vaut dans un laboratoire où les nœuds sont des conteneurs à toi sur ta machine, et il ne vaut pas dans un cluster où quelqu'un d'autre pourrait se mettre sur le réseau entre metrics-server et un nœud, parce que sans vérifier le certificat il n'y a aucun moyen de savoir à qui on parle.
Le manifeste de la v0.9.0 ne porte pas le drapeau ; on l'ajoute avec un patch Kustomize. C'est un programme : metrics-server/kustomization.yaml.
# metrics-server — Lección 9. Versión fijada (v0.9.0). En kind, el certificado de
# cada kubelet no trae ninguna IP y metrics-server no lo acepta sin
# --kubelet-insecure-tls. Eso SOLO vale en un clúster de laboratorio.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.9.0/components.yaml
patches:
- target:
kind: Deployment
name: metrics-server
namespace: kube-system
patch: |-
- op: add
path: /spec/template/spec/containers/0/args/-
value: --kubelet-insecure-tls
Le patch dit : dans le Deployment metrics-server de l'espace de noms kube-system, ajoute à la fin de la liste d'arguments du premier conteneur --kubelet-insecure-tls. Le op: add avec args/- est la façon de dire « ajouter à la fin » d'une liste dans un patch de type JSON. On l'applique avec -k, comme tout le reste :
$ kubectl apply -k metrics-server | tail -3
service/metrics-server unchanged
deployment.apps/metrics-server configured
apiservice.apiregistration.k8s.io/v1beta1.metrics.k8s.io unchanged
Deux objets unchanged (le Service et l'APIService ne changent pas) et un configured : le Deployment. Son modèle a changé, il y aura donc un remplacement du Pod, et cette fois l'attente est bien une attente de condition :
$ kubectl rollout status deployment/metrics-server -n kube-system --timeout=600s
Waiting for deployment "metrics-server" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "metrics-server" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "metrics-server" rollout to finish: 1 old replicas are pending termination...
deployment "metrics-server" successfully rolled out
(espera de 45 s: metrics-server mide en ventanas y necesita un par de ellas)
Vient ensuite une pause qui n'est pas une attente de condition, et il vaut mieux le dire : metrics-server mesure par fenêtres de temps et a besoin de quelques-unes avant de pouvoir répondre avec des données, si bien que la leçon attend 45 secondes d'horloge. Au lieu de deviner, il existe une façon de vérifier qu'il répond déjà : interroger l'APIService, qui doit passer à True.
$ kubectl get apiservice v1beta1.metrics.k8s.io
NAME SERVICE AVAILABLE AGE
v1beta1.metrics.k8s.io kube-system/metrics-server True 98s
AVAILABLE True. Maintenant top a quelqu'un à interroger :
$ kubectl top nodes
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
curso-k8s-l9-control-plane 139m 2% 974Mi 6%
curso-k8s-l9-worker 92m 1% 272Mi 1%
curso-k8s-l9-worker2 53m 0% 316Mi 1%
$ kubectl top pods -n revisor
NAME CPU(cores) MEMORY(bytes)
prueba-6899c74865-cgzkw 1m 1Mi
revisor-67b97d67c9-fnpdj 1m 1Mi
revisor-67b97d67c9-jfdw4 1m 1Mi
revisor-67b97d67c9-tqffn 1m 1Mi
La colonne CPU(cores) est en millicores : 1m est un millième de cœur, la même unité que les requests de la Leçon 5. La documentation explique d'où elle sort : le CPU est rapporté « comme l'utilisation moyenne des cœurs », calculée « comme un taux sur un compteur cumulatif de CPU fourni par le noyau » de Linux, sur une fenêtre de temps que l'API elle-même indique (champ window). Ce n'est pas une photo d'un instant, c'est une moyenne sur une fenêtre courte. Et CPU(%) dans top nodes est le pourcentage du CPU du nœud, et non du request de qui que ce soit ; c'est un autre calcul, et celui qui importe à l'autoscaler est le suivant. Regarde les Pods du revisor : quelques dixièmes de millicore ou quelques millicores d'utilisation, contre les 50m qu'ils ont demandés :
$ kubectl describe pod -n revisor -l app=revisor | grep -E 'Requests|cpu:' | head -4
cpu: 200m
Requests:
cpu: 50m
cpu: 200m
(Le premier cpu: 200m est la limit ; le Requests et son cpu: 50m, le plancher. Le troisième est la limit de l'autre conteneur listé ; le filtre head -4 coupe la sortie là où elle tient.) Un Pod qui utilise 4m sur 50m est à 8 % de ce qu'il a demandé. Ce pourcentage, celui de l'utilisation par rapport aux requests, est l'étalon de l'autoscaler.
La documentation de metrics-server dit aussi à quoi il ne sert pas, et il vaut la peine de l'entendre avant de lui faire confiance : ses cas d'usage sont la mise à l'échelle horizontale par CPU et mémoire, et son README avertit qu'il n'est pas « une source exacte de l'utilisation des ressources ». Pour observer la consommation avec précision, la représenter graphiquement ou alerter, d'autres outils existent. Pour décider du nombre de répliques, il suffit.
9.3 L'autoscaler et sa formule
9.3.1 Ce que c'est et à quelle fréquence il regarde
Le HorizontalPodAutoscaler est un contrôleur, un de plus parmi ceux que tu as vus dans la Leçon 0 faire la même chose que d'habitude : comparer l'état souhaité avec l'état réel et pousser le second vers le premier. La différence tient à ce qu'il entend par « état souhaité » : non pas un nombre de répliques, mais une cible de consommation. « Horizontal » veut dire qu'il change le nombre de répliques, et non la taille de chacune. La documentation dit que la boucle tourne toutes les 15 secondes par défaut (--horizontal-pod-autoscaler-sync-period) (Horizontal Pod Autoscaling).
À chaque tour, il fait trois choses : il lit les métriques des Pods de la cible, il calcule combien de répliques il demanderait, et, si le résultat change le nombre actuel, il le demande au Deployment. La partie qui peut se calculer manuellement est la deuxième, et c'est celle-ci :
réplicas deseadas = ceil( réplicas actuales × (métrica actual ÷ métrica deseada) )
C'est la formule que donne la documentation : desiredReplicas = ceil[currentReplicas × (currentMetricValue / desiredMetricValue)]. ceil signifie « arrondir à l'entier supérieur ». Avec une cible de CPU de 50 % : s'il y a 2 répliques et que l'utilisation moyenne est de 100 % de ce qu'elles ont demandé, le calcul est ceil(2 × 100 ÷ 50) = ceil(4) = 4. Si l'utilisation moyenne est de 25 %, ceil(2 × 25 ÷ 50) = ceil(1) = 1. L'arrondi à l'entier supérieur a une raison : mieux vaut un Pod de trop qu'un Pod de moins quand la cible est sur le point d'être dépassée.
Et le « pourcentage » ? Avec une cible de type Utilization, le contrôleur calcule l'utilisation « comme un pourcentage de la demande (request) équivalente des conteneurs de chaque Pod », et prend la moyenne de tous les Pods cibles. C'est pourquoi un Pod sans requests de CPU est un Pod que l'autoscaler ne peut pas mesurer : la documentation dit que, si un conteneur n'a pas la demande, « l'utilisation de CPU du Pod n'est pas définie et l'autoscaler ne fera rien avec cette métrique ». La Leçon 5 a mis des requests dans chaque conteneur « pour que le planificateur sache où le placer » ; aujourd'hui cette décision sert à une seconde chose qu'on ne voyait pas alors.
9.3.2 La tolérance de 10 %
Si le calcul s'appliquait tel quel, l'autoscaler changerait le nombre de répliques pour des différences d'un point de pourcentage, et le cluster serait en ajustement perpétuel. Pour l'éviter, le contrôleur saute tout ajustement quand le rapport métrique actuelle ÷ métrique souhaitée est « suffisamment proche de 1.0 », et la documentation donne la mesure : une tolérance de 0.1 par défaut, c'est-à-dire de 10 % (depuis Kubernetes 1.37, chaque HPA peut fixer sa propre tolérance dans behavior.scaleUp.tolerance et behavior.scaleDown.tolerance ; cette leçon laisse celle du cluster, 10 %). Avec une cible de 50 %, il n'y a d'ajustement que si l'utilisation mesurée tombe hors de 45 % à 55 % : le rapport doit être inférieur à 0.9 ou supérieur à 1.1.
Un exemple chiffré, pour le calculer manuellement avant de voir le cluster. Avec 3 répliques et une cible de 50 % :
| Utilisation mesurée | Rapport | Hors de 0.9 à 1.1 ? | ceil(3 × rapport) |
Ce qu'il fait |
|---|---|---|---|---|
| 54 % | 1.08 | non | — | rien |
| 57 % | 1.14 | oui | ceil(3.42) = 4 |
monte à 4 |
| 40 % | 0.80 | oui | ceil(2.4) = 3 |
ne change pas (3), mais la recommandation est notée |
| 20 % | 0.40 | oui | ceil(1.2) = 2 |
descend à 2 (si la fenêtre le permet) |
Quand le cluster te donnera des chiffres, tu les vérifieras avec ce tableau.
9.3.3 Le manifeste
C'est un programme : escalado.yaml. Il contient deux objets, l'autoscaler et un budget d'interruptions qui s'explique en 9.11.
# escalado.yaml — Lección 9: réplicas según la carga, con límites, y al menos
# una disponible durante un mantenimiento.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: revisor
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: revisor
minReplicas: 2
maxReplicas: 6
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
behavior:
scaleDown:
stabilizationWindowSeconds: 60
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: revisor
spec:
minAvailable: 1
selector:
matchLabels:
app: revisor
On le lit champ par champ :
scaleTargetRef: qui il gouverne. UnDeploymentnommérevisordu même espace de noms. Le HPA ne touche pas aux Pods : il demande au Deployment de changer son nombre de répliques, et le Deployment fait le reste, comme toujours.minReplicas: 2etmaxReplicas: 6: le plancher et le plafond. Le plancher est 2 pour deux raisons : avec une seule réplique, tout remplacement est une coupure, et avec deux (une sur chaque nœud de travail) la mise à jour de 9.9 et la maintenance de 9.11 peuvent se faire sans perdre le service. Le plafond est 6 à cause de l'habitude de sécurité qui clôt cette leçon : un autoscaler sans plafond transforme une avalanche de requêtes, ou une erreur dans une boucle, en facture ou en nœud épuisé.metrics: ce qu'il mesure et par rapport à quoi. Une ressource, le CPU, avec une cible d'utilisation moyenne de 50 % desrequests. C'est une cible intermédiaire exprès : si elle était de 90 %, l'autoscaler réagirait quand le service est déjà presque saturé.behavior.scaleDown.stabilizationWindowSeconds: 60: la fenêtre de réduction, qui s'explique ci-dessous.
Le champ behavior mérite un paragraphe à part. Sans lui, le HPA utilise les valeurs par défaut de la documentation : pour monter, une fenêtre de stabilisation de 0 seconde et une politique qui permet d'ajouter jusqu'à 4 Pods ou de doubler les répliques toutes les 15 secondes, la plus grande des deux ; pour descendre, une fenêtre de 300 secondes (cinq minutes) et une politique qui permet de retirer d'un coup toutes les répliques excédentaires. La leçon utilise 60 secondes au lieu de 300 pour que tu puisses voir la réduction sans attendre cinq minutes, et elle le dit parce que ce n'est pas une valeur à copier : en production, la longue fenêtre protège contre le va-et-vient (« je monte, je descends, je monte, je descends ») quand la charge est irrégulière.
Que fait la fenêtre, exactement ? La documentation l'explique avec précision : juste avant de mettre à l'échelle, le contrôleur note la recommandation du moment, puis « considère toutes les recommandations à l'intérieur de la fenêtre configurée et choisit la plus haute ». Autrement dit : pour descendre, le HPA n'utilise pas ce qu'il voit maintenant, mais le maximum de ce qu'il a recommandé durant les 60 dernières secondes. S'il recommandait 4 répliques il y a 40 secondes et en recommande 2 maintenant, il reste à 4 jusqu'à ce que cette recommandation de 4 sorte de la fenêtre. C'est une mémoire délibérée du pire récent, et c'est la raison pour laquelle la réduction est lente et la montée ne l'est pas.
Enfin, le kubectl diff de 9.5 va montrer que le HPA, une fois appliqué, porte écrites dans la sortie les valeurs par défaut de scaleUp et scaleDown (selectPolicy: Max, les politiques de 15 secondes) : le serveur complète ce que tu n'as pas écrit.
9.3.4 Pourquoi le Deployment ne porte plus replicas
Dans le dossier de la Leçon 8, le Deployment du revisor disait replicas: 3. Dans celui d'aujourd'hui, ce champ n'est plus là. Ce n'est pas un oubli : c'est la recommandation explicite de la documentation du HPA. Quand il y a un autoscaler, le champ spec.replicas du Deployment devrait être retiré du manifeste, car sinon, « chaque fois qu'un changement est appliqué à cet objet, par exemple avec kubectl apply -f deployment.yaml, Kubernetes mettra à l'échelle le nombre actuel de Pods à la valeur de spec.replicas », ce qui « peut être problématique quand un HPA est actif, et provoquer du va-et-vient ». Deux propriétaires d'un même nombre se disputent : ton fichier dit 3, le HPA vient d'en demander 5, et chaque apply défait ce que l'autre a fait.
La documentation ajoute un avertissement que 9.5 va rendre visible : retirer le champ « peut provoquer une unique dégradation du nombre de Pods, car la valeur par défaut de cette clé est 1 ». C'est-à-dire qu'en appliquant pour la première fois le Deployment sans replicas, le cluster le lit comme « retour à la valeur par défaut » et propose de descendre à une seule réplique. En 9.5 tu verras ce replicas: 1 dans le kubectl diff, et tu verras ce qui se passe ensuite.
9.4 Répartir les répliques
9.4.1 Le problème : rien ne répartit à ta place
Un autoscaler qui monte à quatre répliques ne dit pas où il les met ; cela, c'est le planificateur (scheduler) qui le décide, en cherchant le nœud qui convient le mieux par ressources et autres règles, et par défaut il ne s'efforce pas de répartir. Regarde comment se sont retrouvées les trois répliques du dossier de la Leçon 8, sans aucune règle de répartition :
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-67b97d67c9-fnpdj 1/1 Running 0 22s 10.244.2.6 curso-k8s-l9-worker2 <none> <none>
revisor-67b97d67c9-jfdw4 1/1 Running 0 22s 10.244.2.5 curso-k8s-l9-worker2 <none> <none>
revisor-67b97d67c9-tqffn 1/1 Running 0 22s 10.244.1.3 curso-k8s-l9-worker <none> <none>
La colonne NODE dit où se trouve chacune. Dans cette exécution, deux des trois répliques sont tombées ensemble sur curso-k8s-l9-worker2 et une sur curso-k8s-l9-worker. Ce n'est pas de la malchance : dans cette exécution et dans trois précédentes qui n'ont pas été conservées, deux des trois répliques sont tombées ensemble, tantôt sur un nœud, tantôt sur l'autre. Le planificateur ne promet pas de répartir. Si le nœud qui porte deux répliques tombe en panne, le service perd 67 % de sa capacité d'un coup, et l'autoscaler n'aide pas : son travail est de regarder le CPU, pas la répartition.
9.4.2 topologySpreadConstraints
Kubernetes a une pièce pour cela : les contraintes de répartition par topologie. « Topologie » veut dire ici « dans quelle partie de l'infrastructure vit chaque chose » : sur quel nœud, dans quelle zone d'un cloud, dans quel rack. La documentation définit le vocabulaire (Pod Topology Spread Constraints) : une étiquette de nœud, topologyKey, divise les nœuds en domaines, et les nœuds qui ont la même valeur de cette étiquette forment un même domaine. L'étiquette kubernetes.io/hostname a une valeur différente par nœud, donc avec elle chaque nœud est son propre domaine. C'est ce qu'on veut ici : répartir entre les nœuds.
La contrainte du revisor est la suivante. C'est un fragment du programme revisor-deployment.yaml, à l'intérieur de spec.template.spec :
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
nodeTaintsPolicy: Honor
labelSelector:
matchLabels:
app: revisor
matchLabelKeys:
- pod-template-hash
Chaque champ, dans l'ordre :
topologyKey: kubernetes.io/hostname: chaque nœud est un domaine, comme on l'a dit.labelSelector: quels Pods sont comptés dans chaque domaine. Ceux qui portentapp: revisor; les répliques depruebaou les Pods de charge ne comptent pas, et ne gênent pas.maxSkew: 1: l'asymétrie (skew) maximale permise : la différence entre le nombre de Pods d'un domaine et celui du domaine qui en a le moins. La documentation le définit ainsi : avecDoNotSchedule,maxSkew« est la différence maximale permise entre le nombre de Pods correspondants dans le domaine cible et le minimum global ». AvecmaxSkew: 1et deux nœuds de travail, 2 + 0 n'est pas permis (différence de 2), 1 + 1 oui, 2 + 1 oui, 3 + 1 non.whenUnsatisfiable: DoNotSchedule: que faire si placer le Pod enfreint la contrainte.DoNotSchedule(la valeur par défaut) dit « ne le planifie pas » : le Pod restePending.ScheduleAnywaydit « planifie-le quand même, mais en préférant les endroits qui réduisent l'asymétrie » : c'est une préférence, pas une règle. Cette leçon utilise la règle stricte, parce que la souple laisse passer justement ce qu'on veut éviter : la contrainte sert à ce que la répartition soit réelle, et une préférence ne le garantit pas.nodeTaintsPolicy: Honor: comment traiter les taints des nœuds (les « marques » qui repoussent les Pods) dans le calcul de l'asymétrie. Cela s'explique en 9.4.3 et se vérifie en 9.5.4, parce que c'est le champ qui semble superflu et qui ne l'est pas.matchLabelKeys: [pod-template-hash]: restreint le décompte aux répliques de la même version. Le Deployment met sur chaque Pod une étiquette,pod-template-hash, avec une valeur différente pour chaque version du modèle. La documentation dit que cette étiquette, qui est « ajoutée automatiquement par le contrôleur du Deployment », sert « à distinguer les différentes révisions d'un même Deployment ». Pendant une mise à jour, d'anciennes et de nouvelles répliques cohabitent ; sans ce champ, les anciennes compteraient dans la répartition des nouvelles et les gêneraient ; avec lui, chaque version se répartit pour son propre compte.
9.4.3 Pourquoi le nœud de contrôle exige Honor
Le cluster a trois nœuds et seuls deux reçoivent des répliques. Le nœud de contrôle porte un taint (node-role.kubernetes.io/control-plane:NoSchedule) qui empêche d'y planifier des Pods d'utilisateur. Mais compte-t-il comme domaine pour le calcul du « minimum global » ? Cela dépend de nodeTaintsPolicy. La documentation définit les deux options : Honor inclut « les nœuds sans taints, ainsi que les nœuds marqués pour lesquels le Pod entrant a une tolérance » ; Ignore ignore les taints et inclut tous les nœuds ; et si le champ est vide, le comportement équivaut à Ignore.
Cela signifie que, sans Honor, le nœud de contrôle entre dans le compte comme un domaine à zéro Pod, et le « minimum global » est 0, parce que ce nœud ne peut pas recevoir de réplique. Avec ce zéro fixe, maxSkew: 1 permet au plus 1 Pod par domaine : la troisième réplique n'aurait nulle part où aller. C'est le genre de règle qui fonctionne avec deux répliques et se brise avec la troisième, et c'est précisément pour cela que c'est une erreur qu'on découvre en production un lundi à 9 h 00. 9.5.4 la provoque pour que tu la voies.
9.4.4 Ce que la contrainte ne fait pas
La documentation de cette pièce comporte un avertissement dans sa section des limitations : « il n'y a aucune garantie que les contraintes continuent d'être respectées quand des Pods sont retirés. Par exemple, réduire un Deployment peut laisser les Pods déséquilibrés », et elle recommande des outils comme le Descheduler pour rééquilibrer. La règle s'applique au moment de planifier un nouveau Pod, ni avant ni après. 9.11 va le montrer avec une maintenance qui laisse les répliques ensemble, et montrera aussi comment on y remédie sans rien installer.
9.5 Appliquer les changements
9.5.1 Ce qui change, avant d'appliquer
Avec le dossier de la Leçon 9 prêt, la première étape n'est pas d'appliquer mais de demander ce qui va changer. kubectl diff -k . envoie au serveur le résultat du -k et montre la différence avec ce qui existe. La commande est filtrée (grep -E '^[+-] ') pour ne garder que les lignes qui changent et retirer le bruit des métadonnées (creationTimestamp, uid, resourceVersion) :
$ kubectl diff -k . | grep -E '^[+-] ' | grep -v -E 'creationTimestamp|generation|resourceVersion|uid:|managedFields'
- replicas: 3
+ replicas: 1
- maxSurge: 25%
- maxUnavailable: 25%
+ maxSurge: 1
+ maxUnavailable: 0
+ lifecycle:
+ preStop:
+ sleep:
+ seconds: 5
- terminationGracePeriodSeconds: 10
+ terminationGracePeriodSeconds: 15
+ topologySpreadConstraints:
+ - labelSelector:
+ matchLabels:
+ app: revisor
+ matchLabelKeys:
+ - pod-template-hash
+ maxSkew: 1
+ nodeTaintsPolicy: Honor
+ topologyKey: kubernetes.io/hostname
+ whenUnsatisfiable: DoNotSchedule
+ name: revisor
+ namespace: revisor
+ behavior:
+ scaleDown:
+ policies:
+ - periodSeconds: 15
+ type: Percent
+ value: 100
+ selectPolicy: Max
+ stabilizationWindowSeconds: 60
+ scaleUp:
+ policies:
+ - periodSeconds: 15
+ type: Pods
+ value: 4
+ - periodSeconds: 15
+ type: Percent
+ value: 100
+ selectPolicy: Max
+ stabilizationWindowSeconds: 0
+ maxReplicas: 6
+ metrics:
+ - resource:
+ name: cpu
+ target:
+ averageUtilization: 50
+ type: Utilization
+ type: Resource
+ minReplicas: 2
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: revisor
+ currentMetrics: null
+ desiredReplicas: 0
+ name: revisor
+ namespace: revisor
+ minAvailable: 1
+ selector:
+ matchLabels:
+ app: revisor
+ currentHealthy: 0
+ desiredHealthy: 0
+ disruptionsAllowed: 0
+ expectedPods: 0
[código de salida: 1]
Le code 1 à la fin n'est pas une erreur : kubectl diff sort avec 1 quand il y a des différences, et avec 0 quand il n'y en a pas, pour pouvoir s'utiliser dans un script. Cela se lit en trois blocs :
- replicas: 3/+ replicas: 1. Ce qui a été annoncé en 9.3.4. Le fichier ne dit plusreplicas, et le serveur, qui apporte sa valeur par défaut, propose 1. Si tu appliques cela sans réfléchir, lerevisorpasse de trois répliques à une, ne serait-ce qu'un instant, avant que l'autoscaler le corrige. Ici ce n'est pas grave ; avec 40 personnes connectées à 9 h 00, ce le serait. C'est le coût unique de la remise du contrôle du nombre de répliques, et mieux vaut le savoir avant de le payer.maxSurge: 25%→1etmaxUnavailable: 25%→0. Les valeurs par défaut de la stratégie de mise à jour deviennent explicites. Elles s'expliquent en 9.9.lifecycle/preStop,terminationGracePeriodSeconds: 10→15ettopologySpreadConstraints. La pause avant l'arrêt, le délai plus long dont cette pause a besoin et la répartition de 9.4. Et en dessous, les nouveaux objets : le HPA (avec les valeurs par défaut écrites) et le PodDisruptionBudget.
9.5.2 Appliquer et observer
$ kubectl apply -k . | grep -E 'horizontalpod|poddisruption|deployment'
deployment.apps/prueba unchanged
deployment.apps/revisor configured
poddisruptionbudget.policy/revisor created
horizontalpodautoscaler.autoscaling/revisor created
$ kubectl rollout status deployment/revisor -n revisor --timeout=180s
Waiting for deployment "revisor" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "revisor" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "revisor" rollout to finish: 1 old replicas are pending termination...
deployment "revisor" successfully rolled out
(espera de 30 s: el HPA necesita su primera lectura de métricas)
Quatre lignes : deployment.apps/revisor configured, le PDB et le HPA created, et prueba unchanged (ce qui n'a pas changé n'est pas touché). Le rollout status montre le remplacement des répliques, parce que le modèle a changé (le preStop, le délai et la répartition) et que, pour cette raison, il y a un remplacement ordonné, une réplique à la fois, comme dans la Leçon 2. Ensuite vient une pause de 30 secondes à la vue de tous : le HPA a besoin de sa première lecture de métriques avant d'afficher un nombre.
$ kubectl get hpa,pdb -n revisor
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
horizontalpodautoscaler.autoscaling/revisor Deployment/revisor cpu: 2%/50% 2 6 2 32s
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
poddisruptionbudget.policy/revisor 1 N/A 1 32s
Lis la colonne TARGETS du HPA : cpu: 2%/50% signifie « utilisation actuelle / cible ». L'utilisation est la moyenne, rapportée aux requests des Pods, qu'a expliquée 9.2. MINPODS 2 et MAXPODS 6 sont le plancher et le plafond, et REPLICAS est le nombre actuel. Dans le PDB, ALLOWED DISRUPTIONS dit combien de Pods peuvent être perdus volontairement à l'instant même ; cela s'explique en 9.11.
Un détail de lecture : la première fois qu'on applique le HPA, si tu regardes trop tôt, TARGETS dit cpu: <unknown>/50%. Ce n'est pas une panne : le contrôleur n'a pas encore de mesure des nouveaux Pods. Les événements du HPA, qui se lisent en 9.6, gardent la trace de ces premières secondes.
$ kubectl get pods -n revisor -l app=revisor -o custom-columns=POD:.metadata.name,IMAGEN:.spec.containers[0].image,NODO:.spec.nodeName,BORRANDOSE:.metadata.deletionTimestamp
POD IMAGEN NODO BORRANDOSE
revisor-767bdf965b-5xvt5 curso-docker-revisor:1.6.0 curso-k8s-l9-worker2 <none>
revisor-767bdf965b-857qf curso-docker-revisor:1.6.0 curso-k8s-l9-worker <none>
$ kubectl get deployment revisor -n revisor -o jsonpath='{.spec.replicas}{"\n"}'
2
Deux répliques, une sur chaque nœud de travail. La répartition a été respectée, et c'est le HPA qui a fixé le nombre de répliques : .spec.replicas vaut 2, le plancher. L'enchaînement le plus probable, et celui que suggère l'événement Current number of replicas below Spec.MinReplicas de 9.6, a été : l'apply a laissé le Deployment à 1 (la valeur par défaut), le HPA a vu que 1 était en dessous du plancher de 2 et l'a relevé, et le remplacement a réparti les deux répliques grâce à la contrainte.
9.5.3 Le journal du HPA raconte ce qu'il a fait
Le HPA laisse ses décisions dans les événements. On les lit avec describe, qui sera montré en entier en 9.6 après la charge, et la première ligne est la trace de ce qui précède : New size: 2; reason: Current number of replicas below Spec.MinReplicas. « Le nombre actuel de répliques est en dessous du minimum » : le HPA ne réagissait pas à la charge, il appliquait le plancher.
9.5.4 L'erreur : la répartition sans nodeTaintsPolicy: Honor
Voici maintenant l'erreur promise en 9.4.3. On la provoque en retirant seulement nodeTaintsPolicy du Deployment vivant, avec un patch, et en demandant une troisième réplique manuellement :
$ kubectl patch deployment/revisor -n revisor --type=json -p '[{"op":"remove","path":"/spec/template/spec/topologySpreadConstraints/0/nodeTaintsPolicy"}]'
deployment.apps/revisor patched
$ kubectl rollout status deployment/revisor -n revisor --timeout=180s
Waiting for deployment "revisor" rollout to finish: 0 out of 2 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "revisor" rollout to finish: 1 old replicas are pending termination...
deployment "revisor" successfully rolled out
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-574b5b6c4c-25h2z 1/1 Running 0 6s 10.244.1.9 curso-k8s-l9-worker <none> <none>
revisor-574b5b6c4c-mnskb 1/1 Running 0 3s 10.244.2.9 curso-k8s-l9-worker2 <none> <none>
revisor-767bdf965b-5xvt5 1/1 Terminating 0 38s 10.244.2.8 curso-k8s-l9-worker2 <none> <none>
revisor-767bdf965b-857qf 1/1 Terminating 0 38s 10.244.1.8 curso-k8s-l9-worker <none> <none>
Avec deux répliques, tout semble normal. C'est ce qui rend cette erreur traîtresse : le défaut ne se voit pas avant l'arrivée de la troisième.
$ kubectl scale deployment/revisor -n revisor --replicas=3
deployment.apps/revisor scaled
(espera de 6 s: el planificador decide en un instante)
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-574b5b6c4c-25h2z 1/1 Running 0 12s 10.244.1.9 curso-k8s-l9-worker <none> <none>
revisor-574b5b6c4c-4nwx5 0/1 Pending 0 6s <none> <none> <none> <none>
revisor-574b5b6c4c-mnskb 1/1 Running 0 9s 10.244.2.9 curso-k8s-l9-worker2 <none> <none>
La troisième réplique reste Pending, sans nœud attribué. On suit l'ordre de la Leçon 5 : le describe du Pod en attente (le nom change dans ton exécution), section des événements.
$ kubectl describe pod revisor-574b5b6c4c-4nwx5 -n revisor | sed -n '/Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 6s default-scheduler 0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 node(s) didn't match pod topology spread constraints. preemption: 0/3 nodes are available: 1 Preemption is not helpful for scheduling, 2 No preemption victims found for incoming pod.
Lis-le : 0/3 nodes are available dit que, sur les trois nœuds, aucun ne convient. Et il donne le motif de chacun : un nœud a un taint que le Pod ne tolère pas (untolerated taint ; c'est celui de contrôle), et les deux autres « ne correspondent pas aux contraintes de répartition par topologie » (didn't match pod topology spread constraints). C'est exactement ce qui était annoncé : le nœud de contrôle compte comme un domaine à zéro Pod, le minimum global est 0, et placer la troisième réplique sur l'un des deux autres donnerait une asymétrie de 2.
La correction consiste à réappliquer le fichier, qui porte Honor :
# --- La corrección: volver a aplicar la carpeta (trae Honor) ---
$ kubectl apply -k . | grep -E 'deployment'
deployment.apps/prueba unchanged
deployment.apps/revisor configured
$ kubectl rollout status deployment/revisor -n revisor --timeout=180s
Waiting for deployment "revisor" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 2 out of 3 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "revisor" rollout to finish: 1 old replicas are pending termination...
deployment "revisor" successfully rolled out
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-574b5b6c4c-25h2z 1/1 Terminating 0 18s 10.244.1.9 curso-k8s-l9-worker <none> <none>
revisor-574b5b6c4c-mnskb 1/1 Terminating 0 15s 10.244.2.9 curso-k8s-l9-worker2 <none> <none>
revisor-767bdf965b-5b6k9 1/1 Running 0 6s 10.244.1.10 curso-k8s-l9-worker <none> <none>
revisor-767bdf965b-76td2 1/1 Running 0 2s 10.244.1.11 curso-k8s-l9-worker <none> <none>
revisor-767bdf965b-vcznx 1/1 Running 0 4s 10.244.2.10 curso-k8s-l9-worker2 <none> <none>
(espera de 75 s: la ventana de bajada del autoescalador es de 60 s)
Avec Honor, le nœud de contrôle sort du compte : trois répliques dans deux domaines, 2 + 1, asymétrie de 1. Cela tient. Et comme apply -k ne porte pas replicas, le nombre vit dans le Deployment jusqu'à ce que le HPA le corrige : après la fenêtre de réduction de 60 secondes (à la vue de tous, 75 d'attente), le HPA ramène le compte à 2, ce que la charge demande.
$ kubectl get deployment,hpa -n revisor
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/prueba 1/1 1 1 4m11s
deployment.apps/revisor 2/2 2 2 4m11s
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
horizontalpodautoscaler.autoscaling/revisor Deployment/revisor cpu: 2%/50% 2 6 2 2m5s
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-767bdf965b-5b6k9 1/1 Running 0 82s 10.244.1.10 curso-k8s-l9-worker <none> <none>
revisor-767bdf965b-vcznx 1/1 Running 0 80s 10.244.2.10 curso-k8s-l9-worker2 <none> <none>
9.6 Avec de la charge, l'autoscaler monte
9.6.1 Une charge qu'on peut arrêter
Pour voir l'autoscaler travailler, il faut de la charge, et la charge doit pouvoir s'arrêter quand tu le veux. C'est un programme : carga.yaml.
# carga.yaml — Lección 9: genera carga contra el revisor para ver subir el HPA.
# No forma parte del proyecto: se aplica con -f, se observa y se borra.
apiVersion: batch/v1
kind: Job
metadata:
name: carga
namespace: revisor
spec:
parallelism: 4
backoffLimit: 0
activeDeadlineSeconds: 600
template:
metadata:
labels:
app: carga
acceso-revisor: permitido
spec:
automountServiceAccountToken: false
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: carga
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
command: ["sh", "-c", "while true; do curl -s -o /dev/null http://revisor/salud; done"]
resources:
requests: {cpu: 10m, memory: 16Mi}
limits: {cpu: 200m, memory: 32Mi}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
C'est un Job, l'objet de la Leçon 8 pour les tâches qui se terminent, avec quatre Pods en parallèle (parallelism: 4) qui répètent sans arrêt une requête vers http://revisor/salud. Il n'y a pas de pause entre l'une et l'autre : chaque Pod demande, reçoit, et redemande. On le lit par décisions :
activeDeadlineSeconds: 600: quoi qu'il arrive, le Job s'arrête au bout de 10 minutes : il doit survivre au téléchargement de l'image plus les 200 secondes d'observation. Un générateur de charge sans frein est une panne en attente ; celui-ci se termine même si tu oublies de le supprimer.backoffLimit: 0: si un Pod échoue, on ne réessaie pas. C'est une charge de laboratoire, pas une tâche qui compte.acceso-revisor: permitido: l'étiquette que le réseau fermé de la Leçon 7 exige pour atteindre lerevisor. Sans elle, les Pods de charge n'arriveraient nulle part, et le HPA ne verrait aucune charge. C'est la même autorisation que celle du Poddepurar.limits: cpu: 200m: chaque Pod de charge peut consommer jusqu'à un cinquième de cœur. Cela pose un plafond à la charge qu'on peut générer : quatre Pods de 200m font 800m au total, et la part qui devient du CPU durevisordépend du nombre de requêtes par seconde auxquelles il parvient à répondre. Ce n'est pas un test de performance ; c'est un levier avec une butée.- L'image de
curl, figée par son empreinte, comme dans la Leçon 3. La première fois qu'un nœud l'utilise, il la télécharge depuis internet ; c'est pourquoi le script attend (à la vue de tous) que les Pods de charge soient prêts avant de commencer à regarder l'horloge.
9.6.2 Appliquer, attendre, regarder
$ kubectl apply -f carga.yaml
job.batch/carga created
(espera visible: hasta 600 s a que los Pods de carga arranquen; la primera vez descargan la imagen de curl)
Vient ensuite une attente avec kubectl wait, pour la raison ci-dessus : tant que les Pods de charge ne sont pas Ready, il n'y a pas de charge, et regarder le HPA avant reviendrait à mesurer le silence.
$ kubectl wait --for=condition=Ready pod -l app=carga -n revisor --timeout=600s
pod/carga-29s8s condition met
pod/carga-bz6zl condition met
pod/carga-nv5tb condition met
pod/carga-rnhz5 condition met
Maintenant le temps court. Le script imprime une ligne du HPA toutes les 20 secondes pendant 200 secondes. Dans ton terminal, l'équivalent est kubectl get hpa revisor -n revisor --watch (qui imprime une nouvelle ligne chaque fois que quelque chose change), ou une boucle de get et sleep 20 comme celle du script. L'attente fait partie de la leçon : l'autoscaler n'est pas instantané, et voir combien il met est ce qu'on apprend.
(un renglón del HPA cada 20 s, durante 200 s; empieza a las 17:18:00)
[+ 20s] revisor Deployment/revisor cpu: 27%/50% 2 6 2 2m50s
[+ 40s] revisor Deployment/revisor cpu: 46%/50% 2 6 2 3m10s
[+ 60s] revisor Deployment/revisor cpu: 72%/50% 2 6 2 3m30s
[+ 80s] revisor Deployment/revisor cpu: 58%/50% 2 6 3 3m51s
[+100s] revisor Deployment/revisor cpu: 44%/50% 2 6 4 4m11s
[+120s] revisor Deployment/revisor cpu: 46%/50% 2 6 4 4m31s
[+140s] revisor Deployment/revisor cpu: 38%/50% 2 6 4 4m51s
[+160s] revisor Deployment/revisor cpu: 38%/50% 2 6 4 5m11s
[+180s] revisor Deployment/revisor cpu: 38%/50% 2 6 4 5m31s
[+200s] revisor Deployment/revisor cpu: 38%/50% 2 6 4 5m52s
Chaque ligne se lit de la même façon : le temps écoulé depuis le début de la charge, le nom, la cible, la colonne TARGETS (utilisation actuelle / cible), le plancher, le plafond, les répliques et l'âge du HPA. Il faut les lire avec la formule de 9.3 en main.
Les lignes, avec la formule de 9.3 appliquée à chacune :
| Temps | Utilisation mesurée | Répliques | Rapport (utilisation ÷ 50) | Calcul | Ce qu'il fait |
|---|---|---|---|---|---|
| +20 s | 27 % | 2 | 0.54 | — | la charge arrive à peine |
| +40 s | 46 % | 2 | 0.92 | dans 0.9 à 1.1 | rien : la tolérance l'arrête |
| +60 s | 72 % | 2 | 1.44 | ceil(2 × 1.44) = ceil(2.88) = 3 |
décide de monter à 3 |
| +80 s | 58 % | 3 | 1.16 | ceil(3 × 1.16) = ceil(3.48) = 4 |
décide de monter à 4 |
| +100 s | 44 % | 4 | 0.88 | ceil(4 × 0.88) = ceil(3.52) = 4 |
reste à 4 |
| +120 s | 46 % | 4 | 0.92 | dans la tolérance | rien |
| +140 à +200 s | 38 % | 4 | 0.76 | ceil(4 × 0.76) = ceil(3.04) = 4 |
reste à 4 |
Il y a trois choses à y voir. Première : la ligne de +40 secondes, avec 46 %, ne fait rien alors que la charge est déjà là. Ce n'est pas de la lenteur, c'est la tolérance : 46 % est 8 % en dessous de la cible, dans les 10 % que le contrôleur pardonne. Deuxième : à +60 s, l'utilisation est de 72 % avec 2 répliques et l'autoscaler décide de monter à 3 ; la ligne de +80 s en montre déjà 3, parce qu'entre une ligne et l'autre il s'écoule 20 secondes et que la décision a été prise dans l'intervalle. Le passage de 3 à 4 est séparé du premier d'environ 30 secondes (les événements ci-dessous disent il y a 2m37s et il y a 2m7s), probablement parce que les nouveaux Pods doivent naître et se mettre à travailler avant que leur consommation compte. Troisième, et c'est la plus instructive : de +140 à +200 secondes, l'utilisation reste à 38 % avec 4 répliques, un rapport de 0.76 qui est hors de la tolérance par le bas, et pourtant l'autoscaler ne descend pas. Le calcul explique pourquoi : ceil(4 × 0.76) vaut ceil(3.04), c'est-à-dire 4. L'arrondi à l'entier supérieur maintient la quatrième réplique pour quatre centièmes. Le système s'est stabilisé tout seul : chaque nouvelle réplique a réparti la charge, la moyenne est descendue de 72 à 38 %, et avec cette moyenne quatre répliques sont le nombre que veut la formule. Le plafond de 6 n'a pas été touché.
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-767bdf965b-4q7wh 1/1 Running 0 2m5s 10.244.2.13 curso-k8s-l9-worker2 <none> <none>
revisor-767bdf965b-5b6k9 1/1 Running 0 5m9s 10.244.1.10 curso-k8s-l9-worker <none> <none>
revisor-767bdf965b-pchj4 1/1 Running 0 2m36s 10.244.1.14 curso-k8s-l9-worker <none> <none>
revisor-767bdf965b-vcznx 1/1 Running 0 5m7s 10.244.2.10 curso-k8s-l9-worker2 <none> <none>
$ kubectl top pods -n revisor -l app=revisor
NAME CPU(cores) MEMORY(bytes)
revisor-767bdf965b-4q7wh 18m 6Mi
revisor-767bdf965b-5b6k9 19m 7Mi
revisor-767bdf965b-pchj4 20m 7Mi
revisor-767bdf965b-vcznx 20m 6Mi
$ kubectl get hpa revisor -n revisor -o jsonpath='réplicas={.status.currentReplicas} deseadas={.status.desiredReplicas} cpu={.status.currentMetrics[0].resource.current.averageUtilization} % ({.status.currentMetrics[0].resource.current.averageValue}){"\n"}'
réplicas=4 deseadas=4 cpu=38 % (19m)
kubectl top pods montre la consommation réelle de chaque réplique à l'instant de la requête : 18, 19, 20 et 20 millicores. Face aux 50m que chacune a demandés, cela fait 36 à 40 %. Et le HPA, avec sa propre requête, le dit en une ligne : cpu=38 % (19m) signifie « utilisation moyenne de 38 %, qui équivaut à 19 millicores par Pod ». Le calcul se fait manuellement : (18 + 19 + 20 + 20) ÷ 4 = 19.25 millicores, qui s'arrondit à 19m, et 19 ÷ 50 vaut 0.38. La colonne TARGETS n'est pas un mystère : c'est cette division. réplicas=4 deseadas=4 dit ce qu'on a déjà vu : ce qui existe est ce que veut la formule.
Les événements du HPA sont son journal, et racontent la même chose avec d'autres mots :
$ kubectl describe hpa revisor -n revisor | sed -n '/Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal SuccessfulRescale 5m54s horizontal-pod-autoscaler New size: 2; reason: Current number of replicas below Spec.MinReplicas
Warning FailedGetResourceMetric 4m54s (x3 over 5m39s) horizontal-pod-autoscaler failed to get cpu utilization: unable to get metrics for resource cpu: no metrics returned from resource metrics API
Warning FailedComputeMetricsReplicas 4m54s (x3 over 5m39s) horizontal-pod-autoscaler invalid metrics (1 invalid out of 1), first error is: failed to get cpu resource metric value: failed to get cpu utilization: unable to get metrics for resource cpu: no metrics returned from resource metrics API
Normal SuccessfulRescale 4m39s horizontal-pod-autoscaler New size: 2; reason: All metrics below target
Normal SuccessfulRescale 2m37s horizontal-pod-autoscaler New size: 3; reason: cpu resource utilization (percentage of request) above target
Normal SuccessfulRescale 2m7s horizontal-pod-autoscaler New size: 4; reason: cpu resource utilization (percentage of request) above target
Lis-les de bas en haut, du plus ancien au plus récent. SuccessfulRescale … New size: 2; reason: Current number of replicas below Spec.MinReplicas est le premier ajustement, celui de 9.5 : le plancher. Les deux Warning qui suivent (FailedGetResourceMetric, FailedComputeMetricsReplicas) sont l'autoscaler qui dit qu'il n'a pas encore de métriques pour les nouveaux Pods : unable to get metrics for resource cpu: no metrics returned from resource metrics API. Ils sont normaux durant les premières secondes de vie d'un Pod, pendant que metrics-server fait sa première lecture ; le (x3 over 5m39s) dit qu'ils se sont répétés trois fois. Le suivant, New size: 2; reason: All metrics below target, n'appartient pas à cette charge : c'est le HPA qui ramène à 2 la troisième réplique que 9.5.4 avait demandée manuellement. Et les deux derniers, séparés de 30 secondes (il y a 2m37s et il y a 2m7s), sont les décisions de cette section : New size: 3 et New size: 4, tous deux avec le motif cpu resource utilization (percentage of request) above target : la consommation, mesurée en pourcentage de la demande, au-dessus de la cible.
9.7 Sans charge, il redescend, après la fenêtre
On supprime le Job, et l'on regarde le HPA toutes les 20 secondes pendant 260 secondes. Ce que l'on va voir, c'est la fenêtre de réduction de 9.3.3 :
$ kubectl delete -f carga.yaml
job.batch "carga" deleted from revisor namespace
(un renglón del HPA cada 20 s, durante 260 s; empieza a las 17:21:25)
[+ 20s] revisor Deployment/revisor cpu: 36%/50% 2 6 4 6m15s
[+ 40s] revisor Deployment/revisor cpu: 39%/50% 2 6 4 6m35s
[+ 60s] revisor Deployment/revisor cpu: 3%/50% 2 6 4 6m55s
[+ 80s] revisor Deployment/revisor cpu: 2%/50% 2 6 4 7m15s
[+100s] revisor Deployment/revisor cpu: 3%/50% 2 6 4 7m35s
[+120s] revisor Deployment/revisor cpu: 2%/50% 2 6 2 7m56s
[+140s] revisor Deployment/revisor cpu: 4%/50% 2 6 2 8m16s
[+160s] revisor Deployment/revisor cpu: 2%/50% 2 6 2 8m36s
[+180s] revisor Deployment/revisor cpu: 2%/50% 2 6 2 8m56s
[+200s] revisor Deployment/revisor cpu: 4%/50% 2 6 2 9m16s
[+220s] revisor Deployment/revisor cpu: 3%/50% 2 6 2 9m36s
[+240s] revisor Deployment/revisor cpu: 2%/50% 2 6 2 9m56s
[+260s] revisor Deployment/revisor cpu: 2%/50% 2 6 2 10m
L'horloge se compte ainsi. On supprime le Job à la seconde 0, mais la lecture de +20 et celle de +40 restent hautes (36 % et 39 %) : le plus probable est que les Pods de charge mettent quelques secondes à se terminer, et metrics-server fait la moyenne de la consommation d'une fenêtre courte, non d'un instant. À +60 s la lecture est déjà de 3 %, et elle y reste. Avec 4 répliques et 3 % d'utilisation, le calcul est ceil(4 × 0.06) = ceil(0.24) = 1, mais le plancher est 2, donc ce que l'autoscaler veut, c'est 2. Et pourtant la colonne des répliques dit 4 à +60, +80 et +100, et ne passe à 2 qu'à la ligne de +120. Cette différence est la fenêtre de réduction de 60 secondes de 9.3.3 : pendant cette minute, le HPA n'utilise pas la recommandation d'aujourd'hui mais la plus haute des 60 dernières secondes, et la dernière recommandation de 4 répliques a été faite avec les lectures hautes du début.
Les événements donnent les heures avec plus de précision que le tableau. Pour les lire, il faut soustraire à partir du moment où l'on a lancé le describe (juste à la fin du tableau, environ 262 secondes après la suppression du Job) : New size: 3 s'est produit il y a 2m54s, c'est-à-dire vers +88 s, et New size: 2 il y a 2m39s, vers +103 s. Autrement dit, la réduction s'est faite en deux marches, de 4 à 3 puis, 15 secondes plus tard, de 3 à 2, à environ 50 ou 60 secondes de la dernière lecture haute. C'est ce qu'on attend d'une fenêtre : les recommandations du haut en sortent une par une. (Le tableau ne montre jamais la marche de 3 parce que la colonne REPLICAS du HPA se met à jour avec du retard par rapport au Deployment : c'est status.currentReplicas, que le HPA écrit à son propre tour, toutes les 15 s ; c'est pourquoi la ligne de +100 dit encore 4 alors que l'événement de passage à 3 date de +88. Quel nombre le HPA voit à chaque tour n'a pas été mesuré.)
Deux sorties de plus de describe complètent la lecture. Le tableau des conditions :
$ kubectl describe hpa revisor -n revisor | sed -n '/Conditions:/,/Events:/p'
Conditions:
Type Status Reason Message
---- ------ ------ -------
AbleToScale True ReadyForNewScale recommended size matches current size
ScaledToZero False NotScaledToZero the HPA controller did not scale the workload to zero
ScalingActive True ValidMetricFound the HPA was able to successfully calculate a replica count from cpu resource utilization (percentage of request)
ScalingLimited True TooFewReplicas the desired replica count is less than the minimum replica count
Events:
ScalingLimited True TooFewReplicas: the desired replica count is less than the minimum replica count est le HPA qui dit, en toutes lettres, ce qui a été calculé plus haut : ce que demande la formule (1) est inférieur au plancher (2), et c'est pourquoi il reste à 2. ScalingActive True ValidMetricFound dit qu'il peut bien calculer, c'est-à-dire que metrics-server continue de répondre. C'est le tableau qu'on regarde quand un HPA « ne fait rien » et qu'il faut savoir si c'est parce qu'il ne peut pas ou parce qu'il ne veut pas. Et la consommation réelle des deux répliques qui restent :
$ kubectl top pods -n revisor -l app=revisor
NAME CPU(cores) MEMORY(bytes)
revisor-767bdf965b-5b6k9 1m 6Mi
revisor-767bdf965b-vcznx 1m 5Mi
Un millicore chacune : la charge est vraiment partie. Les événements complets, et les répliques qui restent :
$ kubectl describe hpa revisor -n revisor | sed -n '/Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal SuccessfulRescale 10m horizontal-pod-autoscaler New size: 2; reason: Current number of replicas below Spec.MinReplicas
Warning FailedGetResourceMetric 9m17s (x3 over 10m) horizontal-pod-autoscaler failed to get cpu utilization: unable to get metrics for resource cpu: no metrics returned from resource metrics API
Warning FailedComputeMetricsReplicas 9m17s (x3 over 10m) horizontal-pod-autoscaler invalid metrics (1 invalid out of 1), first error is: failed to get cpu resource metric value: failed to get cpu utilization: unable to get metrics for resource cpu: no metrics returned from resource metrics API
Normal SuccessfulRescale 7m horizontal-pod-autoscaler New size: 3; reason: cpu resource utilization (percentage of request) above target
Normal SuccessfulRescale 6m30s horizontal-pod-autoscaler New size: 4; reason: cpu resource utilization (percentage of request) above target
Normal SuccessfulRescale 2m54s horizontal-pod-autoscaler New size: 3; reason: All metrics below target
Normal SuccessfulRescale 2m39s (x2 over 9m2s) horizontal-pod-autoscaler New size: 2; reason: All metrics below target
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-767bdf965b-5b6k9 1/1 Running 0 9m33s 10.244.1.10 curso-k8s-l9-worker <none> <none>
revisor-767bdf965b-vcznx 1/1 Running 0 9m31s 10.244.2.10 curso-k8s-l9-worker2 <none> <none>
L'événement New size: 2; reason: All metrics below target (« toutes les métriques sous la cible ») est la réduction. Et les deux répliques qui restent sont une sur chaque nœud de travail : la contrainte de répartition de 9.4 reste respectée après le retrait de deux Pods.
9.8 Qui commande les répliques
Le Deployment a replicas vide dans le fichier, mais il a quand même un nombre. Que se passe-t-il si quelqu'un d'autre le change manuellement ? On le teste avec kubectl scale, qui écrit le nombre de répliques dans le Deployment, le même champ que celui que le HPA écrit :
$ kubectl scale deployment/revisor -n revisor --replicas=1
deployment.apps/revisor scaled
(espera de 40 s)
$ kubectl get deployment,hpa -n revisor
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/prueba 1/1 1 1 13m
deployment.apps/revisor 2/2 2 2 13m
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
horizontalpodautoscaler.autoscaling/revisor Deployment/revisor cpu: 2%/50% 2 6 2 10m
$ kubectl describe hpa revisor -n revisor | sed -n '/Events:/,$p' | tail -3
Normal SuccessfulRescale 3m34s horizontal-pod-autoscaler New size: 3; reason: All metrics below target
Normal SuccessfulRescale 3m19s (x2 over 9m42s) horizontal-pod-autoscaler New size: 2; reason: All metrics below target
Normal SuccessfulRescale 33s (x2 over 10m) horizontal-pod-autoscaler New size: 2; reason: Current number of replicas below Spec.MinReplicas
Quarante secondes plus tard, le Deployment a de nouveau 2 répliques, et le dernier événement du HPA le dit : New size: 2; reason: Current number of replicas below Spec.MinReplicas. Le HPA n'a ni discuté ni demandé : à son tour suivant, il a vu que le Deployment avait moins de répliques que le plancher, et il l'a remis. Cela répond à une question pratique : avec un HPA actif, kubectl scale ne sert pas à grand-chose. Si tu descends sous le plancher, le HPA te corrige ; si tu montes au-dessus de ce que la charge demande, il te corrige à la baisse dès que la fenêtre est passée (9.5.4 l'a montré avec la troisième réplique : au bout de 75 secondes, le Deployment était déjà revenu à 2). Pour changer le nombre de répliques avec un HPA vivant, il faut changer le HPA : son minReplicas ou son maxReplicas. Qui veut deux propriétaires pour le même nombre a le problème de 9.3.4.
9.9 Mettre à jour et mesurer la coupure
9.9.1 Comment un Deployment met à jour
Quand tu changes le modèle de Pods d'un Deployment (l'image, une variable, un preStop), on n'arrête pas toutes les répliques pour en démarrer d'autres. Le Deployment crée un ReplicaSet nouveau pour le nouveau modèle et déplace des répliques de l'ancien vers le nouveau, petit à petit. La documentation le décrit ainsi : on déclare le nouvel état en mettant à jour le modèle, « un nouveau ReplicaSet est créé, et le Deployment le fait monter en charge tout en réduisant l'ancien, en s'assurant que les Pods sont remplacés à un rythme contrôlé » (Deployments). Deux nombres gouvernent ce rythme, et ils sont dans le Deployment depuis 9.5 :
maxSurge: combien de Pods en trop peuvent être créés au-dessus du nombre souhaité pendant la mise à jour. La documentation : « le nombre maximal de Pods qui peuvent être créés au-dessus du nombre souhaité de Pods », en nombre ou en pourcentage ; la valeur par défaut est 25 %.maxUnavailable: combien de Pods peuvent être perdus, c'est-à-dire être indisponibles, pendant la mise à jour. Par défaut également 25 % ; la documentation précise qu'il ne peut pas valoir 0 simaxSurgevaut 0 (sinon, la mise à jour ne pourrait pas avancer).
Le revisor les fixe à maxSurge: 1 et maxUnavailable: 0. Cela se lit comme une règle de sécurité : « ne retire pas une réplique qui répond tant qu'une nouvelle n'est pas prête à la remplacer ». Avec deux répliques, le Deployment crée une nouvelle réplique (la troisième, à cause du maxSurge), attend qu'elle soit prête, et seulement alors arrête une ancienne ; puis il recommence avec l'autre. À aucun moment il n'y a moins de deux répliques disponibles. C'est la partie de la mise à jour que Kubernetes sait faire tout seul, et c'est pourquoi rollout status imprime 1 out of 2 new replicas have been updated : il compte les nouvelles.
Mais « disponible » est un mot du plan de contrôle. Ce que ressentent les requêtes est autre chose, et la différence s'appelle situation de compétition (race condition).
9.9.2 La situation de compétition de l'arrêt
Quand le Deployment décide de retirer une ancienne réplique, cette réplique parcourt les étapes que la documentation du cycle de vie d'un Pod décrit comme le flux de terminaison (Pod Lifecycle) :
- Le Pod est marqué comme en cours de terminaison dans l'API, avec un délai (par défaut, 30 secondes ; celui du
revisorest de 15). - Sur le nœud, le
kubeletvoit la marque et commence l'arrêt local : si le conteneur déclare un hookpreStop, il l'exécute en premier. Ensuite, l'environnement d'exécution des conteneurs envoie le signalTERMau processus 1 de chaque conteneur, et le processus dispose de ce qui reste du délai pour se terminer. S'il est encore vivant à l'échéance, il reçoitKILL. - « En même temps que le kubelet commence l'arrêt ordonné du Pod », le plan de contrôle évalue s'il faut le retirer des
EndpointSlicedes Services qui l'utilisent. La documentation ajoute un détail utile : une destination qui représente un Pod en cours d'arrêt n'est pas retirée immédiatement ; elle est marquée comme en cours de terminaison et avecready: false, et les répartiteurs de charge cessent de l'utiliser pour le trafic normal.
L'étape 2 et l'étape 3 ne s'attendent pas l'une l'autre. C'est la situation de compétition. C'est l'observation de Phil Pearl de la scène d'ouverture, mesurée dans un autre contexte : l'avis d'arrêt arrive au processus au moment même où commence à circuler la nouvelle « retire-le de la liste », et la nouvelle doit voyager : du plan de contrôle aux EndpointSlice, de là à kube-proxy sur chaque nœud, et de là aux règles réseau de chaque nœud. Pendant qu'elle voyage, certains nœuds envoient encore des requêtes à l'ancien Pod. Si le processus a déjà fermé son port en recevant TERM, ces requêtes se heurtent à une porte fermée : Connection refused, ou au silence.
Le revisor fait bien sa part : en recevant TERM, il écrit recibí terminated, apagando, termine les requêtes qu'il a en cours et sort avec apagado limpio, avec un délai interne de 5 secondes (la Leçon 5 l'a mesuré). Mais « termine celles qu'il a en cours » n'inclut pas « continue d'accepter de nouvelles ». L'arrêt du revisor utilise Server.Shutdown, de la bibliothèque standard de Go, et la documentation de cette fonction dit comment elle travaille : « d'abord elle ferme tous les ports ouverts, puis ferme toutes les connexions inactives, puis attend » que les connexions actives se terminent (Go, net/http). Le port d'entrée se ferme en premier. Ce qui manque n'est pas un défaut du revisor ; c'est du temps pour que la nouvelle arrive.
Depuis que Pearl a écrit (2019), Kubernetes a ajouté aux EndpointSlice les conditions serving et terminating, et a permis à kube-proxy d'utiliser des destinations en cours de terminaison quand il n'en reste aucune de prête. Cela évite de perdre du trafic quand toutes les destinations sont en train de s'arrêter, mais ne change pas le fait que l'avis d'arrêt et le retrait des destinations voyagent par des chemins différents : la situation de compétition existe toujours et c'est celle que mesure cette leçon.
9.9.3 La pause qui referme la situation de compétition : preStop
Un hook preStop s'exécute avant le signal TERM. Si tout ce qu'il fait est d'attendre quelques secondes, le processus continue de répondre normalement pendant cette attente, tandis que la nouvelle « retire-le de la liste » finit de voyager. Quand TERM arrive enfin, plus personne ne lui envoie de nouveau trafic. Le Pod du revisor n'a pas de shell (l'image est minimale, celle de la Leçon 5), donc le hook ne peut pas être un sh -c "sleep 5" ; Kubernetes fournit une action d'attente native :
lifecycle:
preStop:
sleep:
seconds: 5
C'est tout ce qui a été ajouté au Deployment. Cinq secondes sont un choix, pas une loi : elles doivent suffire pour que la nouvelle atteigne tous les nœuds du cluster, et le temps que cela prend dépend de la taille et de la charge du cluster. Pearl, dans son cas, recommandait d'attendre plus longtemps que le temps que met le répartiteur de charge à se reprogrammer. La pause compte aussi dans le délai d'arrêt : la documentation dit que le temps du preStop fait partie du terminationGracePeriodSeconds, et que s'il en faut davantage, il faut augmenter le délai. C'est pourquoi le Deployment est passé de 10 à 15 secondes : 5 de pause, jusqu'à 5 d'arrêt du revisor, et 5 de marge.
Tout cela reste de la théorie tant qu'on ne mesure pas. Mesurer exige un outil, et l'outil est un programme.
9.9.4 L'interrogation périodique
La mesure doit remplir trois conditions : compter chaque requête, et non un échantillon ; tourner pendant le changement, ni avant ni après ; et distinguer quelle version a répondu et de quelle manière chaque échec s'est produit. C'est un programme : sondeo.sh.
#!/usr/bin/env bash
# sondeo.sh — Lección 9: mide cuántas peticiones se pierden mientras haces algo.
#
# Uso: ./sondeo.sh DURACION PAUSA HILOS [argumentos de curl]
# DURACION segundos que dura el sondeo
# PAUSA segundos entre una petición y la siguiente, en cada hilo (0 = sin pausa)
# HILOS cuántos ciclos de peticiones corren a la vez
# lo demás se le pasa a curl (por ejemplo: --retry 3 --retry-all-errors)
#
# Las peticiones salen del Pod «depurar» (curl dentro del clúster) hacia
# http://revisor/salud, es decir, por el Service, como cualquier cliente de dentro.
# Cada petición deja una línea «segundo cuerpo código rc=salida-de-curl». Al final se cuenta.
#
# 🔴 Antes de hablarle al clúster comprueba que el contexto activo es
# kind-curso-k8s-l9 y, si no, se detiene.
set -euo pipefail
[ "$(kubectl config current-context 2>/dev/null)" = kind-curso-k8s-l9 ] || { echo "contexto equivocado: no sigo" >&2; exit 1; }
[ "$#" -ge 3 ] || { echo "uso: $0 DURACION PAUSA HILOS [argumentos de curl]" >&2; exit 2; }
DURACION="$1"; PAUSA="$2"; HILOS="$3"; shift 3
EXTRA="$*"
# Este guion corre DENTRO de depurar (por la entrada estándar de kubectl exec).
kubectl exec -i -n revisor depurar -- sh -s -- "$DURACION" "$PAUSA" "$HILOS" $EXTRA <<'DENTRO' |
DURACION="$1"; PAUSA="$2"; HILOS="$3"; shift 3
inicio=$(date +%s); fin=$((inicio + DURACION))
for h in $(seq 1 "$HILOS"); do
(
while [ "$(date +%s)" -lt "$fin" ]; do
r=$(curl -s -m 2 -w ' %{http_code}' "$@" http://revisor/salud; echo " rc=$?")
echo "$(( $(date +%s) - inicio )) $(echo $r)"
[ "$PAUSA" = 0 ] || sleep "$PAUSA"
done
) &
done
wait
DENTRO
awk '
{ rc = $NF; code = $(NF - 1); v = "-"
if (match($0, /"version":"[0-9.]+"/)) v = substr($0, RSTART + 11, RLENGTH - 12)
n++; cuenta[code " " rc " " v]++
if (code != "200") { f++; if (nf < 40) { seg[nf++] = $1 } } }
END {
printf "peticiones=%d fallidas=%d\n", n, f
for (k in cuenta) { split(k, a, " "); printf " %5d código %s curl %s versión %s\n", cuenta[k], a[1], a[2], a[3] }
if (f > 0) { printf " segundos (desde el inicio) de las primeras fallas:"; for (i = 0; i < nf; i++) printf " +%s", seg[i]; printf "\n" }
}' | sort
On le lit par parties.
- Où il tourne. La boucle de requêtes tourne à l'intérieur du Pod
depurarde la Leçon 3, celui decurl, auquel on l'envoie par l'entrée standard dekubectl exec -i. Ainsi les requêtes naissent à l'intérieur du cluster et passent par le Service, comme celles de n'importe quel client interne (par exemple, la tâche planifiée de la Leçon 8). Portée : cette mesure couvre le chemin d'un client à l'intérieur du cluster, c'est-à-dire le Service etkube-proxy. Elle ne passe pas par le Gateway d'Envoy, par où entreraient les utilisateurs de l'extérieur : cette pièce prend connaissance des changements de destinations de son côté et peut mettre plus ou moins de temps quekube-proxy, et ce chemin n'a pas été mesuré ici. Pearl a observé le problème sur un Ingress, non sur un Service, et dit que sur les Services la fenêtre est généralement courte. Le<<'DENTRO'avec des guillemets simples empêche ton shell d'interpréter quoi que ce soit de ce bloc : il est envoyé tel quel. - Ce que fait chaque fil. Une boucle qui, tant que le temps n'est pas écoulé, demande
http://revisor/saludaveccurl -s -m 2: silencieux (-s) et avec un maximum de 2 secondes par requête (-m 2).-w ' %{http_code}'lui ajoute à la fin le code HTTP, et l'echo " rc=$?"ajoute la sortie decurllui-même. Ce second chiffre compte beaucoup, parce qu'il distingue comment une requête a échoué, et chaque mode raconte une histoire différente (plus bas). - Une ligne par requête.
echo "$(( $(date +%s) - inicio )) $(echo $r)"rassemble, sur une seule ligne, la seconde où cela s'est produit, le corps de la réponse, le code et la sortie decurl. Une ligne par requête évite que les lignes des quatre fils se mélangent : chaqueechoest une seule écriture. (Le$(echo $r)sans guillemets aplatit les sauts de ligne du corps ; la réponse durevisoren comporte un à la fin, et sans cette astuce la ligne serait coupée en deux.) - Le décompte. L'
awkfinal regroupe par (code, sortie decurl, version qui a répondu) et imprime combien il y en a eu de chaque sorte, ainsi que les secondes des 40 premiers échecs. Un « échec » est tout code différent de200, y compris000, ce quecurlimprime quand il n'a pas reçu de réponse HTTP. - Les deux modes d'échec que tu vas voir.
curlsort avec 7 quand il n'a pas pu se connecter (Failed to connect) : il est arrivé à une adresse où personne n'écoute, ou la connexion a été refusée. Il sort avec 28 quand le temps s'est écoulé (-m 2) sans réponse. Du premier on peut dire quelque chose de concret : quelqu'un a répondu « il n'y a personne ici ». Du second, seulement que personne n'a répondu à temps ; la cause peut se trouver dans la destination, dans le réseau ou dans le client lui-même.
Un avertissement d'honnêteté sur l'instrument : le Pod depurar a une limite de CPU de 100m, et quatre boucles de curl sans pause l'occupent entièrement. Un client au plafond de son CPU peut retarder ses propres requêtes et produire des délais d'attente dépassés qui ne sont pas la faute du serveur. Ce qui est mesuré, c'est l'ensemble client, réseau et serveur. C'est pourquoi l'interrogation de référence de 9.9.5 passe avant tout changement : elle mesure le bruit que produit l'interrogation elle-même.
9.9.5 D'abord, le bruit ; ensuite, la mise à jour avec preStop
Avant de rien changer, on mesure l'interrogation contre un cluster au repos, pour savoir combien de bruit elle produit à elle seule. Ce sont 15 secondes, avec quatre fils et sans pause. Vient ensuite la première mise à jour, celle du manifeste tel quel, avec le preStop en place. Le script lance l'interrogation en arrière-plan (&), attend 3 secondes qu'elle tourne déjà, change l'image de 1.6.0 à 1.6.1 et attend que le déploiement se termine :
$ ./sondeo.sh 15 0 4
172 código 200 curl rc=0 versión 1.6.0
peticiones=172 fallidas=0
$ ./sondeo.sh 45 0 4 & # empieza a las 17:26:44
(pausa de 3 s: que el sondeo ya esté corriendo)
$ kubectl set image deployment/revisor -n revisor revisor=curso-docker-revisor:1.6.1
deployment.apps/revisor image updated
$ kubectl rollout status deployment/revisor -n revisor --timeout=180s
Waiting for deployment "revisor" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "revisor" rollout to finish: 1 old replicas are pending termination...
deployment "revisor" successfully rolled out
# resultado del sondeo (A, con preStop):
56 código 200 curl rc=0 versión 1.6.0
416 código 200 curl rc=0 versión 1.6.1
peticiones=472 fallidas=0
kubectl set image change l'image du conteneur revisor à l'intérieur du Deployment revisor, sans toucher aucun fichier ; c'est le chemin court pour cette mesure (l'habitude de sécurité, plus bas, dit pourquoi ce n'est pas le chemin d'un vrai projet). Le rollout status compte au fur et à mesure : « 1 out of 2 new replicas have been updated » : sur les deux nouvelles répliques, une est déjà mise à jour ; puis « 1 old replicas are pending termination » : une ancienne n'est pas encore terminée. C'est la mécanique du maxSurge: 1 de 9.9.1 vue de l'extérieur : d'abord une nouvelle réplique, puis on retire une ancienne, puis une autre nouvelle.
Le décompte se lit ainsi. L'interrogation de référence a fait 172 requêtes en 15 secondes, 172 avec le code 200 et toutes de la version 1.6.0. Aucun échec : l'instrument, à ce moment-là, ne produit donc pas de bruit visible. Mais 172 requêtes, c'est peu pour estimer le bruit : un bruit de 0.24 % attendrait moins d'un échec sur 172 requêtes, si bien que cette ronde ne distingue pas « zéro » de « quelques pour mille ». La mise à jour A a fait 472 requêtes en 45 secondes : 56 ont été traitées par la version 1.6.0 (celles d'avant le remplacement et celles qui sont tombées sur d'anciennes répliques pendant son avancement) et 416 par la 1.6.1, et aucune n'a échoué. À aucun moment il n'y a eu une ligne avec un autre code. Si c'était la seule ronde, le titre serait « zéro échec sur 472 ». Mais une ronde ne suffit pas, et le reste de la section existe pour dire pourquoi.
9.9.6 La même expérience, sans la pause
Pour savoir ce qu'évite le preStop, il faut le retirer et mesurer la même chose. On le retire avec un patch de type JSON qui supprime le champ lifecycle du premier conteneur :
$ kubectl patch deployment/revisor -n revisor --type=json -p '[{"op":"remove","path":"/spec/template/spec/containers/0/lifecycle"}]'
deployment.apps/revisor patched
op: remove avec un path signifie « supprime ce champ ». Le patch change le modèle de Pods, il provoque donc son propre remplacement ; le script attend qu'il se termine avec rollout status et vérifie que le hook n'est plus là. La vérification est la suivante :
$ kubectl get deployment revisor -n revisor -o jsonpath='{.spec.template.spec.containers[0].lifecycle}{"|"}{.spec.template.spec.terminationGracePeriodSeconds}{"\n"}'
|15
La sortie est |15 : avant la barre, le contenu de lifecycle, qui est vide ; après, le terminationGracePeriodSeconds, 15. (Le | est écrit par la commande elle-même entre les deux champs, pour qu'on puisse voir le vide.) Sans hook, le Pod reçoit TERM à l'instant même où commence son arrêt.
Avec ce modèle, on répète la mise à jour trois fois, en allant et venant entre les deux versions, pour que chaque ronde change d'image et qu'il y ait un vrai remplacement :
# resultado del sondeo (B, ronda 1, sin preStop, hacia 1.6.0):
2 código 000 curl rc=7 versión -
7 código 000 curl rc=28 versión -
51 código 200 curl rc=0 versión 1.6.1
403 código 200 curl rc=0 versión 1.6.0
segundos (desde el inicio) de las primeras fallas: +5 +5 +7 +7 +9 +9 +9 +9 +30
peticiones=463 fallidas=9
# resultado del sondeo (B, ronda 2, sin preStop, hacia 1.6.1):
1 código 000 curl rc=7 versión -
8 código 000 curl rc=28 versión -
47 código 200 curl rc=0 versión 1.6.0
465 código 200 curl rc=0 versión 1.6.1
segundos (desde el inicio) de las primeras fallas: +5 +7 +7 +8 +9 +9 +10 +10 +16
peticiones=521 fallidas=9
# resultado del sondeo (B, ronda 3, sin preStop, hacia 1.6.0):
3 código 000 curl rc=7 versión -
8 código 000 curl rc=28 versión -
105 código 200 curl rc=0 versión 1.6.1
469 código 200 curl rc=0 versión 1.6.0
segundos (desde el inicio) de las primeras fallas: +5 +5 +5 +7 +7 +8 +8 +9 +10 +10 +10
peticiones=585 fallidas=11
Il y a de nouvelles lignes, et chacune raconte quelque chose de différent :
código 000 curl rc=7: le code HTTP000est ce quecurlimprime quand il n'a pas reçu de réponse HTTP, etrc=7est sa propre sortie : « Failed to connect to host ». Quelqu'un a répondu « il n'y a personne ici » à la connexion. C'est la signature qu'on attendrait de la situation de compétition de 9.9.2 : une requête est arrivée, par le Service, sur une réplique qui avait déjà fermé son port.código 000 curl rc=28:rc=28est « Operation timeout » : les 2 secondes de-m 2se sont écoulées sans réponse. Personne n'a dit « non » ; personne n'a rien dit. Cela arrive quand le paquet arrive à une adresse qui n'existe plus (le Pod a déjà disparu et le réseau le désigne encore) ou quand quelqu'un met trop longtemps à répondre. Le délai dépassé a donc plusieurs causes possibles, et l'une d'elles est la même situation de compétition.- La ligne « segundos (desde el inicio) de las primeras fallas » donne la seconde, comptée depuis le démarrage de l'interrogation, où s'est terminée chaque requête échouée ; un échec
curl 28a été envoyé environ 2 secondes plus tôt. Dans les trois rondes sans pause, les échecs tombent entre+5et+10secondes, sauf un ou deux plus tardifs. C'est le moment où les anciennes répliques s'arrêtent : leset imagea été envoyé 3 secondes après le lancement de l'interrogation.
La somme des trois rondes sans preStop : 463 + 521 + 585 = 1 569 requêtes, dont 9 + 9 + 11 = 29 ont échoué. Six étaient des curl 7 (2, 1 et 3) et 23 des curl 28 (7, 8 et 8).
9.9.7 Avec la pause de nouveau, trois rondes de plus
On réapplique le dossier, qui porte le preStop, et l'on répète la mise à jour trois fois de plus :
$ kubectl apply -k . | grep -E 'deployment|horizontal'
deployment.apps/prueba unchanged
deployment.apps/revisor configured
horizontalpodautoscaler.autoscaling/revisor unchanged
# resultado del sondeo (C, ronda 1, con preStop, hacia 1.6.1):
4 código 000 curl rc=28 versión -
57 código 200 curl rc=0 versión 1.6.0
471 código 200 curl rc=0 versión 1.6.1
segundos (desde el inicio) de las primeras fallas: +2 +2 +2 +2
peticiones=532 fallidas=4
# resultado del sondeo (C, ronda 2, con preStop, hacia 1.6.0):
97 código 200 curl rc=0 versión 1.6.1
515 código 200 curl rc=0 versión 1.6.0
peticiones=612 fallidas=0
# resultado del sondeo (C, ronda 3, con preStop, hacia 1.6.1):
1 código 000 curl rc=28 versión -
72 código 200 curl rc=0 versión 1.6.0
360 código 200 curl rc=0 versión 1.6.1
segundos (desde el inicio) de las primeras fallas: +25
peticiones=433 fallidas=1
9.9.8 Ce que disent les sept rondes, et ce qu'elles ne disent pas
En rassemblant toutes les rondes de l'exécution :
| Variante | Rondes | Requêtes | Échecs | curl 7 |
curl 28 |
|---|---|---|---|---|---|
| Interrogation de référence, sans rien toucher | 1 | 172 | 0 | 0 | 0 |
Avec preStop (A et C1 à C3) |
4 | 2 049 | 5 | 0 | 5 |
Sans preStop (B1 à B3) |
3 | 1 569 | 29 | 6 | 23 |
Avec preStop, 5 échecs sur 2 049 requêtes (0.24 % ; intervalle à 95 %, méthode de Wilson : 0.10 à 0.57 %). Sans lui, 29 sur 1 569 (1.85 % ; 1.29 à 2.64 %). Les intervalles ne se chevauchent pas, mais ils supposent des requêtes indépendantes, et ici elles ne le sont pas (quatre fils qui partagent un client, et les quatre échecs de la ronde C1 datent du même instant) ; lis-les comme une indication de l'ordre de grandeur, non comme une garantie. Ce que ces chiffres permettent de dire, avec prudence :
- La connexion refusée n'est apparue que sans la pause. Les six connexions refusées (
curl 7) se sont produites dans les rondes sanspreStop, et aucune dans les quatre rondes avec lui. C'est l'effet que prédit 9.9.2 : sans pause, une réplique ferme son port alors que quelqu'un lui envoie encore des requêtes. Avec la pause sont restés cinq délais dépassés (curl 28) que cette mesure ne permet d'attribuer ni au bruit ni à une situation de compétition résiduelle : ce code a les deux causes possibles. - Les échecs se regroupent là où on les attend. Dans les rondes sans pause, ils tombent dans une fenêtre d'environ cinq ou six secondes, celle qui suit le
set image. Dans les rondes avec pause, il n'y a pas d'échecs dans cette fenêtre : les quatre de la ronde C1 se sont produits à la même seconde,+2, c'est-à-dire quand l'interrogation démarrait à peine (leset imageest envoyé 3 secondes après son lancement), et l'unique échec de la ronde C3 est tombé à+25, la mise à jour étant déjà en cours ; nous n'avons pas enregistré le moment où chaque Pod s'est arrêté, nous ne pouvons donc pas le rattacher à un moment précis. Que les quatre échecs de C1 soient simultanés oriente vers un démarrage lent du client (quatre fils qui commencent en même temps dans un Pod limité à100mde CPU sur une machine partagée et chargée), mais la mesure ne le démontre pas : c'est une hypothèse, non une conclusion. - Zéro n'est pas ce qui a été mesuré. La ronde A, la première, a donné 0 sur 472 ; les trois autres rondes avec pause ont donné 4, 0 et 1. Une leçon qui dirait « avec
preStop, aucune requête n'est perdue » aurait choisi la ronde A et aurait menti. Ce que la mesure soutient bel et bien : la pause a réduit les échecs de la fenêtre de mise à jour à un petit nombre qui n'est pas zéro non plus, et les rondes avec pause ont donné ensemble 5 échecs sur 2 049 requêtes. Nous ne savons pas quelle part vient du bruit du client : l'interrogation de référence, de 172 requêtes sans échec, est trop courte pour l'estimer. - Ce n'est pas une preuve statistique. Ce sont sept rondes de 45 secondes, sur une machine partagée très chargée, avec un client qui partage ses cœurs avec le cluster qu'il mesure. Les rondes n'ont pas été alternées (d'abord la A, puis les trois sans pause et ensuite les trois avec pause), si bien que la variante et le moment vont ensemble, et que la charge de la machine a changé pendant le travail ; pour séparer les deux effets, il faudrait alterner avec et sans pause. Les chiffres servent à comparer des variantes au sein de cette exécution ; non à promettre un pourcentage sur ton cluster. Ce qui est transférable, c'est la méthode : compter toutes les requêtes, distinguer comment elles échouent, répéter, et ne pas croire une seule ronde.
La pause de 5 secondes referme donc la partie de la situation de compétition qui peut se refermer avec une pause. Ce n'est pas une garantie : la documentation des hooks elle-même dit que leur remise est « au moins une fois » et qu'il n'y a pas d'autre promesse. Et il existe une source d'échecs qu'aucune pause ne touche : la panne d'un nœud, qui vient en 9.12.
9.10 L'historique et le retour en arrière
9.10.1 Un ReplicaSet par version
Chaque fois que le modèle du Deployment change, le Deployment crée un ReplicaSet nouveau, et l'ancien reste là, avec zéro réplique, au lieu d'être supprimé. Chacun de ces ReplicaSet conserve le modèle d'une version : son image, ses probes, son preStop. C'est ce qui rend possible le retour en arrière, et la documentation le dit sans détour : « la configuration de chaque révision du Deployment est stockée dans ses ReplicaSet ; par conséquent, une fois qu'un ancien ReplicaSet est supprimé, on perd la capacité de revenir à cette révision ». Combien sont conservés, c'est .spec.revisionHistoryLimit qui le décide, et sa valeur par défaut est 10.
Tu peux mettre un commentaire sur chaque révision avec une annotation, kubernetes.io/change-cause, que rollout history montre dans une colonne. La documentation dit qu'on peut la poser avec kubectl annotate, en éditant le manifeste ou avec des outils qui le font automatiquement, et que le drapeau --record, qui la remplissait auparavant, est obsolète et va être retiré. On l'applique à la révision actuelle :
$ kubectl annotate deployment/revisor -n revisor kubernetes.io/change-cause='ronda de pruebas de la Leccion 9' --overwrite
deployment.apps/revisor annotated
$ kubectl rollout history deployment/revisor -n revisor
deployment.apps/revisor
REVISION CHANGE-CAUSE
1 <none>
3 <none>
8 <none>
9 <none>
12 <none>
13 ronda de pruebas de la Leccion 9
La colonne CHANGE-CAUSE n'a du texte que sur la révision actuelle, parce que l'annotation est copiée sur une révision au moment où cette révision est créée, et que les autres existaient déjà. Les numéros de révision n'ont pas à être consécutifs : ici ils sont 1, 3, 8, 9, 12 et 13. Chaque fois que le modèle redevient identique à celui d'un ReplicaSet qui existait déjà, le Deployment réutilise ce ReplicaSet et lui donne le numéro de révision le plus récent, de sorte que les révisions intermédiaires devenues « la même » disparaissent de la liste. Les ReplicaSet confirment le compte :
$ kubectl get replicasets -n revisor -l app=revisor
NAME DESIRED CURRENT READY AGE
revisor-574b5b6c4c 0 0 0 16m
revisor-5978745747 0 0 0 4m33s
revisor-5cc67d8975 2 2 2 5m33s
revisor-67b97d67c9 0 0 0 18m
revisor-767bdf965b 0 0 0 16m
revisor-7f759cd976 0 0 0 4m51s
(imagen actual: curso-docker-revisor:1.6.1)
Six ReplicaSet, un pour chaque modèle différent qui est passé par le Deployment dans cette exécution : avec preStop et sans lui, avec une version ou une autre, avec nodeTaintsPolicy et sans lui. Un seul a des répliques. Les cinq autres sont l'historique.
9.10.2 rollout undo
rollout undo ramène le Deployment à la révision précédente. Il n'exécute rien de spécial : il écrit dans le Deployment le modèle du ReplicaSet précédent, et le Deployment fait le même remplacement progressif que d'habitude, mais à l'envers.
$ kubectl rollout undo deployment/revisor -n revisor
Warning: resource deployments/revisor was previously managed with 'kubectl apply'. Rolling back will not update the kubectl.kubernetes.io/last-applied-configuration annotation, which may cause unexpected behavior on future 'kubectl apply' operations. Consider using 'kubectl apply' with your previous configuration file instead.
deployment.apps/revisor rolled back
L'avertissement (Warning: resource deployments/revisor was previously managed with 'kubectl apply'…) mérite d'être lu en entier. Il dit que le retour en arrière ne met pas à jour l'annotation last-applied-configuration, celle qui conserve le dernier état que tu as appliqué avec kubectl apply, et que cela « peut provoquer un comportement inattendu lors de futurs kubectl apply ». Traduit : ton fichier dit une chose, le cluster est revenu à une autre, et le prochain apply -k . va faire sa propre comparaison et peut défaire ton retour en arrière. rollout undo est un outil pour éteindre un incendie ; la vraie réparation est de modifier le fichier et de l'appliquer. La documentation de kubectl le suggère avec les mêmes mots à la fin de l'avertissement : « envisage d'utiliser kubectl apply avec ta configuration précédente ».
$ kubectl rollout status deployment/revisor -n revisor --timeout=180s
Waiting for deployment "revisor" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "revisor" rollout to finish: 1 old replicas are pending termination...
deployment "revisor" successfully rolled out
9.10.3 Quand cela se termine vraiment
rollout status a dit successfully rolled out. Est-ce qu'il ne reste plus rien de la version que tu as retirée ? On le regarde avec la colonne nécessaire, celle que le script porte dans tous ses listages de Pods :
$ kubectl get pods -n revisor -l app=revisor -o custom-columns=POD:.metadata.name,IMAGEN:.spec.containers[0].image,NODO:.spec.nodeName,BORRANDOSE:.metadata.deletionTimestamp
POD IMAGEN NODO BORRANDOSE
revisor-5cc67d8975-qbzsl curso-docker-revisor:1.6.1 curso-k8s-l9-worker2 2026-10-21T23:32:40Z
revisor-5cc67d8975-ttszb curso-docker-revisor:1.6.1 curso-k8s-l9-worker 2026-10-21T23:32:37Z
revisor-767bdf965b-58sd9 curso-docker-revisor:1.6.0 curso-k8s-l9-worker2 <none>
revisor-767bdf965b-jzpxs curso-docker-revisor:1.6.0 curso-k8s-l9-worker <none>
Regarde la colonne BORRANDOSE : c'est le champ deletionTimestamp du Pod, l'heure (en UTC) à laquelle le cluster lui a ordonné de se terminer. Les Pods de 1.6.1 l'ont : ils sont en train de se terminer, ils sont encore dans leur pause de preStop (le délai de 15 secondes n'est pas encore échu), et ils répondent encore. Les Pods de 1.6.0 ne l'ont pas : ce sont les vivants d'aujourd'hui. rollout status s'est terminé parce que le Deployment remplit déjà ce qu'on lui a demandé (les répliques souhaitées de la bonne version sont prêtes et les mauvaises sont déjà marquées pour se terminer), et non parce que le dernier mauvais Pod a fini de mourir. Les requêtes le montrent :
$ kubectl exec -n revisor depurar -- sh -c 'for i in 1 2 3 4 5 6; do curl -s http://revisor/salud; echo; done'
{"estado":"vivo","version":"1.6.1"}
{"estado":"vivo","version":"1.6.1"}
{"estado":"vivo","version":"1.6.0"}
{"estado":"vivo","version":"1.6.1"}
{"estado":"vivo","version":"1.6.0"}
{"estado":"vivo","version":"1.6.1"}
Quatre des six réponses disent 1.6.1 : la version que tu viens de retirer. Seules deux disent 1.6.0, celle qui est restée. Si tu avais conclu à ce moment-là que le undo n'avait pas fonctionné, tu te serais trompé. C'est la même situation de compétition que 9.9.2, vue depuis le retour en arrière : le retrait des destinations met un peu de temps à parvenir à tous. Douze secondes plus tard, la pause étant échue :
(espera de 12 s: que termine el preStop de los Pods viejos)
$ kubectl get pods -n revisor -l app=revisor -o custom-columns=POD:.metadata.name,IMAGEN:.spec.containers[0].image,NODO:.spec.nodeName,BORRANDOSE:.metadata.deletionTimestamp
POD IMAGEN NODO BORRANDOSE
revisor-767bdf965b-58sd9 curso-docker-revisor:1.6.0 curso-k8s-l9-worker2 <none>
revisor-767bdf965b-jzpxs curso-docker-revisor:1.6.0 curso-k8s-l9-worker <none>
$ kubectl exec -n revisor depurar -- sh -c 'for i in 1 2 3 4 5 6; do curl -s http://revisor/salud; echo; done'
{"estado":"vivo","version":"1.6.0"}
{"estado":"vivo","version":"1.6.0"}
{"estado":"vivo","version":"1.6.0"}
{"estado":"vivo","version":"1.6.0"}
{"estado":"vivo","version":"1.6.0"}
{"estado":"vivo","version":"1.6.0"}
Il ne reste plus aucune ligne avec BORRANDOSE, et les six réponses disent 1.6.0. Un rollout undo qui semble ne pas avoir fonctionné parce qu'une requête a reçu la réponse de l'ancienne version, c'est le plus souvent cela : on a conclu trop tôt. La règle : avant de décider que le retour en arrière a échoué, regarde deletionTimestamp et attends que le délai soit échu.
Pour voir ce que conservait une révision, rollout history accepte --revision=N, et montre le modèle complet de cette version, image comprise. Le script demande la première :
$ kubectl rollout history deployment/revisor -n revisor --revision=1 | head -14
deployment.apps/revisor with revision #1
Pod Template:
Labels: app=revisor
pod-template-hash=67b97d67c9
Service Account: revisor
Containers:
revisor:
Image: curso-docker-revisor:1.6.0
Port: 8080/TCP (http)
Host Port: 0/TCP (http)
Limits:
cpu: 200m
memory: 64Mi
Requests:
C'est le modèle avec lequel est né le Deployment dans le dossier de la Leçon 8 : image 1.6.0, sans preStop. (La sortie est coupée à 14 lignes avec head pour qu'elle tienne ; le reste, ce sont les Requests, les probes et les volumes.) Pour y revenir, il faudrait dire kubectl rollout undo deployment/revisor --to-revision=1, la forme que donne la documentation.
Et pour boucler la boucle avec la discipline de l'habitude de sécurité, le script réapplique le dossier, de sorte que le cluster et les fichiers soient identiques :
$ kubectl apply -k . | grep -E 'deployment'
deployment.apps/prueba unchanged
deployment.apps/revisor unchanged
unchanged : le modèle laissé par le undo est identique à celui du fichier, et apply n'a rien à faire.
9.10.4 L'erreur : une image qui n'existe pas
L'une des raisons les plus courantes d'un retour en arrière est une mise à jour qui a mal tourné, et pas toujours à cause d'un défaut du programme mais d'une faute de frappe : le tag de l'image est mal écrit, ou pointe vers une version que personne n'a publiée. On la provoque exprès, avec un script à part qui monte son propre cluster minimal : fig09_02.imagen-rota.sh, un programme qui échoue exprès. Il crée curso-k8s-l9 avec seulement ce qui est nécessaire (le revisor et le service de test, sans MetalLB ni Envoy Gateway, qui n'interviennent pas), met deux répliques, lance l'interrogation de 9.9.4 et change l'image pour curso-docker-revisor:9.9.9, un tag qui n'existe pas. Sa sortie est fig09_02.imagen-rota.salida.txt. On le lance ainsi, depuis le dossier de la leçon :
./fig09_02.imagen-rota.sh > fig09_02.imagen-rota.salida.txt 2>&1
Il ne porte pas de ctx && devant parce qu'il crée son propre cluster et que le contexte n'existe pas encore ; il a ses propres garde-fous (kubeconfig temporaire, contexte vérifié avant chaque kubectl, suppression du cluster à la sortie). Si tu as déjà un curso-k8s-l9 vivant, le script s'arrête : il ne touche pas au tien. Il prend environ quatre minutes. La commande qui provoque l'erreur est celle-ci, et elle se passe bien :
$ kubectl set image deployment/revisor -n revisor revisor=curso-docker-revisor:9.9.9
deployment.apps/revisor image updated
image updated, sans protester : le kube-apiserver ne sait pas si l'image existe. C'est la même situation que le selector mal écrit de la Leçon 3 : un nom valide qui ne pointe sur rien. Ce qui suit se lit dans l'ordre, comme n'importe quelle panne.
D'abord, rollout status.
$ kubectl rollout status deployment/revisor -n revisor --timeout=40s
Waiting for deployment "revisor" rollout to finish: 1 out of 2 new replicas have been updated...
error: timed out waiting for the condition
[código de salida: 1]
(espera de 10 s: que el kubelet registre el fallo de descarga)
Il reste à « 1 out of 2 new replicas have been updated » et, le délai de 40 secondes qu'on lui avait donné étant épuisé, il sort avec error: timed out waiting for the condition et le code 1. Remarque qu'il ne dit pas ce qui ne va pas : il dit seulement que cela ne se termine pas. rollout status attend, et la cause doit être cherchée ailleurs. Sans --timeout, il attendrait jusqu'à ce que le Deployment déclare qu'il ne progresse pas : le champ .spec.progressDeadlineSeconds, qui vaut 600 secondes par défaut, après lesquelles le Deployment ajoute une condition Progressing à False avec la raison ProgressDeadlineExceeded (Deployments). La documentation avertit que Kubernetes ne fait rien avec un Deployment bloqué sinon le signaler : il ne le défait pas tout seul.
Deuxièmement, les Pods.
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-6586779dc4-96dfn 0/1 ErrImagePull 0 50s 10.244.2.5 curso-k8s-l9-worker <none> <none>
revisor-85f8668b64-2tjx4 1/1 Running 0 60s 10.244.1.2 curso-k8s-l9-worker2 <none> <none>
revisor-85f8668b64-mqqjf 1/1 Running 0 60s 10.244.2.4 curso-k8s-l9-worker <none> <none>
Trois Pods, non deux. Les deux anciens (85f8668b64) sont toujours 1/1 Running. Le nouveau (6586779dc4) est en 0/1 ErrImagePull : le nœud a essayé de télécharger l'image, ne l'a pas trouvée, et il n'y a pas de conteneur à démarrer. C'est le même état que dans la Leçon 2, avec la même sortie, et il se lit de la même façon : l'étape suivante est le describe.
Troisièmement, les événements du nouveau Pod.
$ kubectl describe pod revisor-6586779dc4-96dfn -n revisor | sed -n '/Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 50s default-scheduler Successfully assigned revisor/revisor-6586779dc4-96dfn to curso-k8s-l9-worker
Normal Pulling 13s (x3 over 50s) kubelet spec.containers{revisor}: Pulling image "curso-docker-revisor:9.9.9"
Warning Failed 12s (x3 over 50s) kubelet spec.containers{revisor}: Failed to pull image "curso-docker-revisor:9.9.9": failed to pull and unpack image "docker.io/library/curso-docker-revisor:9.9.9": failed to resolve reference "docker.io/library/curso-docker-revisor:9.9.9": pull access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failed
Warning Failed 12s (x3 over 50s) kubelet spec.containers{revisor}: Error: ErrImagePull
Normal BackOff 2s (x3 over 49s) kubelet spec.containers{revisor}: Back-off pulling image "curso-docker-revisor:9.9.9"
Warning Failed 2s (x3 over 49s) kubelet spec.containers{revisor}: Error: ImagePullBackOff
L'événement Failed to pull image "curso-docker-revisor:9.9.9" dit, avec plus de texte qu'il n'en tient dans la page, qu'il n'a pas pu résoudre docker.io/library/curso-docker-revisor:9.9.9 : le nœud a cherché le tag sur Docker Hub, où il n'existe pas, parce que l'image du cours ne vit que sur ta machine. Puis ErrImagePull, BackOff et ImagePullBackOff : le kubelet réessaie avec des attentes de plus en plus longues. (x3 over 50s) dit combien de fois il a essayé.
Quatrièmement, ce que dit le Deployment.
$ kubectl get deployment revisor -n revisor
NAME READY UP-TO-DATE AVAILABLE AGE
revisor 2/2 1 2 60s
READY 2/2, UP-TO-DATE 1, AVAILABLE 2. « Prêtes : 2 sur 2 ; à jour : 1 ; disponibles : 2 ». Le Deployment a fait exactement ce que maxSurge: 1 et maxUnavailable: 0 lui demandaient : il a créé une nouvelle réplique, a attendu qu'elle soit prête pour pouvoir retirer une ancienne, et comme elle ne l'a jamais été, il n'en a retiré aucune. Les deux anciennes continuent de servir.
Et l'interrogation périodique, qui a tourné pendant tout le blocage :
# resultado del sondeo (durante toda la actualización rota):
811 código 200 curl rc=0 versión 1.6.0
peticiones=811 fallidas=0
Dans cette ronde, aucune des 811 requêtes n'a échoué : toutes ont été traitées par la version 1.6.0, l'ancienne, parce qu'aucune ancienne réplique n'a été retirée. C'est une seule ronde, mais ce qui rend le résultat possible est mécanique, non statistique : avec maxUnavailable: 0, le Deployment ne retire pas une ancienne réplique sans en avoir une nouvelle prête, et la nouvelle n'est jamais arrivée à l'être. Voilà l'utilité concrète de maxUnavailable: 0 : une mise à jour cassée reste bloquée sans retirer ce qui fonctionne. Attention à ce qu'elle ne mesure pas : comme aucune ancienne réplique n'a jamais été arrêtée, elle ne prouve rien non plus sur la situation de compétition de l'arrêt. Avec la valeur par défaut de 25 %, un Deployment de quatre répliques s'autoriserait à en retirer une avant d'avoir la nouvelle prête, et un faux pas de ce genre pourrait se remarquer.
La correction est la cause, et l'outil est celui de 9.10.2 :
$ kubectl rollout undo deployment/revisor -n revisor
Warning: resource deployments/revisor was previously managed with 'kubectl apply'. Rolling back will not update the kubectl.kubernetes.io/last-applied-configuration annotation, which may cause unexpected behavior on future 'kubectl apply' operations. Consider using 'kubectl apply' with your previous configuration file instead.
deployment.apps/revisor rolled back
$ kubectl rollout status deployment/revisor -n revisor --timeout=180s
deployment "revisor" successfully rolled out
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-85f8668b64-2tjx4 1/1 Running 0 65s 10.244.1.2 curso-k8s-l9-worker2 <none> <none>
revisor-85f8668b64-mqqjf 1/1 Running 0 65s 10.244.2.4 curso-k8s-l9-worker <none> <none>
$ kubectl get deployment revisor -n revisor -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
curso-docker-revisor:1.6.0
Le undo ramène le modèle au précédent, le Pod qui ne démarrait pas disparaît, et l'image revient à curso-docker-revisor:1.6.0. (Ici, aucun Pod de la mauvaise version n'a continué de répondre, comme en 9.10.3 : il n'est jamais devenu prêt.) Dans un vrai projet, la correction définitive est de réparer le tag dans le fichier et de l'appliquer ; le undo est le premier secours.
9.11 Maintenance d'un nœud : le budget d'interruptions
9.11.1 Deux sortes d'interruption
Tout ce qui retire un Pod à un service n'est pas équivalent. La documentation de Kubernetes sépare les interruptions involontaires, qui sont des « cas inévitables » comme « une panne matérielle de la machine qui porte le nœud », des volontaires, qui sont les actions de la personne qui exploite le système : la personne propriétaire de l'application, ou l'administrateur du cluster, qui peut entre autres « vider un nœud pour le réparer ou le mettre à jour » (Disruptions). La différence compte parce que sur les volontaires on peut négocier : le cluster te laisse dire combien tu es prêt à perdre. Sur les involontaires, non : personne ne demande à un disque s'il peut tomber en panne.
L'outil de la négociation est le PodDisruptionBudget (PDB), le second objet de escalado.yaml. La documentation définit ses deux formes : minAvailable, le nombre de Pods qui doivent rester disponibles après une éviction, et maxUnavailable, le nombre qui peut devenir indisponible ; on déclare l'une ou l'autre, pas les deux. Celui du revisor dit minAvailable: 1 : avec deux répliques, l'une peut partir, et l'autre doit rester. On lit son état :
$ kubectl get pdb revisor -n revisor
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
revisor 1 N/A 1 17m
ALLOWED DISRUPTIONS 1 est le compte que le cluster tient à cet instant : deux répliques saines, moins une qui doit rester debout, laissent une qui peut être perdue. Si l'une des deux cesse d'être saine, ce nombre tombe à 0, et la prochaine éviction est refusée.
9.11.2 Comment on vide un nœud
kubectl drain est l'ordre « ce nœud part en maintenance ». Il fait deux choses, dans cet ordre : il le marque comme non planifiable (cordon : plus aucun nouveau Pod n'y arrive) et il évince un par un les Pods qui y tournent. La documentation (Safely Drain a Node) explique qu'une éviction sûre permet aux conteneurs de se terminer avec ordre et « respecte les PodDisruptionBudget que tu as spécifiés ». La discipline qui se cache derrière, selon la page des interruptions : kubectl demande chaque éviction par l'API d'éviction ; cette demande peut être refusée temporairement, et l'outil réessaie celles qui échouent jusqu'à ce que tous les Pods du nœud se terminent ou qu'expire un délai que tu configures (--timeout). La commande du script porte deux drapeaux. --ignore-daemonsets : la documentation dit qu'il est nécessaire quand il y a des Pods d'un DaemonSet (comme les speaker de MetalLB), qui sont laissés où ils sont et retirés quand le nœud quitte le cluster. --delete-emptydir-data : il accepte de perdre les données des volumes emptyDir, comme le /datos du revisor, qui meurt de toute façon avec le Pod.
9.11.3 L'erreur : un budget sans marge
On la provoque avec fig09_01.pdb-estricto.yaml, un programme qui échoue exprès : le même PDB, avec minAvailable: 2.
# fig09_01.pdb-estricto.yaml — FALLA A PROPÓSITO: un PodDisruptionBudget que no deja
# perder NI UNA réplica (minAvailable: 2 con solo 2 réplicas). Mientras exista,
# «kubectl drain» no puede desalojar ningún Pod del revisor y se queda reintentando.
# Se deshace con: kubectl apply -k . (escalado.yaml trae el PDB bueno)
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: revisor
spec:
minAvailable: 2
selector:
matchLabels:
app: revisor
On l'applique dans l'espace de noms du revisor, avec -n revisor : le fichier ne déclare pas d'espace de noms, et un apply -f sans -n crée l'objet là où pointe le contexte (l'espace default), qui ne protège personne. C'est une erreur facile à commettre, et elle a été commise en préparant cette leçon : oublier le -n revisor. Le cluster ne se plaint pas : le message était poddisruptionbudget.policy/revisor created au lieu de configured, le bon budget de l'espace revisor est resté intact (ALLOWED DISRUPTIONS 1) et le drain est passé sans entraves, comme si l'expérience n'existait pas. L'indice était dans le mot : created au lieu de configured. On lit le message de l'apply, et l'on vérifie avec get pdb -n revisor.
$ kubectl apply -n revisor -f fig09_01.pdb-estricto.yaml
poddisruptionbudget.policy/revisor configured
$ kubectl get pdb revisor -n revisor
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
revisor 2 N/A 0 17m
(el nodo que se va a vaciar: curso-k8s-l9-worker2)
configured : l'objet existait déjà et dit maintenant autre chose. MIN AVAILABLE 2 et, le chiffre qui compte, ALLOWED DISRUPTIONS 0. Avec deux répliques et l'obligation d'en conserver deux, aucune ne peut être perdue. On essaie maintenant de vider un nœud, avec un délai court pour que le script n'attende pas indéfiniment :
$ kubectl drain curso-k8s-l9-worker2 --ignore-daemonsets --delete-emptydir-data --timeout=30s 2>&1 | grep -v -E '^(Warning: ignoring|evicting pod kube-system)'
node/curso-k8s-l9-worker2 cordoned
evicting pod revisor/prueba-6899c74865-cgzkw
evicting pod metallb-system/controller-76f4cf76b7-m9mlh
evicting pod revisor/revisor-767bdf965b-58sd9
evicting pod envoy-gateway-system/envoy-revisor-revisor-b269644b-867f46cd7d-8fbvq
error when evicting pods/"revisor-767bdf965b-58sd9" -n "revisor" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
pod/controller-76f4cf76b7-m9mlh evicted
pod/prueba-6899c74865-cgzkw evicted
evicting pod revisor/revisor-767bdf965b-58sd9
error when evicting pods/"revisor-767bdf965b-58sd9" -n "revisor" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
evicting pod revisor/revisor-767bdf965b-58sd9
error when evicting pods/"revisor-767bdf965b-58sd9" -n "revisor" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
pod/envoy-revisor-revisor-b269644b-867f46cd7d-8fbvq evicted
evicting pod revisor/revisor-767bdf965b-58sd9
error when evicting pods/"revisor-767bdf965b-58sd9" -n "revisor" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
evicting pod revisor/revisor-767bdf965b-58sd9
error when evicting pods/"revisor-767bdf965b-58sd9" -n "revisor" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
evicting pod revisor/revisor-767bdf965b-58sd9
error when evicting pods/"revisor-767bdf965b-58sd9" -n "revisor" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
evicting pod revisor/revisor-767bdf965b-58sd9
There are pending pods in node "curso-k8s-l9-worker2" when an error occurred: error when evicting pods/"revisor-767bdf965b-58sd9" -n "revisor": global timeout reached: 30s
pod/revisor-767bdf965b-58sd9
error: unable to drain node "curso-k8s-l9-worker2" due to error: error when evicting pods/"revisor-767bdf965b-58sd9" -n "revisor": global timeout reached: 30s, continuing command...
There are pending nodes to be drained:
curso-k8s-l9-worker2
error when evicting pods/"revisor-767bdf965b-58sd9" -n "revisor": global timeout reached: 30s
[código de salida: 1]
Cela se lit comme une pièce de théâtre. Le nœud est cordonné (cordoned). Des évictions sont demandées pour les Pods qui y vivent, et partent ceux qui ne sont pas protégés : prueba, le controller de MetalLB et le Pod d'Envoy qui dessert le point d'entrée se terminent (evicted). Et alors apparaît, encore et encore, toutes les 5 secondes :
error when evicting pods/"revisor-…" -n "revisor" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
« Impossible d'évincer le Pod car cela violerait le budget d'interruptions ». Le cluster ne s'est pas trompé : il a fait exactement ce qu'on lui demandait. Au bout de 30 secondes, le délai du drain expire (global timeout reached: 30s) et la commande sort avec le code 1. Regarde dans quel état est resté le nœud :
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
curso-k8s-l9-control-plane Ready control-plane 26m v1.37.0
curso-k8s-l9-worker Ready <none> 26m v1.37.0
curso-k8s-l9-worker2 Ready,SchedulingDisabled <none> 26m v1.37.0
Ready,SchedulingDisabled : il est toujours sain mais cordonné. Un drain interrompu laisse le nœud dans cet état jusqu'à ce que quelqu'un le remette avec uncordon ; sinon, aucun nouveau Pod n'y tombe, et c'est l'une des façons de manquer de capacité sans que rien soit cassé. Le script le remet :
$ kubectl uncordon curso-k8s-l9-worker2
node/curso-k8s-l9-worker2 uncordoned
La correction consiste à réappliquer le dossier, qui porte le bon PDB (minAvailable: 1), et à répéter le drain. Maintenant ALLOWED DISRUPTIONS revient à 1 et l'éviction du revisor passe :
# --- La corrección: el PDB de la carpeta ---
$ kubectl apply -k . | grep -E 'poddisruption'
poddisruptionbudget.policy/revisor configured
$ kubectl get pdb revisor -n revisor
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
revisor 1 N/A 1 18m
$ kubectl drain curso-k8s-l9-worker2 --ignore-daemonsets --delete-emptydir-data --timeout=120s 2>&1 | grep -v -E '^(Warning: ignoring|evicting pod kube-system)'
node/curso-k8s-l9-worker2 cordoned
evicting pod revisor/revisor-767bdf965b-58sd9
pod/revisor-767bdf965b-58sd9 evicted
node/curso-k8s-l9-worker2 drained
evicted dès la première tentative, sans nouvelle tentative ni erreurs. Mais regarde où sont restées les répliques :
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-767bdf965b-28v89 1/1 Running 0 6s 10.244.1.32 curso-k8s-l9-worker <none> <none>
revisor-767bdf965b-jzpxs 1/1 Running 0 109s 10.244.1.28 curso-k8s-l9-worker <none> <none>
Les deux répliques se sont retrouvées sur le même nœud, curso-k8s-l9-worker. L'éviction du revisor est passée (le PDB permettait d'en perdre une), mais la nouvelle réplique est née sur le seul nœud qui pouvait la recevoir, et maintenant les deux vivent ensemble : la perte de ce nœud emporterait tout le service. Ce que Honor explique très probablement : un nœud cordonné porte le taint node.kubernetes.io/unschedulable, et avec nodeTaintsPolicy: Honor les nœuds marqués (pour lesquels le Pod n'a pas de tolérance) sortent du compte de la répartition. Tant que worker2 était cordonné, le seul domaine était worker, avec une asymétrie de 0, et la répartition a permis les deux répliques là. La contrainte a été respectée ; il n'y avait pas d'autre endroit.
9.11.4 Rétablir la répartition
On remet le nœud en service et l'on attend de voir si le cluster s'en charge tout seul :
$ kubectl uncordon curso-k8s-l9-worker2
node/curso-k8s-l9-worker2 uncordoned
(espera de 30 s: ¿vuelve el reparto por sí solo?)
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-767bdf965b-28v89 1/1 Running 0 37s 10.244.1.32 curso-k8s-l9-worker <none> <none>
revisor-767bdf965b-jzpxs 1/1 Running 0 2m20s 10.244.1.28 curso-k8s-l9-worker <none> <none>
Non. Les deux répliques restent ensemble : rien ne les a déplacées. C'est exactement la limitation de 9.4.4 : la contrainte de répartition est évaluée au moment de planifier un Pod, pas après, et la documentation dit qu'il n'y a aucune garantie qu'elle continue d'être respectée quand des Pods sont retirés, et recommande des outils comme le Descheduler pour rééquilibrer. Sans rien installer, le remède est de provoquer une nouvelle planification, et kubectl rollout restart le fait : il remplace les répliques avec la même procédure progressive qu'une mise à jour, et chaque nouveau Pod est planifié avec la contrainte en place.
$ kubectl rollout restart deployment/revisor -n revisor
deployment.apps/revisor restarted
$ kubectl rollout status deployment/revisor -n revisor --timeout=180s
Waiting for deployment "revisor" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "revisor" rollout to finish: 1 old replicas are pending termination...
Waiting for deployment "revisor" rollout to finish: 1 old replicas are pending termination...
deployment "revisor" successfully rolled out
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-5877db576c-8bn64 1/1 Running 0 12s 10.244.2.25 curso-k8s-l9-worker2 <none> <none>
revisor-5877db576c-mf7c2 1/1 Running 0 10s 10.244.1.33 curso-k8s-l9-worker <none> <none>
De nouveau une réplique sur chaque nœud de travail. La répartition se maintient grâce à une habitude, non à une configuration : après une maintenance, on regarde -o wide.
9.12 Un nœud qui tombe en panne
Jusqu'ici tout était planifié : une mise à jour que tu lances, une maintenance que tu demandes. Reste l'autre cas : la machine qui s'arrête sans prévenir. C'est la troisième question du directeur. On y répond en arrêtant exprès l'un des nœuds de travail, en mesurant, et en demandant au cluster quand il s'en est aperçu.
9.12.1 Ce que sait le cluster, et quand
Un nœud en panne n'envoie pas d'avertissement : il cesse d'envoyer des battements de cœur (heartbeats). La documentation de « Node Status » décrit deux formes de battement : les mises à jour du .status du nœud et des objets Lease (baux) dans l'espace de noms kube-node-lease, un par nœud ; le kubelet renouvelle le sien toutes les 10 secondes par défaut (Node Status). Quand les battements cessent d'arriver, le contrôleur de nœuds attend un délai, node-monitor-grace-period, avant de déclarer quoi que ce soit. La condition Ready du nœud passe à Unknown si le contrôleur de nœuds « n'a pas eu de nouvelles du nœud depuis node-monitor-grace-period (par défaut, 50 secondes) ». C'est le délai de détection, et c'est le cœur de la section.
Pendant que ces 50 secondes s'écoulent, le plan de contrôle croit que le nœud va bien. Ses Pods figurent comme Running et Ready, leurs adresses restent dans les EndpointSlice, et les Services continuent de leur répartir des requêtes. Quand le délai expire, deux choses distinctes se produisent :
- Le nœud passe à
NotReady(Unknown), et les Pods qui y vivaient cessent de compter comme prêts ; leurs adresses sont marquéesready: falsedans lesEndpointSlice, et le Service cesse de les utiliser. Ici se termine la fenêtre de panne pour le trafic. - Le nœud reçoit le taint
node.kubernetes.io/unreachableavec l'effetNoExecute. Les Pods qui y vivent ont, d'origine, une tolérance à ce taint de 300 secondes : la documentation de « Taints and Tolerations » dit que Kubernetes ajoute automatiquement les tolérances denot-readyetunreachableavectolerationSeconds=300, et que c'est pourquoi les Pods « restent liés aux nœuds pendant 5 minutes après la détection d'un de ces problèmes ». Ce délai passé, les Pods sont évincés et le ReplicaSet crée des remplaçants sur un autre nœud. Ici se termine la fenêtre de capacité, qui dure bien plus longtemps que celle de panne.
Il convient de séparer les deux fenêtres parce qu'on les confond. Celle de panne (requêtes perdues) dure depuis la chute du nœud jusqu'à ce que le cluster s'en aperçoive : environ 50 secondes. Celle de capacité (service avec une réplique de moins) dure 5 minutes de plus, jusqu'à la naissance des remplaçants. Cette leçon mesure la première, celle que ressentent tes utilisateurs. La seconde n'a pas été mesurée, parce que le nœud est rallumé avant qu'elle n'expire.
9.12.2 L'expérience
Le script choisit le nœud qu'il va arrêter avec une condition : ce doit être un nœud qui porte une réplique du revisor et qui n'est pas celui où vit le Pod depurar, d'où partent les requêtes (si l'on arrêtait le nœud du client, la mesure mourrait avec lui). Avec deux répliques réparties, l'un des deux nœuds de travail remplit la condition. Il lance ensuite deux interrogations à la fois, avec une requête toutes les 2 secondes et un seul fil, pendant 100 secondes :
- la première, telle quelle :
./sondeo.sh 100 2 1; - la seconde, avec des nouvelles tentatives du client :
./sondeo.sh 100 2 1 --retry 3 --retry-all-errors.
--retry 3 demande à curl de réessayer jusqu'à 3 fois une requête qui a échoué, et --retry-all-errors étend « échoué » à n'importe quelle erreur, délais dépassés compris ; le manuel de curl décrit l'attente entre les tentatives : d'abord une seconde, puis il « double le temps d'attente » à chaque fois. Le manuel appelle --retry-all-errors le « marteau » des nouvelles tentatives et déconseille de l'utiliser par défaut ; ici c'est une expérience contrôlée contre un service dont on sait qu'il peut être répété sans risque (GET /salud). Avec une requête idempotente (qui peut être répétée sans effets), les nouvelles tentatives sont inoffensives ; avec une qui ne l'est pas, elles seraient un risque.
Dix secondes après le lancement des interrogations, docker stop arrête le conteneur du nœud, ce qui est ce que kind a de plus proche de « la machine est tombée en panne » : le kubelet et tout ce qui tourne à l'intérieur cessent d'exister d'un coup. La leçon attend 25 secondes et pose deux questions : comment le cluster voit-il le nœud, et que disent les EndpointSlice des adresses du revisor.
$ ./sondeo.sh 100 2 1 & # sin reintentos, empieza a las 17:34:53
$ ./sondeo.sh 100 2 1 --retry 3 --retry-all-errors & # con reintentos
(pausa de 10 s: ambos sondeos ya corren)
$ docker stop curso-k8s-l9-worker
curso-k8s-l9-worker
(nodo apagado a las 17:35:04)
(espera de 25 s: el nodo ya no contesta, pero el clúster todavía no lo sabe)
[+ 25s desde docker stop] curso-k8s-l9-worker Ready
À 25 secondes d'arrêt, le nœud dit encore Ready. Le cluster ne sait rien. Et la preuve qu'il ne le sait toujours pas se trouve dans les destinations :
$ kubectl get endpointslices -n revisor -l kubernetes.io/service-name=revisor -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]} ready={.conditions.ready} nodo={.nodeName}{"\n"}{end}'
10.244.2.25 ready=true nodo=curso-k8s-l9-worker2
10.244.1.33 ready=true nodo=curso-k8s-l9-worker
(el nodo pasó a NotReady a los 57 s de docker stop)
Les deux destinations du revisor figurent ready=true, y compris celle qui vit sur le nœud arrêté (nodo=curso-k8s-l9-worker). Toute requête que le Service répartit vers cette adresse va vers un Pod qui n'existe plus. Voilà la cause des échecs, écrite en une ligne.
Le script interroge toutes les 3 secondes l'état du nœud jusqu'à le voir NotReady, puis il redemande :
$ kubectl get endpointslices -n revisor -l kubernetes.io/service-name=revisor -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]} ready={.conditions.ready} nodo={.nodeName}{"\n"}{end}'
10.244.2.25 ready=true nodo=curso-k8s-l9-worker2
10.244.1.33 ready=false nodo=curso-k8s-l9-worker
Le nœud est passé à NotReady 57 secondes après l'arrêt (le délai de la documentation est de 50 ; le reste s'explique par jusqu'à 5 secondes du cycle du contrôleur, node-monitor-period, et 3 de l'interrogation du script ; en outre, le dernier battement du nœud a pu précéder l'arrêt). Et maintenant la destination du nœud en panne figure ready=false : le cluster a cessé de lui répartir du trafic. C'est le moment où se termine la fenêtre de panne.
9.12.3 Ce qu'ont mesuré les interrogations
# resultado SIN reintentos (los segundos cuentan desde el inicio del sondeo; docker stop fue en +10):
7 código 000 curl rc=28 versión -
35 código 200 curl rc=0 versión 1.6.0
segundos (desde el inicio) de las primeras fallas: +21 +25 +29 +38 +42 +60 +64
peticiones=42 fallidas=7
# resultado CON reintentos de curl:
1 código 000 curl rc=28 versión -
31 código 200 curl rc=0 versión 1.6.0
segundos (desde el inicio) de las primeras fallas: +37
peticiones=32 fallidas=1
Une précision sur l'en-tête : le script note « +10 », qui est la pause qu'il avait programmée avant d'arrêter le nœud. L'heure imprimée dit que le nœud s'est arrêté 11 secondes après le lancement de l'interrogation, parce que docker stop a pris près d'une seconde. Les calculs qui suivent utilisent 11.
L'interrogation sans nouvelles tentatives a fait 42 requêtes en 100 secondes, et non les 50 qu'un rythme d'une toutes les 2 secondes permettrait, parce que chaque requête qui échoue occupe les 2 secondes entières de son délai d'attente en plus de la pause. 7 ont échoué, toutes avec curl 28 (délai dépassé, non connexion refusée : la destination n'existait plus). Les secondes des échecs, comptées depuis le début de l'interrogation, sont +21 +25 +29 +38 +42 +60 +64. Comme docker stop a été lancé à la seconde 11 de l'interrogation, en soustrayant on obtient combien de temps chacun est survenu après l'arrêt : de 10 à 53 secondes. Le dernier échec, à 53 secondes de l'arrêt, tombe juste avant l'observation de NotReady à 57 : les sept échecs sont tombés entre 10 et 53 secondes après l'arrêt (mesuré à la fin de chaque requête). Qu'on n'en ait pas vu davantage ne prouve pas que le Service avait déjà cessé d'utiliser la destination morte : avec une requête toutes les 2 secondes et la moitié d'entre elles tombant, selon le modèle, sur la réplique vivante, quelques-unes de suite peuvent réussir sans que rien n'ait changé. Ce qui a bien été mesuré, c'est que la destination est passée à ready=false quand le nœud a figuré NotReady, à 57 secondes.
Cela fait 7 échecs sur 42, soit 17 %. Ce chiffre mélange la fenêtre de panne avec le reste des 100 secondes de l'interrogation, il ne dit donc pas « le service échoue une fois sur six » : il dit ce qui s'est passé dans cette expérience. Ce qui explique les échecs, c'est que, pendant la fenêtre (jusqu'à 57 secondes, jusqu'à ce que la destination passe à ready=false), l'une des deux destinations du Service était morte, et le Service répartit entre ses destinations sans savoir laquelle est morte, si bien qu'une partie des requêtes de cette période allait atterrir sur la réplique qui n'existait plus. Avec deux répliques et un nœud mort, cette part serait d'environ la moitié ; avec quatre répliques réparties, elle serait d'environ un quart ; avec dix, d'un dixième. Cette proportion (une réplique sur N) est un modèle, non une mesure de cette exécution : ce qui a été mesuré, c'est 7 sur 42, mais c'est ce qui explique pourquoi « plus de répliques réparties » raccourcit les dégâts sans raccourcir la durée.
L'interrogation avec nouvelles tentatives a fait 32 requêtes et a été plus lente (chaque nouvelle tentative attend 1, 2 et 4 secondes avant d'essayer de nouveau). Une a échoué, à 37 secondes, sur 32 : un échec contre sept pour l'autre interrogation, en échange de quelques réponses plus longues. C'est une seule ronde, de petite taille et avec moins de requêtes que l'autre : elle suggère que les nouvelles tentatives aident, mais ne suffit pas pour promettre combien de requêtes sont récupérées. C'est le second levier : les nouvelles tentatives côté client. Elles fonctionnent parce que la fois suivante où curl demande, le Service peut choisir la réplique vivante, et elles ne fonctionnent pas si la destination est la même (par exemple, avec une seule réplique).
Ensuite, l'état du cluster au moment de rallumer le nœud :
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
curso-k8s-l9-control-plane Ready control-plane 29m v1.37.0
curso-k8s-l9-worker NotReady <none> 28m v1.37.0
curso-k8s-l9-worker2 Ready <none> 28m v1.37.0
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-5877db576c-8bn64 1/1 Running 0 114s 10.244.2.25 curso-k8s-l9-worker2 <none> <none>
revisor-5877db576c-mf7c2 1/1 Running 0 112s 10.244.1.33 curso-k8s-l9-worker <none> <none>
Le nœud étant NotReady, les deux Pods du revisor figurent toujours Running, y compris celui du nœud arrêté : le cluster ne les évince pas encore, parce que la tolérance de 300 secondes n'a pas expiré. Et il ne les évincera pas : le nœud est rallumé.
$ docker start curso-k8s-l9-worker
curso-k8s-l9-worker
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
curso-k8s-l9-control-plane Ready control-plane 29m v1.37.0
curso-k8s-l9-worker Ready <none> 28m v1.37.0
curso-k8s-l9-worker2 Ready <none> 28m v1.37.0
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-5877db576c-8bn64 1/1 Running 0 2m16s 10.244.2.25 curso-k8s-l9-worker2 <none> <none>
revisor-5877db576c-mf7c2 1/1 Running 1 (21s ago) 2m14s 10.244.1.4 curso-k8s-l9-worker <none> <none>
Le nœud revient à Ready, et le Pod qui y vivait est toujours là : le conteneur a redémarré (RESTARTS 1), aucun autre Pod n'a été créé, et il a changé d'adresse réseau (10.244.1.4 au lieu de 10.244.1.33), mais c'est le même Pod. Dans cette exécution, le nœud est revenu bien avant les 300 secondes et ses Pods sont restés.
9.12.4 Ce qui raccourcit la fenêtre et ce qui ne la raccourcit pas
| Levier | Raccourcit-il ? | Pourquoi |
|---|---|---|
| Plus de répliques, réparties entre les nœuds | raccourcit les dégâts, non la durée | avec N répliques réparties, le modèle de répartition attend une requête sur N sur la réplique morte (calcul, non mesure) |
| Nouvelles tentatives côté client | oui, pour les requêtes qu'on peut répéter sans risque | la seconde fois peut tomber sur une réplique vivante (mesuré plus haut) |
Diminuer node-monitor-grace-period (dans le plan de contrôle) |
oui, au prix de fausses alertes | le cluster déclare en panne un nœud qui n'a fait que prendre du retard |
| Les readiness probes | non | elles sont exécutées par le kubelet du nœud en panne lui-même, qui ne rapporte plus rien |
tolerationSeconds |
non | il décide quand les Pods sont remplacés, non quand ils cessent de recevoir du trafic |
Les deux dernières lignes sont celles auxquelles on croit le plus et qui fonctionnent le moins, et c'est pourquoi l'Exercice 4 les met à l'épreuve. Une readiness probe (celle de la Leçon 5) sort un Pod des destinations du Service quand elle échoue, mais celui qui pose la question est le kubelet de ce nœud. Si le nœud entier est arrêté, il n'y a pas de kubelet pour poser la question ni pour rapporter ; le cluster ne peut qu'attendre que le délai de détection expire. Et tolerationSeconds est le délai de grâce avant d'évincer les Pods d'un nœud déjà déclaré unreachable, c'est-à-dire un compte qui commence après la détection : plus il est court, plus tôt les Pods sont remplacés, mais ils ne sortent pas plus tôt les destinations mortes du Service.
Diminuer node-monitor-grace-period est le seul levier qui s'attaque à la durée, et il coûte des fausses alertes (il ne faut pas trop le baisser : il doit être plusieurs fois supérieur à la fréquence à laquelle le kubelet rapporte ; vérifie le drapeau dans la référence du kube-controller-manager de ta version) : un nœud avec dix secondes de retard réseau serait déclaré en panne, ses Pods seraient marqués non prêts et le Service cesserait de les utiliser sans raison. C'est un drapeau du kube-controller-manager, qui dans kind vit à l'intérieur du conteneur du nœud de contrôle ; ce n'est pas quelque chose qu'on change avec kubectl, et cette leçon n'y touche pas.
Le directeur a sa réponse, avec ce qui a été mesuré dans cette exécution : avec deux répliques, sept requêtes sur 42 (17 %) ont été perdues pendant la fenêtre d'un nœud en panne, dont le dernier échec est tombé 53 secondes après l'arrêt et qui s'est terminée quand la destination est passée à ready=false, à 57. Par la répartition du Service, nous attendrions qu'environ la moitié des requêtes de cette fenêtre tombe sur la réplique morte (c'est un calcul, non une mesure). Avec des nouvelles tentatives côté client, il y a eu un échec sur 32, mais une seule ronde ne suffit pas pour promettre combien sont récupérées. Quand le nœud revient, le cluster se recompose tout seul.
9.12.5 Après l'expérience : le <unknown> de l'autoscaler
La dernière question de l'exécution est de savoir si tout est resté comme les fichiers le disent, et la réponse comporte un détail qui appartient à cette leçon :
$ kubectl diff -k . >/dev/null && echo 'sin diferencias con los archivos'
sin diferencias con los archivos
$ kubectl get deployment,hpa,pdb -n revisor
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/prueba 1/1 1 1 24m
deployment.apps/revisor 2/2 2 2 24m
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
horizontalpodautoscaler.autoscaling/revisor Deployment/revisor cpu: <unknown>/50% 2 6 2 21m
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
poddisruptionbudget.policy/revisor 1 N/A 1 21m
Avec sin diferencias con los archivos, le cluster et le dossier coïncident : tout ce qui a été fait avec des commandes (set image, patch, scale, rollout undo) a été défait ou réappliqué. Et dans le HPA, TARGETS dit cpu: <unknown>/50%. C'est le même mot qui apparaît parfois dans les premières secondes de 9.5.2, et il faut savoir le lire, car c'est la question la plus courante sur les autoscalers : <unknown> signifie « je ne sais pas combien consomment les Pods », et il a trois causes distinctes :
metrics-serverne répond pas. C'est la cause de 9.2. On le vérifie aveckubectl get apiservice v1beta1.metrics.k8s.io: s'il ditFalse, c'est le compteur.- Les Pods n'ont pas de
requestsde CPU. Sans eux, il n'y a rien par rapport à quoi calculer le pourcentage, et la documentation dit que l'autoscaler ne fera rien avec cette métrique. On le vérifie aveckubectl describe pod. - Les Pods sont nouveaux et n'ont pas encore leur première lecture. Cela se voit dans les événements du HPA (
kubectl describe hpa) sous la formeFailedGetResourceMetric … no metrics returned from resource metrics API, le même avertissement que 9.6 a montré dans les premières secondes des nouveaux Pods, et il disparaît tout seul.
Dans cette conclusion, la troisième est la candidate : l'exécution a rallumé le nœud arrêté, a attendu 20 secondes et a capturé l'état ; le Pod du revisor de ce nœud avait redémarré un instant avant (RESTARTS 1) et n'avait pas encore de lecture. La mesure ne distingue pas cette cause de la première, parce qu'on n'a pas interrogé l'APIService à ce moment-là ni vérifié sur quel nœud tournait metrics-server (s'il tournait sur le nœud arrêté, ce serait la première) ; ce qu'on peut dire, c'est comment les distinguer. Si c'était la troisième, il suffirait d'attendre quelques secondes ; sinon, le <unknown> reste. L'habitude : devant un <unknown>, d'abord get apiservice, puis describe pod en cherchant les requests, puis l'horloge.
L'habitude de sécurité de la leçon : des limites à la mise à l'échelle, et des mises à jour qui ne font pas tomber le service
Le tableau des habitudes du cours assigne à cette leçon une phrase en deux moitiés : des limites à la mise à l'échelle et les mises à jour ne font pas tomber le service. On les vérifie avec le cluster sous les yeux.
Des limites à la mise à l'échelle. Un autoscaler convertit de la charge en ressources : plus de requêtes, plus de répliques, plus de CPU et de mémoire réservés. C'est exactement ce qu'on veut face à un lundi à 9 h 00, et c'est exactement ce qu'un attaquant, ou une erreur, peut exploiter. Un client qui envoie des requêtes en boucle (le Job carga de la leçon en est un, à petite échelle) coûte au service de plus en plus de répliques. Sans plafond, la limite est posée par le cluster entier, et un service mal élevé laisse les autres sans ressources. Il y a trois freins en couches, du plus proche au plus général :
- Le plafond de l'autoscaler lui-même.
maxReplicas: 6. Avec lesrequestsde la Leçon 5, six répliques réservent6 × 50m = 300mde CPU et6 × 32Mi = 192Mide mémoire, un nombre que tu peux écrire et discuter, au lieu de « ce qu'il y aura ». Dans l'exécution de cette leçon, l'autoscaler n'a pas atteint ce plafond : la charge d'un Job decurlen boucle s'est contenue à quatre répliques. Le plafond est un garde-fou ; on n'a pas prouvé qu'il arrête quoi que ce soit, et cette distinction compte. - Qui peut générer la charge. Le réseau fermé de la Leçon 7 ne laisse passer vers le
revisorque les Pods portant l'étiquetteacceso-revisor: permitidoet les Envoy du point d'entrée. Le Jobcargaporte cette étiquette exprès ; sans elle, ses requêtes n'arriveraient pas et l'autoscaler ne verrait rien. Qui contrôle le point d'entrée contrôle qui peut faire monter la facture de CPU. - Le budget de l'espace de noms. Il existe des objets (
ResourceQuota,LimitRange) qui posent un plafond total de CPU, de mémoire et de nombre de Pods à tout un espace de noms, de sorte qu'un autoscaler mal configuré ne puisse pas le dépasser même si son propre plafond est élevé. La Leçon 5 les a nommés et les a laissés hors du cours ; dans un cluster partagé, ils sont la couche qui protège réellement les voisins.
Il y a en outre une habitude pour qui met à l'échelle avec metrics-server : le drapeau --kubelet-insecure-tls de 9.2 n'existe que dans ce laboratoire. Il convient de l'écrire là où le verra qui héritera du fichier (le commentaire de metrics-server/kustomization.yaml le dit), parce qu'un drapeau de laboratoire copié dans un autre cluster est une faille de sécurité qui a l'air d'une configuration.
Les mises à jour ne font pas tomber le service. Ce qui a été fait pour y arriver, et dans quelle mesure on y arrive, est le contenu de 9.9 et de 9.11 :
| Pièce | Ce qu'elle empêche | Où c'est mesuré |
|---|---|---|
maxUnavailable: 0 avec maxSurge: 1 |
qu'il y ait moins de répliques que souhaité pendant le remplacement | 9.9 |
preStop avec sleep |
que des requêtes arrivent sur une réplique qui a déjà fermé sa porte | 9.9 |
PodDisruptionBudget |
qu'une maintenance volontaire retire plus de répliques que le service n'en supporte | 9.11 |
répartition avec topologySpreadConstraints |
qu'une seule machine concentre toutes les répliques | 9.4 et 9.11 |
Remarque que la phrase du tableau des habitudes dit « ne font pas tomber », non « ne perdent rien ». Ce qui a été mesuré en 9.9 dit combien on perd avec et sans chaque pièce, et ce chiffre, avec son incertitude, est ce qu'on remet à qui demande. Une promesse de « zéro » que personne n'a mesurée est pire qu'un petit chiffre qui, lui, l'a été.
Et une discipline de plus : la leçon met à jour avec kubectl set image et kubectl patch, des commandes impératives, parce que ce sont le chemin court pour mesurer. Dans un vrai projet, le changement s'écrit dans le fichier, se relit et s'applique avec kubectl apply -k ., pour que ce qui tourne dans le cluster et ce qui est dans le dépôt soient identiques. La fin de l'exécution le vérifie avec kubectl diff -k . : « sin diferencias con los archivos ».
Les erreurs courantes
Laisser replicas dans le Deployment que gouverne un HPA. Chaque apply rend au nombre la valeur du fichier et défait ce que l'autoscaler a décidé. On le retire du manifeste.
Un HPA sur des Pods sans requests de CPU. Le pourcentage se mesure par rapport aux requests ; sans eux, l'autoscaler n'a rien pour calculer et ne fait rien.
Copier --kubelet-insecure-tls dans un vrai cluster. C'est la sortie rapide pour que metrics-server démarre dans un laboratoire. Dans un cluster où il y a plus de monde, la solution est que les certificats des kubelet soient signés par l'autorité du cluster.
Croire que le HPA réagit à une différence de quelques points. Dans les 10 % de tolérance, il ne fait rien. Et à la baisse, il n'utilise pas ce qu'il voit aujourd'hui, mais ce qu'il a recommandé de plus haut dans la fenêtre.
Une cible de CPU très haute (90 %). Quand l'autoscaler réagit, le service est déjà saturé, et les nouvelles répliques mettent du temps à naître.
ScheduleAnyway quand ce qu'il faut est une garantie. C'est une préférence : le planificateur la rompt s'il ne trouve pas de meilleur endroit.
Une répartition par topologie sans nodeTaintsPolicy: Honor dans un cluster avec un nœud de contrôle marqué par un taint. Elle fonctionne avec deux répliques et laisse la troisième en Pending.
Croire que la répartition se maintient toute seule. Elle s'applique au moment de planifier un Pod ; une maintenance peut laisser les répliques ensemble et rien ne les sépare sans un remplacement.
Mettre à jour sans pause avant l'arrêt. Une ancienne réplique ferme sa porte alors que le réseau lui envoie encore des requêtes.
Conclure « sans échecs » à partir d'une seule interrogation courte. Une ronde sans échecs ne démontre pas qu'il n'y en a pas ; avec trois rondes de chaque variante, ce qui a été mesuré en 9.9 montre que les échecs apparaissent et disparaissent.
Croire que rollout status signifie « il ne reste plus rien d'ancien qui réponde ». Il se termine quand le Deployment remplit ce qu'on lui a demandé (les répliques souhaitées de la nouvelle version sont prêtes), non quand le dernier ancien Pod a fini son preStop. On regarde deletionTimestamp.
Un PodDisruptionBudget qui ne laisse perdre aucune réplique. Le drain reste à réessayer et la maintenance n'avance pas. Le budget doit laisser de la marge.
Un PDB sans son espace de noms. Un objet avec -f et sans -n est créé là où pointe le contexte, et un PDB dans le mauvais espace de noms ne protège personne. On lit le message created et l'on vérifie avec get pdb -n revisor.
Compter sur les probes pour raccourcir la panne d'un nœud. Elles sont exécutées par le kubelet du nœud en panne ; elles ne peuvent rien rapporter.
Faire confiance au fait que le cluster s'aperçoit immédiatement d'une panne. Il y a une fenêtre de détection, et les requêtes qui y tombent sont perdues.
Exercices
Les trois premiers s'exécutent dans le cluster de la leçon, dans l'ordre et avec l'état dans lequel il est resté après 9.12. Avant chaque commande qui change quelque chose, ctx doit réussir. correr.sh parcourt la leçon, non les exercices ; leurs solutions citent ce qui a été mesuré dans la leçon ou le calculent, et le disent. Avant d'exécuter ou de calculer chacun, écris dans le journal de bord ce que tu crois qu'il va se passer. Le quatrième s'écrit avec tes mots.
Exercice 1 — La formule, calculée manuellement
Avec la cible de 50 %, minReplicas: 2 et maxReplicas: 6, calcule ce que ferait l'autoscaler dans chaque cas, et dis si la tolérance l'arrête :
a) 2 répliques avec une utilisation moyenne de 63 %. b) 3 répliques avec une utilisation moyenne de 46 %. c) 4 répliques avec une utilisation moyenne de 90 %. d) 6 répliques avec une utilisation moyenne de 20 %, une minute après que l'utilisation a baissé.
Ensuite compare (a) et (b) avec la colonne TARGETS et les événements de 9.6.
Exercice 2 — Qui commande
Sans regarder 9.8, écris quel nombre de répliques tu crois que le Deployment aura 40 secondes après kubectl scale deployment/revisor -n revisor --replicas=1, et à quel événement du HPA tu t'en rendras compte. Exécute la commande et vérifie.
Exercice 3 — Ta propre interrogation périodique
Exécute ./sondeo.sh 45 0 4 pendant que tu fais kubectl set image entre 1.6.0 et 1.6.1, trois fois avec le preStop du dossier. Note par ronde les requêtes, les échecs et le type de chaque échec (curl 7 ou curl 28). Retire le hook avec kubectl patch (la commande de 9.9.6) et recommence. Compare tes totaux avec ceux de la leçon et explique, en trois phrases, pourquoi les tiens peuvent différer et quelle conclusion ils soutiennent bel et bien. Pour séparer l'effet de la pause de l'effet du moment, alterne les variantes (une ronde avec pause, une sans, une autre avec…) au lieu de faire toutes celles de l'une puis toutes celles de l'autre. À la fin, reviens à apply -k ..
Exercice 4 — Défends la décision
Un collègue dit : « les readiness probes sortent déjà une réplique en panne du Service, et tolerationSeconds est le délai de détection d'un nœud, donc en le baissant à 5 secondes la coupure du nœud est finie ». En cinq phrases au maximum, dis-lui ce qui se passe réellement quand un nœud s'arrête sans prévenir, ce que mesure chacun de ces deux réglages, et ce qui raccourcirait vraiment la fenêtre et à quel prix.
Solutions
Solution 1
C'est du calcul, non de la mesure, sauf là où c'est dit.
a) 2 répliques, 63 %. Rapport 1.26, hors de 0.9 à 1.1. ceil(2 × 1.26) = ceil(2.52) = 3. Il monte à 3. (En 9.6, avec 72 % et 2 répliques, il a demandé 3 par la même règle.)
b) 3 répliques, 46 %. Rapport 0.92 : dans la tolérance. Il ne fait rien. Même si la tolérance n'existait pas, ceil(3 × 0.92) = ceil(2.76) = 3, le même nombre. (En 9.6, avec 4 répliques et 46 %, il est resté à 4 : la même situation.)
c) 4 répliques, 90 %. Rapport 1.8. ceil(4 × 1.8) = ceil(7.2) = 8. Mais maxReplicas vaut 6, donc le HPA reste à 6. Il le noterait dans la condition ScalingLimited, la même que 9.7 montre pour le plancher, ici pour le plafond ; ce cas n'a pas été mesuré dans cette exécution parce que la charge n'a jamais atteint le plafond. C'est le cas du plafond : sans maxReplicas, ce serait 8.
d) 6 répliques, 20 %, une minute après la baisse de l'utilisation. Rapport 0.4. ceil(6 × 0.4) = ceil(2.4) = 3. Le calcul dit 3, mais la fenêtre de 60 secondes dit qu'on utilise la recommandation la plus haute des 60 dernières secondes : si, il y a 59 secondes, la recommandation était de 6, elle reste à 6 un instant de plus. Il descend quand cette recommandation sort de la fenêtre.
Solution 2
Mesuré dans correr.salida.txt :
$ kubectl scale deployment/revisor -n revisor --replicas=1
deployment.apps/revisor scaled
(espera de 40 s)
$ kubectl get deployment,hpa -n revisor
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/prueba 1/1 1 1 13m
deployment.apps/revisor 2/2 2 2 13m
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
horizontalpodautoscaler.autoscaling/revisor Deployment/revisor cpu: 2%/50% 2 6 2 10m
$ kubectl describe hpa revisor -n revisor | sed -n '/Events:/,$p' | tail -3
Normal SuccessfulRescale 3m34s horizontal-pod-autoscaler New size: 3; reason: All metrics below target
Normal SuccessfulRescale 3m19s (x2 over 9m42s) horizontal-pod-autoscaler New size: 2; reason: All metrics below target
Normal SuccessfulRescale 33s (x2 over 10m) horizontal-pod-autoscaler New size: 2; reason: Current number of replicas below Spec.MinReplicas
Deux répliques, de nouveau. Le Deployment est revenu à 2/2 au bout de 40 secondes, et le dernier événement du HPA le dit : New size: 2; reason: Current number of replicas below Spec.MinReplicas, d'il y a 33s. Le kubectl scale a baissé le champ spec.replicas à 1 ; le HPA, à son tour suivant, a vu que 1 était inférieur au plancher de 2 et l'a corrigé. Avec un autoscaler vivant, le nombre de répliques est le sien. Ce qu'on change, c'est le HPA.
Solution 3
Cela dépend de ta machine, et c'est en cela que consiste l'exercice. Les totaux de cette exécution sont en 9.9.8 : avec preStop, 5 échecs sur 2 049 requêtes en quatre rondes, tous curl 28 ; sans lui, 29 sur 1 569 en trois rondes, dont 6 curl 7. Ce qu'on peut dire, et ce qu'on ne peut pas :
- Tes totaux ne vont pas coïncider avec ceux d'ici, parce qu'ils dépendent du CPU qu'a eu ton client, de la charge de ta machine et du nombre de requêtes que chaque ronde a pu faire (ici il y a eu des rondes de 433 et de 612 requêtes dans le même temps).
- Ce qui doit se répéter, c'est la forme : les échecs sans pause se regroupent dans les secondes qui suivent le
set image, il y a des connexions refusées (curl 7) seulement sans pause, et avec pause les rondes donnent peu d'échecs, avec des rondes à zéro et d'autres avec quelques-uns isolés. - Ce que ta mesure ne soutient pas : un pourcentage exact, ni la phrase « avec
preStop, rien n'est perdu ». Si l'une de tes rondes avec pause a donné zéro, cela ne signifie pas que la pause élimine les échecs ; cela signifie que cette ronde n'en a vu aucun. - Si tes rondes avec pause ne se distinguent pas des rondes sans elle, la conclusion honnête est que cette machine, avec ce client, n'a pas la résolution pour voir la différence. Non que la pause ne serve à rien.
Solution 4
Une réponse possible : « Quand un nœud s'arrête sans prévenir, le cluster ne s'en aperçoit pas immédiatement : il attend node-monitor-grace-period, 50 secondes par défaut, que le nœud rapporte. Pendant ce temps, les destinations des Pods du nœud restent marquées comme prêtes et le Service leur répartit des requêtes qui sont perdues ; dans l'exécution de la leçon, 7 sur 42 ont été perdues, et le dernier échec est survenu 53 secondes après l'arrêt. Les readiness probes n'aident pas parce qu'elles sont exécutées par le kubelet de ce nœud, qui est arrêté. tolerationSeconds, de 300 secondes par défaut, est le délai que le cluster attend après avoir déclaré le nœud injoignable avant de remplacer ses Pods ; le baisser remplace donc les Pods plus tôt mais ne sort pas plus tôt les destinations mortes du Service. Ce qui aide vraiment, c'est d'avoir plus de répliques réparties (par la répartition, on peut s'attendre à ce qu'une requête sur N tombe sur la morte), des nouvelles tentatives côté client pour les requêtes qu'on peut répéter, et, au prix de fausses alertes, de baisser node-monitor-grace-period dans le plan de contrôle. »
Supprimer le cluster
Quand tu as fini les exercices, supprime le cluster de la leçon. Avec lui partent MetalLB, Envoy Gateway, metrics-server, le Pod depurar et tout le reste :
$ kind delete cluster --name curso-k8s-l9
Deleting cluster "curso-k8s-l9" ...
Deleted nodes: ["curso-k8s-l9-control-plane" "curso-k8s-l9-worker2" "curso-k8s-l9-worker"]
(borrar tardó 2 s)
$ kind get clusters | grep -c curso-k8s-l9
0
Ensuite, supprime les token.txt et le dossier temporaire du kubeconfig, qui conserve l'identifiant d'administrateur du cluster que tu viens de supprimer. Le rm -rf est dangereux si la variable est vide ou pointe ailleurs, il est donc protégé par un case qui ne le laisse s'exécuter que si le chemin est celui qu'a créé mktemp en 9.1.2 :
# La carpeta temporal del kubeconfig, y SOLO si es la que creó mktemp en 9.1.2.
case "$KUBECONFIG" in
/tmp/curso-k8s-l9.?*/kubeconfig) rm -rf -- "$(dirname "$KUBECONFIG")" ;;
*) echo "No borro nada: KUBECONFIG es «$KUBECONFIG», no la carpeta de la lección." ;;
esac
unset KUBECONFIG
rm -f token.txt punto-de-partida/token.txt
Vérifie en outre qu'il ne reste aucun conteneur client (docker ps -a --filter name=curso-k8s-l9 ne doit rien montrer) et que le nœud que tu as arrêté en 9.12 n'est pas resté arrêté : si tu as supprimé le cluster avec le nœud arrêté, kind delete cluster le supprime aussi. Si tu as créé un réseau Docker dédié pour exécuter plusieurs leçons à la fois, on le supprime après le cluster, et seulement celui-là : docker network rm <ton-réseau>. Le réseau kind normal n'est pas touché.
Comment savoir que tu y es arrivé
- Devant
error: Metrics API not available, tu dis ce qui manque (un répondeur de l'API de métriques), et devantx509: cannot validate certificate for … because it doesn't contain any IP SANs, tu dis ce qui se passe et ce que corrige le drapeau, et pourquoi il n'est que pour le laboratoire. - Tu calcules manuellement les répliques que demandera le HPA avec
ceil(actuelles × mesurée ÷ cible), tu appliques la tolérance de 10 % et tu l'as vérifié avec la colonneTARGETSet les événementsSuccessfulRescale. - Tu expliques pourquoi le Deployment ne porte plus
replicaset ce qu'a montrékubectl diffenreplicas: 3→replicas: 1. - Tu expliques pourquoi la fenêtre de réduction rend la réduction lente et la montée rapide, et tu l'as vu sur l'horloge.
- Tu lis le
FailedSchedulingd'une contrainte de répartition et tu dis pourquoi le nœud de contrôle exigenodeTaintsPolicy: Honor. - Tu exécutes
./sondeo.sh, tu lis les trois colonnes (code, sortie decurl, version) et tu dis ce qui distingue uncurl 7d'uncurl 28. - Tu dis, avec tes chiffres, combien on perd dans une mise à jour avec et sans
preStop, et quelles limites a ta mesure. - Tu fais un
rollout undo, tu regardesdeletionTimestampet tu dis pourquoi une requête a pu répondre avec la version que tu viens de retirer. - Tu provoques un
drainbloqué par un PDB sans marge, tu le lis et tu le corriges ; et tu dis pourquoi, après une maintenance, les répliques peuvent se retrouver ensemble. - Tu arrêtes un nœud, tu mesures la fenêtre de panne, et tu dis ce qui la raccourcit (plus de répliques réparties, nouvelles tentatives du client, un délai de détection plus court dans le plan de contrôle) et ce qui ne la raccourcit pas (les probes,
tolerationSeconds). - Tu exécutes
programas/09-escalar-y-actualizar/correr.sh > /tmp/mi-corrida-l9.txt 2>&1et tu compares aveccorrer.salida.txt(ne redirige pas vers ce fichier : c'est la référence) ce qui ne change pas : lex509, leMetrics API not availableet leMissingEndpoints;replicas: 3→replicas: 1dans lediff; le plancher de 2 répliques, une par nœud ; leFailedSchedulingavecdidn't match pod topology spread constraints; le HPA qui monte en dépassant 55 % et redescend une fenêtre plus tard ; les deux modes d'échec de l'interrogation ; le retour en arrière avec une requête qui répond encore avec la version retirée ; ledrainbloqué par le PDB strict ; et le nœud qui passe àNotReadyau bout d'environ 50 secondes. Les nombres exacts de requêtes, les durées, les noms des Pods, les IP et la répartition des échecs entre les rondes changent à chaque exécution : on les observe, on ne les compare pas. - Tu provoques une mise à jour avec une image qui n'existe pas, tu lis
ErrImagePulletImagePullBackOff, tu vois que le Deployment resteUP-TO-DATE 1avec les anciennes répliques disponibles, et tu la ramènes en arrière avecrollout undo. - À la fin,
kind get clustersne montre pluscurso-k8s-l9, le dossier temporaire dukubeconfign'existe plus dans/tmp, et il ne reste aucuntoken.txt.
Résumé
Réponds sans regarder la leçon :
- Que manque-t-il dans un cluster fraîchement créé pour que
kubectl topfonctionne, et pourquoimetrics-serverne démarre-t-il pas tel quel dans kind ? - Quelle est la formule du HPA, par rapport à quoi se mesure le pourcentage, et que fait la tolérance ?
- Que fait la fenêtre de réduction de 60 secondes, et pourquoi la montée n'en a-t-elle pas une semblable ?
- Pourquoi un Deployment gouverné par un HPA ne doit-il pas porter
replicas? - Que disent
maxSkew,DoNotScheduleetnodeTaintsPolicy: Honor, et que se passe-t-il sans le dernier ? - Quelles deux choses se produisent en même temps quand un Pod s'arrête, et que referme le
preStop? - Qu'est-ce qui distingue un échec
curl 7d'uncurl 28? - Qu'a montré l'interrogation périodique avec et sans
preStop, et que ne démontre-t-elle pas ? - Pourquoi une version que tu viens de retirer avec
rollout undopeut-elle encore répondre ? - Qu'est-ce qu'un PDB, à quel type d'interruptions s'applique-t-il, et que se passe-t-il avec un
drainquand il n'y a pas de marge ? - Combien de temps le cluster a-t-il mis à remarquer le nœud en panne, et qu'est-ce qui raccourcit cette fenêtre ?
- Que devient le service quand une mise à jour pointe vers une image qui n'existe pas, et pourquoi ?
Sources
Toutes consultées le 21 octobre 2026.
- Kubernetes, « Horizontal Pod Autoscaling » : la formule
desiredReplicas, le calcul par rapport auxrequests, la tolérance de 10 %, la fenêtre de stabilisation de 300 secondes par défaut pour la réduction, la politique de comportement par défaut et la recommandation de retirerspec.replicasdu Deployment. - Kubernetes, « Pod Topology Spread Constraints » :
topologyKey,maxSkew,whenUnsatisfiable,nodeTaintsPolicy,matchLabelKeysavecpod-template-hashet la limitation concernant les Pods retirés. - Kubernetes, « Pod Lifecycle » : le flux de terminaison d'un Pod :
preStop, signalTERM, délai,KILL, et le retrait des destinations en parallèle. - Kubernetes, « Container Lifecycle Hooks » : les trois types de hook (Exec, HTTP, Sleep) et le délai d'arrêt qui inclut le
preStop. - Kubernetes, « Deployments » :
maxSurge,maxUnavailable,rollout undo,--to-revision, l'annotationkubernetes.io/change-cause,revisionHistoryLimitet la conditionAvailable. - Kubernetes, « Disruptions » : interruptions volontaires et involontaires, le budget qui n'évite pas les secondes mais les compte, et
kubectl drainavec l'API d'éviction. - Kubernetes, « Specifying a Disruption Budget for your Application » :
minAvailableetmaxUnavailable. - Kubernetes, « Safely Drain a Node » : ce que fait
kubectl drainet pourquoi il respecte les PDB ;--ignore-daemonsets. - Kubernetes, « Node Status » : la condition
ReadyàUnknownsi le contrôleur de nœuds n'a pas de nouvelles du nœud dansnode-monitor-grace-period(50 secondes par défaut) et les battements de cœur avecLease. - Kubernetes, « Taints and Tolerations » : les taints
not-readyetunreachable, et les tolérances de 300 secondes qui s'ajoutent toutes seules. - Kubernetes, « Resource metrics pipeline » : la Metrics API,
metrics-serveretkubectl top; le CPU comme taux sur une fenêtre. - metrics-server, README : les prérequis (certificat du
kubeletsigné par l'autorité du cluster),--kubelet-insecure-tls« uniquement pour des tests », et les usages et non-usages. - metrics-server, notes de la v0.9.0 : la version qu'utilise la leçon.
- Phil Pearl, « Kubernetes' dirty endpoint secret and Ingress » (26 juillet 2019) : le retrait des destinations et l'arrêt se produisent en même temps ; la pause avant l'arrêt.
- Go,
net/http,Server.Shutdown: l'arrêt ordonné ferme d'abord les ports ouverts puis les connexions inactives. - Kubernetes, « Job » :
activeDeadlineSecondscourt depuis la création du Job et le termine quoi qu'il arrive. - Kubernetes, « kube-controller-manager » :
--node-monitor-grace-period(50 s) et--node-monitor-period(5 s). - curl, « man page » :
--retry,--retry-all-errorset--max-time. - curl, « libcurl error codes » : les codes 7 et 28.
Vous préférez l'e-mail ? Écrivez-nous à hola@habil.mx