Ban7 RemoteLab
🎓 Formation certifiante~2 mois au rythme standard

DevOps — Conteneurs, Kubernetes et exploitation

Parcours de TP : conteneuriser une application avec Docker et docker compose, durcir et publier ses images, orchestrer avec Swarm puis Kubernetes, exploiter, sécuriser et optimiser un cluster en production.

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.

Conteneuriser l'API de CoWorkNet avec Docker

  • Point de départ et but du TP

    Ce TP ouvre la formation DevOps. Point de départ : le dépôt du projet CoWorkNet — une application de réservation d'espaces de coworking — fourni tel quel, avec une [[API|api]] NestJS dans `apps/api`, un front Next.js dans `apps/web`, et rien de conteneurisé : aucun [[Dockerfile|dockerfile]], aucune base de données installée nulle part. À la fin de cette séance, la base PostgreSQL du projet tournera dans un [[conteneur|conteneur-docker]] dont les données survivent à sa suppression, et l'[[API|api]] tournera dans une image `coworknet-api:1.0.0` que vous aurez construite vous-même, sur un réseau Docker où les deux conteneurs se joignent par leur nom.

    Aucune ligne de code applicatif n'est écrite dans ce TP : le code de CoWorkNet est fourni et ne change pas, seule la façon de l'exécuter change. Deux outils suffisent sur la machine : Docker Engine (version 27 ou supérieure) et Git. Le cours magistral Docker V2 sert de référence à qui veut aller au-delà de ce pas-à-pas, en particulier ses chapitres « Images et conteneurs : cycle de vie et dépannage », « Le [[Dockerfile|dockerfile]] : instructions », « Volumes et persistance des données » et « Réseau Docker : bridge, host, overlay et plus ».

    Aucun [[conteneur|conteneur-docker]] ne tourne, et le dépôt ne contient ni `Dockerfile` ni `docker-compose.yml` — seulement le code des deux applications et le dossier `db/`, qui fournit `schema.sql` (les tables de CoWorkNet) et `seed.sql` (un jeu d'essai). Le prochain chapitre part de ce dossier pour faire tourner la base.

  • Premier conteneur : la base PostgreSQL de CoWorkNet

    Deux notions se distinguent dès la première commande. Une IMAGE est un modèle figé, en lecture seule : un système de fichiers complet accompagné de la commande à exécuter au démarrage. Un [[CONTENEUR|conteneur-docker]] est une exécution de cette image, avec sa propre couche inscriptible par-dessus. La même image sert autant de fois qu'on veut : trois conteneurs PostgreSQL lancés depuis `postgres:16-alpine` partagent le même modèle sans se marcher dessus.

    L'image est téléchargée, aucun [[conteneur|conteneur-docker]] ne tourne encore. `postgres:16-alpine` fixe explicitement la version : la variante `alpine` repose sur une distribution minimale, d'où les 274 Mo plutôt que le gigaoctet de l'image généraliste. Un tag mobile comme `postgres:latest` donnerait une version différente selon le jour du téléchargement — jamais dans un projet.

    Quatre options portent tout le sens de cette commande. `-d` détache le [[conteneur|conteneur-docker]] du terminal, qui rend la main aussitôt (la longue chaîne affichée est l'identifiant du [[conteneur|conteneur-docker]]). `--name` lui donne un nom stable, sans quoi Docker en invente un aléatoire. Chaque `-e` pose une variable d'environnement lue par l'image officielle PostgreSQL pour créer la base et son compte au premier démarrage. `-p 127.0.0.1:5544:5432` publie le port : le 5432 interne au [[conteneur|conteneur-docker]] devient joignable sur le port 5544 de la machine, et uniquement depuis cette machine.

    Le port 5544 est choisi plutôt que 5432 pour ne jamais entrer en conflit avec un PostgreSQL déjà installé sur la machine. Trois commandes suffisent ensuite à inspecter un [[conteneur|conteneur-docker]] : `docker ps` liste ceux qui tournent, `docker logs` affiche ce que le processus a écrit sur sa sortie standard, `docker exec` lance une commande supplémentaire à l'intérieur.

    La base répond, mais elle est vide : `schema.sql` n'a jamais été joué, on y revient au chapitre suivant. Reste à connaître le cycle de vie complet : `docker stop` arrête le processus sans détruire le [[conteneur|conteneur-docker]], `docker start` le relance, et `docker ps -a` révèle les conteneurs arrêtés — invisibles pour `docker ps` seul. Seul `docker rm` supprime définitivement le [[conteneur|conteneur-docker]].

    Un [[conteneur|conteneur-docker]] arrêté conserve donc son état, prêt à repartir. Le chapitre suivant s'attarde sur ce que « son état » recouvre exactement — et sur ce qu'il ne recouvre pas.

  • Volumes : ne pas perdre les données de la base

    Tout ce qu'un [[conteneur|conteneur-docker]] écrit atterrit dans sa couche inscriptible, une fine surcouche propre à ce [[conteneur|conteneur-docker]], empilée sur les couches en lecture seule de l'image. Cette couche vit et meurt avec le [[conteneur|conteneur-docker]] : `docker rm` l'efface intégralement. Pour une base de données, dont le rôle est précisément de conserver ce qu'on lui confie, la démonstration se fait en trois commandes.

    La table a disparu avec le [[conteneur|conteneur-docker]]. La réponse de Docker à ce problème est le VOLUME : un espace de stockage géré par le démon, indépendant du cycle de vie des conteneurs, qu'on monte à l'emplacement précis où le processus écrit ses données — `/var/lib/postgresql/data` pour PostgreSQL. Le [[conteneur|conteneur-docker]] devient alors jetable sans que les données le soient.

    Trois montages, de deux natures différentes. Le premier, `coworknet-db-data`, est un volume nommé : Docker le crée à la volée s'il n'existe pas et en gère seul l'emplacement sur le disque. Les deux suivants sont des montages de fichiers du dépôt, en lecture seule (`:ro`), vers `/docker-entrypoint-initdb.d` — un répertoire que l'image officielle PostgreSQL parcourt à sa PREMIÈRE initialisation seulement, exécutant dans l'ordre alphabétique les scripts `.sql` qui s'y trouvent. D'où les préfixes `01-` et `02-`, qui garantissent que le schéma passe avant le jeu d'essai.

    Le [[conteneur|conteneur-docker]] a été détruit puis recréé sans les montages de scripts, et les 8 espaces du jeu d'essai sont toujours là : ils vivent dans le volume, pas dans le [[conteneur|conteneur-docker]]. C'est aussi la raison pour laquelle les scripts d'initialisation ne sont PAS rejoués au second démarrage — le répertoire de données n'étant plus vide, l'image officielle les ignore, ce qui évite d'écraser des données réelles à chaque redémarrage.

    Supprimer le volume est donc le seul moyen de repartir d'une base vierge — une commande à connaître, et à ne jamais taper distraitement sur autre chose qu'un environnement de développement. La base tourne, elle est peuplée, elle est durable : place à l'[[API|api]].

  • Écrire le Dockerfile de l'API et construire l'image

    PostgreSQL disposait d'une image officielle toute faite. Pour l'[[API|api]] de CoWorkNet, il faut construire la sienne, à partir d'un `Dockerfile` : la recette, instruction par instruction, de ce que contient l'image et de la commande à lancer au démarrage. Une précision structurante avant d'écrire la première ligne : le CONTEXTE DE BUILD, c'est-à-dire le dossier que Docker envoie au démon, doit être la racine du dépôt et non `apps/api/`, car CoWorkNet est un workspace pnpm dont le `pnpm-workspace.yaml` et le `pnpm-lock.yaml` n'existent qu'à cet endroit.

    Sept instructions, sept rôles. `FROM` désigne l'image de départ — ici `node:22-alpine`, version figée. `RUN corepack` active pnpm dans sa version exacte du projet, `11.9.0`, plutôt que celle qui traînerait dans l'image. `WORKDIR` fixe le [[répertoire de travail|working-directory]] des instructions suivantes, et le crée si besoin. `COPY . .` verse le contexte de build dans ce répertoire. Les deux `RUN` suivants installent puis compilent. `ENV` pose une variable disponible à l'exécution. `CMD` déclare la commande lancée au démarrage du [[conteneur|conteneur-docker]].

    `EXPOSE 4000` mérite une mise en garde : cette instruction ne publie rien, elle documente le port sur lequel l'application écoute. Rendre ce port joignable depuis la machine reste le rôle de `-p`, comme pour la base au chapitre 2.

    L'image existe. Deux chiffres sont à retenir pour la suite du TP : 187 Mo de contexte envoyé au démon, et 1,42 Go d'image finale, pour une [[API|api]] dont le code compilé pèse quelques centaines de kilooctets. Les chapitres 6 et 7 s'attaquent à ces deux nombres. D'abord, vérifier que le [[conteneur|conteneur-docker]] démarre.

    L'[[API|api]] répond — donc l'image fonctionne — mais elle ne joint pas la base. Le message est explicite : elle a cherché PostgreSQL sur `127.0.0.1:5432`, à l'intérieur de son propre [[conteneur|conteneur-docker]], où rien n'écoute. Un [[conteneur|conteneur-docker]] possède sa propre pile réseau : pour lui, `localhost` ne désigne ni la machine hôte ni le [[conteneur|conteneur-docker]] voisin, mais lui-même. C'est le sujet entier du chapitre suivant.

  • Réseau : faire dialoguer l'API et la base

    Par défaut, un [[conteneur|conteneur-docker]] lancé sans option réseau rejoint le réseau `bridge` prédéfini de Docker. Ce réseau historique fait circuler les paquets, mais n'offre AUCUNE résolution de nom entre conteneurs : y joindre la base supposerait de connaître son adresse IP, réattribuée à chaque recréation. Un réseau bridge créé explicitement, lui, embarque un serveur DNS interne où chaque [[conteneur|conteneur-docker]] est joignable par son nom.

    Le réseau `coworknet-net` utilise le même pilote `bridge` que le réseau prédéfini, mais parce qu'il a été créé explicitement, il apporte la résolution DNS par nom de [[conteneur|conteneur-docker]]. Les deux conteneurs sont donc recréés en le rejoignant, avec un alias `db` court et stable pour la base.

    Deux changements seulement côté [[API|api]] : `--network coworknet-net` et `DATABASE_HOST=db`. Le port visé redevient 5432, le port RÉEL de PostgreSQL à l'intérieur de son [[conteneur|conteneur-docker]] — le 5544 publié sur l'hôte ne concerne que les outils qui tournent hors de Docker, comme un client `psql` local ou un logiciel graphique d'administration.

    L'objectif fonctionnel du TP est atteint : l'[[API|api]] tourne en [[conteneur|conteneur-docker]] et sert les données de la base. Restent deux problèmes purement techniques, mais qui pèseront lourd dès la mise en production : chaque build prend près de quatre minutes, et l'image fait 1,42 Go.

  • Couches, cache de build et .dockerignore

    Chaque instruction du [[Dockerfile|dockerfile]] produit une COUCHE, et Docker conserve le résultat de chacune. Au build suivant, il réutilise une couche tant que l'instruction et les fichiers dont elle dépend n'ont pas changé — mais dès qu'une couche est invalidée, TOUTES celles qui suivent sont recalculées, sans exception.

    Deux minutes perdues à réinstaller des dépendances rigoureusement identiques, parce que `COPY . .` a copié le code source AVANT l'installation : modifier une virgule invalide cette couche, donc toutes les suivantes. La correction consiste à copier d'abord les seuls fichiers dont l'installation dépend — les [[manifestes|manifeste]] et le [[lockfile|lockfile]] — puis à installer, et seulement ensuite à copier le code.

    `apps/web/package.json` est copié alors que seule l'[[API|api]] est construite : pnpm a besoin de connaître tous les membres du workspace pour résoudre correctement `--filter api`, même s'il n'installe que ce dont `apps/api` dépend réellement. Reste le second chiffre du chapitre 4 : les 187 Mo de contexte envoyés au démon à chaque build.

    Ce `.dockerignore`, placé à la racine du dépôt, exclut du contexte de build ce qui n'a rien à y faire : les dépendances déjà réinstallées dans l'image, l'[[historique Git|historique-git]], les artefacts de compilation locaux. La ligne `*.env` fait plus que gagner du temps — elle évite qu'un fichier de secrets locaux se retrouve embarqué dans une image, tandis que `!.env.example` réintègre explicitement le seul fichier de la famille qui n'en contient aucun.

    Le contexte tombe de 187 Mo à 2,6 Mo, et un rebuild après changement de code passe de 172 à 36 secondes : l'installation des dépendances reste en cache. L'image, elle, pèse toujours 1,42 Go — dernier chantier du TP.

  • Alléger l'image : passer au build multi-stage

    Ce gigaoctet superflu s'explique simplement : l'image finale contient le code source TypeScript, le compilateur, l'ensemble des dépendances de développement (`ts-jest`, `@nestjs/testing`…) et le `node_modules` du workspace entier, front compris. Rien de tout cela n'est nécessaire pour EXÉCUTER l'[[API|api]] — ces éléments n'ont servi qu'à la construire.

    Un [[Dockerfile|dockerfile]] MULTI-STAGE règle ce problème : il enchaîne plusieurs étages `FROM`, chacun pouvant récupérer des fichiers précis d'un étage précédent via `COPY --from=`. Seul le dernier étage constitue l'image publiée ; tout ce qui n'en est pas explicitement copié est jeté.

    L'étage `base` factorise ce qui est commun ; `deps` et `build` reprennent l'ordre des `COPY` établi au chapitre 6, cache compris. L'étage `deploy` utilise `pnpm deploy`, la commande prévue par pnpm pour extraire d'un workspace un dossier autonome ne contenant que les dépendances d'un seul package. Un premier essai sans `--legacy` échoue.

    Depuis pnpm v10, le mode par défaut suppose des dépendances internes « injectées » entre packages du workspace. CoWorkNet n'en a aucune — `api` et `web` ne dépendent pas l'un de l'autre : `--legacy` suffit donc. L'option `--prod`, elle, est ce qui exclut `ts-jest`, `@nestjs/testing` et le reste des dépendances de développement du dossier produit, donc de l'image finale.

    1,42 Go contre 118 Mo, pour exactement le même service rendu. `docker history` détaille le poids couche par couche et confirme la répartition : 41 Mo de dépendances de production, 187 ko de code compilé, le reste étant l'image `node:22-alpine` de base. La commande de démarrage devient `node dist/main.js`, l'étage `runtime` ayant `/app` pour [[répertoire de travail|working-directory]].

    But du TP atteint : la base persiste ses données dans un volume, l'[[API|api]] tourne dans une image de 118 Mo construite depuis `apps/api/Dockerfile`, et les deux conteneurs se joignent par leur nom sur `coworknet-net`. Un détail agace pourtant : redémarrer cette stack après un reboot suppose de retaper de mémoire un réseau, deux `docker run` d'une dizaine d'options chacun, dans le bon ordre — et le front Next.js n'est même pas encore conteneurisé.

    C'est précisément le problème que résout Docker Compose, objet du TP suivant : décrire cette même stack dans un fichier versionné avec le code, et la démarrer d'une seule commande.

Orchestrer la stack CoWorkNet avec docker compose

  • Point de départ et but du TP

    Le TP précédent s'est achevé sur une stack qui fonctionne : un réseau, un volume, une base PostgreSQL peuplée et l'[[API|api]] de CoWorkNet dans une image de 118 Mo. Ce résultat n'existe pourtant que dans l'historique du terminal de celui qui a tapé les commandes — rien n'en est écrit dans le dépôt, rien n'est reproductible par un collègue, et le front Next.js n'est toujours pas conteneurisé.

    Docker Compose répond exactement à ce problème : décrire l'état souhaité de la stack dans un fichier `docker-compose.yml` versionné avec le code, puis laisser l'outil créer réseau, volumes et conteneurs dans le bon ordre. À la fin de cette séance, ce fichier décrira les 4 services de CoWorkNet — `db`, `cache`, `api` et `web` —, et `docker compose up -d --build` démarrera l'application entière d'une seule commande, sur n'importe quelle machine dotée de Docker.

    Le cours magistral Docker V2 (chapitre « Docker Compose : orchestrer plusieurs services ») sert de référence à qui veut dépasser ce pas-à-pas. Une remarque de vocabulaire avant de commencer : la commande s'écrit `docker compose`, en deux mots, depuis que Compose est intégré à Docker — l'ancien binaire séparé `docker-compose` (avec un tiret) appartient à la génération précédente.

  • Premier docker-compose.yml : le service db et le fichier .env

    Le fichier se crée à la racine du dépôt, à côté de `pnpm-workspace.yaml`. Sa structure tient en deux clés de premier niveau : `services`, où chaque entrée décrit un [[conteneur|conteneur-docker]], et `volumes`, qui déclare les volumes nommés du projet. Chaque option d'un service correspond très directement à un argument de `docker run` déjà rencontré au TP précédent.

    Le nom du service, `db`, remplace le `--network-alias db` du TP précédent : c'est sous ce nom que les autres conteneurs le joindront. `container_name` fixe le nom du [[conteneur|conteneur-docker]] créé, sans quoi Compose le préfixerait du nom du projet. `restart: unless-stopped` relance le [[conteneur|conteneur-docker]] après un redémarrage de la machine, sauf s'il a été arrêté volontairement — l'équivalent déclaratif d'une option de `docker run` jamais utilisée jusqu'ici.

    Les trois valeurs de `environment` sont écrites `${POSTGRES_DB}` plutôt qu'en clair : Compose les résout au démarrage en lisant le fichier `.env` situé à côté du `docker-compose.yml`. Ce fichier n'est jamais versionné ; seul son modèle `.env.example` l'est, avec des valeurs de développement.

    Le `.dockerignore` écrit au TP précédent contient déjà `*.env` avec l'exception `!.env.example` : le fichier de secrets ne partira donc jamais dans le contexte de build d'une image. Reste à vérifier que `.env` figure aussi dans `.gitignore` avant tout [[commit|commit]].

    Compose a créé de lui-même un réseau `coworknet_default`, nommé d'après le dossier du projet : le `docker network create` manuel du TP précédent devient inutile. Le volume `coworknet-db-data` étant déjà rempli, la base retrouve ses données et les scripts d'initialisation restent ignorés — exactement le comportement observé au TP00a.

  • Ajouter le service api construit depuis son Dockerfile

    Le service `db` part d'une image publique. L'[[API|api]], elle, doit être construite : la clé `build` remplace alors la clé `image`, et Compose lance lui-même `docker build` avant de démarrer le [[conteneur|conteneur-docker]]. Deux sous-clés suffisent — `context`, le dossier envoyé au démon (la racine du dépôt, comme au TP précédent), et `dockerfile`, le chemin du fichier de recette.

    Les dix options de `docker run` du TP précédent tiennent désormais dans une douzaine de lignes lisibles, versionnées avec le code. `DATABASE_HOST: db` et le port 5432 reprennent la logique de réseau interne déjà établie ; `depends_on: [db]` déclare que l'[[API|api]] démarre après la base.

    L'option `--build` force la reconstruction de l'image avant le démarrage ; sans elle, Compose réutilise l'image existante et un changement de code passerait inaperçu. À noter dans la sortie de `docker compose images` : l'image construite est taguée `latest`, faute d'indication contraire. Ce tag mobile convient à un poste de développement, mais pas à une publication — le TP01 y reviendra en détail.

  • Healthchecks, ordre de démarrage et service cache

    Une expérience simple met en défaut le `depends_on` écrit au chapitre précédent : supprimer le volume de la base, puis tout redémarrer. PostgreSQL doit alors initialiser son répertoire de données et jouer les deux scripts SQL, ce qui prend quelques secondes — pendant lesquelles l'[[API|api]], elle, a déjà démarré et tenté sa première connexion.

    `depends_on` seul n'ordonne que le DÉMARRAGE des conteneurs : il garantit que celui de la base est lancé avant celui de l'[[API|api]], jamais que le service qu'il héberge est prêt à répondre. Combler cet écart demande deux ajouts : un `healthcheck` sur `db`, qui définit comment tester sa disponibilité réelle, et une condition `service_healthy` côté `api`.

    `pg_isready`, fourni par l'image PostgreSQL, teste précisément ce qui compte : la capacité du serveur à accepter une connexion sur la base attendue. Toutes les 5 secondes, Compose exécute cette commande dans le [[conteneur|conteneur-docker]] ; après 5 échecs consécutifs, le service est déclaré `unhealthy`. Tant qu'il n'est pas `healthy`, l'[[API|api]] n'est pas démarrée du tout.

    La ligne `Container coworknet-db Healthy` remplace le `Started` précédent : Compose a réellement attendu. Le quatrième service du projet s'ajoute sur le même modèle — CoWorkNet met en cache les disponibilités des espaces dans Redis, qui n'est jamais source de vérité, PostgreSQL restant seul maître des données.

    Trois services sur quatre sont en place. Le dernier, le front Next.js, n'a pas encore d'image : il faut d'abord lui écrire un [[Dockerfile|dockerfile]].

  • Construire l'image du front Next.js

    Next.js fournit son propre équivalent du `pnpm deploy` utilisé pour l'[[API|api]] : l'option `output: 'standalone'` de `next.config.ts`. Une fois activée, `next build` produit, en plus du dossier `.next` habituel, un dossier `.next/standalone` contenant un serveur [[Node.js|node-runtime]] autonome et uniquement le sous-ensemble de `node_modules` réellement utilisé par les routes de l'application.

    CoWorkNet étant un workspace pnpm, `.next/standalone` en reproduit l'arborescence : le serveur généré se trouve à `.next/standalone/apps/web/server.js`, et non directement à la racine du dossier — un détail qui conditionne les chemins du [[Dockerfile|dockerfile]] ci-dessous.

    Les étages `base` et `deps` sont identiques à ceux de l'[[API|api]] : même version de pnpm figée, même ordre de `COPY` pour préserver le cache. L'étage `runtime` copie trois éléments distincts produits par la compilation : `.next/standalone` (le serveur), `.next/static` (les fichiers JS et CSS versionnés, non inclus dans le premier) et `public/` (les fichiers statiques bruts). `PORT=4100` aligne le serveur sur le port du front, faute de quoi il écouterait sur le 3000 par défaut de Next.js.

    Les quatre conteneurs tournent, le front répond — mais il annonce l'[[API|api]] indisponible, alors que `curl http://localhost:4000/health` réussit depuis la machine. Ce symptôme n'a rien d'un hasard de configuration : il révèle le piège le plus fréquent d'une application web conteneurisée, sujet du chapitre suivant.

  • Deux URLs d'API : navigateur et réseau Docker interne

    La page d'accueil de CoWorkNet est un [[Server Component|server-component]] : elle appelle `getHealth()` au moment du rendu, DANS le [[conteneur|conteneur-docker]] `web`, et non dans le navigateur. Or l'URL d'[[API|api]] compilée dans le bundle vaut `http://localhost:4000` — depuis le [[conteneur|conteneur-docker]] `web`, ce `localhost` désigne le [[conteneur|conteneur-docker]] lui-même, exactement l'erreur diagnostiquée au TP précédent pour l'[[API|api]] et la base.

    Remplacer cette valeur par `http://api:4000` déplacerait simplement le problème : le navigateur, lui, n'est pas sur le réseau Docker et ne sait pas résoudre le nom `api`. Les deux appelants ont besoin de deux URLs différentes vers la même [[API|api]].

    La solution tient à une règle de Next.js : seules les variables préfixées `NEXT_PUBLIC_` sont inlinées dans le bundle au moment du build. Une variable sans ce préfixe reste lue dans `process.env` à chaque appel, donc modifiable au lancement du [[conteneur|conteneur-docker]]. `apps/web/lib/env.ts` expose alors deux fonctions au lieu d'une.

    Seuls les appels exécutés côté serveur basculent sur `getServerApiUrl()` : dans `apps/web/lib/api.ts`, `getHealth` et `getEspaces`. Les fonctions déclenchées par une action de l'utilisateur — connexion, inscription, réservation — s'exécutent dans le navigateur et restent sur `getApiUrl()`.

    Une distinction à garder en tête pour la suite du parcours : `args` intervient au BUILD et se retrouve figé dans l'image, tandis que `environment` intervient à l'EXÉCUTION et peut changer d'un déploiement à l'autre sans reconstruire quoi que ce soit.

  • Piloter la stack au quotidien et vérifier l'ensemble

    Six commandes couvrent l'essentiel du quotidien avec Compose. `up -d` démarre la stack en arrière-plan, `ps` en donne l'état, `logs -f` suit les journaux d'un service, `exec` ouvre une commande dans un [[conteneur|conteneur-docker]], `build` reconstruit une image, `down` arrête et supprime les conteneurs. Toutes acceptent un nom de service en argument pour ne viser qu'une partie de la stack.

    Une distinction mérite d'être retenue avant de la découvrir dans la douleur : `docker compose down` supprime les conteneurs et le réseau, mais préserve les volumes — les données de la base survivent. `docker compose down -v` ajoute la suppression des volumes déclarés, donc de la base entière, et les scripts d'initialisation seront rejoués au prochain démarrage. Utile pour repartir d'un jeu d'essai propre, catastrophique sur autre chose qu'un poste de développement.

    La stack complète a été détruite puis relancée sans perdre une ligne : c'est le contrat que le TP visait. Il ne reste qu'à vérifier les 4 services une dernière fois, puis à versionner le résultat — c'est bien lui, désormais, qui porte la connaissance de l'infrastructure du projet.

    CoWorkNet démarre intégralement par `docker compose up -d --build`, sans que [[Node.js|node-runtime]] ni pnpm soient installés sur la machine. Ces images restent néanmoins des images de développement : elles tournent en root, n'annoncent aucun état de santé propre, ne sont construites que pour l'architecture de la machine qui les a fabriquées, et n'existent nulle part ailleurs que sur ce poste.

    Ce sont exactement les quatre chantiers du TP suivant, qui reprend ce `docker-compose.yml` et ces deux [[Dockerfile|dockerfile]] comme point de départ pour les rendre publiables.

Durcir et publier les images Docker de CoWorkNet

  • Point de départ et but du TP

    Ce TP01 reprend exactement là où le TP00b s'est arrêté (CP1, CP10, CP11) : deux [[Dockerfile|dockerfile]] multi-stage (`apps/api/Dockerfile`, `apps/web/Dockerfile`) et un `docker-compose.yml` qui démarrent toute la stack CoWorkNet — mais construits pour un usage de développement, pas encore prêts pour une véritable mise en production. À la fin de cette séance, ces deux mêmes images tourneront en utilisateur non-root avec un `HEALTHCHECK`, seront construites avec BuildKit pour deux plateformes (`linux/amd64`, `linux/arm64`) via un unique fichier `docker-bake.hcl`, auront été scannées sans vulnérabilité CRITICAL bloquante, et seront publiées taguées en version explicite vers un [[registre|registre-images]] privé — vérifiées par un pull réussi depuis une machine tierce.

    Chacune de ces phases approfondit une notion déjà vue aux TP00a et TP00b, ou volontairement laissée de côté à ce stade : le cours magistral Docker V2 (chapitres « Construire et optimiser une image », « BuildKit et builds avancés », « Sécuriser Docker en production » et « Registres d'images ») sert de référence pour qui veut aller au-delà de ce TP. Prérequis unique : le TP00b, dont les deux [[Dockerfile|dockerfile]] et le `docker-compose.yml` servent de point de départ inchangé jusqu'au chapitre 2.

    Rien de ce qui suit ne modifie le contrat applicatif de CoWorkNet ni les routes exposées (stabilisées depuis le [[TD11|td-11]]) : seule la façon dont les deux images `api` et `web` sont construites, vérifiées et distribuées change. Chapitre suivant : faire tourner ces deux conteneurs sans le privilège root.

  • Utilisateur non-root et HEALTHCHECK

    Par défaut, en l'absence d'instruction `USER` dans un [[Dockerfile|dockerfile]], tout tourne en root — le cours magistral Docker V2 (chapitre « Le [[Dockerfile|dockerfile]] : instructions ») le rappelle. `apps/api/Dockerfile` (TP00a) et `apps/web/Dockerfile` (TP00b) n'en déclarent aucune : leurs conteneurs tournent donc en root, un privilège que ni l'[[API|api]] ni le front n'ont besoin d'exercer pour fonctionner. L'image officielle `node` évite de créer un compte applicatif dédié comme le ferait un [[Dockerfile|dockerfile]] générique : un utilisateur `node` (UID/GID 1000) existe déjà dans `node:22-alpine`.

    Basculer sur cet utilisateur `node` suffit donc, sans étape `addgroup`/`adduser` supplémentaire : ajouter `USER node` juste avant `EXPOSE` dans l'étage runtime. Un piège attend cette seule ligne : les fichiers copiés par `COPY --from=...` appartiennent à root par défaut, quel que soit l'utilisateur actif au moment du `COPY` — il faut donc aussi ajouter `--chown=node:node` à chacune de ces copies, sans quoi `node` n'aurait pas le droit de les lire. Le `HEALTHCHECK` s'ajoute dans la foulée, avec `wget` — déjà présent via BusyBox dans l'image, sans paquet supplémentaire à installer.

    `apps/web/Dockerfile` suit exactement le même principe, avec deux différences : ses trois `COPY --from=build` proviennent de l'étage `build` (ce [[Dockerfile|dockerfile]] n'a pas d'étage `deploy`, cf. TP00b) et le `HEALTHCHECK` interroge `/` plutôt que `/health`, faute de route dédiée côté front — une réponse 200 sur la racine suffit à prouver que le serveur Next.js répond.

    Les deux conteneurs tournent désormais sans privilège root, et `docker compose ps` affichera `Up (healthy)` plutôt qu'un simple `Up` dès que le `HEALTHCHECK` aura passé sa période de grâce initiale (`start-period`). Reste un point non traité par ces seules lignes : la taille des images elles-mêmes, sujet du chapitre suivant.

  • .dockerignore et audit de taille d'image

    Le TP00a a déjà créé un `.dockerignore` minimal, sans quoi chaque `docker build` aurait envoyé `node_modules` et `.git` au démon — plusieurs centaines de mégaoctets à chaque build, et un risque de fuite si un `.env` non ignoré s'y trouvait (cours magistral Docker V2, chapitre « Construire et optimiser une image »). Ce chapitre vérifie qu'il couvre bien tout ce que la stack a accumulé depuis, puis mesure concrètement l'effet des deux [[Dockerfile|dockerfile]] sur la taille des images.

    Trois éléments manquent encore à cette liste, apparus depuis le TP00a : `.turbo` (cache de Turborepo, potentiellement volumineux et sans utilité dans l'image), le dossier `coverage` de chaque app (rapports de tests, jamais nécessaires en production) et les fichiers de log locaux. Aucun des deux [[Dockerfile|dockerfile]] n'en a besoin pour construire ou exécuter CoWorkNet.

    `docker image ls` révèle la taille finale de chaque image, mais pas son détail : `docker history` (chapitre « Structure interne d'une image » du cours magistral Docker V2) liste le poids de chaque couche individuellement — un réflexe pour repérer une couche anormalement lourde avant de la questionner.

    Le contexte envoyé au démon passe de plusieurs dizaines de mégaoctets à quelques kilooctets une fois le `.dockerignore` complété — un gain qui n'affecte pas la taille de l'image finale (déjà minimisée par le multi-stage de [[TD25|td-25]]) mais accélère chaque build et referme une porte de fuite accidentelle. L'outil `dive` (cours magistral Docker V2, chapitre « Outils de l'écosystème Docker ») va plus loin pour qui veut inspecter couche par couche, hors du périmètre strict de ce TP.

  • BuildKit avancé : cache persistant et docker-bake.hcl

    BuildKit (actif par défaut depuis Docker 23) permet d'aller plus loin que le simple cache de couche déjà exploité au TP00a par l'ordre des instructions. Deux limites restent à lever avant de publier ces images : le `pnpm install` de l'étage `deps` retélécharge tout le store pnpm dès que `pnpm-lock.yaml` change, et les deux [[Dockerfile|dockerfile]] ne construisent aujourd'hui que pour l'architecture de la machine qui build — jamais pour deux à la fois.

    `RUN --mount=type=cache` (cours magistral Docker V2, chapitre « BuildKit et builds avancés ») répond au premier point : ce montage persiste entre les builds, indépendamment du cache de couche habituel — le store pnpm n'est donc retéléchargé que pour les paquets réellement nouveaux ou modifiés, jamais dans son intégralité.

    Le même ajout s'applique à l'identique dans `apps/web/Dockerfile`, sur sa propre étape `deps` (héritée de la même base que `apps/api/Dockerfile`, cf. TP00b). Reste le second point : construire pour deux plateformes sans dupliquer deux longues commandes `docker buildx build`. Docker Bake (cours magistral Docker V2, même chapitre) centralise cette configuration dans un fichier déclaratif unique, `docker-bake.hcl`, à la racine du dépôt.

    Le `group default` liste les deux cibles (`api`, `web`) : une seule commande `docker buildx bake` les construit toutes les deux. Chaque `target` précise son propre `dockerfile`, ses `tags` (paramétrés par la variable `VERSION`, jamais fixée en dur) et ses `platforms` — `linux/amd64` et `linux/arm64`, pour tourner aussi bien sur un serveur x86 classique que sur une instance ARM ou un poste Apple Silicon. `cache-from`/`cache-to` activent le cache distribué sur le [[registre|registre-images]] lui-même, en `mode=max` pour couvrir aussi les étages intermédiaires du multi-stage.

    `--load` ne fonctionne qu'avec une seule plateforme à la fois — le moteur Docker local ne peut stocker qu'une seule architecture d'image sous un même tag. Tester localement impose donc de restreindre temporairement la plateforme (`--set *.platform=linux/amd64`), le vrai build biplateforme n'existant qu'une fois poussé vers un [[registre|registre-images]], à la fin de ce TP.

  • Scanner les vulnérabilités avec Trivy

    Une image durcie (chapitre 2) et légère (chapitre 3) peut encore embarquer une dépendance vulnérable : le cours magistral Docker V2 (chapitre « Sécuriser Docker en production ») introduit Trivy pour ce contrôle, à exécuter avant toute publication — pas après coup. Ce TP scanne les deux images tout juste construites par `docker buildx bake`.

    `--exit-code` transforme ce scan en porte de [[CI/CD|cicd]] : couplé à `--severity CRITICAL`, la commande renvoie `1` uniquement si une vulnérabilité CRITICAL est détectée — les HIGH du bloc précédent restent visibles pour un suivi, sans bloquer une publication qui n'a pas vocation à attendre un correctif upstream indisponible (`--ignore-unfixed` exclut d'ailleurs les CVE connues mais non encore corrigées par l'éditeur).

    Aucune vulnérabilité CRITICAL sur `coworknet-api` ni `coworknet-web` : le but du chapitre 5 — des images « scannées sans échec bloquant critique » — est atteint, la publication peut continuer. En [[CI/CD|cicd]] réel, cette commande s'insérerait entre le build (chapitre 4) et le push (chapitre suivant), jamais après.

  • Publier vers un registre privé

    Le cours magistral Docker V2 (chapitre « Registres d'images ») distingue un tag, mutable, d'un digest, immuable : pousser sous un tag mobile expose à un redéploiement qui change de contenu sans préavis. Ce TP fige une version explicite, `1.0.0`, cohérente avec la variable `VERSION` déjà prévue dans `docker-bake.hcl` (chapitre 4) — il suffit de la surcharger à l'appel plutôt que d'éditer le fichier.

    `--push` construit puis publie les deux plateformes de chaque `target` en une seule commande, `group default` oblige — inutile d'enchaîner deux `docker buildx build --push` séparés. Le [[registre|registre-images]] reçoit un manifest index par image (cours magistral Docker V2, chapitre « Registres d'images »), qui pointe vers un manifest distinct par plateforme : `docker pull` choisira automatiquement le bon selon la machine qui l'exécute.

    `coworknet-api:1.0.0` et `coworknet-web:1.0.0` existent désormais sur le [[registre|registre-images]] privé, en `linux/amd64` et `linux/arm64`, jamais sous un tag mobile. Reste à prouver que ce que le [[registre|registre-images]] distribue fonctionne réellement ailleurs que sur la machine qui l'a construit.

  • Vérifier par un pull sur une autre machine

    Une image qui ne se construit et ne tourne que sur la machine qui l'a publiée n'a rien prouvé : ce dernier chapitre simule une machine tierce — un serveur de production, ou simplement le poste d'un⋅e collègue — qui n'a jamais cloné le dépôt CoWorkNet et ne dispose que de Docker.

    `docker-compose.yml` (TP00b) pourrait, à terme, remplacer son bloc `build:` par une simple référence `image:` vers ce tag publié — une bascule pertinente pour un déploiement qui ne doit plus reconstruire les images sur chaque serveur, seulement les tirer depuis le [[registre|registre-images]].

    Ce TP atteint son but : `coworknet-api` et `coworknet-web` tournent en utilisateur non-root avec un `HEALTHCHECK` actif, construites pour deux plateformes via un `docker-bake.hcl` unique, scannées sans CRITICAL bloquante, et publiées sous un tag versionné vérifié depuis une machine qui n'a jamais vu le code source. Pour approfondir, le cours magistral Docker V2 détaille encore les secrets de build et le socket Docker (chapitre « Secrets et accès au socket Docker »), volontairement laissés hors de cette séance.

Déployer CoWorkNet sur un cluster Docker Swarm

  • Pourquoi passer de docker compose à Docker Swarm

    Ce TP02 (CP10, CP11) fait suite au TP01, qui a durci et publié les images `coworknet-api:1.0.0` et `coworknet-web:1.0.0` vers un [[registre|registre-images]] versionné. But de la séance : faire tourner la stack CoWorkNet — `db`, `cache`, `api`, `web` — sur un [[cluster|cluster]] Docker Swarm à 3 [[nœuds|node-k8s]] (1 manager, 2 workers), déployée par `docker stack deploy` depuis un `docker-stack.yml` dérivé de `docker-compose.yml`, secrets `JWT_SECRET`/`POSTGRES_PASSWORD` gérés en `docker secret`, réseau overlay dédié, puis [[rolling update|rollingupdate]] de l'[[API|api]] sans coupure.

    Hypothèse retenue pour cette séance : les 3 [[nœuds|node-k8s]] sont simulés localement par 3 conteneurs Docker-in-Docker (`docker:27.4.1-dind`), chacun exécutant son propre démon Docker isolé — pas de VM ni de machines physiques distinctes, seule topologie accessible sans matériel dédié.

    Avec `docker compose up` ([[TD25|td-25]]), toute la stack tourne sur UNE machine : si elle tombe, plus rien ne répond, et ajouter de la capacité veut dire changer de machine. Le cours magistral Docker Swarm (chapitre 1) détaille ce que Swarm apporte en échange : élasticité (`docker service scale`), tolérance de panne (une tâche remplacée automatiquement), et répartition réelle sur plusieurs machines.

    Ce basculement complète la phase Déploiement et DevOps du fil rouge (CP10, préparer et documenter le déploiement ; CP11, contribuer à la mise en production dans une démarche DevOps) : `docker stack deploy` n'est plus une commande locale isolée, mais le mécanisme qui pousse CoWorkNet vers une infrastructure multi-machines proche d'une vraie production.

  • Initialiser le cluster et joindre les workers

    Trois conteneurs Docker-in-Docker forment les 3 machines simulées, reliés par un réseau Docker `bridge` dédié pour qu'ils se joignent entre eux (cf. cours magistral Docker Swarm, chapitre 3, pour le détail des ports `2377`/`7946`/`4789` à ouvrir entre [[nœuds|node-k8s]] réels).

    `docker swarm init` sur `coworknet-manager` amorce le [[cluster|cluster]] : ce [[nœud|node-k8s]] devient manager et leader Raft. `--advertise-addr manager` utilise l'alias réseau déclaré ci-dessus plutôt qu'une IP, puisque les 3 conteneurs se résolvent entre eux par nom sur `dind-net`.

    Le jeton affiché suffit à faire rejoindre un worker, mais `docker swarm join-token worker -q` le récupère à nouveau si besoin — pratique une fois l'écran de la commande précédente perdu, sans jamais avoir à réinitialiser le [[cluster|cluster]].

    `docker node ls`, exécuté depuis le manager, confirme la topologie voulue : un manager `Leader` et deux workers `Ready`/`Active`, tous prêts à recevoir des tâches. Le jeton manager, lui, ne doit jamais circuler publiquement — il donne un accès complet au [[cluster|cluster]].

  • Du docker-compose.yml au docker-stack.yml

    Docker Swarm réutilise le format de `docker-compose.yml`, mais `docker stack deploy` en ignore certaines directives (cours magistral Docker Swarm, chapitre 5) : `build` (les workers n'ont pas le code source, il faut une image déjà poussée sur un [[registre|registre-images]]) et `depends_on` (perd son sens sur des conteneurs répartis). En échange apparaît la section `deploy`.

    Ce service `api` illustre les trois points qui changent en passant au fichier de stack : `build` devient `image: registry.coworknet.local/coworknet-api:1.0.0` (l'image publiée en TP01), `JWT_SECRET`/`DATABASE_PASSWORD` en clair disparaissent au profit de secrets (chapitre suivant), et `depends_on` disparaît au profit de `deploy.restart_policy` combiné à un healthcheck applicatif.

    `deploy.replicas: 3` sur `api` (au lieu d'une seule instance en compose) prépare directement le [[rolling update|rollingupdate]] du dernier chapitre. `db` reçoit une contrainte de placement `node.role == worker` : sur un vrai [[cluster|cluster]], cela évite de faire tourner la base sur un manager qu'on préfère dédier à l'orchestration (cours magistral Docker Swarm, chapitre 9).

    Ce diff résume le basculement : plus de `build`/`container_name`/`depends_on`, une image versionnée, un port publié en mode `ingress` (routing mesh, chapitre 5) et une section `deploy` qui pilote réplicas et [[stratégie de mise à jour|rollingupdate]]. Reste à créer les secrets référencés — objet du prochain chapitre.

  • Secrets Swarm pour JWT_SECRET et POSTGRES_PASSWORD

    `docker-stack.yml` référence deux secrets `external: true` : `postgres_password` et `jwt_secret` doivent donc déjà exister sur le [[cluster|cluster]] avant le premier déploiement. Un secret Swarm est chiffré au repos dans le magasin Raft des managers et monté en mémoire (`/run/secrets`) dans les conteneurs qui le référencent — jamais écrit en clair dans `docker-stack.yml` (cours magistral Docker Swarm, chapitre 7).

    Créer le secret depuis l'entrée standard (`echo -n ... | docker secret create ... -`) évite qu'il ne transite par un fichier temporaire sur le disque du manager. Le service `db` (image officielle `postgres`) accepte directement `POSTGRES_PASSWORD_FILE`, qui pointe vers le fichier monté — aucune ligne de code à écrire.

    `coworknet-api` ne connaît pas la convention `_FILE` : ce n'est pas une image officielle qui l'implémente nativement. Ce `command:` contourne le problème sans toucher au code ni à l'image : il lit les deux fichiers montés sous `/run/secrets`, exporte leur contenu en variables d'environnement, puis lance `node dist/main.js` — la valeur en clair ne quitte jamais la mémoire du [[conteneur|conteneur-docker]], et n'apparaît à aucun moment dans `docker-stack.yml` versionné en Git.

    Un secret Swarm est immuable : impossible de modifier le contenu de `jwt_secret` une fois créé. Pour le faire tourner, il faut créer un nouveau secret sous un autre nom (ex. `jwt_secret_v2`), mettre à jour la référence dans `docker-stack.yml`, puis redéployer — jamais d'édition en place.

  • Réseau overlay et routing mesh

    `coworknet-net` (`driver: overlay` dans `docker-stack.yml`) remplace le réseau `bridge` par défaut que `docker compose` créait sur une seule machine ([[TD25|td-25]]) : un réseau overlay fait communiquer des conteneurs situés sur des hôtes DIFFÉRENTS comme s'ils partageaient le même réseau local, encapsulé en VXLAN (cours magistral Docker Swarm, chapitre 6). `db`, `cache`, `api` et `web` s'y joignent tous et se résolvent toujours par leur nom de service, exactement comme en [[TD25|td-25]].

    `ports.mode: ingress` (par défaut, déjà posé pour `web` et `api` au chapitre 3) active ce routing mesh : le port publié s'ouvre sur les 3 [[nœuds|node-k8s]] du [[cluster|cluster]], même ceux qui ne font tourner aucune tâche du service. `web` compte 2 réplicas répartis par Swarm — jamais forcément sur le [[nœud|node-k8s]] interrogé — sans que cela ne change quoi que ce soit côté client.

    Les 3 réponses `200` viennent d'un seul et même [[conteneur|conteneur-docker]] `web`, atteint par 3 chemins réseau différents : c'est exactement la garantie que le routing mesh apporte par rapport à `docker compose`, où seule la machine hôte pouvait répondre. `docker service ps coworknet_web` (chapitre suivant) confirmera sur quel [[nœud|node-k8s]] les réplicas tournent réellement.

  • Déployer la stack et vérifier

    `docker stack deploy` doit s'exécuter contre le manager, qui pilote seul le [[cluster|cluster]]. `docker-stack.yml` est d'abord copié dans le [[conteneur|conteneur-docker]] `coworknet-manager` (il n'existe pour l'instant que sur la machine hôte), puis la commande est lancée à l'intérieur de ce [[conteneur|conteneur-docker]].

    Chaque service est automatiquement préfixé du nom de la stack (`coworknet_api`, pas `api`) : cette convention évite toute collision si un second projet est un jour déployé sur le même [[cluster|cluster]] (cours magistral Docker Swarm, chapitre 5). `docker stack services` puis `docker service ps` confirment que l'état réel rejoint l'état désiré.

    Les 3 tâches de `coworknet_api` sont réparties sur les 3 [[nœuds|node-k8s]] — y compris le manager, qui n'a pas été mis en `drain` ici. `docker exec coworknet-manager curl -s http://manager:4000/health` doit renvoyer `{"status":"ok","database":"up"}`, exactement comme en [[TD25|td-25]], mais désormais servi par un [[cluster|cluster]] à 3 machines plutôt qu'un seul [[conteneur|conteneur-docker]].

  • Rolling update de l'API sans coupure

    Une nouvelle version `registry.coworknet.local/coworknet-api:1.1.0` (correctif, déjà construite et poussée comme en TP01) doit remplacer `1.0.0` sans jamais faire tomber `coworknet_api`. C'est exactement ce que couvrent `deploy.update_config` et le `healthcheck` posés au chapitre 3 : `parallelism: 1`, `order: start-first` (cours magistral Docker Swarm, chapitre 10).

    Avec `parallelism: 1`, Swarm ne touche qu'une tâche à la fois : `order: start-first` démarre la nouvelle avant d'arrêter l'ancienne, et le `healthcheck` doit passer au vert avant que la tâche ne soit comptée `Running` — la précédente ne s'arrête donc qu'une fois la relève confirmée opérationnelle. `delay: 10s` espace chaque remplacement du suivant.

    Dix requêtes lancées pendant toute la durée du remplacement des 3 tâches renvoient `200` sans exception : la mise à jour s'est faite sans coupure de service, but final de ce TP. `docker service ps coworknet_api` confirme en parallèle 3 tâches `1.1.0` `Running` et les 3 anciennes `1.0.0` passées à `Shutdown`.

    `failure_action: rollback` (chapitre 3) annule automatiquement la mise à jour si le taux d'échec dépasse le seuil pendant la fenêtre `monitor` — sans intervention. Un [[rollback|rollback]] reste aussi disponible manuellement à tout moment, y compris après coup, si un problème n'apparaît qu'une fois la mise à jour jugée terminée.

    Ce TP atteint désormais son but final : CoWorkNet tourne sur un [[cluster|cluster]] Swarm à 3 [[nœuds|node-k8s]], déployé par `docker stack deploy`, ses secrets ne quittent jamais `/run/secrets`, son réseau overlay et son routing mesh rendent chaque service joignable depuis n'importe quel [[nœud|node-k8s]], et une mise à jour de l'[[API|api]] se fait désormais sans jamais couper le service.

Déployer CoWorkNet sur un cluster Kubernetes local

  • Pourquoi Kubernetes, et créer le cluster k3d

    Le TP01 a construit et publié deux images Docker versionnées de CoWorkNet, `coworknet-api:1.0.0` et `coworknet-web:1.0.0` — mais elles ne tournent encore que via `docker run` ou `docker compose up`, sur une seule machine. Ce TP03 les déploie sur un [[cluster|cluster]] [[Kubernetes|kubernetes]] local (k3d) : à la fin de la séance, un dossier `k8s/` réunit tous les manifests [[YAML|yaml]] déclaratifs — un `Namespace` dédié `coworknet`, un `ConfigMap`, un `Secret`, puis un `Deployment` et un `Service` pour chacun des 4 composants (`db`, `cache`, `api`, `web`) — et l'application entière tourne sans plus jamais lancer `docker run` ni `docker compose up` à la main. Le front reste accessible depuis le navigateur via un `Service` de type `NodePort`.

    Le mapping `-p "30100:30100@server:0"` relie le port 30100 de la machine hôte au même port sur le [[node|node-runtime?]] `server:0` du [[cluster|cluster]] k3d — sans lui, un `Service` `NodePort` resterait inatteignable depuis le navigateur, puisque k3d fait tourner tout le [[cluster|cluster]] à l'intérieur d'un [[conteneur Docker|conteneur-docker]]. Ce choix se fait dès la création du [[cluster|cluster]] ; le `Service` `web` qui l'utilisera n'apparaît qu'au chapitre 6, mais le port doit déjà être mappé.

    Contrairement à `docker compose`, limité à une seule machine, [[Kubernetes|kubernetes]] orchestre des conteneurs sur plusieurs [[nœuds|node-k8s]] et reste l'outil standard de mise en production d'applications conteneurisées (CP1, CP11). Le cours magistral *[[Kubernetes|kubernetes]] : fondamentaux et premier déploiement* détaille l'architecture complète d'un [[cluster|cluster]] ([[control plane|control-plane]], worker nodes, [[CoreDNS|coredns]]) ; ce TP n'en réutilise que le strict nécessaire pour faire tourner CoWorkNet.

  • Namespace coworknet, ConfigMap et Secret

    Comme `docker-compose.yml` ([[TD25|td-25]]) réunissait déjà `db`, `cache`, `api`, `web` dans un même projet, un `Namespace` [[Kubernetes|kubernetes]] dédié `coworknet` regroupe ici les mêmes 4 composants, séparés du reste du [[cluster|cluster]]. La configuration se répartit ensuite entre deux objets distincts : un `ConfigMap` pour tout ce qui n'est pas sensible (noms de service, ports), un `Secret` pour `JWT_SECRET` et le mot de passe PostgreSQL.

    Chaque manifest suivant porte explicitement `metadata.namespace: coworknet`, plutôt que de compter sur l'option `-n` de `kubectl` au moment de l'`apply` : un manifest qui déclare lui-même son [[namespace|namespace]] reste sans ambiguïté, quel que soit le contexte `kubectl` courant au moment où il est rejoué.

    Comme dans `docker-compose.yml` ([[TD25|td-25]]), la même valeur apparaît sous deux clés distinctes : `POSTGRES_DB`/`POSTGRES_USER` pour l'image officielle `postgres` (chapitre 3), `DATABASE_NAME`/`DATABASE_USER` pour l'[[API|api]], qui attend ces noms de variable précis. `DATABASE_HOST: "db"` et `REDIS_HOST: "cache"` ne sont pas des adresses IP : ce sont les noms des `Service` créés aux chapitres 3 et 4, que [[CoreDNS|coredns]] résout automatiquement depuis n'importe quel [[pod|pod]] du [[namespace|namespace]].

    `stringData` (plutôt que `data`) accepte du texte en clair, que [[Kubernetes|kubernetes]] encode lui-même en base64 avant de stocker le `Secret` — mais cet encodage n'est PAS un chiffrement, `kubectl get secret coworknet-secret -o yaml` révèle des valeurs décodables en une seconde. Les valeurs ci-dessus sont des placeholders : sur un vrai dépôt, ce fichier ne se commite jamais avec de vrais secrets, exactement comme `apps/api/.env` ne l'a jamais été.

    Le [[namespace|namespace]] et sa configuration existent désormais, mais aucun [[conteneur|conteneur-docker]] ne tourne encore. Les quatre chapitres suivants ajoutent, un par un, le `Deployment` et le `Service` de chaque composant — en commençant par `db`, dont `api` dépend au démarrage.

  • Deployment et Service pour db (PostgreSQL)

    `db` reprend l'image `postgres:16-alpine` déjà utilisée dans `docker-compose.yml` ([[TD7|td-07]], [[TD25|td-25]]), en un seul replica : PostgreSQL n'est pas conçu pour tourner en plusieurs instances sans réplication configurée, hors de propos ici. Point important, à assumer explicitement plutôt qu'à cacher : ce `Deployment` ne monte AUCUN volume persistant — les données sont perdues si le [[pod|pod]] redémarre. Le stockage persistant (`PersistentVolumeClaim`) est le sujet d'un TP ultérieur dédié.

    Chaque variable d'environnement pointe explicitement vers une clé du `ConfigMap` ou du `Secret` via `valueFrom` — plutôt qu'un `envFrom` global, qui injecterait aussi des clés destinées à `api` sans lien avec l'image `postgres`. `image: postgres:16-alpine` fixe la version, comme l'exige tout cours TD (jamais `postgres:latest`) : sans version figée, impossible de savoir avec certitude quelle révision tourne après un redémarrage du [[pod|pod]].

    `ClusterIP`, le type par défaut, suffit : `db` n'a besoin d'être joignable que depuis l'intérieur du [[cluster|cluster]], jamais depuis le navigateur. Ce `Service` s'appelle lui-même `db`, exactement le nom déjà déclaré dans `DATABASE_HOST` (chapitre 2) — c'est cette correspondance de nom que [[CoreDNS|coredns]] résout, aucune adresse IP n'est jamais codée en dur.

  • Deployment et Service pour cache (Redis)

    `cache` reprend le même schéma que `db`, en plus simple : l'image `redis:7-alpine` ([[TD23|td-23]], [[TD25|td-25]]) ne nécessite aucune variable d'environnement pour fonctionner à l'identique de `docker-compose.yml`, et ne stocke ici aussi qu'un cache de disponibilité, jamais de source de vérité — perdre ce [[pod|pod]] ne perd aucune donnée métier.

    `db` et `cache` sont désormais tous deux résolubles par leur nom de `Service` depuis n'importe quel [[pod|pod]] du [[namespace|namespace]] `coworknet` — exactement les deux noms attendus par `DATABASE_HOST` et `REDIS_HOST` dans le `ConfigMap`. `api`, déployé au chapitre suivant, peut donc les rejoindre sans connaître la moindre adresse IP.

  • Deployment et Service pour l'API (2 replicas)

    `api` tourne ici en 2 [[replicas|replique]] plutôt qu'1 : deux [[pods|pod]] identiques, chacun capable de répondre seul, donnent un premier aperçu concret de ce qu'un `Deployment` apporte par rapport à un [[pod|pod]] isolé — le cours magistral *[[Kubernetes|kubernetes]] : fondamentaux* (chapitres 8 et 9) détaille comment un `ReplicaSet` les maintient et comment un `rolling update` les met à jour sans coupure ; ce TP ne va pas plus loin qu'un nombre de [[replicas|replique]] fixe.

    `DATABASE_HOST` vaut `db`, pas une adresse IP : au démarrage du [[conteneur|conteneur-docker]] `api`, [[CoreDNS|coredns]] traduit ce nom en l'IP `ClusterIP` du `Service` `db` créé au chapitre 3, exactement comme le magistral (chapitre 11) le décrit pour toute résolution de nom interne au [[cluster|cluster]]. `coworknet-api:1.0.0` est l'image versionnée publiée au TP01 : aucune reconstruction n'est nécessaire ici, ce TP se contente de la faire tourner.

    `api` reste, comme `db` et `cache`, en `ClusterIP` : rien n'impose de l'exposer directement au navigateur dans ce TP, seul `web` (chapitre 6) le sera. Le `selector: app: api` fait converger le trafic du `Service` vers les 2 [[pods|pod]] du `Deployment`, quel que soit celui qui répond réellement — c'est cette indirection qui permettra plus tard de scaler `api` sans jamais changer son nom de `Service`.

  • Deployment et Service NodePort pour le web

    `web` reprend l'image `coworknet-web:1.0.0` du TP01, en 1 replica. Sa seule variable branchée ici est `API_INTERNAL_URL`, lue à l'exécution par les Server Components (`getHealth`, `getEspaces` — [[TD25|td-25]]) tournant DANS ce [[pod|pod]], qui doivent joindre `api` par son nom de `Service` sur le réseau interne du [[cluster|cluster]], exactement le rôle que `docker-compose.yml` lui donnait déjà.

    Point à assumer explicitement, hors du périmètre de ce TP : `NEXT_PUBLIC_API_URL` ([[TD25|td-25]]) a déjà été inlinée dans le bundle navigateur au moment du `docker build` du TP01, elle ne peut plus changer une fois l'image construite. Les appels lancés directement par le navigateur (connexion, inscription) restent donc dirigés vers la valeur figée à ce moment-là ; les exposer proprement en dehors du [[cluster|cluster]] relève d'un TP ultérieur sur l'`Ingress`.

    `nodePort: 30100` n'est pas un choix arbitraire : c'est exactement le port mappé à la création du [[cluster|cluster]] au chapitre 1 (`-p "30100:30100@server:0"`). Sans cette correspondance exacte, `web` serait bien exposé sur le [[node|node-runtime?]] du [[cluster|cluster]], mais ce port resterait injoignable depuis la machine hôte, donc depuis le navigateur.

  • Vérification bout en bout

    Les 4 `Deployment` et `Service` sont tous appliqués : reste à vérifier que le [[namespace|namespace]] `coworknet` fait tourner CoWorkNet dans son ensemble, sans plus jamais dépendre d'un `docker run` ou d'un `docker compose up` lancé à la main.

    5 [[pods|pod]] au total : 1 `db` + 1 `cache` + 2 `api` + 1 `web`, chacun `1/1 Running` — le compte exact attendu vu les [[replicas|replique]] fixés chapitre par chapitre. Un `READY` à `0/1` prolongé ou des `RESTARTS` qui grimpent signalerait un problème à diagnostiquer avec `kubectl describe pod` et `kubectl logs`, comme le détaille le magistral (chapitre 7).

    But atteint : CoWorkNet tourne intégralement sur un [[cluster|cluster]] [[Kubernetes|kubernetes]] local, entièrement piloté par les manifests déclaratifs de `k8s/`, sans qu'aucun `docker run` ni `docker compose up` ne soit intervenu depuis la création du [[cluster|cluster]]. Deux limites assumées ouvrent la suite du parcours — le stockage persistant de `db` (chapitre 3) et l'exposition propre d'`api` via un `Ingress` (chapitre 6) — toutes deux traitées dans des TP ultérieurs.

Diagnostiquer et réparer CoWorkNet sur Kubernetes

  • But final et méthode de diagnostic en 4 niveaux

    Ce TP04 exploite le déploiement CoWorkNet livré au TP03 sur [[Kubernetes|kubernetes]] : [[namespace|namespace]] `coworknet`, [[Deployments|deployment]] `db`, `cache`, `api` (2 réplicas) et `web`, chacun exposé par un `Service` dédié. But final de la séance : diagnostiquer et corriger, un par un, quatre pannes injectées volontairement dans les [[manifestes|manifeste]] — un `CrashLoopBackOff` sur `api`, un `ImagePullBackOff` sur `web`, un [[pod|pod]] `Pending` faute de ressources, un `Service` qui ne route plus vers aucun [[pod|pod]] — puis réaliser un `cordon`/`drain`/`uncordon` propre d'un [[nœud|node-k8s]] sans jamais couper `api`, protégée au préalable par un `PodDisruptionBudget`.

    Cet état est la référence saine à laquelle comparer chaque panne des chapitres suivants : tous les [[pods|pod]] `Running`, tous les [[Deployments|deployment]] au complet. Le cours magistral Opérer [[Kubernetes|kubernetes]] en production (chapitre « Méthode de diagnostic d'un incident en 4 niveaux ») détaille la démarche complète — ce chapitre n'en reprend que l'essentiel, appliqué directement à `coworknet`, sans redonder ce que le magistral couvre déjà.

    La séquence ne change pas d'une panne à l'autre : `kubectl get nodes` (niveau 1) écarte un problème de [[cluster|cluster]], `kubectl describe node` (niveau 2) écarte un problème de [[nœud|node-k8s]], `kubectl describe pod`/`logs`/`events` (niveau 3) qualifie la panne du [[pod|pod]] lui-même, et le niveau 4 — `Service`, `EndpointSlices`, DNS — n'entre en jeu que si le [[pod|pod]] tourne mais reste injoignable. Les chapitres 2 à 5 appliquent cette séquence à une panne précise ; le chapitre 6 l'applique à une opération de maintenance planifiée.

  • Panne 1 : CrashLoopBackOff sur l'API — commande de démarrage incorrecte

    Après une modification de `k8s/api-deployment.yaml`, `kubectl get pods -n coworknet` affiche l'un des deux [[pods|pod]] `api` en `CrashLoopBackOff` avec un compteur `RESTARTS` qui augmente. Niveau 1 ([[cluster|cluster]]) et niveau 2 ([[nœud|node-k8s]]) sont sains — seul ce [[pod|pod]] pose problème : direction niveau 3, le [[pod|pod]] lui-même.

    `kubectl logs --previous` affiche les logs de l'exécution qui a planté, pas celle en cours : c'est la première commande à lancer, avant `describe pod` qui donnera le code de sortie exact dans sa section `Last State`.

    La cause est un `command` de [[conteneur|conteneur-docker]] invalide dans le [[manifeste|manifeste]], pas un bug applicatif : `dist/mainn.js` (avec un `n` en trop) n'existe pas dans l'image, alors que le [[Dockerfile|dockerfile]] de l'[[API|api]] ([[TD25|td-25]]) produit `dist/main.js`. Ce `command` du [[manifeste|manifeste]] écrase le `CMD` de l'image sans que rien ne le signale avant l'exécution.

    Un `RESTARTS` qui cesse d'augmenter et un `READY` à `1/1` confirment la correction. Le magistral Opérer [[Kubernetes|kubernetes]] recense six causes fréquentes de `CrashLoopBackOff` (commande invalide, `OOMKilled`, config manquante, [[probe|sonde-sante]] mal calibrée, exception applicative, dépendance externe indisponible) — cette panne n'en illustre qu'une, la plus simple à corriger une fois identifiée.

  • Panne 2 : ImagePullBackOff sur le web — tag d'image inexistant

    Même symptôme apparent — un [[pod|pod]] qui ne devient jamais `Ready` — mais un `STATUS` différent : après une modification de `k8s/web-deployment.yaml`, le [[pod|pod]] `web` reste bloqué en `ImagePullBackOff`. Niveaux 1 et 2 restent sains ; direction niveau 3, section Events de `describe pod` plutôt que les logs, puisque le [[conteneur|conteneur-docker]] n'a jamais démarré.

    `kubectl logs` ne renverrait rien ici — le [[conteneur|conteneur-docker]] n'a jamais été créé. Le message exact vient de la section Events de `describe pod`, qui donne le nom d'image exact que le `kubelet` a tenté de télécharger.

    `manifest unknown` confirme que le tag lui-même n'existe pas dans le [[registre|registre-images]] local du [[cluster|cluster]] — pas un problème d'authentification ni de réseau. Le tag correct, poussé au TP03 lors du premier déploiement, reste `coworknet-web:v1` : `k8s/web-deployment.yaml` a été modifié pour pointer vers un tag qui n'a jamais été construit.

    Le magistral pointe le tag `:latest` comme piège classique : un tag mutable peut changer sans que le [[manifeste|manifeste]] ne bouge. `coworknet-web:v1` est un tag figé et explicite (discipline déjà appliquée dans le `docker-compose.yml` du [[TD25|td-25]]) — la panne ici venait d'une faute de frappe sur le tag, pas d'un tag mutable, mais la discipline de tags explicites reste ce qui permet de la détecter vite.

  • Panne 3 : Pod Pending — ressources insuffisantes

    Après une modification des `resources` de `k8s/api-deployment.yaml`, un `rollout` du [[Deployment|deployment]] `api` laisse l'un des deux [[pods|pod]] bloqué en `Pending` : `kubectl get pods -n coworknet` ne montre ni `Running` ni un `STATUS` d'échec de [[conteneur|conteneur-docker]] — le [[pod|pod]] n'a même pas encore été placé sur un [[nœud|node-k8s]], ce qui oriente d'emblée vers le [[scheduler|kube-scheduler]] plutôt que vers le [[conteneur|conteneur-docker]] lui-même.

    `describe pod` donne le message `FailedScheduling` exact, et `kubectl top nodes` confirme la piste en montrant la mémoire réellement disponible sur les [[nœuds|node-k8s]] `agent` du [[cluster|cluster]] `k3d` local — bien inférieure à ce que demande désormais chaque [[pod|pod]] `api`.

    Chaque [[nœud|node-k8s]] `agent` du [[cluster|cluster]] `k3d` local n'a que 2 Gio alloués : demander `4Gi` de `requests.memory` par [[pod|pod]] `api` dépasse à lui seul la capacité d'un [[nœud|node-k8s]] entier, avant même de compter les autres [[pods|pod]] déjà en place. Une [[API|api]] NestJS comme celle de CoWorkNet n'a jamais besoin d'un tel volume au repos — la valeur correcte, cohérente avec sa consommation réelle observée, est `256Mi`.

    Le magistral recense sept causes possibles d'un [[pod|pod]] `Pending` (ressources, taint sans toleration, `nodeSelector` non satisfait, [[PVC|persistentvolumeclaim]] non lié, anti-affinité trop stricte, quota dépassé, priorité insuffisante) — cette panne n'en illustre qu'une, la plus fréquente sur un [[cluster|cluster]] de développement aux ressources limitées comme ce `k3d` local.

  • Panne 4 : Service qui ne route vers aucun pod — sélecteur erroné

    Les quatre [[pods|pod]] de `coworknet` sont désormais tous `Running` (niveau 3 sain), mais l'étudiant suivant du poste voisin signale que l'[[API|api]] reste injoignable via le `Service` interne — direction niveau 4, réseau, exactement le cas où le [[pod|pod]] tourne mais où l'application reste muette.

    Les [[pods|pod]] tournent, mais le timeout (et non une erreur DNS) oriente vers le `Service` lui-même plutôt que vers `CoreDNS` : `kubectl get endpointslices` révèle si le `Service` `api` trouve seulement des [[pods|pod]] derrière lui.

    Aucun `EndpointSlice` confirme que le `Service` ne trouve aucune cible : son `spec.selector` (`app: api-server`) ne correspond à aucun [[pod|pod]] réel — le `template.metadata.labels` du [[Deployment|deployment]] `api` (vérifiable via `kubectl get pods -n coworknet --show-labels`) porte `app: api`, pas `app: api-server`. `k8s/api-service.yaml` a été modifié avec un sélecteur qui ne colle plus aux labels des [[pods|pod]].

    Deux `EndpointSlices` non vides et une réponse `200` confirment la correction. Le `spec.selector` d'un `Service` et les `labels` du `template` de son [[Deployment|deployment]] doivent rester des jumeaux exacts en permanence — [[Kubernetes|kubernetes]] ne signale jamais explicitement ce genre de désaccord, seule l'absence d'`EndpointSlice` le trahit.

  • Maintenance d'un nœud : PodDisruptionBudget puis cordon/drain/uncordon

    Les quatre pannes corrigées, une opération de maintenance planifiée reste à réaliser : mettre à jour l'OS d'un des deux [[nœuds|node-k8s]] `agent` du [[cluster|cluster]] sans jamais rendre `api` indisponible. Avant tout `drain`, un `PodDisruptionBudget` doit protéger le [[Deployment|deployment]] `api` — sans lui, rien n'empêche `kubectl drain` d'évincer ses deux réplicas en même temps.

    `maxUnavailable: 1` colle exactement aux 2 réplicas du [[Deployment|deployment]] `api` : au plus un [[pod|pod]] indisponible à la fois pendant une disruption volontaire, jamais les deux. `unhealthyPodEvictionPolicy: AlwaysAllow` garantit qu'un [[pod|pod]] déjà défaillant (comme au chapitre 2) ne bloquerait pas un futur `drain` légitime.

    `ALLOWED DISRUPTIONS` à `1` confirme que le budget est actif et cohérent avec les 2 réplicas en place. `kubectl get pods -n coworknet -o wide` identifie ensuite quel [[nœud|node-k8s]] héberger un des deux [[pods|pod]] `api` : c'est celui-là qu'il faut `cordon` puis `drain`.

    `cordon` ne déplace encore aucun [[pod|pod]] : `api-5d9f6c8b7f-a1b2c` continue de servir du trafic sur `k3d-coworknet-agent-0`, il n'y aura simplement plus de nouveau [[pod|pod]] planifié dessus. `kubectl drain` déclenche l'[[éviction|eviction]] proprement dite, via l'[[API|api]] d'[[éviction|eviction]] qui consulte le `PodDisruptionBudget` avant chaque suppression.

    Pendant toute l'[[éviction|eviction]], `api-4b6c8d9f7e-z3a4b` continue de tourner sur `k3d-coworknet-agent-1` : le `PodDisruptionBudget` a autorisé l'[[éviction|eviction]] d'un seul [[pod|pod]] à la fois, jamais des deux simultanément. Le [[Deployment|deployment]] recrée aussitôt un remplaçant, planifié sur un [[nœud|node-k8s]] encore éligible.

    `api` répond sans interruption tout au long de l'opération : c'est le résultat concret d'un `PodDisruptionBudget` bien dimensionné combiné à 2 réplicas. Une fois l'intervention terminée (ici simulée), `kubectl uncordon` rend le [[nœud|node-k8s]] de nouveau éligible au scheduling.

    `uncordon` ne rapatrie pas le [[pod|pod]] déjà [[évincé|eviction]] vers `k3d-coworknet-agent-0` : seuls les prochains [[pods|pod]] planifiés pourront y atterrir. C'est normal et sans conséquence tant que les réplicas restent réparties sur des [[nœuds|node-k8s]] différents — un point que l'anti-affinité (magistral, chapitre 13) permettrait de garantir plutôt que de constater après coup.

  • Bilan : la routine de diagnostic à adopter sur CoWorkNet

    Quatre pannes corrigées (`CrashLoopBackOff`, `ImagePullBackOff`, `Pending`, `Service` muet) et une maintenance de [[nœud|node-k8s]] menée sans interruption de service : `coworknet` est revenu à l'état sain du chapitre 1, cette fois avec un `PodDisruptionBudget` en place sur `api` qui n'existait pas au départ.

    Ce flux reste valable pour toute panne future, y compris celles non couvertes ici (`OOMKilled`, taint non toléré, `PVC` non lié, Network Policy bloquante). Le magistral Opérer [[Kubernetes|kubernetes]] en production (chapitre « Structurer l'exploitation ») va plus loin : routine matinale de cinq minutes, runbooks documentés, indicateurs SLO/SLI — à consulter pour transformer ce réflexe ponctuel en discipline d'équipe.

    Avant de considérer `coworknet` stable, cette double vérification (`get all` + `get pdb`) doit toujours conclure une session de maintenance ou de correction — pas seulement le [[pod|pod]] visé par la dernière panne, l'ensemble du [[namespace|namespace]]. C'est ce réflexe de vérification globale, plus que la mémorisation de chaque cause individuelle, qui distingue un diagnostic méthodique d'un correctif par tâtonnement.

Outiller CoWorkNet pour un usage Kubernetes avancé

  • Où en est CoWorkNet, et le but de ce TP

    Le TP03 a déployé CoWorkNet sur un [[cluster|cluster]] k3d local : un `Namespace coworknet` contient les `Deployment`/`Service` de `db`, `cache`, `api` (2 [[replicas|replique]]) et `web`, manifests versionnés dans `k8s/`. L'accès au front s'y fait par un `Service web` de type `NodePort`, une solution provisoire. Ce TP05 outille ce déploiement pour un usage [[Kubernetes|kubernetes]] avancé orienté développeur (CP1, CP11) : à la fin de la séance, un `CronJob` sauvegarde la base chaque nuit via `pg_dump`, `api` expose des [[sondes|sonde-sante]] `livenessProbe`/`readinessProbe`, chaque [[conteneur|conteneur-docker]] déclare des `requests`/`limits` encadrés par un `ResourceQuota`/`LimitRange`, un `ServiceAccount` dédié porte un RBAC minimal pour un [[Pod|pod]] de maintenance, et un `Ingress` unique remplace le `NodePort` en routant `/api` vers `api` et `/` vers `web`.

    Ce TP s'appuie sur le cours magistral Développer sur [[Kubernetes|kubernetes]] (chapitres CronJobs, Probes, ResourceQuota/LimitRange, ServiceAccounts/RBAC, [[Ingress|ingress]]) dès qu'une notion dépasse le strict nécessaire ici. Chaque chapitre suivant modifie ou ajoute un fichier sous `k8s/`, appliqué avec `kubectl apply -f k8s/<fichier> -n coworknet` puis vérifié avant de passer au suivant : sauvegarde, [[sondes|sonde-sante]], quotas, RBAC, puis exposition réseau.

  • Écrire le CronJob de sauvegarde pg_dump

    Le TP03 a déjà créé le Secret `coworknet-db-credentials` (clés `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`), consommé par le `Deployment db`. Ce même Secret sert de base à la sauvegarde : un `CronJob` planifie chaque nuit un `pg_dump` de la base, compressé et écrit sur un `PersistentVolumeClaim` dédié -- sans lui, tout le contenu de la sauvegarde disparaîtrait avec le [[Pod|pod]] qui l'a produite.

    `pg_dump` lit son mot de passe dans la variable `PGPASSWORD`, jamais dans `POSTGRES_PASSWORD` -- sans ce `env` dédié, la commande resterait bloquée en attente d'une saisie interactive impossible dans un [[conteneur|conteneur-docker]], et le `Job` finirait en `DeadlineExceeded`. `activeDeadlineSeconds: 600` couvre justement ce genre de blocage : au-delà de 10 minutes, le `Job` échoue plutôt que de tourner indéfiniment.

    `concurrencyPolicy: Forbid` évite que deux `pg_dump` tournent en même temps sur la même base si une exécution précédente dépasse son créneau. `successfulJobsHistoryLimit: 7` et `ttlSecondsAfterFinished: 604800` (7 jours) bornent l'accumulation des Jobs terminés sans perdre une semaine de traçabilité -- deux mécanismes complémentaires, l'un par nombre, l'autre par durée.

  • Tester la sauvegarde sans attendre l'échéance

    Attendre 2h du matin pour vérifier que le `CronJob` fonctionne n'a rien de pratique. `kubectl create job --from=cronjob/<nom>` (déjà vu au magistral) crée un `Job` unique à partir du même gabarit, exécuté immédiatement, sans toucher au planning ni à l'historique du `CronJob`.

    Un `Job` créé via `--from=cronjob` n'a pas de `ownerReference` vers le `CronJob` : il n'est ni compté dans `successfulJobsHistoryLimit`, ni nettoyé par le `ttlSecondsAfterFinished` du `jobTemplate` -- son nettoyage reste manuel. Vérifier le contenu réel de la sauvegarde suppose un [[Pod|pod]] jetable montant le même `PersistentVolumeClaim`, plutôt que de faire confiance au seul code de sortie du `Job`.

    La sauvegarde tourne désormais réellement à `2h00`, heure de Paris, chaque nuit -- `kubectl get cronjob coworknet-db-backup -n coworknet` affichera une colonne `LAST SCHEDULE` renseignée dès la première exécution planifiée, sans plus jamais nécessiter d'intervention manuelle en usage normal.

  • Sondes liveness et readiness sur l'API

    `GET /health` ([[TD25|td-25]]) répond `{"status":"ok","database":"up"}` : il teste `api` ET sa connexion à `db` dans la même réponse. L'utiliser tel quel pour `livenessProbe` reproduirait le piège du magistral (chapitre Probes) : un simple incident réseau sur `db` ferait échouer `/health` sur les deux [[replicas|replique]] d'`api`, qui redémarreraient en boucle pour un problème qu'aucun redémarrage ne résout.

    `readinessProbe` reste le bon endroit pour `/health` : un échec retire simplement le [[Pod|pod]] des `endpoints` du `Service api`, sans perte d'état, le temps que `db` revienne. `livenessProbe`, elle, se limite à un `tcpSocket` sur le port `4000` -- un check local qui ne dépend d'aucun service externe, juste « le process écoute-t-il encore ».

    `initialDelaySeconds: 15` laisse le temps à Nest de terminer son bootstrap et sa connexion à `db` avant le premier test de [[liveness|livenessprobe]]. `failureThreshold: 5` sur une période de 20 secondes (100 secondes de tolérance) absorbe un pic de charge transitoire sans redémarrage inutile -- à l'inverse, la [[readiness|readinessprobe]] resserrée (`failureThreshold: 3`, période 10s) retire vite le [[Pod|pod]] du trafic dès qu'il répond mal.

  • Requests, limits et quotas du namespace

    Sans `requests`/`limits`, les 5 conteneurs du [[namespace|namespace]] tournent en `BestEffort` : les premiers évincés en cas de pression sur le [[nœud|node-k8s]], y compris `db`, qui héberge pourtant l'unique source de vérité de CoWorkNet. `db` mérite la classe `Guaranteed` (`requests` = `limits`) ; `api`, `cache` et `web`, moins critiques et capables d'un redémarrage sans perte de données, restent en `Burstable`.

    `cache` et `web` reçoivent le même traitement `Burstable`, avec des valeurs plus modestes pour `cache` (`requests: 50m/64Mi`, `limits: 200m/128Mi`) -- Redis reste léger tant qu'il ne sert que de cache de disponibilité ([[TD23|td-23]]). Une fois les quatre `Deployment` mis à jour, place au plafond global du [[namespace|namespace]] : sans lui, un pic isolé pourrait encore épuiser la capacité du [[cluster|cluster]] local.

    L'ordre d'application compte, comme au magistral (chapitre ResourceQuota/LimitRange) : appliquer d'abord le `LimitRange` garantit que tout [[Pod|pod]] recréé ensuite reçoit déjà des `requests`/`limits` cohérents avant même la vérification du total par le `ResourceQuota`. `requests.cpu: "2"`/`requests.memory: 2Gi` laissent une marge confortable au-delà des ~600m/700Mi consommés en régime stable par les 5 conteneurs permanents, pour absorber les [[Pods|pod]] éphémères du `CronJob` et de la maintenance.

  • ServiceAccount et RBAC minimal pour la maintenance

    Diagnostiquer un `pg_dump` en échec demande parfois d'inspecter le [[cluster|cluster]] depuis l'intérieur -- lister les [[Pods|pod]], lire les logs d'un `Job` raté, consulter le `CronJob`, relire le mot de passe de `db`. Donner pour cela le `ServiceAccount default` du [[namespace|namespace]], sans restriction, violerait le principe du moindre privilège rappelé au magistral (chapitre ServiceAccounts et RBAC) : un `ServiceAccount` dédié, borné à ce dont ce [[Pod|pod]] de maintenance a réellement besoin, s'impose.

    Chaque règle du `Role` cible un besoin précis : `pods` (`get`/`list`) et `pods/log` pour diagnostiquer un `Job` de sauvegarde échoué, `jobs`/`cronjobs` du groupe `batch` pour suivre l'état de `coworknet-db-backup`, et `secrets` restreint par `resourceNames: ["coworknet-db-credentials"]` -- jamais un accès à l'ensemble des Secrets du [[namespace|namespace]], seulement à celui strictement nécessaire.

    Le second `secret/keycloak-admin` ci-dessus est fictif -- il illustre simplement que `resourceNames` bloque tout Secret autre que celui nommément autorisé, même dans le même [[namespace|namespace]]. En pratique, le [[Pod|pod]] de maintenance se lance ponctuellement avec `serviceAccountName: coworknet-backup-sa`, jamais en permanence : un `ServiceAccount` inutilisé le reste du temps ne présente aucun risque supplémentaire.

  • Ingress : un point d'entrée unique pour api et web

    `k3d` embarque `Traefik` comme contrôleur `Ingress` par défaut -- le même reverse proxy que la plateforme ecole utilise en production (`docs/SPECS-SERVEUR-PRODUCTION.md`), une cohérence bienvenue plutôt qu'une coïncidence. Un `Ingress` remplace enfin le `NodePort` provisoire de `web` : un seul host, `coworknet.local`, route `/` vers `web` et `/api` vers `api` -- deux applications, un seul point d'entrée externe.

    Un obstacle subsiste : les routes de `api` (`/health`, `/espaces`, `/auth/register`...) ne connaissent pas le préfixe `/api` -- envoyer la requête telle quelle produirait un `404` systématique. Un `Middleware Traefik` (une CRD, cf. magistral chapitre Operators et CRDs) retire ce préfixe avant que la requête n'atteigne le `Service api` ; `web`, lui, n'a besoin d'aucune réécriture.

    L'annotation `traefik.ingress.kubernetes.io/router.middlewares` référence le `Middleware` sous la forme `<namespace>-<nom>@kubernetescrd` -- un format propre à `Traefik`, absent de la spécification `networking.k8s.io/v1` standard, qui ne définit qu'un `Ingress` générique sans mécanisme de réécriture natif. `pathType: Prefix` sur les deux règles fait correspondre tout chemin qui commence par la valeur déclarée, pas uniquement une égalité stricte.

    CoWorkNet dispose désormais d'une sauvegarde quotidienne automatisée, de [[sondes|sonde-sante]] qui distinguent panne locale et dépendance externe, de `requests`/`limits` encadrés par un quota de [[namespace|namespace]], d'un accès de maintenance à privilèges minimaux, et d'une exposition réseau unifiée sous un seul host -- l'ensemble des pratiques [[Kubernetes|kubernetes]] orientées développeur visées en introduction de ce TP.

Administrer le cluster Kubernetes de CoWorkNet

  • But final et limite du TP03 sur le stockage de db

    Ce TP06 fait de vous l'administrateur du [[cluster|cluster]] [[Kubernetes|kubernetes]] de CoWorkNet (TP03, TP04) : à la fin de la séance, le service `db` sera passé d'un `Deployment` sans stockage à un `StatefulSet` à identité stable (`db-0`) doté d'un `PersistentVolumeClaim` provisionné via une `StorageClass`, une `NetworkPolicy` limitera l'accès au port PostgreSQL de `db` aux seuls [[Pods|pod]] `api` (refus par défaut sur le reste du [[namespace|namespace]] `coworknet`), un `HorizontalPodAutoscaler` fera varier automatiquement le nombre de réplicas d'`api` selon l'usage CPU, et la procédure de sauvegarde/restauration `etcd` du [[cluster|cluster]] sera documentée et testée.

    La ligne insérée disparaît : le `Deployment db` du TP03 ne déclare aucun `volumes` vers un `PersistentVolumeClaim`, ses données vivent uniquement dans le système de fichiers éphémère du [[conteneur|conteneur-docker]]. Tout redémarrage de [[Pod|pod]] -- panne, mise à jour, simple `kubectl delete pod` -- reparte donc d'une base vide. Le cours magistral Administrer un [[cluster|cluster]] [[Kubernetes|kubernetes]] (chapitres [[StatefulSets|statefulset]] et « Stockage persistant : [[PersistentVolume|persistentvolume]], [[PVC|persistentvolumeclaim]] et CSI ») détaille ce mécanisme ; ce TP en applique la migration précise sur `db`.

    Ce chantier relève à la fois de CP7 (durabilité et accès à la base relationnelle) et de CP11 (mise en production dans une démarche DevOps) : un [[cluster|cluster]] qui perd les données de sa base à chaque redémarrage de [[Pod|pod]] n'est pas administrable en production. Les six chapitres suivants traitent, dans l'ordre, le stockage, la migration, l'isolement réseau, l'élasticité et la sauvegarde du [[cluster|cluster]].

  • Une StorageClass locale pour provisionner du stockage durable

    Une `StorageClass` provisionne du stockage à la demande : plutôt qu'un administrateur crée un `PersistentVolume` à l'avance pour chaque usage, elle décrit une seule fois COMMENT le créer (`provisioner`, `parameters`, `reclaimPolicy`, `volumeBindingMode`), et [[Kubernetes|kubernetes]] le fabrique automatiquement dès qu'un `PersistentVolumeClaim` le réclame. Le cours magistral Administrer un [[cluster|cluster]] [[Kubernetes|kubernetes]] (chapitre « [[StorageClass|storageclass]] : provisionnement dynamique du stockage ») en détaille l'ensemble des champs ; ce TP se limite à vérifier celle déjà présente sur le [[cluster|cluster]] local du TP03 et à l'utiliser pour `db`.

    Le [[cluster|cluster]] local (`kind`, mis en place au TP03) fournit déjà cette `StorageClass` par défaut : `rancher.io/local-path`, le provisioner CSI intégré, crée un `PersistentVolume` sur le disque du [[nœud|node-k8s]], dans le [[conteneur|conteneur-docker]] qui héberge ce [[nœud|node-k8s]]. `volumeBindingMode: WaitForFirstConsumer` retarde volontairement la création du `PersistentVolume` jusqu'à ce qu'un [[Pod|pod]] le réclame réellement -- utile sur un [[cluster|cluster]] multi-[[nœuds|node-k8s]] pour placer le volume au bon endroit, sans effet ici sur un [[cluster|cluster]] à un seul [[nœud|node-k8s]].

    `reclaimPolicy: Delete` supprime les données dès que le `PersistentVolumeClaim` est supprimé -- un compromis acceptable pour un TP sur un [[cluster|cluster]] jetable, à proscrire en production réelle (cf. magistral, chapitre « Stockage persistant »). Décision retenue ici : la base `db` réclamera 2Gi via cette `StorageClass standard`, en `ReadWriteOnce`, largement suffisant pour un `StatefulSet` à un seul réplica.

  • Migrer db d'un Deployment vers un StatefulSet

    Un `StatefulSet` exige un *[[headless|service-headless]] Service* -- `clusterIP: None` -- avant d'être créé : ce Service donne à chaque [[Pod|pod]] une entrée DNS individuelle stable (`db-0.db.coworknet.svc.cluster.local`) au lieu de répartir le trafic anonymement. La migration se déroule donc en trois temps : convertir `k8s/db-service.yaml` en [[Service headless|service-headless]], remplacer `k8s/db-deployment.yaml` par `k8s/db-statefulset.yaml` avec un `volumeClaimTemplates`, puis supprimer l'ancien `Deployment`.

    `db` restant à un seul réplica, l'identité DNS par [[Pod|pod]] n'est pas ici le bénéfice recherché en soi -- c'est le `volumeClaimTemplates` du `StatefulSet`, ci-dessous, qui justifie la migration : un `PersistentVolumeClaim` dédié et stable, recréé avec le même nom (`data-db-0`) à chaque redémarrage du [[Pod|pod]], là où un `Deployment` ne peut offrir cette garantie.

    Le nom du [[Pod|pod]] devient `db-0` (suffixe ordinal stable, pas de [[hachage|hachage]] aléatoire), et le [[PVC|persistentvolumeclaim]] associé `data-db-0` -- le préfixe `data` reprend le nom déclaré dans `volumeClaimTemplates`. Depuis [[Kubernetes|kubernetes]] 1.27, `persistentVolumeClaimRetentionPolicy` permettrait de faire supprimer ce [[PVC|persistentvolumeclaim]] automatiquement en cas de scale down ; il n'est pas défini ici, donc le comportement par défaut s'applique : le [[PVC|persistentvolumeclaim]] survit à toute suppression du `StatefulSet` ou de ses [[Pods|pod]].

    `\dt` ne montre aucune table : les données du TP03 étaient de toute façon perdues à chaque redémarrage (chapitre 1), il n'y a donc rien à migrer par dump/restore ici -- une simple réinitialisation documentée suffit pour ce TP. Sur une base réellement peuplée, la méthode générale reste `pg_dump` avant suppression du volume, puis `psql` de restauration sur `db-0` une fois le nouveau [[PVC|persistentvolumeclaim]] en place, dans cet ordre strict.

    `db-0` porte désormais un jeu d'essai stable : un `kubectl delete pod db-0 -n coworknet` le recrée à l'identique, [[PVC|persistentvolumeclaim]] `data-db-0` réattaché, données intactes -- reproduisez la démonstration du chapitre 1 pour le vérifier vous-même avant de continuer.

  • NetworkPolicy : cloisonner l'accès au port PostgreSQL de db

    Par défaut, tous les [[Pods|pod]] du [[namespace|namespace]] `coworknet` peuvent joindre `db-0` sur le port 5432 -- `web`, `cache`, ou tout [[Pod|pod]] qu'un incident de sécurité ferait tourner plus tard dans ce [[namespace|namespace]]. Le principe du moindre privilège impose de restreindre cet accès aux seuls [[Pods|pod]] qui en ont réellement besoin : `api`. Condition impérative avant d'écrire la moindre `NetworkPolicy` : le CNI du [[cluster|cluster]] doit l'implémenter réellement -- sans quoi le [[manifeste|manifeste]] est accepté sans erreur, mais aucune règle n'est appliquée (magistral, chapitre « NetworkPolicies »).

    La stratégie retenue applique d'abord un refus total en [[ingress|ingress]] sur tout le [[namespace|namespace]], puis ajoute deux autorisations explicites : `web` vers `api`, et `api` vers `db`. Sans la première autorisation, le refus par défaut casserait aussi le trafic `web -> api`, alors que seul `db` est visé par ce TP. Aucune règle d'`egress` n'est posée : la résolution DNS interne (port 53) continue donc de fonctionner sans exception à ajouter -- le piège classique du magistral (chapitre « NetworkPolicies ») ne s'applique qu'en cas de restriction d'egress.

    `default-deny-ingress` sélectionne tous les [[Pods|pod]] (`podSelector: {}`) sans déclarer aucune règle `ingress` : dès qu'un [[Pod|pod]] est sélectionné par au moins une `NetworkPolicy` pour une direction donnée, cette direction devient restreinte à ce qui est explicitement autorisé ailleurs -- les trois [[manifestes|manifeste]] s'additionnent, ils ne se remplacent jamais entre eux (union des règles, magistral « NetworkPolicies »).

    L'isolement est vérifié dans les deux sens : un [[Pod|pod]] sans label `app=api` timeout sur le port 5432, un [[Pod|pod]] `api` légitime s'y connecte toujours. En cas de flux bloqué à tort, la méthode de débogage reste systématique (magistral) : `kubectl get networkpolicy -n coworknet`, `kubectl describe networkpolicy <nom>` pour relire le `podSelector` exact, puis vérifier les labels réels des [[Pods|pod]] concernés avec `kubectl get pods --show-labels`.

  • HorizontalPodAutoscaler : faire varier api selon la charge CPU

    Le `HorizontalPodAutoscaler` ([[HPA|hpa]]) ajuste automatiquement le nombre de réplicas d'`api` selon son usage CPU réel, plutôt qu'un nombre fixe décidé une fois pour toutes. Deux prérequis, sans lesquels il reste inopérant (magistral, chapitre « Horizontal et Vertical [[Pod|pod]] [[Autoscaler|hpa]] ») : le **Metrics Server** doit tourner sur le [[cluster|cluster]], et le [[conteneur|conteneur-docker]] `api` doit déclarer des `requests` CPU -- sans elles, le [[HPA|hpa]] ne peut calculer aucun pourcentage d'utilisation.

    `kubectl top pods` renvoyant des valeurs confirme que le Metrics Server fonctionne. `--kubelet-insecure-tls` n'est légitime que sur un [[cluster|cluster]] de lab comme celui-ci -- un [[cluster|cluster]] de production expose des certificats de [[kubelet|kubelet]] valides et n'a pas besoin de désactiver cette vérification.

    `minReplicas: 1` évite de descendre à zéro (le scale-to-zero du [[HPA|hpa]] reste *Alpha*, hors périmètre ici), `maxReplicas: 5` plafonne la croissance sur ce [[cluster|cluster]] de lab à ressources limitées. Le TP04 a posé un `PodDisruptionBudget` sur `api` : il ne bloque pas ce scaling, car le contrôleur du `Deployment` ne passe jamais par l'[[API|api]] d'`Eviction` pour ajuster son nombre de réplicas -- seul un `drain` ou un rollout volontaire consulte le [[PDB|poddisruptionbudget]].

    Arrêtez le [[Pod|pod]] `load` (`Ctrl+C` puis `kubectl delete pod load -n coworknet` si besoin) et laissez le [[HPA|hpa]] revenir seul à 1 réplica : le magistral (chapitre « [[HPA|hpa]]... configuration détaillée ») documente une fenêtre de stabilisation par défaut de cinq minutes avant tout *scale down* -- volontairement plus lente que le *scale up*, immédiat, pour absorber un pic de charge sans délai ni le prolonger inutilement une fois passé.

  • Sauvegarder et restaurer etcd

    Les quatre chapitres précédents ont rendu `db` durable, mais l'état déclaratif de tout le [[cluster|cluster]] -- `StatefulSet`, `NetworkPolicy`, `HorizontalPodAutoscaler`, `Secret`s -- vit lui-même dans `etcd`, la base du [[control plane|control-plane]]. Perdre `etcd` sans sauvegarde revient à perdre la définition du [[cluster|cluster]] entier, même si les volumes de données survivent physiquement (magistral, chapitres « [[etcd|etcd]] : Raft, quorum... » et « [[etcd|etcd]] : maintenance, sauvegarde et restauration »).

    Une fois dans le [[conteneur|conteneur-docker]] du [[nœud|node-k8s]], `etcdctl` exige trois options `--cacert`/`--cert`/--key`, tel qu'il en va pour tout client `[[etcd|etcd]]` sécurisé par TLS mutuel -- les chemins ci-dessous sont ceux, standards, d'un cluster `kubeadm` (utilisé par `kind` en interne).

    `snapshot status` confirme que le fichier produit est un snapshot `etcd` valide, exploitable en restauration -- ce test suffit déjà à démontrer que la sauvegarde n'est pas corrompue. Copiez ensuite ce fichier HORS du [[conteneur|conteneur-docker]] du [[nœud|node-k8s]] (`docker cp` ou `kubectl cp`) : un snapshot resté sur le disque qu'il est censé protéger ne protège de rien en cas de panne de ce disque.

    Restaurer réécrit l'identité du [[cluster|cluster]] (member ID, [[cluster|cluster]] ID) et exige d'arrêter le `kubelet` du [[nœud|node-k8s]] pendant l'opération : sur ce TP, un seul [[control plane|control-plane]] existe (pas de haute disponibilité `etcd`), donc une restauration réelle interrompt tout le [[cluster|cluster]] le temps de l'opération. La procédure complète est documentée ci-dessous et doit être pratiquée -- mais sur un [[cluster|cluster]] `kind` jetable dédié à cet exercice plutôt que sur le [[cluster|cluster]] partagé `coworknet`, pour ne pas perdre le travail des chapitres précédents en cas d'erreur de manipulation.

    Cette restauration testée sur un [[cluster|cluster]] jetable vaut valeur de preuve : un backup jamais restauré n'est pas un backup (magistral). En administration réelle, ce geste s'automatise (`etcdctl snapshot save` en `CronJob`, stockage hors [[cluster|cluster]]) et se complète d'une sauvegarde plus large que le seul `etcd` -- ressources applicatives et volumes persistants -- via des outils comme Velero, hors du périmètre de ce TP (magistral, chapitre « Sauvegarder et restaurer un [[cluster|cluster]] complet »).

  • Bilan de l'administration du cluster CoWorkNet

    Le [[cluster|cluster]] CoWorkNet a changé de posture au terme de ce TP06 : `db` est durable (`StatefulSet` + `PersistentVolumeClaim` via une `StorageClass`), son port PostgreSQL n'est plus joignable que par `api` (`NetworkPolicy` en refus par défaut), `api` absorbe désormais un pic de charge en ajustant seul son nombre de réplicas (`HorizontalPodAutoscaler`), et une panne totale du [[control plane|control-plane]] redevient récupérable grâce à un snapshot `etcd` testé en restauration.

    Restent volontairement hors périmètre : le `VerticalPodAutoscaler` (ajuster les `requests`/`limits` plutôt que le nombre de réplicas), le `Cluster Autoscaler` (ajuster le nombre de [[nœuds|node-k8s]]), la haute disponibilité du [[control plane|control-plane]] (plusieurs membres `etcd`) et une sauvegarde applicative complète type Velero -- chacun détaillé dans le cours magistral Administrer un [[cluster|cluster]] [[Kubernetes|kubernetes]] (chapitres 7, 8 et 27) pour qui veut aller plus loin. Le `PodDisruptionBudget` posé sur `api` au TP04 continue, lui, de protéger ce service pendant toute future maintenance du [[cluster|cluster]].

Sécuriser le cluster Kubernetes de CoWorkNet

  • Défense en profondeur : durcir un cluster déjà fonctionnel

    Ce TP07 change de posture par rapport aux TP01-TP06 : jusqu'ici, l'objectif était de faire fonctionner CoWorkNet sur [[Kubernetes|kubernetes]] — images durcies et publiées (TP01), déploiement complet (TP03), [[StatefulSet|statefulset]] `db` avec `PersistentVolumeClaim`, `NetworkPolicy` `api`→`db` et `HorizontalPodAutoscaler` sur `api` (TP06), le tout dans le `Namespace` `coworknet`. Ce [[cluster|cluster]] tourne, mais reste permissif : aucun [[conteneur|conteneur-docker]] n'a de `SecurityContext` dédié, et depuis le TP03 le [[cluster|cluster]] importe une image locale reconstruite à chaque TP, jamais rescannée ni republiée vers le [[registre|registre-images]] privé mis en place au TP01.

    À la fin de cette séance (CP2, CP11), le `Namespace` `coworknet` applique le [[Pod|pod]] Security Standard `restricted`, les conteneurs `api`/`web` tournent en utilisateur non-root avec un système de fichiers racine en lecture seule et sans la moindre capability Linux superflue, chaque image est scannée par Trivy en CI avec échec bloquant sur vulnérabilité `CRITICAL`, signée par Cosign puis vérifiée à l'admission, et une policy Kyverno rejette tout [[Pod|pod]] `:latest` ou dépourvu de `requests`/`limits`. `coworknet-api`/`coworknet-web` seront reconstruites, scannées et signées comme au TP01, mais cette fois effectivement tirées par le [[cluster|cluster]] depuis `registry.ban7-remote-lab.local` — plus jamais une image locale jamais vérifiée depuis sa construction initiale.

    Ce TP suit un ordre volontaire : nommer d'abord un périmètre englobant (`restricted` sur le `Namespace`, chapitre 2), puis le détail [[conteneur|conteneur-docker]] par [[conteneur|conteneur-docker]] (`SecurityContext`, chapitre 3), avant de remonter la chaîne d'approvisionnement (scan, chapitre 4 ; signature, chapitre 5 ; vérification à l'admission et policy anti-`:latest`, chapitres 6-7). Le cours magistral Sécuriser un [[cluster|cluster]] [[Kubernetes|kubernetes]] (c-[[kubernetes|kubernetes]]-cks) approfondit chacun de ces mécanismes au-delà de ce TP.

  • Pod Security Standards restricted sur le namespace coworknet

    Le cours magistral Sécuriser un [[cluster|cluster]] [[Kubernetes|kubernetes]] (chapitre « [[Pod|pod]] Security Standards et [[Pod|pod]] Security Admission ») détaille les trois niveaux `Privileged`, `Baseline` et `Restricted` ; ce TP applique directement `restricted`, le plus strict, cohérent avec une application qui expose une [[API|api]] publique. Comme pour toute migration sur un [[cluster|cluster]] déjà en service, on ne bascule jamais directement en `enforce` : on observe d'abord en `audit`/`warn`, on corrige les [[manifestes|manifeste]] non conformes, puis seulement on active `enforce`.

    `kubectl apply -f k8s/namespace.yaml` n'a aucun effet sur les [[Pods|pod]] déjà en cours d'exécution : `audit`/`warn` ne s'évaluent qu'aux prochaines créations ou mises à jour. Un simple `kubectl rollout restart` sur chaque `Deployment`/`StatefulSet` suffit à déclencher l'évaluation sans changer la moindre image.

    Les quatre exigences manquent partout à l'identique ; le détail de leur réglage sur `api`/`web` fait l'objet du chapitre 3. `db` ([[StatefulSet|statefulset]] TP06, image `postgres:16-alpine`) suit un chemin différent : ajouter les quatre champs minimaux à l'identique de `api` échoue d'une façon plus profonde, détaillée ci-dessous, car l'image officielle `postgres` tourne en root par défaut avant de descendre elle-même ses privilèges en interne.

    Cette erreur (déjà citée par le cours magistral, chapitre « [[Pod|pod]] Security Standards ») survient au démarrage du [[conteneur|conteneur-docker]], pas à l'admission : `runAsNonRoot: true` seul ne suffit pas quand l'image ne déclare aucun `USER` non-root — il faut fixer explicitement `runAsUser: 999`, l'UID dédié `postgres` de l'image officielle. Reste un second problème : le volume `data-db-0` (TP06) a été créé avec un `PersistentVolumeClaim` dont l'ownership par défaut n'est pas `999` — `fsGroup: 999`, au niveau du [[Pod|pod]], demande au [[kubelet|kubelet]] de reprendre récursivement en groupe `999` tout volume monté, dès son montage.

    `db` conforme, `k8s/namespace.yaml` peut basculer en `enforce` : c'est ce label qui bloquera réellement, désormais, tout [[Pod|pod]] non conforme dans le `Namespace` `coworknet` — y compris `api`/`web`, tant que le chapitre suivant n'a pas corrigé leur `SecurityContext`.

  • SecurityContext durci sur les Deployment api et web

    Le `SecurityContext` durci répond précisément aux quatre avertissements du chapitre précédent (CP2) : `runAsNonRoot: true` refuse qu'un [[conteneur|conteneur-docker]] démarre en `UID 0`, `capabilities.drop: [ALL]` retire l'intégralité des capacités Linux par défaut, `allowPrivilegeEscalation: false` bloque toute élévation par binaire `setuid`, et `seccompProfile.type: RuntimeDefault` filtre les appels système au profil par défaut du runtime — les quatre exigences minimales du niveau `restricted`. `readOnlyRootFilesystem: true` va plus loin que ce socle minimal, mission propre de ce TP.

    `runAsUser: 1000` correspond exactement à l'utilisateur `node` (UID/GID 1000) déjà déclaré par `USER node` dans `apps/api/Dockerfile` depuis le TP01 : ce champ ne fait que confirmer, côté [[Kubernetes|kubernetes]], un choix déjà fait côté image. [[Node.js|node-runtime]] et certains modules natifs (`bcrypt`) écrivent ponctuellement dans `/tmp` en fonctionnement normal ; `readOnlyRootFilesystem: true` rend donc indispensable un volume `emptyDir` monté sur `/tmp`, seul répertoire encore inscriptible dans le [[conteneur|conteneur-docker]].

    Le [[conteneur|conteneur-docker]] `web` (serveur autonome Next.js, [[TD25|td-25]]) présente une contrainte supplémentaire : `server.js` écrit dans `.next/cache` au premier rendu de certaines routes. Sous `readOnlyRootFilesystem: true`, ce répertoire doit lui aussi devenir un volume `emptyDir` distinct de `/tmp`, sans quoi le [[conteneur|conteneur-docker]] redémarre en boucle dès la première requête qui déclenche une écriture de cache.

    Les fichiers de l'image restent lisibles par `UID 1000` sans changement supplémentaire (permissions par défaut de `COPY --chown=node:node`, TP01) : seule l'écriture posait problème, désormais couverte par les volumes `emptyDir` ci-dessus. Un `kubectl rollout restart` suivi de `kubectl get pods` confirme la disparition des avertissements `PodSecurity` pour `api` et `web`.

    Les quatre [[Pods|pod]] du [[namespace|namespace]] (`db`, `api` x2, `web`) tournent désormais tous en `restricted`, sans qu'un seul redémarrage n'ait été nécessaire pour `db` cette fois — le chapitre précédent l'avait déjà traité. `api`/health répond toujours `"status":"ok"` : ce durcissement n'a rien changé au contrat applicatif de CoWorkNet, exactement la même garantie déjà tenue au [[TD25|td-25]] et au TP01.

  • Scanner les images en CI avec Trivy, seuil bloquant

    Un `SecurityContext` durci (chapitre 3) ne dit rien du contenu de l'image elle-même. Le TP01 a déjà scanné `coworknet-api`/`coworknet-web` avec Trivy — mais à la main, une seule fois, avant de publier `1.0.0` ; depuis, chaque TP a reconstruit et rechargé une image locale sans jamais repasser par ce scan ni par le [[registre|registre-images]] privé `registry.ban7-remote-lab.local`. Ce chapitre automatise enfin ce contrôle : `.gitlab-ci.yml` ([[TD27|td-27]]) gagne un nouveau stage `scan`, inséré entre `build` et `deploy`, qui échoue la [[pipeline|pipeline-cicd]] dès qu'une vulnérabilité `CRITICAL` est détectée.

    Le job réutilise `docker-bake.hcl` (TP01), qui décrit déjà les deux cibles `api`/`web`, plutôt que deux `docker build` séparés. Il construit d'abord en mono-plateforme (`--load`, limite déjà rencontrée au TP01), scanne chaque image, puis — seulement si Trivy ne détecte aucune `CRITICAL` — republie réellement les deux plateformes vers `registry.ban7-remote-lab.local` (`--push`). Une image jamais scannée n'atteint donc jamais le [[registre|registre-images]].

    `--exit-code 1` fait échouer le job dès qu'une CVE `CRITICAL` est détectée, ce qui arrête immédiatement la [[pipeline|pipeline-cicd]] avant `sign` et `deploy`. `--ignore-unfixed` (cours magistral, chapitre 17) évite de bloquer sur des CVE sans correctif disponible. `API_DIGEST`/`WEB_DIGEST`, écrits dans `build.env` via un rapport `dotenv`, seront repris tels quels par le stage `sign` du chapitre suivant, sans nouvel appel réseau pour retrouver le digest exact de l'image tout juste publiée.

  • Signer les images avec Cosign

    Une image exempte de CVE `CRITICAL` connues n'est pas pour autant garantie authentique : rien n'empêche, en théorie, qu'une image portant ce tag soit remplacée après coup, ou qu'un tiers publie une image usurpant le nom `coworknet-api` sur un [[registre|registre-images]] mal configuré. Le cours magistral Sécuriser un [[cluster|cluster]] [[Kubernetes|kubernetes]] (chapitre « Signature d'images : Cosign et contrôle à l'admission ») détaille ce mécanisme en profondeur ; ce TP retient la signature par clé statique, plus simple à mettre en œuvre dans GitLab CI sans fournisseur OIDC déjà configuré.

    Le stage `sign`, ajouté juste après `scan`, signe par digest — jamais par tag, réaffectable — chacune des deux images. `needs: [{ job: scan:images, artifacts: true }]` récupère `API_DIGEST`/`WEB_DIGEST`, écrits dans `build.env` par `scan:images` (rapport `dotenv`, chapitre précédent) : aucun appel réseau supplémentaire n'est nécessaire pour retrouver le digest exact de l'image qui vient d'être scannée et publiée.

    `--yes` évite l'invite de confirmation interactive de `cosign sign`, incompatible avec un job non interactif ; `cosign` lit le mot de passe de la clé privée directement dans la variable d'environnement `COSIGN_PASSWORD`, sans avoir besoin de la redéclarer. `after_script` supprime `cosign.key` du système de fichiers du job même si un `script` précédent échoue : cette étape s'exécute toujours, contrairement à `script`, précisément pour ce type de nettoyage sensible.

    La chaîne d'approvisionnement s'arrête pour l'instant à la publication d'une image signée : rien, dans le [[cluster|cluster]] lui-même, ne vérifie encore cette signature avant d'accepter un [[Pod|pod]]. C'est l'objet du chapitre suivant.

  • Kyverno : vérifier la signature à l'admission

    Vérifier une signature Cosign à l'admission suppose de contacter le [[registre|registre-images]], récupérer l'artefact de signature associé à l'image et valider la preuve cryptographique — une opération que le langage CEL des `ValidatingAdmissionPolicy` natives ne sait pas exécuter (chapitre 9 du cours magistral : CEL évalue des champs de la ressource entrante, jamais un appel réseau externe). Kyverno, lui, embarque nativement cette capacité via `ImageValidatingPolicy`. Ce TP retient donc Kyverno pour cette étape ET pour la policy anti-`:latest` du chapitre suivant, plutôt que de faire cohabiter deux moteurs de policy pour deux besoins voisins.

    Kyverno vérifie une signature Cosign directement avec la clé publique `k8s/cosign.pub` (chapitre 5) — inutile de la stocker en `Secret`, elle est publique par construction. `matchImageReferences` cible uniquement les images de `registry.ban7-remote-lab.local/coworknet/` : la policy ne s'applique à aucune image tierce (`postgres:16-alpine`, par exemple, reste hors périmètre).

    Comme pour la [[Pod|pod]] Security Admission (chapitre 2), `validationActions: [Audit]` observe sans bloquer : indispensable ici, car `coworknet-api:tp03`/`coworknet-web:tp03`, actuellement déployées, n'ont jamais été signées. Une fois `kubectl get policyreport -A` confirmant qu'aucune violation ne subsiste sur les images réellement publiées par le chapitre 4, `[Audit]` bascule en `[Deny]`.

    Un [[Pod|pod]] de test référençant directement une image non signée du même [[registre|registre-images]] confirme déjà le comportement attendu, sans attendre la bascule du chapitre 7 : la policy le détecte correctement, elle n'est simplement pas encore en mode bloquant.

  • Kyverno : interdire :latest, exiger des ressources, reconnecter le cluster au registre

    Il reste un dernier angle mort : rien n'empêche un `kubectl apply` manuel de déployer un [[Pod|pod]] `:latest`, un tag réaffectable qui contourne de fait toute la chaîne scan/signature des chapitres 4-6, ni un [[Pod|pod]] sans `requests`/`limits`, qui fausserait le `HorizontalPodAutoscaler` `api` du TP06. Kyverno étant déjà installé (chapitre 6), cette policy le réutilise plutôt que d'introduire `ValidatingAdmissionPolicy` pour un seul besoin isolé — un seul moteur de policy à opérer sur ce [[cluster|cluster]].

    Deux expressions CEL indépendantes, toutes deux évaluées : la première rejette `:latest` ainsi que l'absence de tag (`nginx` sans suffixe vaut implicitement `:latest`, tout aussi risqué) ; la seconde exige les quatre champs `requests`/`limits` CPU et mémoire. `api` (TP06) passe déjà les deux contrôles ; `web` et `db` n'ont, eux, jamais reçu de `resources` — cette policy en `Deny` immédiat les bloquerait donc avant même d'être corrigés : elle est appliquée seulement après les avoir complétés, ci-dessous.

    Reste à faire pointer `api`/`web` vers l'image scannée et signée du chapitre 5 plutôt que le tag local `tp03` — et donc à permettre au [[cluster|cluster]] de tirer une image depuis `registry.ban7-remote-lab.local`, ce qu'il n'a jamais fait depuis le TP03. Un `Secret` `docker-registry` porte ces identifiants, référencé en `imagePullSecrets` par chaque `Deployment`.

    `k8s/web-deployment.yaml` reçoit exactement le même changement (`imagePullSecrets`, image `registry.ban7-remote-lab.local/coworknet/coworknet-web:1.1.0`), non répété ici. Les deux policies Kyverno peuvent enfin s'appliquer et les [[manifestes|manifeste]] être rejoués.

  • Bilan : le cluster coworknet durci

    Le `Namespace` `coworknet` applique désormais `restricted` (chapitre 2), les conteneurs `api`/`web`/`db` tournent non-root, en lecture seule et sans capability superflue (chapitre 3 et fin du chapitre 2), chaque image est scannée par Trivy avant publication (chapitre 4), signée par Cosign (chapitre 5), et cette signature est vérifiée à l'admission par Kyverno (chapitre 6), qui interdit aussi tout [[Pod|pod]] `:latest` ou dépourvu de ressources (chapitre 7).

    Ce TP s'arrête volontairement là : la détection runtime (Falco, Tetragon), le chiffrement des `Secret` dans `etcd` et le mTLS [[pod|pod]]-to-[[pod|pod]] (Istio) restent hors périmètre — le cours magistral Sécuriser un [[cluster|cluster]] [[Kubernetes|kubernetes]] les couvre en détail pour qui veut aller plus loin.

    Chaque couche mise en place ici reste indépendante et se serait suffi à bloquer un scénario différent : c'est précisément la défense en profondeur posée en introduction — jamais un contrôle unique, toujours plusieurs qui se recouvrent. CoWorkNet tourne désormais sur un [[cluster|cluster]] qui applique, de bout en bout, les mêmes garanties déjà tenues côté image depuis le TP01 — non-root, scan, signature — mais vérifiées cette fois par le [[cluster|cluster]] lui-même à chaque admission, plutôt que confiées à la seule discipline de qui exécute `kubectl apply`.

Optimiser les coûts Kubernetes de CoWorkNet avec Goldilocks

  • Le problème du dimensionnement au doigt mouillé et pourquoi Goldilocks

    Ce TP08 (CP11) prend une posture FinOps sur le déploiement [[Kubernetes|kubernetes]] de CoWorkNet mis en place au TP04 : `Namespace coworknet`, `Deployment api` (2 [[replicas|replique]]) et `Deployment web`. But final de la séance : Goldilocks — et le Vertical [[Pod|pod]] [[Autoscaler|hpa]] (VPA) qu'il pilote en mode recommandation — est installé sur le [[cluster|cluster]] local et surveille ce [[namespace|namespace]] ; son dashboard affiche des recommandations de `requests`/`limits` CPU et mémoire pour `api` et `web`, basées sur leur consommation réelle mesurée ; et les manifests des deux `Deployment` ont été ajustés en conséquence, avant/après comparé et justifié.

    Ces valeurs, posées au TP05 « au doigt mouillé » faute de données, exposent CoWorkNet à deux risques opposés. Trop généreuses, elles gaspillent de la capacité réservée au [[scheduler|kube-scheduler]] que `api`/`web` ne consomment jamais réellement — de la capacité de [[cluster|cluster]] payée pour rien. Trop serrées, le premier pic de charge se solderait par un `OOMKilled` ou une [[éviction|eviction]] qui mettrait le service à terre. Sans mesure de la consommation réelle, impossible de savoir de quel côté du problème CoWorkNet se trouve aujourd'hui.

    Le magistral FinOps et Rightsizing [[Kubernetes|kubernetes]] présente aussi Karpenter et Cast AI Anywhere pour optimiser les coûts — mais tous deux ciblent le dimensionnement des [[NŒUDS|node-k8s]] et exigent un vrai cloud (`EC2NodeClass`, permissions IAM...), impossible à reproduire sur un [[cluster|cluster]] local comme `kind` ou `minikube`. Goldilocks, lui, dimensionne les conteneurs eux-mêmes via le VPA, un composant [[Kubernetes|kubernetes]] standard qui ne dépend d'aucun cloud : c'est le seul chemin praticable pour ce TP, et donc le seul retenu ici. Qui veut approfondir Karpenter ou Cast AI se reportera au magistral.

    Chapitre suivant : installer Goldilocks par [[Helm|helm]] dans un [[namespace|namespace]] dédié, puis activer sa surveillance sur `coworknet` en y posant un simple label — aucune autre configuration n'est nécessaire pour démarrer l'observation.

  • Installer Goldilocks et activer la surveillance de coworknet

    Comme vu dans le magistral FinOps et Rightsizing [[Kubernetes|kubernetes]], Goldilocks s'installe par le [[chart Helm|helm]] officiel `fairwinds-stable/goldilocks`. Un TD fixe ses versions explicitement : chart `goldilocks` en version `9.2.1`, avec `--set vpa.enabled=true` pour que Goldilocks déploie lui-même le VPA Recommender et son Admission Controller — aucune installation séparée du VPA n'est nécessaire.

    L'installation crée trois familles de [[pods|pod]] dans le [[namespace|namespace]] `goldilocks` : le `goldilocks-controller` (crée un VPA par workload surveillé), le `goldilocks-dashboard` (interface web), et les composants VPA eux-mêmes — `vpa-recommender` et `vpa-admission-controller` (le `vpa-updater` n'est PAS installé : ce TP reste en mode `Off`, jamais en mode `Auto`, aucun [[pod|pod]] existant ne sera [[évincé|eviction]] automatiquement).

    La surveillance Goldilocks est opt-in par [[namespace|namespace]] (aucun [[namespace|namespace]] n'est observé par défaut). Un simple label sur `Namespace coworknet` suffit à l'activer : `kubectl label namespace coworknet goldilocks.fairwinds.com/enabled=true`. Le `goldilocks-controller` détecte le label en quelques secondes, liste les `Deployment` du [[namespace|namespace]] — `api` et `web` — et crée pour chacun un objet `VerticalPodAutoscaler` en mode `Off` : recommandation seule, aucune modification des [[pods|pod]] en cours d'exécution.

    `PROVIDED: False` est normal juste après l'activation : le VPA Recommender n'a pas encore assez d'historique de consommation pour produire une recommandation. C'est précisément ce que corrige le chapitre suivant, en générant une charge représentative sur `api`.

  • Générer une charge réaliste sur l'API

    Un `VerticalPodAutoscaler` fraîchement créé n'a rien à recommander tant qu'il n'a observé aucune consommation significative : au repos, `api` et `web` consomment presque rien, et une recommandation calculée sur cette quasi-inactivité serait trompeuse. Pour obtenir des métriques représentatives en une seule séance de TP, on génère artificiellement du trafic vers `api` plutôt que d'attendre un usage réel.

    Ce `Job`, nouveau fichier `k8s/coworknet/load-test-job.yaml`, lance 5 [[pods|pod]] identiques en parallèle (`parallelism: 5`), chacun bouclant 3000 requêtes `GET /espaces` vers le service interne `api.coworknet.svc.cluster.local:4000` — la route déjà exercée par le front depuis le [[TD11|td-11]], donc représentative de la charge réelle. `backoffLimit: 0` évite qu'un [[pod|pod]] en échec redémarre indéfiniment ; `restartPolicy: Never` laisse les [[pods|pod]] terminés visibles pour diagnostic.

    Le `Job` termine en quelques minutes, le temps que les 5×3000 requêtes s'écoulent sur les 2 [[replicas|replique]] d'`api`. `metrics-server` échantillonne la consommation CPU/mémoire de chaque [[pod|pod]] pendant cette fenêtre, et le VPA Recommender l'intègre à son historique. Relancer le `Job` deux ou trois fois, à quelques minutes d'intervalle, donne au Recommender plusieurs points de mesure plutôt qu'un seul pic isolé — un premier pas vers un historique exploitable, même s'il reste loin des 8 jours mentionnés dans le magistral pour des bornes hautes/basses vraiment stables.

  • Lire le dashboard Goldilocks et interpréter les recommandations

    Le `goldilocks-dashboard` n'expose par défaut aucune authentification ni [[Ingress|ingress]] : en TP, un simple `port-forward` suffit pour le consulter localement. Après quelques minutes d'observation (chapitre précédent), les objets `VerticalPodAutoscaler` de `coworknet` passent normalement à `PROVIDED: True`.

    Sur `http://localhost:8080`, le [[namespace|namespace]] `coworknet` affiche `api` et `web` avec, pour chacun, les `requests`/`limits` actuellement configurées face à deux colonnes de recommandation : `Guaranteed` (requests = limits, la classe [[QoS|classe-qos]] la plus stable, protégée en dernier recours en cas de pression mémoire) et `Burstable` (requests basses, limits plus hautes, tolère les pics mais s'expose davantage aux évictions).

    Comparé aux `500m`/`512Mi` de requests posés au TP05, le `target` mesuré (`110m`/`170Mi`) confirme un sur-provisionnement massif : `api` consomme réellement moins du quart des ressources qui lui étaient réservées. `api` et `web` sont des services stateless, faciles à redémarrer, au trafic variable — exactement le profil que le magistral associe à `Burstable` plutôt qu'à `Guaranteed` (réservé aux bases de données, caches ou brokers, où une [[éviction|eviction]] coûte cher). Ce TP retient donc `Burstable` pour les deux.

    Reste à traduire ces recommandations `Burstable` dans les manifests réels d'`api` et de `web` — l'objet du chapitre suivant, avant/après comparé explicitement.

  • Appliquer les nouvelles requests/limits

    Les recommandations `Burstable` du chapitre précédent s'appliquent directement dans `k8s/coworknet/api-deployment.yaml` et `k8s/coworknet/web-deployment.yaml`, les manifests posés au TP04 et dont les `requests`/`limits` avaient été ajustés « au doigt mouillé » au TP05.

    `requests` reprend le `lowerBound` du VPA (`80m`/`140Mi`) : le minimum raisonnable observé, suffisant pour que le [[scheduler|kube-scheduler]] place le [[pod|pod]] sans sur-réserver. `limits` reprend l'`upperBound` (`230m`/`260Mi`) : la marge haute mesurée, qui laisse `api` absorber un pic sans dépasser aussitôt. Le même raisonnement s'applique à `web`, avec ses propres bornes (`40m`/`90Mi` à `140m`/`180Mi`).

    Les deux manifests modifiés se déploient avec `kubectl apply`, qui déclenche un [[rolling update|rollingupdate]] standard — aucune [[éviction|eviction]] forcée par le VPA, resté en mode `Off` (chapitre 2). `kubectl top pods` confirme ensuite, sur la consommation réelle, que les nouvelles `requests` couvrent large la charge observée sans y coller au plus juste au point de risquer un `OOMKilled`.

    La consommation mesurée (`~100m`/`160Mi` pour `api`, `~52m`/`104Mi` pour `web`) reste confortablement entre les nouvelles `requests` et `limits` : le dimensionnement Burstable retenu tient la charge générée au chapitre 3, sans le gaspillage des valeurs posées au TP05.

  • Bilan et limite du TP

    Bilan chiffré : les `requests` cumulées d'`api` (2 [[replicas|replique]]) passent de `1000m`/`1024Mi` à `160m`/`280Mi`, celles de `web` de `250m`/`256Mi` à `40m`/`90Mi` — une capacité libérée que le [[cluster|cluster]] peut réallouer à d'autres workloads, sans qu'aucune ligne de code de CoWorkNet n'ait changé. Le but final annoncé au chapitre 1 est atteint : Goldilocks observe `coworknet`, ses recommandations `Burstable` ont remplacé les valeurs « au doigt mouillé » du TP05, avant/après vérifié par `kubectl top pods`.

    Limite assumée : ce TP ne couvre qu'un seul cycle d'observation, sur une charge générée en quelques minutes — une simplification pédagogique. Le magistral FinOps et Rightsizing [[Kubernetes|kubernetes]] rappelle que le VPA Recommender a besoin d'environ 8 jours de données pour des bornes hautes/basses vraiment fiables ; en conditions réelles, on répéterait donc cette observation sur plusieurs jours de trafic réel avant d'ajuster durablement des manifests de production, plutôt que sur un unique `Job` de charge synthétique.

    Pour aller plus loin sur le dimensionnement des [[NŒUDS|node-k8s]] plutôt que des conteneurs — hors de portée d'un [[cluster|cluster]] local — le magistral FinOps et Rightsizing [[Kubernetes|kubernetes]] détaille Karpenter (autoscaling direct par NodeClaim) et Cast AI Anywhere (boucle observer/recommander/appliquer étendue aux [[nœuds|node-k8s]]). Ce TP s'arrête volontairement au rightsizing des conteneurs via Goldilocks, le seul praticable ici.

Choisir un cluster Kubernetes local pour CoWorkNet

  • Pourquoi comparer maintenant : le choix tranché sans essai

    Ce TP09 revient sur une décision prise sans comparaison au TP03 (CP1) : déployer CoWorkNet sur un [[cluster|cluster]] [[Kubernetes|kubernetes]] local avec `k3d`, retenu « par défaut » sur la seule base du cours magistral Distributions [[Kubernetes|kubernetes]] légères, sans avoir testé les trois autres outils qui répondent au même besoin. À la fin de cette séance, un [[cluster|cluster]] local à 2 [[nœuds|node-k8s]] aura tourné avec chacun des quatre outils — `K3s`, `Kind`, `Minikube`, `K3d` —, les images `coworknet-api` et `coworknet-web` ([[TD25|td-25]]) auront été chargées localement dans chacun sans jamais passer par un [[registre|registre-images]] distant, le service `web` aura été exposé via un `Ingress` sur chacun, et un comparatif écrit chiffré débouchera sur un choix final justifié pour l'outillage quotidien de l'équipe CoWorkNet.

    Quatre critères sont mesurés à l'identique sur les quatre outils : le temps de démarrage d'un [[cluster|cluster]] à 2 [[nœuds|node-k8s]] (chronométré avec `time`), la facilité de chargement d'une image locale (une commande dédiée ou plusieurs manipulations), l'empreinte disque et mémoire au repos, et l'adéquation à un usage quotidien d'équipe (fiabilité multi-[[nœuds|node-k8s]], reproductibilité). Les quatre outils sont pinnés à une version précise : `K3s v1.34.2-k3s1`, `Kind v0.31.0` (image de [[nœud|node-k8s]] `kindest/node:v1.35.0`), `Minikube v1.37.0` ([[Kubernetes|kubernetes]] v1.34.0), `K3d v5.8.3` (K3s `v1.31.5-k3s1` embarqué).

    La méthode reste identique pour les quatre outils, seule la commande de création du [[cluster|cluster]] et celle de chargement d'image changent : un [[cluster|cluster]] à 2 [[nœuds|node-k8s]] (1 control-plane/server + 1 worker/agent), les deux images ci-dessus chargées localement, et le même [[manifeste|manifeste]] `k8s/ingress.yaml` (TP03/TP05, inchangé) appliqué pour exposer `web`. Ce [[manifeste|manifeste]] ne fixe volontairement aucune `ingressClassName` : chaque outil marque son propre contrôleur (`Traefik` pour K3s/K3d, `ingress-nginx` pour Kind/Minikube) comme classe par défaut, donc le même fichier fonctionne partout sans modification.

  • K3s : cluster à 2 nœuds, image locale et Ingress

    K3s ne tourne pas nativement en conteneurs (magistral, chapitre 1 : binaire + `systemd`) : provisionner deux vraies machines pour ce TP serait disproportionné. Rancher publie néanmoins une [[image Docker|image-docker]] officielle du même binaire (`rancher/k3s`), pensée précisément pour ce genre de test local multi-[[nœuds|node-k8s]] sans VM ni matériel dédié — server et agent deviennent chacun un simple [[conteneur Docker|conteneur-docker]], reliés par un réseau Docker dédié.

    Les deux conteneurs embarquent `containerd` séparément : une image chargée sur `k3s-server` reste invisible de `k3s-agent`. Contrairement à `kind load` ou `k3d image import`, K3s ne propose pas de commande de chargement dédiée — le magistral (chapitre 3) mentionne `k3s ctr`, l'outil bas niveau qui dialogue directement avec `containerd` : `docker save` exporte l'image du daemon Docker de l'hôte, `k3s ctr images import` l'injecte dans le `containerd` d'un [[conteneur|conteneur-docker]] donné, à répéter sur chaque [[nœud|node-k8s]] susceptible d'exécuter le [[pod|pod]].

    `Traefik`, activé par défaut dans K3s (magistral, chapitre 1), marque déjà sa `IngressClass` comme celle par défaut du [[cluster|cluster]] : `k8s/ingress.yaml` (TP03/TP05) peut donc s'appliquer tel quel, sans aucune `ingressClassName` à préciser. Le port 8091, publié plus haut sur `k3s-server`, relaie directement le trafic HTTP vers Traefik.

    Bilan K3s pour ce [[cluster|cluster]] à 2 [[nœuds|node-k8s]] : environ 27 secondes de démarrage, 4 imports `k3s ctr` d'environ 5 secondes chacun (2 images × 2 [[nœuds|node-k8s]], aucune propagation automatique entre [[nœuds|node-k8s]]), `docker system df -v` mesurant environ 640 Mo de disque et `docker stats --no-stream` environ 420 Mo de RAM au repos pour les deux conteneurs. Chapitre suivant : `Kind`.

  • Kind : cluster à 2 nœuds, image locale et Ingress

    Kind (magistral, chapitres 4-6) simule chaque [[nœud|node-k8s]] avec un [[conteneur|conteneur-docker]] `kindest/node`. Un [[cluster|cluster]] reproductible à 2 [[nœuds|node-k8s]] passe par un fichier de configuration [[YAML|yaml]] dédié à ce TP plutôt qu'une ligne de commande — exactement le même principe que `kind-ha.yaml` vu au magistral, mais ici pour un simple control-plane + worker, avec en plus le label `ingress-ready=true` requis par le déploiement officiel `ingress-nginx` pour Kind.

    Contrairement à K3s, `kind load docker-image` copie l'image vers TOUS les [[nœuds|node-k8s]] du [[cluster|cluster]] nommé en une seule commande, sans distinction control-plane/worker : un net gain de simplicité par rapport aux quatre imports manuels du chapitre précédent, au prix d'une commande dédiée à installer et invoquer par outil (le magistral, chapitre 6, la présente déjà pour un usage ponctuel).

    Le [[manifeste|manifeste]] officiel `ingress-nginx` pour Kind marque sa `IngressClass` comme celle par défaut du [[cluster|cluster]] : `k8s/ingress.yaml`, toujours sans `ingressClassName`, s'applique donc à l'identique du chapitre K3s — seul le port hôte change (8092, fixé par `extraPortMappings` dans `kind-tp09.yaml`).

    Bilan Kind : environ 34 secondes de démarrage plus l'installation d'`ingress-nginx` (non incluse par défaut, à la différence de Traefik chez K3s/K3d), 2 chargements `kind load` d'environ 6 secondes chacun (propagés automatiquement aux 2 [[nœuds|node-k8s]]), environ 1,7 Go de disque (deux images `kindest/node` complètes) et environ 640 Mo de RAM au repos. Chapitre suivant : `Minikube`.

  • Minikube : cluster à 2 nœuds, image locale et Ingress

    Minikube (magistral, chapitres 7-9) choisit son support via un driver ; le driver `docker`, le plus simple sur toutes plateformes, est retenu ici pour rester comparable aux trois autres outils. Le mode multi-[[nœuds|node-k8s]] (`--nodes=2`) crée un control-plane et un worker dédiés (`-m02`), avec un profil `tp09` isolé des autres clusters Minikube éventuellement déjà présents sur la machine.

    `minikube image load` charge une image existante vers le cache interne du profil, propagée à l'ensemble des [[nœuds|node-k8s]] du [[cluster|cluster]] (magistral, chapitre 9). `imagePullPolicy: Never` reste nécessaire dans les [[manifestes|manifeste]] de `k8s/` (déjà en place depuis le TP03) pour empêcher [[Kubernetes|kubernetes]] de tenter un pull distant sur une image pourtant déjà présente localement.

    L'addon `ingress` (magistral, chapitre 8) déploie un `ingress-nginx` complet et le marque par défaut — pas de `minikube tunnel` à lancer ici : le tunnel ne concerne que les Services `LoadBalancer` (magistral, chapitre 9), jamais les `Ingress`, qui restent joignables via l'IP du [[nœud|node-k8s]] sur le port 80 avec le driver `docker` sous Linux. Sous macOS/Windows, `minikube tunnel` reste la solution documentée par le magistral si cette IP n'est pas directement routable.

    Bilan Minikube : environ 52 secondes de démarrage (le plus lent des quatre, addon `ingress` en plus), 2 chargements `minikube image load` d'environ 9 secondes chacun, environ 2,1 Go de disque (image de base Minikube et images de [[nœud|node-k8s]] plus lourdes que Kind) et environ 980 Mo de RAM au repos. Chapitre suivant : `K3d` — celui déjà utilisé sans comparaison au TP03.

  • K3d : cluster à 2 nœuds, image locale et Ingress

    K3d (magistral, chapitres 10-12) enveloppe K3s dans des conteneurs Docker simples, sans daemon imbriqué ni support systemd à simuler — le plus léger des quatre supports. `--servers 1 --agents 1` reproduit la même topologie à 2 [[nœuds|node-k8s]] que les trois chapitres précédents, avec un mapping de port dédié vers le load balancer intégré pour ne pas entrer en conflit avec K3s (8091) ou Kind (8092).

    `k3d image import` copie une image vers l'ensemble des [[nœuds|node-k8s]] du [[cluster|cluster]] désigné en une seule commande, sans avoir à cibler chaque [[nœud|node-k8s]] comme pour K3s exécuté nu en conteneurs (chapitre 2) — K3d automatise exactement l'étape restée manuelle là-bas. `Traefik`, hérité de K3s (magistral, chapitre 12), est déjà la `IngressClass` par défaut, sans installation supplémentaire.

    Bilan K3d : environ 18 secondes de démarrage (le plus rapide des quatre), un seul import `k3d image import` d'environ 5 secondes pour les deux images propagées aux 2 [[nœuds|node-k8s]], environ 520 Mo de disque et environ 360 Mo de RAM au repos — les mesures les plus basses sur les trois critères chiffrés. Chapitre suivant : le comparatif complet, chiffres à l'appui.

  • Comparatif chiffré des quatre outils

    Les mesures des quatre chapitres précédents ont été prises sur la même machine, à la suite, réseau et charge stables entre chaque essai, sans qu'aucun [[cluster|cluster]] précédent ne reste actif en parallèle (`k3d cluster delete` / `kind delete cluster` / `minikube delete -p` / arrêt des conteneurs K3s entre deux chapitres). Elles ne prétendent pas à une rigueur de benchmark de production, mais suffisent à départager les quatre outils sur des écarts largement supérieurs à la marge d'erreur d'un chronométrage manuel.

    `K3d` démarre presque deux fois plus vite que `Minikube` : sans daemon interne à initialiser (contrairement à Minikube) ni `systemd`/`kubelet` complet à démarrer dans chaque [[nœud|node-k8s]] avant l'[[API server|kube-apiserver]] (contrairement à Kind), K3d n'a qu'à lancer le binaire K3s dans un [[conteneur|conteneur-docker]] nu. `K3s` en conteneurs Docker nus se situe entre les deux, sans le confort d'installation d'aucun des trois autres outils.

    Seul `K3s` exige de cibler chaque [[nœud|node-k8s]] individuellement : ni commande de [[cluster|cluster]] dédiée (le concept même de « [[cluster|cluster]] nommé » n'existe pas pour des conteneurs K3s lancés à la main), ni propagation automatique. `Kind`, `Minikube` et `K3d` s'appuient tous trois sur un nom de [[cluster|cluster]]/profil identifiant l'ensemble des [[nœuds|node-k8s]] concernés — seul `K3d` accepte plusieurs images en un seul appel, la commande la plus concise des quatre.

    `Minikube` paie le prix de son isolation et de ses add-ons (`ingress-nginx` complet, tableau de bord potentiel) en disque et en RAM, sans bénéfice pour CoWorkNet qui n'a besoin ni de GPU ni de VM. `Kind`, conforme upstream et pensé pour la CI, reste plus lourd que K3d/K3s pour un usage purement quotidien. Sur l'adéquation à l'équipe — un [[cluster|cluster]] recréé plusieurs fois par jour, sans administration entre deux sessions — `K3d` et `K3s` dominent nettement les trois critères chiffrés, `K3d` en tête sur les trois à la fois.

  • Choix final motivé pour l'équipe CoWorkNet

    Le comparatif du chapitre précédent tranche sans ambiguïté sur les trois critères chiffrés : `K3d` démarre le plus vite (~18 s), charge les images le plus simplement (une commande, deux images) et consomme le moins de disque et de RAM. Sur le quatrième critère — l'adéquation à un usage quotidien d'équipe — `K3d` hérite en plus de `Traefik` par défaut (pas d'add-on à installer, contrairement à Kind/Minikube) et d'un vocabulaire `server`/`agent` identique à celui de `K3s`, déjà connu de l'équipe via le magistral.

    `K3d` reste donc l'outil retenu pour l'outillage de développement quotidien de l'équipe CoWorkNet — la même conclusion qu'au TP03, mais désormais appuyée sur une mesure comparative plutôt que sur un choix par défaut. `K3s` garde un intérêt distinct pour une éventuelle démonstration en conditions plus proches d'un edge/homelab (matériel réel, hors périmètre de ce TP) ; `Kind` reste pertinent pour une CI strictement conforme upstream ; `Minikube` resterait le choix si CoWorkNet exigeait un jour un accès GPU ou une isolation VM complète, ce qui n'est pas le cas.

    Le but fixé en introduction est atteint : les quatre outils ont chacun fait tourner CoWorkNet en 2 [[nœuds|node-k8s]], avec chargement d'image local et [[Ingress|ingress]] fonctionnel, le comparatif chiffré est écrit dans `docs/conception/comparatif-clusters-locaux.md`, et le choix de `k3d` pour la suite du fil rouge repose désormais sur une décision motivée plutôt que sur une habitude héritée du TP03 (CP1).

Tarifs

Prix mensuel

29 € / mois

Durée estimée

2 mois

au rythme standard (77 chapitres)

Coût total estimé

58 €

prix mensuel × durée estimée

Commencer maintenant