Ban7 RemoteLab
🎓 Formation certifiante~1 mois au rythme standard

Kubernetes en 2 jours : déployer et exploiter une application

TP intensif de deux jours sur le projet Bibliobox (API Node 22 et base PostgreSQL 16), mené sur le cluster Kubernetes intégré à Docker Desktop. Jour 1 : porter l'application sur le cluster — namespace, Pod, Deployment, Service, ConfigMap et Secret, sondes de santé et accès depuis le navigateur. Jour 2 : la fiabiliser et l'exploiter — stockage persistant et StatefulSet, mise à jour sans coupure et retour arrière, requests/limits, mise à l'échelle automatique, Ingress, puis diagnostic de quatre pannes injectées. Prérequis : pratique courante de Docker (images, conteneurs, Compose) ; aucune connaissance de Kubernetes.

S'inscrire

Programme complet

Les textes des cours sont en accès libre. Les vidéos et exercices (🔒) sont réservés aux abonnés.

Déployer Bibliobox sur le cluster Kubernetes de Docker Desktop

  • Le cluster de Docker Desktop, et ce qui change quand on vient de Docker

    Vous savez construire une image et lancer un [[conteneur|conteneur-docker]]. Ces deux journées ne reviennent pas là-dessus : elles répondent à la question qui vient juste après. Un [[conteneur|conteneur-docker]] qui s'arrête reste arrêté ; une machine qui redémarre perd tout ce qui tournait dessus ; tripler la capacité d'un service demande de retaper des commandes. [[Kubernetes|kubernetes]] est la réponse industrielle à ces trois problèmes, et c'est cette réponse que vous allez mettre en œuvre sur une application réelle.

    Le fil rouge s'appelle **Bibliobox** : le service de prêt d'une médiathèque de quartier. Deux composants seulement — une [[API|api]] [[Node|node-runtime]] 22 qui expose le catalogue et enregistre les emprunts, une base PostgreSQL 16 qui les stocke. Peu de code, donc, mais assez pour rencontrer tout ce qui compte.

    Le [[cluster|cluster]] sera celui **intégré à Docker Desktop**. Ce n'est pas un choix par défaut : c'est le seul [[cluster|cluster]] local dont le magasin d'images est celui du démon Docker que vous utilisez déjà, ce qui supprime une étape et un oubli classique. Le chapitre 2 y revient en détail.

    Activez le [[cluster|cluster]] : **Docker Desktop → Settings → [[Kubernetes|kubernetes]] → cocher « Enable [[Kubernetes|kubernetes]] » → Apply & restart**. Le premier démarrage télécharge les images du [[control plane|control-plane]] et prend plusieurs minutes ; l'indicateur en bas de la fenêtre passe au vert quand c'est prêt. Docker Desktop installe au passage le client `kubectl` et écrit un contexte nommé `docker-desktop` dans votre fichier `~/.kube/config`.

    Un seul [[nœud|node-k8s]], portant le rôle `control-plane`, et pourtant vos applications tourneront dessus. Sur un vrai [[cluster|cluster]], le [[control plane|control-plane]] est réservé aux composants système et les applications vont sur des [[nœuds|node-k8s]] de travail dédiés. Docker Desktop lève cette réserve pour tenir sur une seule machine — retenez-le, car c'est la principale différence de comportement entre ce [[cluster|cluster]] et celui de votre production.

    La dernière ligne du tableau est la plus importante, et c'est celle qui déroute le plus au début. Avec Docker, vous donnez des ordres : construis, lance, arrête. Avec [[Kubernetes|kubernetes]], vous **écrivez un [[état voulu|etat-voulu]]** dans l'[[API|api]] — « je veux trois exemplaires de cette image » — et des contrôleurs comparent en permanence cet [[état voulu|etat-voulu]] à l'état réel, puis agissent pour combler l'écart. Vous ne demandez jamais « démarre un [[pod|pod]] » : vous demandez qu'il y en ait trois, et le [[cluster|cluster]] s'en occupe indéfiniment.

    Ces six [[pods|pod]] sont [[Kubernetes|kubernetes]] lui-même : la base `etcd` qui garde l'[[état voulu|etat-voulu]], l'`kube-apiserver` par lequel passe absolument toute lecture et toute écriture, le [[scheduler|kube-scheduler]] qui choisit le [[nœud|node-k8s]], le controller-manager qui fait tourner les [[boucles de réconciliation|boucle-reconciliation]], `kube-proxy` qui programme le routage des Services, et [[CoreDNS|coredns]] qui résout les noms internes. Vous les recroiserez tous au chapitre 5 et au second jour.

  • L'application Bibliobox et son image locale

    Créez un dossier `bibliobox/` : il portera tout le TD, sur les deux journées. L'[[API|api]] tient en un fichier. Elle expose quatre routes : `/livres` pour le catalogue, `/emprunts` pour enregistrer un prêt, et deux routes de santé, `/healthz` et `/readyz`, dont le chapitre 8 fera un usage précis.

    Deux détails du serveur méritent d'être signalés avant de le lire. La réponse de chaque route embarque `servi_par`, alimenté par la variable `HOSTNAME` — dans un [[pod|pod]], cette variable vaut le nom du [[pod|pod]], ce qui rendra visible la répartition entre [[répliques|replique]] au chapitre 5. Et `/readyz` interroge réellement la base, là où `/healthz` se contente de répondre.

    Le jeu de données initial est un simple script SQL. PostgreSQL exécute automatiquement tout fichier `.sql` déposé dans `/docker-entrypoint-initdb.d` **au tout premier démarrage d'une base vide** — comportement que vous connaissez de Docker et qui vaut identiquement dans un [[pod|pod]]. Au chapitre 7, ce fichier y sera monté depuis un [[ConfigMap|configmap]].

    Vous n'irez pas plus loin : **pas de `docker push`, pas de [[registre|registre-images]]**. C'est la particularité qui a motivé le choix de Docker Desktop pour ce TD. Son [[cluster|cluster]] [[Kubernetes|kubernetes]] tourne dans la même machine virtuelle que le démon Docker et lit le même magasin d'images. Une image que vous venez de construire est donc immédiatement visible par le [[cluster|cluster]], sous le nom exact que vous lui avez donné.

    Cette facilité a une contrepartie qu'il faut connaître dès maintenant, car elle produit l'erreur `ErrImagePull` la plus courante du module. [[Kubernetes|kubernetes]] ne télécharge une image que si sa [[politique de récupération|imagepullpolicy]] le lui demande. Cette politique vaut `IfNotPresent` par défaut — utiliser l'image locale si elle existe — **sauf** quand le tag est `latest` ou absent : dans ce cas elle bascule sur `Always`, et le [[cluster|cluster]] part chercher `bibliobox-api:latest` sur Docker Hub, où il ne trouve évidemment rien.

    D'où la règle appliquée dans tous les [[manifestes|manifeste]] de ces deux journées : **une image toujours étiquetée par une version explicite** (`1.0.0`, puis `1.1.0` au jour 2), et `imagePullPolicy: IfNotPresent` écrit noir sur blanc plutôt que laissé au défaut.

  • Namespace et premier Pod : l'unité de base, et sa limite

    Premier objet : le **[[Namespace|namespace]]**. C'est un cloisonnement logique du [[cluster|cluster]] — les noms d'objets y sont uniques, les Services y sont résolus en priorité, et une suppression de [[namespace|namespace]] emporte tout ce qu'il contient. Travailler dans `default` est le réflexe qui rend une salle de TP ingérable au bout d'une heure ; Bibliobox aura le sien.

    Quatre champs se retrouveront dans **tous** les [[manifestes|manifeste]] de ce TD, et il vaut mieux les nommer une bonne fois : `apiVersion` désigne la version d'[[API|api]] qui définit le type, `kind` le type d'objet, `metadata` son identité (nom, [[namespace|namespace]], étiquettes), et `spec` — absent ici, car un [[Namespace|namespace]] n'a rien à spécifier — l'[[état voulu|etat-voulu]]. Le fichier se pousse dans le [[cluster|cluster]] avec `kubectl apply -f`.

    Deuxième objet : le **[[Pod|pod]]**, l'unité que le [[cluster|cluster]] sait placer et exécuter. Un [[Pod|pod]] n'est pas un [[conteneur|conteneur-docker]] : c'est une enveloppe qui contient un ou plusieurs conteneurs partageant la même adresse IP, les mêmes volumes et le même espace de noms réseau. Deux conteneurs d'un même [[Pod|pod]] se joignent sur `localhost` ; c'est ce qui distingue un [[Pod|pod]] d'un simple `docker run`.

    Le [[pod|pod]] tourne, mais il est injoignable : son adresse IP appartient au réseau interne du [[cluster|cluster]], que votre machine ne route pas. `kubectl port-forward` ouvre un tunnel temporaire depuis votre poste vers le [[pod|pod]] — outil de mise au point, jamais de mise en service.

    Les deux réponses disent déjà l'essentiel de ce que le chapitre 8 exploitera : le processus est vivant, mais il n'est pas prêt — aucune base n'est configurée, `PGHOST` vaut `undefined`. Retenez cet écart, il est au cœur de la notion de [[sonde|sonde-sante]].

    Maintenant, la démonstration qui justifie tout le chapitre suivant. Interrompez le tunnel (`Ctrl+C`) et supprimez le [[pod|pod]].

    C'est pourquoi **on n'écrit pratiquement jamais un [[manifeste|manifeste]] de [[Pod|pod]] en production**. Le [[Pod|pod]] est l'unité d'exécution, pas l'unité de gestion : on décrit un objet de plus haut niveau qui, lui, se charge d'en maintenir le bon nombre. Supprimez le fichier de test, il ne servira plus.

  • Le Deployment : trois répliques qui se réparent toutes seules

    Le **[[Deployment|deployment]]** est l'objet que vous écrirez pour toute application sans état. Il ne crée pas les [[pods|pod]] directement : il crée un [[ReplicaSet|replicaset]], qui lui maintient le nombre de [[pods|pod]] demandé. Cette indirection paraît gratuite aujourd'hui ; elle est ce qui rendra possible la [[mise à jour progressive|rollingupdate]] et le [[retour arrière|rollback]] du second jour.

    Un piège de lecture avant d'écrire le fichier : il contient **deux** blocs `metadata` et **deux** niveaux d'étiquettes. Celui du haut identifie le [[Deployment|deployment]] lui-même ; celui de `spec.template.metadata` identifie les [[pods|pod]] qu'il fabriquera. Et `spec.selector.matchLabels` doit correspondre exactement aux étiquettes du gabarit — c'est ainsi que le [[ReplicaSet|replicaset]] reconnaît « ses » [[pods|pod]]. Une divergence entre les deux est rejetée par l'[[API|api]].

    Lisez la composition des noms de [[pods|pod]] : `api` (le [[Deployment|deployment]]) + `6b4d78c9f5` (l'empreinte du gabarit, donc le [[ReplicaSet|replicaset]]) + un suffixe aléatoire. Un changement d'image produira une nouvelle empreinte, donc un nouveau [[ReplicaSet|replicaset]] — la trace de la version précédente reste, et c'est ce qui permet d'y revenir.

    Reprenons maintenant l'expérience du chapitre précédent, avec un gardien cette fois.

    Voilà la [[boucle de réconciliation|boucle-reconciliation]] en action, et c'est le changement de posture le plus important de ces deux jours. Vous n'avez pas relancé un [[pod|pod]] : vous aviez déclaré qu'il en fallait trois, et cette déclaration est **permanente**. Le corollaire est qu'un objet supprimé à la main revient tant que sa déclaration existe — pour arrêter réellement l'application, il faut supprimer le [[Deployment|deployment]], pas ses [[pods|pod]].

    Cette dernière remarque mérite d'être prise au sérieux : un `kubectl scale`, un `kubectl edit` ou un `kubectl patch` créent un écart silencieux entre le [[cluster|cluster]] et vos fichiers. La règle de ce TD est donc : **le dossier `k8s/` fait foi**, les commandes impératives ne servent qu'à observer ou à dépanner.

  • Le Service : une adresse stable et le DNS interne

    Trois [[pods|pod]] tournent, chacun avec sa propre adresse IP — et vous venez de voir que ces adresses changent à chaque remplacement de [[pod|pod]]. Aucun autre composant ne peut donc s'y adresser directement. Le **Service** résout ce problème : c'est un nom stable, doublé d'une adresse IP virtuelle stable, derrière lesquels le [[cluster|cluster]] répartit le trafic vers les [[pods|pod]] du moment.

    Le lien entre un Service et ses [[pods|pod]] ne se fait **jamais par leur nom** : il se fait par étiquette. Le Service déclare un `selector`, et tout [[pod|pod]] du [[namespace|namespace]] qui porte ces étiquettes devient une destination. C'est un couplage volontairement lâche — n'importe quel [[Deployment|deployment]] pourra plus tard produire des [[pods|pod]] `app: api` et entrer dans la rotation sans que le Service soit modifié.

    Trois valeurs à ne pas confondre. `port` est le port sur lequel le Service écoute ; `targetPort` est le port du [[conteneur|conteneur-docker]] vers lequel il traduit — ici on le désigne par le nom `http` déclaré dans le [[Deployment|deployment]], ce qui évite de dupliquer le numéro à deux endroits. Le type `ClusterIP`, enfin, signifie : joignable depuis l'intérieur du [[cluster|cluster]] seulement.

    Les trois adresses de l'[[EndpointSlice|endpointslice]] sont celles de vos trois [[pods|pod]]. Vérifions maintenant la résolution de nom et la répartition depuis l'intérieur du [[cluster|cluster]], avec un [[pod|pod]] jetable.

    Deux enseignements. Le `servi_par` change d'un appel à l'autre : `kube-proxy` a bien réparti les connexions entre les trois [[pods|pod]], sans qu'aucun composant supplémentaire ait été installé. Et le nom court `api` a suffi : [[CoreDNS|coredns]] complète automatiquement en `api.bibliobox.svc.cluster.local` pour un [[pod|pod]] du même [[namespace|namespace]]. Depuis un autre [[namespace|namespace]], il aurait fallu écrire `api.bibliobox`.

  • ConfigMap et Secret : sortir la configuration du manifeste

    L'[[API|api]] attend cinq variables pour joindre sa base. Les écrire dans le [[Deployment|deployment]] marcherait, et serait une faute : la configuration serait figée dans l'objet qui décrit l'exécution, impossible à partager avec un autre composant, et le mot de passe se retrouverait dans un fichier versionné. [[Kubernetes|kubernetes]] sépare donc les deux, en deux objets distincts.

    Notez les guillemets autour de `"5432"` et de `"1.0.0"` : les valeurs d'un [[ConfigMap|configmap]] sont **toujours** des chaînes, et [[YAML|yaml]] transformerait sinon la première en entier — l'[[API|api]] rejetterait le [[manifeste|manifeste]]. La valeur `db` de `PGHOST` est, elle, le nom du Service PostgreSQL que vous créerez au chapitre suivant : le nom est décidé maintenant, l'objet viendra après.

    `stringData` accepte la valeur en clair et laisse l'[[API|api]] la coder ; `data`, l'autre clé possible, exigerait du base64 écrit à la main. C'est le seul cas de ce TD où un fichier de `k8s/` n'est **pas** destiné à Git : ajoutez `k8s/02-secret.yaml` à votre `.gitignore` et considérez ce mot de passe comme jetable, valable pour un poste de développement et rien d'autre.

    Reste à injecter les deux dans le [[conteneur|conteneur-docker]]. `envFrom` importe **toutes** les clés d'un objet d'un coup, chacune devenant une variable d'environnement du même nom — c'est exactement pourquoi les clés ont été nommées `PGHOST`, `PGUSER`… plutôt qu'en minuscules. Modifiez le [[Deployment|deployment]] de l'[[API|api]].

    Le mot de passe s'affiche en clair, ce qui confirme le tableau plus haut : un Secret protège du versionnement accidentel et des regards distraits, pas d'un accès au [[cluster|cluster]]. Un point de comportement, enfin, à connaître avant de le découvrir en production : modifier un [[ConfigMap|configmap]] **ne redémarre pas** les [[pods|pod]] qui l'ont importé par `envFrom`. Les variables d'environnement sont fixées au démarrage du processus.

  • PostgreSQL dans le cluster et câblage de l'API

    Au tour de la base. Un avertissement d'abord : ce que vous allez écrire dans ce chapitre est délibérément **incomplet**. La base tournera dans un [[Deployment|deployment]], avec un volume `emptyDir` dont la durée de vie est celle du [[pod|pod]] — supprimez le [[pod|pod]], les données disparaissent. C'est le premier constat du jour 2, et le chapitre 2 du second module le corrigera.

    Le fichier `db/init.sql` doit arriver dans le [[conteneur|conteneur-docker]]. Un [[ConfigMap|configmap]] sait porter des fichiers entiers, pas seulement des paires clé-valeur, et `kubectl create configmap --from-file` en génère un depuis le disque. On l'écrit ici sous la forme `--dry-run=client -o yaml` pour obtenir un [[manifeste|manifeste]] versionnable plutôt qu'un objet créé à la main.

    Le [[Deployment|deployment]] de la base reprend des mécanismes déjà vus, avec une nouveauté : `env` avec `valueFrom` va chercher **une clé précise** d'un [[ConfigMap|configmap]] ou d'un Secret, là où `envFrom` importait tout. C'est nécessaire ici parce que l'image `postgres` attend `POSTGRES_USER` et `POSTGRES_PASSWORD`, des noms différents de ceux que l'[[API|api]] utilise.

    Le nom `db` de ce Service n'est pas décoratif : c'est lui que le [[ConfigMap|configmap]] a inscrit dans `PGHOST`, et c'est [[CoreDNS|coredns]] qui fera le lien. Appliquez l'ensemble du dossier d'un seul coup — `-f` accepte un répertoire et traite les fichiers dans l'ordre alphabétique, ce à quoi sert le préfixe numérique des noms de fichiers.

    Vérifiez d'abord que la base s'est bien initialisée avec le script SQL, puis interrogez l'[[API|api]]. C'est le moment où les deux moitiés du système se rejoignent.

    L'application est complète : une [[API|api]] en trois [[répliques|replique]] qui joint sa base par un nom logique, sans qu'aucune adresse IP n'apparaisse nulle part. Testez l'écriture aussi, pour confirmer que le chemin fonctionne dans les deux sens.

  • Sondes de santé et accès depuis le navigateur

    Une dette est restée ouverte depuis le chapitre 3. Les [[pods|pod]] de l'[[API|api]] affichent `1/1 Running` dès que le processus [[Node|node-runtime]] démarre, c'est-à-dire avant même que la base soit joignable. Pendant ces quelques secondes, le Service les considère comme des destinations valides et leur envoie du trafic — que l'[[API|api]] ne peut pas servir. C'est ce que les [[sondes|sonde-sante]] corrigent.

    L'[[API|api]] a été écrite dès le chapitre 2 avec cette distinction en tête : `/healthz` répond toujours 200 tant que le processus vit, `/readyz` renvoie 503 si `SELECT 1` échoue. Il ne reste qu'à les déclarer.

    Vérifions que la [[readiness|readinessprobe]] fait bien ce qu'elle annonce, en cassant volontairement la base. Supprimer le [[Deployment|deployment]] `db` fait échouer `SELECT 1` dans les trois [[pods|pod]] de l'[[API|api]] : ils doivent quitter les destinations du Service sans être redémarrés.

    Le Service n'a plus aucune destination : plutôt que de servir des erreurs, il ne sert rien. Remettez la base en place — et notez au passage que les données de l'emprunt de Nadia ont disparu avec le [[pod|pod]], puisque `emptyDir` ne survit pas. C'est le premier sujet de demain.

    Dernière étape de la journée : se passer du tunnel `port-forward`. Un Service de type **[[NodePort|nodeport]]** ouvre un port fixe sur chaque [[nœud|node-k8s]] du [[cluster|cluster]], dans la plage 30000-32767. Sur Docker Desktop, le [[nœud|node-k8s]] unique est votre propre machine : le port devient donc directement joignable sur `localhost`, ce qui n'est vrai ni sur un [[cluster|cluster]] distant ni sur kind sans configuration supplémentaire.

    Ouvrez `http://localhost:30080/livres` dans votre navigateur et rechargez plusieurs fois : le champ `servi_par` change. L'objectif de la journée est atteint — sept [[manifestes|manifeste]] versionnés décrivent l'intégralité du système, `kubectl apply -f k8s/` le reconstruit à l'identique sur n'importe quel poste, et le [[cluster|cluster]] maintient seul l'[[état déclaré|etat-voulu]].

    Trois manques sont pourtant flagrants, et ce sont les trois premiers chapitres de demain : la base perd ses données à chaque redémarrage de [[pod|pod]], aucune limite de ressources n'est posée — un [[pod|pod]] peut donc affamer le [[nœud|node-k8s]] entier — et le [[NodePort|nodeport]] reste un accès de dépannage, sans nom d'hôte ni routage par chemin. À demain.

Fiabiliser et exploiter Bibliobox sur Kubernetes

  • Constater ce que le déploiement de la veille ne tient pas

    La journée d'hier s'est terminée sur une application qui fonctionne. Celle-ci commence par la casser, parce qu'un déploiement qui marche tant qu'on n'y touche pas n'est pas un déploiement exploitable. Reprenez le dossier `bibliobox/` là où vous l'avez laissé et vérifiez que tout est bien en place avant la démonstration.

    Enregistrez un emprunt, puis supprimez le [[pod|pod]] de la base — exactement ce que ferait une mise à jour du [[cluster|cluster]], une panne de [[nœud|node-k8s]] ou un simple redémarrage de Docker Desktop.

    Zéro emprunt. Le [[pod|pod]] remplaçant a démarré sur un `emptyDir` neuf, PostgreSQL y a vu un répertoire vide, a rejoué le script d'initialisation, et l'emprunt de Thomas n'existe plus. Aucune erreur nulle part : le [[cluster|cluster]] a fait exactement ce qu'on lui avait demandé, et ce qu'on lui avait demandé était faux.

    Le but final de la journée est vérifiable en une phrase : à la fin, vous pourrez arrêter puis relancer complètement Docker Desktop, et retrouver les emprunts enregistrés avant l'arrêt, en ouvrant `http://bibliobox.localhost/livres` sans aucun numéro de port.

  • PersistentVolumeClaim : demander un disque au cluster

    [[Kubernetes|kubernetes]] sépare en trois objets ce que Docker gère en un seul volume nommé. Le **[[PersistentVolume|persistentvolume]]** ([[PV|persistentvolume]]) est un morceau de stockage réel ; le **[[PersistentVolumeClaim|persistentvolumeclaim]]** ([[PVC|persistentvolumeclaim]]) est la demande qu'un [[pod|pod]] adresse au [[cluster|cluster]] — « il me faut 2 Gio accessibles en lecture-écriture » ; la **[[StorageClass|storageclass]]** est le pilote qui fabrique le [[PV|persistentvolume]] à la volée quand un [[PVC|persistentvolumeclaim]] arrive.

    Cette indirection est ce qui rend un [[manifeste|manifeste]] portable : votre [[PVC|persistentvolumeclaim]] demande de l'espace sans savoir s'il sera servi par un disque local, un volume EBS ou une baie NFS. Docker Desktop fournit une [[StorageClass|storageclass]] par défaut, `hostpath`, qui provisionne un répertoire de la machine hôte.

    Deux colonnes méritent attention. `RECLAIMPOLICY: Delete` signifie que **supprimer le [[PVC|persistentvolumeclaim]] détruit les données** — c'est acceptable en développement, jamais en production, où l'on préfère `Retain`. `VOLUMEBINDINGMODE: Immediate` signifie que le volume est créé dès l'arrivée du [[PVC|persistentvolumeclaim]], sans attendre qu'un [[pod|pod]] le réclame ; sur un [[cluster|cluster]] multi-[[nœuds|node-k8s]] on utilise plutôt `WaitForFirstConsumer`, pour que le volume naisse là où le [[pod|pod]] ira.

    Écrivez le [[PVC|persistentvolumeclaim]]. Ne précisez pas de `storageClassName` : l'omettre fait retomber sur la classe marquée par défaut, ce qui garde le [[manifeste|manifeste]] valable sur d'autres clusters. Le mode `ReadWriteOnce` signifie « montable en écriture par un seul [[nœud|node-k8s]] à la fois » — c'est exactement ce qu'exige une base de données, qui ne tolère pas deux instances sur le même répertoire.

    Statut `Bound` des deux côtés : le [[PV|persistentvolume]] a été fabriqué et rattaché au [[PVC|persistentvolumeclaim]]. Un [[PVC|persistentvolumeclaim]] qui reste en `Pending` est le symptôme le plus courant de ce chapitre — soit aucune [[StorageClass|storageclass]] par défaut n'existe, soit le mode d'accès demandé n'est pas supporté par le provisionneur. `kubectl describe pvc db-donnees` le dit toujours en clair dans ses événements.

  • Du Deployment au StatefulSet pour la base

    On pourrait s'arrêter là : monter ce [[PVC|persistentvolumeclaim]] dans le [[Deployment|deployment]] `db` à la place de l'`emptyDir` suffirait à conserver les données. Mais le [[Deployment|deployment]] reste le mauvais objet pour une base, et deux raisons le montrent.

    Un [[StatefulSet|statefulset]] exige un **[[Service headless|service-headless]]** : un Service dont `clusterIP` vaut `None`. Il ne fait alors plus de répartition de charge et ne porte plus d'adresse virtuelle ; il se contente de publier dans le DNS une entrée par [[pod|pod]]. C'est ce qui donne à `db-0` un nom propre et stable. Modifiez d'abord le Service de la veille.

    Le [[ConfigMap|configmap]] continuera de dire `PGHOST: db` : avec une seule [[réplique|replique]], le nom du [[Service headless|service-headless]] se résout vers l'unique [[pod|pod]], et l'[[API|api]] n'a rien à savoir de plus. Le [[PVC|persistentvolumeclaim]], lui, ne sera plus écrit à la main — `volumeClaimTemplates` demande au [[StatefulSet|statefulset]] d'en créer un par [[réplique|replique]], nommé `<template>-<pod>`.

    Le [[PVC|persistentvolumeclaim]] `db-donnees` du chapitre précédent devient inutile : c'est le gabarit qui produira désormais `donnees-db-0`. Supprimez son fichier, ainsi que l'ancien [[Deployment|deployment]] de la base — un objet retiré du dossier n'est pas retiré du [[cluster|cluster]], `kubectl apply` n'a aucun moyen de deviner qu'un fichier a disparu.

    Le [[pod|pod]] s'appelle `db-0`, et son [[PVC|persistentvolumeclaim]] `donnees-db-0`. Refaites maintenant l'expérience du premier chapitre : un emprunt, une suppression de [[pod|pod]], un comptage.

    L'emprunt a survécu au remplacement du [[pod|pod]]. Notez au passage que le script `init.sql` **n'a pas été rejoué** : le répertoire `pgdata` n'était pas vide, l'image PostgreSQL a donc sauté l'initialisation, exactement comme elle le fait avec un volume Docker nommé.

  • Publier une nouvelle version sans coupure, et revenir en arrière

    L'[[API|api]] va gagner une route : `/livres/:id/disponibilite`, qui retranche les emprunts en cours du nombre d'exemplaires. Ajoutez-la juste avant l'appel à `listen`, puis mettez la version à `1.1.0` dans le `package.json`.

    Avant de déployer, déclarez explicitement la [[stratégie de mise à jour|rollingupdate]]. `maxUnavailable: 0` interdit de descendre sous trois [[pods|pod]] disponibles, et `maxSurge: 1` autorise un [[pod|pod]] supplémentaire pendant la transition : le nouveau démarre et devient *ready* avant qu'un ancien ne soit retiré. C'est la combinaison qui garantit zéro requête perdue — au prix d'un [[pod|pod]] de capacité en plus, le temps du déploiement.

    N'oubliez pas la seconde moitié du changement : `APP_VERSION` vit dans le [[ConfigMap|configmap]], il faut l'y passer à `1.1.0`. Ce couplage entre deux fichiers est volontairement laissé visible — c'est précisément le genre de duplication que [[Kustomize|kustomize]] et [[Helm|helm]] servent à supprimer, sujets qui dépassent le cadre de ces deux journées.

    L'ancien [[ReplicaSet|replicaset]] est conservé à zéro [[réplique|replique]]. Il ne consomme rien, et c'est lui qui rend le [[retour arrière|rollback]] possible : le gabarit complet de la version précédente y est encore décrit. Vérifiez la nouvelle route, puis provoquons une panne.

    Observez ce qui **ne s'est pas** produit : les trois [[pods|pod]] de la version 1.1.0 sont intacts et continuent de servir. `maxUnavailable: 0` a interdit d'en retirer un tant que le remplaçant n'était pas prêt, et le remplaçant ne le sera jamais. Le déploiement est bloqué, mais le service ne l'est pas — c'est exactement le comportement recherché.

    Le [[pod|pod]] fautif a disparu, et rien n'a jamais cessé de répondre. Une réserve importante pour terminer : `rollout undo` a remis le [[cluster|cluster]] dans l'état de la révision précédente, **sans toucher à vos fichiers**. Votre dépôt dit encore `1.1.0`, ce qui tombe juste ici, mais après un vrai [[retour arrière|rollback]] il faut toujours corriger le [[manifeste|manifeste]] — sinon le prochain `apply` rejoue la panne.

  • Requests, limits et classes de QoS

    Aucun de vos conteneurs n'a déclaré ce dont il a besoin. Deux conséquences, l'une immédiate et l'autre différée. Le [[scheduler|kube-scheduler]] place les [[pods|pod]] à l'aveugle, puisqu'il ignore ce qu'ils consomment ; et rien n'empêche une fuite mémoire dans l'[[API|api]] d'épuiser la mémoire du [[nœud|node-k8s]], emportant PostgreSQL avec elle.

    Deux notions à ne jamais confondre. La **request** est une réservation : le [[scheduler|kube-scheduler]] la soustrait de la capacité du [[nœud|node-k8s]] et ne place le [[pod|pod]] que s'il reste la place. La **limit** est un plafond appliqué à l'exécution. Un [[pod|pod]] peut donc consommer plus que sa request tant qu'il reste sous sa limit — et tant que le [[nœud|node-k8s]] le lui permet.

    Appliquez, puis lisez la **[[classe de QoS|classe-qos]]** attribuée. [[Kubernetes|kubernetes]] en calcule une pour chaque [[pod|pod]], et c'est elle qui décide de l'ordre d'[[éviction|eviction]] quand le [[nœud|node-k8s]] manque de mémoire : `Guaranteed` (requests égales aux limits partout) est [[évincé|eviction]] en dernier, `Burstable` (les vôtres) ensuite, et `BestEffort` (aucune ressource déclarée, l'état d'hier) en premier.

    Le récapitulatif du [[nœud|node-k8s]] est la vue qui compte pour un exploitant : la colonne *Requests* dit ce qui est **réservé**, donc ce qui reste réellement plaçable, tandis que la colonne *Limits* dit ce qui pourrait être consommé si tout le monde poussait en même temps. Un total de limits supérieur à 100 %, comme ici, s'appelle du [[surengagement|surengagement]] — c'est normal et voulu, mais c'est aussi ce qui rend une [[éviction|eviction]] possible.

  • Mise à l'échelle automatique avec metrics-server et HPA

    Trois [[répliques|replique]] en permanence, c'est trop la nuit et trop peu le jour de la rentrée. L'**[[HorizontalPodAutoscaler|hpa]]** ajuste ce nombre en fonction d'une mesure — ici, la consommation CPU rapportée à la request que vous venez de déclarer. C'est l'ordre logique : sans request CPU, l'[[HPA|hpa]] n'a aucun dénominateur et reste inopérant.

    Encore faut-il que quelqu'un mesure. Docker Desktop ne fournit **pas** [[metrics-server|metrics-server]], à la différence de la plupart des clusters infogérés : `kubectl top` échoue tant qu'il n'est pas installé.

    Ce correctif est propre aux clusters locaux : le [[kubelet|kubelet]] y présente un certificat auto-signé que [[metrics-server|metrics-server]] refuse par défaut. Sur un [[cluster|cluster]] de production, désactiver cette vérification serait une faute — on y provisionne de vrais certificats de [[kubelet|kubelet]].

    Un piège de cohabitation, avant d'appliquer : le [[Deployment|deployment]] déclare `replicas: 3` et l'[[HPA|hpa]] va piloter ce même champ. Chaque `kubectl apply` remettra donc brutalement 3, effaçant la décision de l'[[HPA|hpa]]. La pratique établie est de **retirer la ligne `replicas` du [[Deployment|deployment]]** dès qu'un [[HPA|hpa]] le pilote — le champ devient alors la propriété exclusive de l'[[autoscaler|hpa]].

    Lisez la chronologie : montée à 143 % de la request, passage à 7 [[répliques|replique]] en une décision, retour à 61 % puisque la charge est répartie, puis descente à 2 — le minimum — deux minutes après la fin du pic, grâce à la fenêtre de stabilisation. Cette asymétrie est délibérée dans [[Kubernetes|kubernetes]] : on monte vite, on descend lentement, parce qu'une descente prématurée coûte des erreurs si la charge revient.

  • Ingress : un nom d'hôte plutôt qu'un port

    Le [[NodePort|nodeport]] a rendu service, mais il ne passe pas l'échelle : un port arbitraire à retenir, un seul service publiable par port, aucun routage par chemin, et rien pour terminer le TLS. L'**[[Ingress|ingress]]** répond à ces quatre points. C'est un objet de configuration HTTP — des règles « tel nom d'hôte, tel chemin, vers tel Service » — appliquées par un **[[contrôleur Ingress|controleur-ingress]]**, qui est le composant qui reçoit réellement le trafic.

    Cette séparation surprend souvent : écrire un [[Ingress|ingress]] sans contrôleur installé ne produit **aucune erreur**, seulement un objet qui ne fait rien. Docker Desktop n'en fournit pas ; installez [[ingress-nginx|controleur-ingress]], le plus répandu.

    La colonne `EXTERNAL-IP` affiche `localhost`, et c'est la seconde particularité de Docker Desktop qui nous arrange : il implémente les Services de type [[LoadBalancer|loadbalancer]] en les publiant sur la machine hôte. Le contrôleur écoute donc directement sur les ports 80 et 443 de votre poste, là où kind exigerait un mappage de ports déclaré à la création du [[cluster|cluster]].

    Le nom `bibliobox.localhost` n'exige aucune configuration : les navigateurs modernes et les résolveurs récents renvoient `127.0.0.1` pour tout sous-domaine de `.localhost`. Si votre `curl` échoue en résolution, ajoutez la ligne `127.0.0.1 bibliobox.localhost` à votre fichier `hosts` — `C:\Windows\System32\drivers\etc\hosts` sous Windows.

    Un mot sur ce qui n'est pas fait ici, pour que la limite soit nette. Le TLS s'ajouterait par une section `tls:` référençant un Secret de type `kubernetes.io/tls`, généralement produit automatiquement par [[cert-manager|cert-manager]]. Et le routage par chemin — `/api` vers un Service, `/` vers un autre — se déclare en ajoutant des entrées à la liste `paths`, avec le même `host`.

  • Diagnostiquer quatre pannes, et garantir la disponibilité

    Dernier chapitre, et le plus utile en exploitation. Trois commandes suffisent à traiter l'immense majorité des pannes, et il faut les jouer **dans cet ordre** : `kubectl get pods` pour lire le statut, `kubectl describe pod` pour lire les événements — c'est là que le [[cluster|cluster]] explique ce qu'il a tenté —, et `kubectl logs` pour lire l'application. Le réflexe qui coûte le plus de temps est de sauter la deuxième.

    **Panne 1 — [[CrashLoopBackOff|crashloopbackoff]].** Cassez volontairement la commande de démarrage de l'[[API|api]], en ajoutant un `command` qui pointe vers un fichier inexistant.

    Le compteur `RESTARTS` qui monte et le mot `BackOff` disent la même chose : [[Kubernetes|kubernetes]] relance, échoue, et espace ses tentatives — jusqu'à cinq minutes entre deux essais. Là encore, `maxUnavailable: 0` a protégé le service : les anciens [[pods|pod]] sont restés. Réparez en retirant le patch.

    **Panne 2 — [[Pod Pending|pending]].** Demandez une réservation mémoire que le [[nœud|node-k8s]] ne peut pas honorer. Le [[pod|pod]] ne démarrera pas du tout : il n'y a rien à lire dans ses journaux, seulement dans ses événements — c'est le cas qui justifie le plus l'ordre des trois commandes.

    `0/1 nodes are available: 1 Insufficient memory` : le [[scheduler|kube-scheduler]] nomme la ressource qui manque et le nombre de [[nœuds|node-k8s]] écartés. Un `kubectl apply -f k8s/` remet la valeur du dépôt et répare la panne — c'est l'intérêt d'avoir gardé le dossier comme source de vérité.

    **Panne 3 — Service sans destination.** La plus déroutante, parce que tous les [[pods|pod]] sont sains. Changez le sélecteur du Service pour une valeur qui ne correspond à aucun [[pod|pod]].

    [[EndpointSlice|endpointslice]] vide alors que les [[pods|pod]] sont prêts : le diagnostic est immédiat, le sélecteur ne rattrape personne. Retenez la règle générale — **quand un Service ne répond pas, on regarde ses [[EndpointSlices|endpointslice]] avant de soupçonner le réseau**. Une liste vide a exactement deux causes : un sélecteur qui ne correspond pas, ou aucun [[pod|pod]] passant sa [[readiness|readinessprobe]].

    Réparez avec `kubectl apply -f k8s/`, puis terminons par une protection contre une panne que vous provoquerez vous-même un jour : la maintenance. Vider un [[nœud|node-k8s]] avec `kubectl drain` évacue tous ses [[pods|pod]] — et sur un [[cluster|cluster]] à un [[nœud|node-k8s]], cela vide l'application entière. Le **[[PodDisruptionBudget|poddisruptionbudget]]** pose une limite que ces opérations volontaires doivent respecter.

    Une précision à ne pas manquer : un [[PodDisruptionBudget|poddisruptionbudget]] ne protège **que des perturbations volontaires** — drain, [[éviction|eviction]], mise à jour de [[nœud|node-k8s]]. Il n'empêche pas un [[pod|pod]] de mourir d'une panne matérielle ou d'un [[OOM|oomkilled]], événements qu'aucune politique ne peut interdire. Et `minAvailable` doit rester strictement inférieur au nombre minimal de [[répliques|replique]], sans quoi tout drain se bloque indéfiniment.

    Épreuve finale, celle annoncée au premier chapitre. Enregistrez un emprunt, arrêtez complètement Docker Desktop — pas seulement les [[pods|pod]] : quittez l'application — puis relancez-la et attendez que le [[cluster|cluster]] reparte.

    Deux exemplaires disponibles sur trois : l'emprunt de Nadia a traversé l'arrêt complet de la machine virtuelle. Rien n'a été rejoué à la main, aucun script de restauration n'existe — le [[PVC|persistentvolumeclaim]] a été rattaché à `db-0` au redémarrage, et les contrôleurs ont reconstruit le reste depuis l'[[état déclaré|etat-voulu]] dans [[etcd|etcd]].

    Le parcours s'arrête ici, avec trois sujets volontairement laissés de côté et qu'il faut savoir nommer. La **duplication entre environnements** — vos [[manifestes|manifeste]] sont écrits en dur pour un seul environnement, ce que [[Kustomize|kustomize]] ou [[Helm|helm]] résolvent. Le **cloisonnement réseau** : dans ce [[namespace|namespace]], n'importe quel [[pod|pod]] peut joindre la base, ce qu'une [[NetworkPolicy|networkpolicy]] interdirait. Et le **[[déploiement continu|cicd]]**, où c'est un opérateur comme [[Argo CD|argocd]] qui applique le dépôt à votre place, plutôt que votre `kubectl apply`.

Tarifs

Prix mensuel

29 € / mois

Durée estimée

1 mois

au rythme standard (16 chapitres)

Coût total estimé

29 €

prix mensuel × durée estimée

Commencer maintenant