Les pods
Pourquoi
Avec Docker, l'unité de travail était le conteneur : docker run en lançait un, et l'on y attachait un réseau, des volumes, une politique de redémarrage. Kubernetes ne lance jamais un conteneur seul. Sa plus petite unité est le Pod (« cosse », comme celle qui contient des petits pois) : un ou plusieurs conteneurs qui partagent une adresse IP, des volumes, et un même destin.
Pourquoi ne pas s'en tenir au conteneur ? Parce que certains programmes doivent vivre ensemble : un serveur web et le processus qui recharge sa configuration, une application et l'agent qui expédie ses journaux. Les mettre dans le même conteneur casse le principe « un processus par conteneur » vu dans le cours Docker : les fondamentaux ; les mettre dans deux conteneurs indépendants oblige à les relier par le réseau et à espérer qu'ils atterrissent sur la même machine. Le Pod règle la question : ses conteneurs sont toujours placés sur le même nœud, démarrés et arrêtés ensemble, et se parlent par localhost.
Cette leçon fait tourner Signalements dans un Pod, puis s'intéresse à tout ce qui peut mal se passer autour : un conteneur qui plante en boucle, une application qui reçoit du trafic avant d'être prête, une sonde mal choisie qui redémarre tout le parc au premier ralentissement de la base, un arrêt brutal qui coupe des requêtes en cours. À la fin, vous saurez lire l'état d'un Pod et régler ce qui décide de sa vie.
Une précision avant de commencer : en production, on ne crée presque jamais un Pod à la main. On décrit un modèle de Pod dans un objet de plus haut niveau, le plus souvent un Deployment (leçon 5), qui en crée et en remplace autant qu'il faut. Mais tout ce que vous allez voir ici s'applique tel quel aux pods créés par un Deployment : c'est le même objet.
Les concepts
Ce que partagent les conteneurs d'un Pod
La documentation de Kubernetes décrit le contexte partagé d'un Pod comme un ensemble d'espaces de noms Linux, de groupes de contrôle (cgroups) et éventuellement d'autres facettes d'isolation : exactement les briques du cours Docker, assemblées différemment. Concrètement :
| Partagé entre les conteneurs du Pod | Propre à chaque conteneur |
|---|---|
| L'adresse IP et les ports (espace de noms réseau commun) | L'image et donc le système de fichiers racine |
| Les volumes déclarés dans le Pod, montés là où chaque conteneur le demande | Les variables d'environnement, la commande, les ressources |
| Le nom d'hôte | Le processus principal, ses redémarrages |
Deux conséquences pratiques. D'abord, deux conteneurs d'un même Pod ne peuvent pas écouter sur le même port : ils partagent la même pile réseau. Ensuite, ils se joignent par localhost, sans passer par le réseau du cluster.
Comment Kubernetes garde-t-il ces espaces de noms vivants si un conteneur redémarre ? Par un petit conteneur supplémentaire, que vous ne déclarez pas : le conteneur d'infrastructure, souvent appelé conteneur pause. Son programme, pause.c dans le dépôt de Kubernetes, tient en une soixantaine de lignes : il installe des gestionnaires pour SIGINT et SIGTERM (qui le font sortir proprement) et pour SIGCHLD (qui ramasse les processus zombies), puis attend indéfiniment. Il démarre en premier, crée l'espace de noms réseau, et les conteneurs applicatifs viennent s'y greffer. Quand votre application plante et redémarre, l'adresse IP du Pod ne change pas, parce que c'est le conteneur pause qui la porte.
Un conteneur applicatif, presque toujours
Pouvoir mettre plusieurs conteneurs dans un Pod ne veut pas dire qu'il faut le faire. La règle est simple : deux conteneurs vont dans le même Pod s'ils doivent être sur la même machine et vivre et mourir ensemble. Signalements et sa base PostgreSQL n'en font pas partie : la base a son propre cycle de vie, ses propres sauvegardes, et doit survivre au redéploiement de l'application. Deux exemplaires de Signalements non plus : on veut au contraire pouvoir les placer sur deux nœuds différents, ce qui exige deux Pods.
Les cas légitimes de Pods à plusieurs conteneurs sont des auxiliaires : un conteneur d'initialisation qui prépare quelque chose avant l'application, ou un sidecar qui l'accompagne (agent de journaux, mandataire réseau). Ils sont détaillés plus bas.
Le manifeste de Signalements
Voici un Pod complet pour Signalements, tel qu'on pourrait l'écrire pour un premier essai sur le cluster formation de la leçon 3 :
apiVersion: v1
kind: Pod
metadata:
name: signalements
namespace: signalements
labels:
app.kubernetes.io/name: signalements
app.kubernetes.io/version: "1.2.0"
spec:
containers:
- name: api
image: ghcr.io/lyneko-formation/signalements:1.2.0
ports:
- name: http
containerPort: 8000
env:
- name: APP_VERSION
value: "1.2.0"
- name: DATABASE_URL
value: "postgresql://signalements:essai@postgresql:5432/signalements"
startupProbe:
httpGet:
path: /
port: http
periodSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet:
path: /sante
port: http
periodSeconds: 5
failureThreshold: 2
livenessProbe:
httpGet:
path: /
port: http
periodSeconds: 10
failureThreshold: 6
lifecycle:
preStop:
sleep:
seconds: 5
terminationGracePeriodSeconds: 40Lisez-le de haut en bas :
apiVersion: v1etkind: Pod: le Pod appartient au groupe d'API de base, sans préfixe (leçon 3).- Les étiquettes (labels)
app.kubernetes.io/...suivent les conventions recommandées par la documentation. Elles ne servent à rien pour un Pod isolé ; elles deviendront essentielles dès que des Services et des Deployments devront retrouver les pods (leçons 5 et 6). imageest épinglée sur une version précise, jamaislatest: sans version, deux nœuds peuvent faire tourner deux images différentes sous le même nom. Le cours Construire des images de conteneurs va plus loin, avec l'épinglage par empreinte.- Le port porte un nom (
http). Les sondes et, plus tard, le Service le désignent par ce nom : si le port change un jour, une seule ligne bouge. DATABASE_URLcontient ici un mot de passe en clair, ce qui est acceptable pour un essai local et inacceptable ailleurs. La leçon 7 le déplace dans un Secret.- Les ressources (processeur et mémoire réservés et plafonnés) manquent volontairement : elles sont l'objet de la leçon 8. Un Pod sans ressources fonctionne, mais le planificateur le place à l'aveugle.
- Les trois sondes,
lifecycle.preStopetterminationGracePeriodSecondssont expliqués dans les sections suivantes. Ce sont les lignes qui font la différence entre un Pod qui fonctionne en démonstration et un Pod qui se comporte bien en production.
Le cycle de vie : phases et états
Un Pod a une phase, qui résume où il en est. La documentation en définit cinq :
| Phase | Signification |
|---|---|
Pending | Le Pod est accepté par le cluster, mais au moins un conteneur n'est pas encore prêt à tourner : il attend d'être placé sur un nœud, ou que ses images soient téléchargées |
Running | Le Pod est lié à un nœud, tous ses conteneurs sont créés, et au moins un tourne, démarre ou redémarre |
Succeeded | Tous les conteneurs se sont terminés avec succès et ne seront pas redémarrés |
Failed | Tous les conteneurs se sont terminés, et au moins un en échec |
Unknown | L'état du Pod n'a pas pu être obtenu, en général parce que le nœud ne répond plus |
La phase est un résumé grossier : un Pod Running peut très bien avoir un conteneur qui plante toutes les trente secondes. Pour le détail, chaque conteneur a son propre état : Waiting (pas encore lancé, avec une raison comme ContainerCreating, ImagePullBackOff ou CrashLoopBackOff), Running (avec l'heure de démarrage), ou Terminated (avec le code de sortie, la raison et les heures de début et de fin). C'est l'état des conteneurs que l'on lit en diagnostic (leçon 12).
Note
La colonne STATUS de kubectl get pods n'est ni la phase ni l'état d'un conteneur, mais un mélange calculé par kubectl pour être lisible : elle affiche par exemple CrashLoopBackOff (une raison d'attente) pour un Pod dont la phase est Running. Pour la vraie phase, lisez .status.phase avec kubectl get pod <nom> -o jsonpath='{.status.phase}'.
Redémarrer : restartPolicy et CrashLoopBackOff
Le champ spec.restartPolicy décide de ce que fait le kubelet (l'agent de chaque nœud, leçon 2) quand un conteneur s'arrête :
Always(valeur par défaut) : redémarrer dans tous les cas. C'est ce qu'on veut pour un service qui ne doit jamais s'arrêter, comme Signalements.OnFailure: redémarrer seulement si le code de sortie est différent de zéro. Pour une tâche qui doit aller au bout (leçon 10).Never: ne jamais redémarrer.
Un redémarrage se fait sur place : même Pod, même nœud, même adresse IP, nouveau conteneur. Si le conteneur replante aussitôt, le kubelet ne s'acharne pas : il attend entre deux tentatives un délai qui double à chaque fois. La documentation donne la suite : 10 s, 20 s, 40 s, et ainsi de suite, plafonnée à 300 secondes (cinq minutes). Si le conteneur tient dix minutes sans incident, le compteur repart de zéro. Pendant ces attentes, l'état du conteneur est Waiting avec la raison CrashLoopBackOff : « en boucle de plantage, j'attends avant de réessayer ».
CrashLoopBackOff n'est donc pas une cause, c'est un symptôme : le conteneur s'arrête, pour une raison qu'il faut chercher dans ses journaux et son code de sortie (leçon 12). Un code 137 (128 + 9, SIGKILL) avec la raison OOMKilled signale un dépassement de mémoire ; un code 1 est une erreur de l'application elle-même, comme une base injoignable au démarrage.
Note
Deux fonctionnalités récentes touchent à ce délai. KubeletCrashLoopBackOffMax, en bêta depuis Kubernetes 1.35, permet à l'administrateur de réduire le plafond nœud par nœud dans la configuration du kubelet. ReduceDefaultCrashLoopBackOffDecay, en alpha depuis la 1.33 et toujours désactivée par défaut, ramène les valeurs à 1 s au départ et 60 s au plus. Sur un cluster ordinaire, retenez 10 s, doublé, plafonné à cinq minutes.
Les conteneurs d'initialisation
Un conteneur d'initialisation (init container) s'exécute avant les conteneurs applicatifs, jusqu'au bout, et doit réussir. S'il y en a plusieurs, ils passent l'un après l'autre, dans l'ordre de déclaration. Si l'un échoue, le kubelet le relance jusqu'à ce qu'il réussisse (avec le même délai croissant qu'un conteneur ordinaire), sauf si la politique du Pod est Never, auquel cas le Pod entier est en échec ; l'application ne démarre pas tant qu'il n'a pas réussi.
Ils servent à préparer : attendre qu'une dépendance réponde, télécharger un fichier, appliquer des droits sur un volume. Pour Signalements, un conteneur d'initialisation peut attendre que PostgreSQL accepte des connexions :
spec:
initContainers:
- name: attendre-la-base
image: postgres:17-alpine
command: ["sh", "-c", "until pg_isready -h postgresql -p 5432; do echo 'base indisponible'; sleep 2; done"]
containers:
- name: api
# ... comme plus hautpg_isready, fourni avec PostgreSQL, sort avec le code 0 dès que le serveur accepte des connexions. Le conteneur boucle jusque-là, puis se termine, et l'API démarre.
Utile, mais à doser : une application qui ne supporte pas l'absence passagère de sa base au démarrage la supportera mal aussi en cours de route. La vraie solution est une application qui réessaie ses connexions ; le conteneur d'initialisation est une béquille acceptable pendant que l'on corrige le code.
Les sidecars natifs
Un sidecar (« side-car », la nacelle d'une moto) est un conteneur auxiliaire qui accompagne l'application pendant toute sa vie : un agent qui lit des fichiers de journaux et les expédie, un mandataire qui chiffre le trafic sortant. Pendant longtemps, on les déclarait simplement comme des conteneurs ordinaires à côté de l'application, ce qui posait deux problèmes : rien ne garantissait qu'ils démarrent avant l'application, et dans un Job (leçon 10), un sidecar qui ne s'arrête jamais empêchait le Job de se terminer.
Kubernetes a introduit des sidecars natifs : on les déclare dans initContainers, avec restartPolicy: Always au niveau du conteneur :
spec:
initContainers:
- name: expediteur-journaux
image: fluent/fluent-bit:4.0
restartPolicy: Always
volumeMounts:
- name: journaux
mountPath: /var/log/signalements
containers:
- name: api
image: ghcr.io/lyneko-formation/signalements:1.2.0
volumeMounts:
- name: journaux
mountPath: /var/log/signalements
volumes:
- name: journaux
emptyDir: {}(L'image fluent/fluent-bit n'est qu'un exemple d'agent de journaux, et sa configuration est omise ; les volumes sont le sujet de la leçon 9.)
Ce restartPolicy: Always change tout. La documentation décrit le comportement : le sidecar démarre dans l'ordre des conteneurs d'initialisation, mais le kubelet n'attend pas qu'il se termine, seulement qu'il soit démarré (ou que sa sonde de démarrage réussisse) avant de passer au suivant ; il continue ensuite de tourner pendant toute la vie du Pod, redémarre seul s'il s'arrête, et n'est arrêté qu'après les conteneurs applicatifs, dans l'ordre inverse de déclaration. Dans un Job, il n'empêche plus la fin du travail.
La fonctionnalité est apparue en alpha dans Kubernetes 1.28, est passée en bêta (activée par défaut) en 1.29, et est stable depuis la 1.33. Sur un cluster 1.36, elle est toujours disponible.
Les trois sondes
Le kubelet ne sait pas, de lui-même, si votre application fonctionne : un processus vivant peut être bloqué, ou pas encore prêt. Les sondes (probes) le lui disent. Chacune est un test que le kubelet exécute périodiquement, par l'un de quatre mécanismes : une requête HTTP (httpGet, réussie pour un code de 200 à 399), une connexion TCP (tcpSocket), une commande dans le conteneur (exec, réussie pour un code de sortie 0), ou un appel gRPC (grpc).
Les trois sondes posent trois questions différentes, et leurs échecs ont trois conséquences différentes :
| Sonde | Question | En cas d'échec |
|---|---|---|
Démarrage (startupProbe) | L'application a-t-elle fini de démarrer ? | Tant qu'elle n'a pas réussi, les deux autres sondes sont suspendues ; si elle échoue failureThreshold fois, le conteneur est tué et redémarré |
Vie (livenessProbe) | L'application est-elle dans un état dont elle ne sortira pas seule ? | Le conteneur est tué et redémarré |
Disponibilité (readinessProbe) | L'application peut-elle recevoir du trafic maintenant ? | Le Pod est retiré des Services : il ne reçoit plus de requêtes, mais continue de tourner |
Leurs paramètres communs ont des valeurs par défaut à connaître : periodSeconds vaut 10 (une vérification toutes les dix secondes), failureThreshold vaut 3 (trois échecs consécutifs avant d'agir), initialDelaySeconds vaut 0. Avec ces valeurs, une sonde de vie tue un conteneur après une trentaine de secondes de non-réponse.
Le piège de la sonde de vie qui teste la base
La route GET /sante de Signalements vérifie que l'application répond et que la base de données est joignable. C'est exactement ce qu'il faut pour la disponibilité : si la base est injoignable depuis ce Pod, autant ne plus lui envoyer de requêtes.
Ce serait une erreur grave de l'utiliser pour la vie. Imaginez la base de données indisponible trente secondes, pour une bascule ou une maintenance. Tous les pods de Signalements voient /sante échouer en même temps. Leurs sondes de vie échouent, et le kubelet redémarre tous les conteneurs, ensemble. Redémarrer n'a aucune chance de réparer la base ; en revanche, chaque redémarrage perd les connexions en cours, recharge l'application, et quand la base revient, tous les pods redémarrés se reconnectent en même temps et la surchargent. La documentation de Kubernetes met en garde précisément contre ces défaillances en cascade : une sonde de vie doit signaler une panne irrécupérable du conteneur lui-même, comme un interblocage, pas un problème passager ou une dépendance.
D'où les choix du manifeste :
- la disponibilité teste
/sante(application et base), avec un seuil court : un Pod qui perd la base sort du trafic en une dizaine de secondes ; - la vie teste
/, qui ne touche pas la base, avec un seuil long (six échecs à dix secondes) : seul un processus réellement bloqué est tué ; - la démarrage teste aussi
/, avec une marge large (30 essais à 2 secondes, soit une minute) : elle protège un démarrage lent contre une sonde de vie trop pressée, sans rendre la sonde de vie molle pour le reste de la vie du conteneur.
Tip
En cas de doute, commencez sans sonde de vie, avec une sonde de disponibilité. Une application dont le processus plante sort d'elle-même, et la politique Always la redémarre. La sonde de vie ne sert qu'aux applications qui peuvent rester vivantes mais bloquées.
L'arrêt d'un Pod
Un Pod s'arrête souvent : à chaque nouvelle version, à chaque réduction du nombre de répliques, à chaque maintenance de nœud. La documentation décrit la séquence, dont plusieurs étapes se déroulent en parallèle :
- La demande de suppression est enregistrée, avec un délai de grâce (
terminationGracePeriodSeconds, 30 secondes par défaut). - Le kubelet du nœud exécute le crochet
preStops'il existe, puis envoieSIGTERMau processus principal de chaque conteneur. - En même temps, le plan de contrôle marque le Pod comme en cours d'arrêt dans les EndpointSlices des Services (leçon 6) : les mandataires réseau de chaque nœud vont cesser de lui envoyer de nouvelles connexions, mais chacun à son rythme.
- À l'expiration du délai de grâce, tout ce qui tourne encore reçoit
SIGKILL.
Deux exigences en découlent pour l'application.
Traiter SIGTERM. Le signal n'est envoyé qu'au processus principal du conteneur, le PID 1 de son espace de noms. Si l'image lance gunicorn à travers un shell (CMD gunicorn ... en forme shell), c'est le shell qui reçoit le signal, ne le transmet pas, et gunicorn est tué net trente secondes plus tard. C'est la raison de la forme exec (ENTRYPOINT ["gunicorn", ...]) vue dans le cours Docker : les fondamentaux, et du rôle de PID 1 expliqué dans le même cours. Bien lancé, gunicorn traite TERM comme un arrêt gracieux : d'après sa documentation, l'arbitre attend que ses workers terminent les requêtes en cours, pendant au plus graceful_timeout secondes (30 par défaut).
Survivre au délai de propagation. L'étape 3 est la plus mal comprise. Entre l'envoi de SIGTERM et le moment où plus aucun nœud n'envoie de connexion au Pod, il s'écoule un court instant, parce que les règles réseau sont mises à jour de façon asynchrone. Un serveur qui ferme ses ports dès SIGTERM refuse donc quelques requêtes encore en route. Le remède classique est un crochet preStop qui attend quelques secondes avant que le signal n'arrive : pendant ce temps, l'application sert normalement, et le retrait du Pod se propage. Depuis Kubernetes 1.34, l'action sleep est stable et ne demande aucun binaire dans l'image (preStop: {sleep: {seconds: 5}}) ; auparavant, on écrivait exec: {command: ["sleep", "5"]}, ce qui suppose un sleep dans l'image, absent des images distroless.
Le délai de grâce doit couvrir le tout : 5 secondes de preStop, plus le temps que gunicorn finisse ses requêtes (jusqu'à 30 s), plus une marge. D'où les 40 secondes du manifeste. À l'inverse, si une requête peut durer plus longtemps, augmentez graceful_timeout de gunicorn et le délai de grâce du Pod ; l'un sans l'autre ne sert à rien.
Les pods sont jetables
Dernier concept, et peut-être le plus important : un Pod n'est jamais déplacé. Si son nœud tombe en panne, le Pod est perdu ; s'il est supprimé, il ne revient pas. La documentation insiste : un Pod de remplacement est un nouveau Pod, avec un nouvel identifiant unique (metadata.uid), une nouvelle adresse IP, et ses volumes temporaires repartent à zéro.
C'est pourquoi on ne crée pas de Pods nus en production : personne ne recréerait celui qui disparaît. On confie ce travail à un contrôleur, le plus souvent un Deployment (leçon 5), qui maintient en permanence le nombre de pods voulu. Et c'est pourquoi rien d'important ne doit vivre dans un Pod : pas de données sur son disque, pas d'état en mémoire que l'on ne saurait perdre. Signalements garde ses données dans PostgreSQL, qui vit ailleurs.
En pratique
Les commandes suivantes s'exécutent sur le cluster kind formation de la leçon 3, dans le namespace signalements. Elles supposent qu'une base PostgreSQL joignable sous le nom postgresql existe dans ce namespace ; si ce n'est pas le cas, retirez la variable DATABASE_URL : Signalements garde alors ses données en mémoire, et /sante répond sans tester de base. La leçon décrit ce que vous devez observer, sans reproduire de sortie.
Créer le namespace et lancer un premier Pod
$ kubectl create namespace signalements
$ kubectl config set-context --current --namespace=signalements
La seconde commande fait de signalements le namespace par défaut du contexte courant : vous n'aurez plus à écrire -n signalements à chaque commande.
La façon la plus rapide de lancer un Pod est kubectl run :
$ kubectl run essai --image=ghcr.io/lyneko-formation/signalements:1.2.0 --port=8000
La commande crée un Pod nommé essai, avec l'étiquette run=essai. Pratique pour un test, mais rien de ce qu'elle crée n'est écrit dans un fichier : on ne pourra ni le relire, ni le versionner. Supprimez-le, et passez au manifeste :
$ kubectl delete pod essai
Appliquer le manifeste et suivre le démarrage
Enregistrez le manifeste de la section Les concepts dans pod.yaml, puis :
$ kubectl apply -f pod.yaml
$ kubectl get pod signalements --watch
--watch affiche une nouvelle ligne à chaque changement. Vous voyez le Pod passer par ContainerCreating (téléchargement de l'image, création du conteneur), puis Running avec 0/1 dans la colonne READY : le conteneur tourne, mais la sonde de disponibilité n'a pas encore réussi. Quelques secondes plus tard, READY passe à 1/1. Interrompez avec Ctrl+C.
Pour le détail, describe :
$ kubectl describe pod signalements
La sortie regroupe le nœud choisi, l'adresse IP, l'état de chaque conteneur (avec Started et le nombre de redémarrages), les sondes telles que Kubernetes les a comprises (avec les valeurs par défaut complétées), les conditions du Pod (PodScheduled, Initialized, ContainersReady, Ready), et surtout, en bas, la section Events : la chronologie des actions du planificateur et du kubelet (placement, téléchargement de l'image, création, démarrage, échecs de sondes). C'est presque toujours là que commence un diagnostic.
Lire les journaux, entrer dans le conteneur
$ kubectl logs signalements
$ kubectl logs signalements --follow
$ kubectl logs signalements --previous
- Sans option, la sortie standard et la sortie d'erreur du conteneur depuis son démarrage : les messages de démarrage de gunicorn, puis une ligne par requête si l'accès est journalisé.
--followsuit les nouvelles lignes, commetail -f.--previousaffiche les journaux de l'exécution précédente du conteneur : indispensable après un redémarrage, puisque les journaux de l'exécution qui a planté ne sont plus ceux de l'exécution courante.
Pour un Pod à plusieurs conteneurs, précisez lequel avec -c (par exemple -c attendre-la-base).
exec lance une commande dans le conteneur, comme docker exec :
$ kubectl exec signalements -- env
$ kubectl exec -it signalements -- sh
Le -- sépare les options de kubectl de la commande à lancer. La première affiche les variables d'environnement du conteneur, et vous y retrouvez APP_VERSION et DATABASE_URL. La seconde ouvre un shell interactif, si l'image en contient un ; sur une image distroless, il n'y en a pas, et la leçon 12 montre les conteneurs de débogage éphémères.
Joindre l'application depuis le poste
$ kubectl port-forward pod/signalements 8000:http
La commande ouvre le port 8000 de votre poste et fait suivre les connexions vers le port nommé http du Pod, à travers l'API de Kubernetes. Dans un autre terminal :
$ curl -s http://localhost:8000/
$ curl -s http://localhost:8000/sante
La première renvoie l'identité et la version de l'application, la seconde son état et celui de la base. port-forward est un outil de diagnostic, pas un moyen d'exposer une application : il passe par votre session et s'arrête avec elle. L'exposition est le sujet de la leçon 6.
Provoquer un CrashLoopBackOff
Pour voir le mécanisme, cassez volontairement le démarrage. Supprimez le Pod, puis recréez-le avec une commande qui échoue immédiatement :
$ kubectl delete pod signalements
$ kubectl run plante --image=ghcr.io/lyneko-formation/signalements:1.2.0 \
--command -- python3 -c "import sys; print('erreur au démarrage'); sys.exit(1)"
$ kubectl get pod plante --watch
--command remplace le point d'entrée de l'image par la commande donnée. Le conteneur imprime son message et sort avec le code 1 ; avec la politique Always de kubectl run, il est relancé, replante, et la colonne STATUS alterne entre Error et CrashLoopBackOff, pendant que RESTARTS augmente de plus en plus lentement : 10 s, 20 s, 40 s entre les tentatives.
$ kubectl describe pod plante
$ kubectl logs plante --previous
Dans describe, l'état du conteneur est Waiting avec la raison CrashLoopBackOff, et Last State montre Terminated, raison Error, code de sortie 1. Les journaux de l'exécution précédente contiennent erreur au démarrage. Supprimez le Pod (kubectl delete pod plante).
Observer l'arrêt gracieux
Réappliquez pod.yaml, puis, dans un terminal, suivez les journaux, et dans un autre, supprimez le Pod :
$ kubectl logs signalements --follow
$ kubectl delete pod signalements
La suppression ne rend pas la main immédiatement : kubectl delete attend que le Pod ait disparu. Pendant les cinq premières secondes, rien ne se passe dans les journaux : c'est le preStop. Puis gunicorn journalise la réception du signal et l'arrêt de ses workers, et le Pod disparaît. Si gunicorn était lancé par un shell, vous ne verriez aucun message d'arrêt, et la suppression durerait les 40 secondes du délai de grâce, jusqu'au SIGKILL.
Sous le capot
Le kubelet est un réconciliateur. Le kubelet de chaque nœud reçoit de l'API la liste des Pods qui lui sont affectés, et compare en boucle cet état voulu à ce qui tourne réellement, en dialoguant avec le moteur de conteneurs (containerd sur kind et sur Kapsule) par l'interface CRI (leçon 2). La documentation le décrit comme une boucle de contrôle : il démarre ce qui manque, arrête ce qui est en trop, et met à jour status dans l'API. C'est le même principe que la réconciliation d'Argo CD vue dans le cours GitOps avec Argo CD, un étage plus bas.
Le Pod sandbox. Pour créer un Pod, le kubelet demande d'abord au moteur de conteneurs un Pod sandbox : c'est lui qui porte le conteneur pause, les espaces de noms partagés, et que le plugin réseau (CNI) branche sur le réseau du cluster pour lui donner son adresse IP. Les conteneurs applicatifs sont ensuite créés dans ce sandbox. Le code de pause.c le confirme : s'il ne tourne pas en PID 1, il affiche un avertissement, et son gestionnaire de SIGCHLD récolte les orphelins quand l'espace de noms des processus est partagé.
Le délai de grâce dans l'API. Quand vous supprimez un Pod, l'objet n'est pas effacé tout de suite : l'API lui ajoute un champ metadata.deletionTimestamp et un deletionGracePeriodSeconds. C'est ce qui le fait apparaître Terminating dans kubectl get pods, et c'est ce champ que le kubelet et les contrôleurs d'EndpointSlices observent. Quand le kubelet a fini (ou que le délai a expiré), il demande la suppression définitive. Si le nœud est injoignable, le Pod reste Terminating jusqu'à ce que le nœud revienne ou que quelqu'un force la suppression.
Les sondes sont faites par le kubelet, depuis le nœud. Une sonde HTTP est une requête du kubelet vers l'adresse IP du Pod. Elle ne passe pas par un Service, ni par le réseau du cluster au sens large. Une sonde de disponibilité qui réussit ne prouve donc pas que les autres pods peuvent joindre celui-ci ; elle prouve que l'application répond à son nœud.
Pièges courants
latest ou pas d'étiquette. Le kubelet ne retélécharge une image déjà présente que si imagePullPolicy le demande ; avec une étiquette mutable, deux nœuds peuvent exécuter deux contenus différents. Épinglez une version, et en production une empreinte.
Une sonde de vie sur une dépendance. Le piège de cette leçon, à l'origine de pannes réelles : la base ralentit, toutes les applications redémarrent ensemble, et la panne de la base devient une panne de tout le service.
Une sonde de vie sans sonde de démarrage. Une application qui met une minute à démarrer, sous une sonde de vie qui tue après trente secondes, ne démarre jamais : elle est tuée en boucle, et l'on voit un CrashLoopBackOff dont les journaux ne montrent aucune erreur. La sonde de démarrage, ou un failureThreshold plus large, règle le problème.
CrashLoopBackOff sans journaux. kubectl logs affiche l'exécution courante, souvent vide puisque le conteneur attend. Pensez à --previous.
Un arrêt de 30 secondes à chaque déploiement. Le symptôme d'un processus principal qui ne reçoit pas SIGTERM : un shell en PID 1, un script d'entrée qui ne fait pas exec. Chaque arrêt attend le délai de grâce, puis tue tout.
Des erreurs 502 à chaque déploiement. Le symptôme inverse : l'application ferme ses connexions dès SIGTERM, avant que le retrait du Pod se soit propagé. Le preStop qui attend quelques secondes le fait disparaître.
Deux conteneurs sur le même port dans un Pod. Ils partagent la pile réseau : le second ne démarre pas, avec une erreur d'adresse déjà utilisée dans ses journaux.
Sécurité
- Le Pod est une frontière d'isolation faible entre ses conteneurs. Ils partagent le réseau et peuvent partager des volumes : un sidecar compromis voit le trafic
localhostde l'application. N'ajoutez pas dans un Pod un conteneur auquel vous ne feriez pas confiance autant qu'à l'application. kubectl execest un accès au conteneur. Qui peut faireexecdans un Pod peut lire ses variables d'environnement, donc ses secrets, et agir avec son identité. Le droitpods/execse donne avec parcimonie (cours Sécurité de Kubernetes).- Les variables d'environnement s'affichent.
kubectl describe podmontre les valeurs écrites en clair dans le manifeste, et quiconque peut lire le Pod les lit. C'est une raison de plus pour la leçon 7. - Les journaux sont des données.
kubectl logsest accessible à qui peut lire les pods du namespace ; n'y journalisez ni mot de passe ni donnée personnelle. - Le contexte de sécurité (exécution sous un utilisateur non privilégié, système de fichiers en lecture seule, capacités retirées) se déclare dans le Pod (
securityContext). L'image de Signalements tourne déjà sous un utilisateur non privilégié (cours Construire des images de conteneurs) ; le cours Sécurité de Kubernetes montre comment l'imposer à tout un namespace.
En production
- Pas de Pod nu. Un Deployment, un StatefulSet ou un Job crée les pods ; les manifestes vivent dans Git et sont appliqués par un outil comme Argo CD. Chez Lyneko, sur le cluster Kapsule
lyneko-apps, aucune personne ne faitkubectl applysur un Pod d'application. - Les sondes se règlent sur des mesures. Le temps de démarrage réel (au 99e centile, pas en moyenne) fixe la sonde de démarrage ; le comportement de l'application sous charge fixe les seuils de disponibilité. Une sonde de disponibilité trop sensible retire des pods sains au premier pic, et reporte la charge sur les autres.
- L'arrêt gracieux se teste. Un test de charge pendant un redéploiement montre immédiatement si des requêtes échouent ; c'est le seul moyen d'être sûr que
preStop,graceful_timeoutet le délai de grâce sont cohérents. - Une route de santé qui ne coûte rien. Les sondes interrogent chaque pod toutes les quelques secondes : multipliées par le nombre de pods, elles font une charge réelle sur la base si
/santefait une requête lourde. Une requêteSELECT 1suffit.
Exercices
1. Choisir les sondes (niveau 100). Pour chacune de ces applications, dites quelles sondes vous configureriez et ce qu'elles testeraient : (a) une API qui démarre en deux secondes et dépend d'une base ; (b) une application Java qui met trois minutes à charger un cache au démarrage ; (c) un programme qui traite des messages d'une file et n'a pas de serveur HTTP.
Solution
(a) Une sonde de disponibilité sur une route qui teste la base, et éventuellement une sonde de vie sur une route qui ne la teste pas, avec un seuil généreux ; pas de sonde de démarrage nécessaire. (b) Une sonde de démarrage avec une marge suffisante (par exemple periodSeconds: 10, failureThreshold: 30, soit cinq minutes), puis vie et disponibilité ; sans elle, la sonde de vie tuerait l'application en plein chargement. (c) Pas de trafic entrant, donc pas de sonde de disponibilité utile ; une sonde de vie par commande (exec) qui vérifie, par exemple, qu'un fichier témoin a été mis à jour récemment par la boucle de traitement, pour détecter un blocage. Ou aucune sonde, si le programme sort de lui-même en cas d'erreur.
2. Lire un CrashLoopBackOff (niveau 200). Un collègue vous montre un Pod en CrashLoopBackOff avec 14 redémarrages. kubectl logs est vide. Quelles commandes lancez-vous, dans quel ordre, et que cherchez-vous dans chacune ?
Solution
kubectl describe pod <nom> : l'état du dernier conteneur terminé (Last State), avec sa raison et son code de sortie (OOMKilled et 137 : mémoire ; Error et 1 : erreur applicative ; 126 ou 127 : commande non exécutable ou introuvable), et la section Events (échecs de sonde de vie, par exemple). Puis kubectl logs <nom> --previous : les journaux de l'exécution qui a planté, puisque l'exécution courante n'a encore rien écrit. Si le conteneur est tué par la sonde de vie, les événements le disent (Liveness probe failed, puis Container ... failed liveness probe, will be restarted), et l'on regarde alors si l'application a eu le temps de démarrer.
3. Régler l'arrêt (niveau 200). Signalements a des requêtes d'export qui peuvent durer 50 secondes. Proposez les valeurs de graceful_timeout (gunicorn), preStop et terminationGracePeriodSeconds, et justifiez-les. Que se passe-t-il si vous ne changez que l'une d'elles ?
Solution
Par exemple preStop de 5 secondes, graceful_timeout de 60 secondes, et terminationGracePeriodSeconds de 75 (5 + 60 + une marge). Le délai de grâce court depuis le début de l'arrêt et englobe le preStop : il doit couvrir l'attente et l'arrêt gracieux. Si l'on n'augmente que graceful_timeout, le kubelet envoie SIGKILL au bout de 30 secondes (valeur par défaut), et l'export est coupé quand même. Si l'on n'augmente que le délai de grâce, gunicorn tue lui-même ses workers au bout de 30 secondes. Mieux encore à terme : sortir les exports de la requête HTTP, dans une tâche asynchrone (leçon 10).
4. Sidecar ou conteneur d'initialisation (niveau 200). Pour chacun de ces besoins, choisissez entre conteneur d'initialisation, sidecar natif, ou ni l'un ni l'autre : (a) appliquer les migrations de schéma de la base avant le démarrage de l'API ; (b) expédier vers un service central les journaux que l'application écrit dans un fichier ; (c) faire tourner PostgreSQL à côté de l'API.
Solution
(a) Un conteneur d'initialisation fonctionne, mais avec plusieurs répliques, chaque pod lancerait les migrations en même temps : mieux vaut un Job dédié, exécuté une fois avant le déploiement (leçon 10, et le cours GitOps qui le fait avec un hook). (b) Un sidecar natif : il doit tourner pendant toute la vie de l'application, partager un volume avec elle, et s'arrêter après elle pour expédier les dernières lignes. Encore mieux : que l'application écrive sur sa sortie standard, que Kubernetes collecte sans sidecar. (c) Ni l'un ni l'autre : la base a son propre cycle de vie, et doit survivre au redéploiement de l'API ; elle va dans son propre objet (StatefulSet, leçon 10) ou dans un service managé.
Récapitulatif
- Un Pod regroupe un ou plusieurs conteneurs qui partagent adresse IP, ports et volumes, sont placés sur le même nœud et vivent ensemble ; le conteneur pause porte les espaces de noms partagés.
- Un Pod contient presque toujours un seul conteneur applicatif, plus d'éventuels auxiliaires.
- La phase (
Pending,Running,Succeeded,Failed,Unknown) résume ; l'état des conteneurs (Waiting,Running,Terminated, avec raison et code de sortie) explique. restartPolicy(Alwayspar défaut) redémarre sur place ; un conteneur qui replante attend 10 s, 20 s, 40 s... jusqu'à 5 minutes : c'est leCrashLoopBackOff, un symptôme dont la cause est danslogs --previousetdescribe.- Les conteneurs d'initialisation s'exécutent avant, jusqu'au bout ; les sidecars natifs (
initContainersavecrestartPolicy: Always, stables depuis 1.33) accompagnent l'application et s'arrêtent après elle. - Démarrage protège un démarrage lent, vie redémarre un conteneur bloqué, disponibilité retire du trafic. Une sonde de vie ne teste jamais une dépendance.
- L'arrêt :
preStop,SIGTERMau PID 1, retrait des Services en parallèle,SIGKILLà la fin du délai de grâce (30 s par défaut). UnpreStopqui attend quelques secondes évite les requêtes perdues. - Un Pod est jetable et n'est jamais déplacé : on le confie à un contrôleur.
Pour aller plus loin
- La page Pod Lifecycle de la documentation de Kubernetes, qui détaille la séquence d'arrêt et les conditions du Pod.
- Le tutoriel Pods And Endpoints Termination Flow de la documentation, qui montre pas à pas le retrait d'un Pod des EndpointSlices pendant son arrêt.
- La KEP-753 sur les sidecars natifs, pour comprendre les problèmes qu'ils résolvent et les choix de conception.
- La leçon suivante, Deployments et mises à jour progressives, qui confie les pods à un contrôleur et les remplace sans coupure.
Sources
- Kubernetes, Pods
- Kubernetes, Pod Lifecycle
- Kubernetes, Init Containers
- Kubernetes, Sidecar Containers
- Kubernetes, Liveness, Readiness, and Startup Probes
- Kubernetes, référence des feature gates (SidecarContainers, PodLifecycleSleepAction, ReduceDefaultCrashLoopBackOffDecay)
- kubernetes/kubernetes, build/pause/linux/pause.c (le conteneur d'infrastructure)
- Gunicorn, Signal Handling et réglage graceful_timeout
- KEP-753, Sidecar Containers