Mode de lecture
Récit — sections 01-03 + manifestes, monitoring, commandes, comparaison, pièges
POC · Capitalisation pédagogique

Docker / Kubernetes
From Zero to Hero

Récit d'une journée de conteneurisation d'une appli Flask + Postgres + Traefik — du poste local à Kubernetes avec monitoring Prometheus/Grafana — doublé d'une synthèse de référence pour équipes issues du monde VM.

AuteurDavid Mabillot
Durée du POC~7-8h
Phases0 → 3
Sections9 (mode Récit)14 (mode Manuel)
StackFlask · Postgres · Traefik · minikube
— les sections sont repliées par défaut, cliquer sur un titre pour ouvrir
01 / 14

CONTEXTE

Tout a commencé par un besoin simple : une todo-list avec upload de fichiers, développée en Python. L'idée était de créer une vraie appli CRUD — créer, modifier, supprimer des tâches, et pouvoir joindre un document à chacune. Rien de révolutionnaire, mais assez concret pour que chaque couche technologique ait du sens. Ce contexte sert de fil rouge à travers tout le parcours : la même application, portée progressivement du poste local jusqu'à un cluster Kubernetes supervisé.
Cadre du POC · Choix volontairement simplifiés pour exposer les couches une à une : Flask en debug, secrets en clair, pas de TLS, pas de WSGI devant Flask. Ces simplifications sont la condition de la lisibilité pédagogique. La section 07 · Passer en production couvre explicitement ce qu'il faudrait durcir pour viser un déploiement réel.Bascule en Manuel complet en haut de page pour voir ce qu'il faudrait durcir afin de viser un déploiement réel.

Parcours pratique de conteneurisation d'une todo-list CRUD avec pièces jointes, réalisé en une journée. Destiné à des équipes issues du monde VM (VMware, KVM, « 1 serveur = 1 appli »).

Principe directeur : chaque concept Docker/Kubernetes est mis en parallèle avec son équivalent VM. Les pièges mentaux du transfuge VM→conteneur sont signalés explicitement, au moment où ils apparaissent.

Décisions d'architecture
  • uv — venv + dépendances + lockfile en une commande
  • Flask minimaliste — psycopg2 direct, pas de SQLAlchemy, pour voir le SQL réel
  • Traefik v3 — configuration par labels, pas de fichier de conf séparé
  • minikube avec driver Docker — cluster Kubernetes local dans un conteneur
  • Docker Hub — flux prod réel (build → push → pull par Kubernetes)
  • Postgres en Deployment en Phase 3 — pour comprendre PVC avant d'utiliser un Operator
Environnement
ComposantVersion
OSUbuntu 24.04 (zaltux)
Docker29.4.3
Docker Composev2.35.1
kubectlv1.35.5
minikubev1.35.0 · Kubernetes v1.32.0
Helmv3.21.0
uv / Python0.9.8 / 3.10
02 / 14

ARCHITECTURE

L'architecture applicative n'a pas changé — toujours Traefik devant, Flask au milieu, Postgres derrière. Ce qui évolue à chaque phase, c'est comment les trois services se trouvent, se parlent, et persistent leurs données. Quatre schémas — cliquez sur un onglet pour voir comment isolation, réseau, stockage et exposition changent d'une phase à l'autre.
Phase 0 — Exécution locale (sans Docker)
MACHINE HÔTE zaltux (Ubuntu 24.04) Navigateur localhost :5000 Flask uv run python main.py 127.0.0.1:5000 :5432 PostgreSQL 16 apt install postgresql systemd · /var/lib/postgresql/ Pas de reverse proxy 📁 ~/todo-app/uploads/ 📦 /var/lib/postgresql/data
Flask tourne directement via uv run python main.py, sans isolation. Accessible uniquement sur 127.0.0.1:5000 — personne d'autre sur le réseau ne peut y accéder.
PostgreSQL est installé comme service système (apt install postgresql), géré par systemd. Ses données vivent dans /var/lib/postgresql/ — elles persistent naturellement car c'est le filesystem de la machine.
Pas de reverse proxy. Le navigateur parle directement à Flask sur le port 5000. En prod, on ne fait jamais ça — on passe toujours par un proxy qui gère TLS, le routage, et l'exposition.
Les uploads sont stockés dans le dossier du projet (~/todo-app/uploads/). Ils persistent car c'est un répertoire local normal.
Phase 1 — Docker pur (3 conteneurs lancés à la main)
MACHINE HÔTE Navigateur :80 RÉSEAU DOCKER todo-net todo-traefik Traefik v3 · :80 labels Docker → routing todo-flask Flask · :5000 host="0.0.0.0" todo-postgres postgres:16 · :5432 non exposé à l'hôte 🔌 /var/run/docker.sock 📁 ./uploads (bind mount) 📦 todo-pgdata (volume nommé) 3 terminaux · 3 docker run séparés · docker run --name todo-traefik ··· docker run --name todo-flask ··· docker run --name todo-postgres ···
Traefik est le premier conteneur lancé. Il monte /var/run/docker.sock pour lire les labels des autres conteneurs et construire sa config de routing automatiquement. C'est pour ça que Flask n'a pas de fichier de config Traefik — ses labels Docker sont la config.
Flask doit écouter sur 0.0.0.0 (toutes les interfaces) et non 127.0.0.1. Dans un conteneur, 127.0.0.1 = le conteneur lui-même, pas la machine hôte. Sans ça, le port mapping est inutile.
Postgres est accessible uniquement depuis le réseau Docker todo-net. Son port 5432 n'est pas exposé à la machine hôte (-p absent). Flask le joint par le nom de conteneur todo-postgres — la résolution DNS est assurée par le réseau Docker nommé.
docker.sock — monter le socket Unix du daemon Docker dans un conteneur lui donne les droits root sur l'hôte. Risque de sécurité à connaître, acceptable en dev, à mitiger en prod via un socket proxy.
Le volume nommé todo-pgdata survit aux arrêts et suppressions de conteneur. Différent du bind mount des uploads qui pointe directement vers un dossier de la machine hôte.
Phase 2 — Docker Compose (même stack, un seul fichier)
MACHINE HÔTE · docker compose up -d Navigateur :80 RÉSEAU COMPOSE todo-app_default (auto) traefik Traefik v3 · :80 service: traefik flask build: . depends_on: postgres postgres postgres:16 healthcheck: pg_isready attend healthy 🔌 docker.sock 📁 ./uploads (bind mount) 📦 todo-pgdata (volume nommé) Source unique : docker-compose.yml → remplace les 3 docker run de la Phase 1 Compose préfixe auto les ressources : todo-app_flask_1 , todo-app_postgres_1 , etc.
Les conteneurs sont identiques à la Phase 1 — même image, même réseau, mêmes volumes. La différence est dans la déclaration : tout est dans un seul docker-compose.yml au lieu de 3 commandes docker run dans 3 terminaux.
depends_on: condition: service_healthy — Flask attend que Postgres soit dans l'état healthy avant de démarrer. "Healthy" est défini par le healthcheck pg_isready : Postgres doit répondre aux connexions, pas juste avoir démarré son processus.
Compose crée automatiquement un réseau nommé todo-app_default (préfixe = nom du dossier). Les services se joignent par leur nom de service (postgres, flask) — pas besoin de --network manuel.
Limite de Compose : tout tourne sur une seule machine. Si la machine tombe, tout tombe. Pas de scheduling, pas de self-healing inter-nœuds. C'est la limite qui justifie le passage à Kubernetes.
Phase 3 — Kubernetes via minikube (cluster local)
CONTROL PLANE (API Server · etcd · Scheduler · Controller Manager) kubectl apply -f k8s/ → le scheduler place les Pods sur les nœuds disponibles minikube = 1 seul nœud joue les rôles control plane + worker NAMESPACE todo Navigateur NodePort IngressRoute CRD Traefik Traefik Pod · NodePort :30080 Service flask ClusterIP · port: http selector: app=flask Deployment flask Pod · todo-flask:v5 ConfigMap flask-config Secret postgres-secret Service postgres ClusterIP · postgres.todo.svc.cluster.local Deployment postgres Pod postgres-xxx + PVC 1Gi Prometheus ServiceMonitor → /metrics Grafana :3000 Rolling update : kubectl set image deployment/flask flask=dmab44/todo-flask:v6 → 0 downtime
Le Control Plane (API Server, etcd, Scheduler) gère l'état du cluster. Avec minikube, il tourne sur la même machine que les workloads. En prod, il est sur des nœuds dédiés séparés. kubectl apply envoie les manifestes à l'API Server, qui les stocke dans etcd et demande au Scheduler de placer les Pods. Voir section 05 · Control plane pour le détail des 7 composants et le trajet complet d'un kubectl apply.
L'IngressRoute est une CRD Traefik — un objet Kubernetes personnalisé installé par le chart Helm Traefik. Le pod Traefik surveille ces objets et met à jour son routing automatiquement. En dehors du cluster, on accède à Traefik via un NodePort (:30080).
Le Service Flask est la façade stable devant le Deployment. Le Deployment peut redémarrer le Pod à n'importe quel moment — le Service garde la même IP et le même nom DNS. C'est lui que Traefik cible, jamais directement le Pod. Le port est nommé http — ce nom est référencé par le ServiceMonitor.
Le Service Postgres expose un nom DNS stable postgres.todo.svc.cluster.local — c'est la valeur de DB_HOST dans le ConfigMap. Flask n'a jamais besoin de connaître l'IP du Pod Postgres.
Prometheus + Grafana sont installés via Helm (kube-prometheus-stack). Prometheus scrape Flask via le ServiceMonitor sans qu'on touche à un seul fichier de config Prometheus. C'est la valeur ajoutée de l'Operator.
Rolling update — mettre à jour l'image Flask en production se fait sans downtime : Kubernetes démarre le nouveau Pod, attend qu'il soit Ready, puis arrête l'ancien. La commande en bas du schéma montre la syntaxe exacte utilisée pendant le parcours.
🔍 Zoom par couche — relire le schéma avec un seul filtre à la fois

Chaîne réseau du POC, du browser au Pod :

Browser
  ↓ http://<minikube-ip>:30080
NodePort Service (Traefik)         ← exposition cluster, port 30080
  ↓
Pod Traefik
  ↓ surveille les IngressRoute (CRD)
IngressRoute "flask-route"          ← règle HTTP host/path
  ↓ référence
Service "flask" (ClusterIP)         ← IP virtuelle stable, port nommé "http"
  ↓ équilibre vers
Pod Flask (1 ou N replicas)         ← sélectionné par label app=flask
  ↓ utilise
Service "postgres" (ClusterIP)      ← DNS : postgres.todo.svc.cluster.local
  ↓
Pod Postgres                        ← écoute sur 5432

Concepts à retenir : trois objets Service (NodePort en entrée, ClusterIP pour Flask et Postgres) + une CRD IngressRoute. Aucun Pod n'expose directement de port hors du cluster — seul le Service NodePort le fait.

Chaîne de stockage persistant pour Postgres :

Pod Postgres
  ↓ monte
volumeMount : /var/lib/postgresql/data
  ↓ référence
PersistentVolumeClaim "postgres-pvc"   ← demande : 1Gi, RWO
  ↓ bind dynamique via
StorageClass "standard" (default)      ← provisioner minikube hostpath
  ↓ provisionne
PersistentVolume "pvc-xxx-xxx"         ← volume réel sur /tmp/hostpath-provisioner

Concepts à retenir : séparation PVC (déclaration de besoin) / PV (volume réel) / StorageClass (politique de provisioning). En minikube, le PV est un répertoire sur le système de fichiers de la VM minikube. En production, c'est un EBS / GCE PD / Azure Disk / NFS / CephFS selon la StorageClass choisie. RWO = ReadWriteOnce = le volume ne peut être monté que par un Pod à la fois — fondement du choix POC explicité en section 4.4.

Comment Flask reçoit sa configuration :

ConfigMap "flask-config"           Secret "flask-secret"
  DB_HOST=postgres.todo.svc...       DB_PASSWORD=<base64>
  DB_NAME=tododb                     SECRET_KEY=<base64>
  DB_USER=todouser
        ↓ envFrom: configMapRef             ↓ envFrom: secretRef
              ↘                       ↙
                Pod Flask (env vars)
                  ↓ os.environ["DB_HOST"]
                application Python

Concepts à retenir : ConfigMap pour la config non sensible (hostnames, ports, mode applicatif), Secret pour la config sensible (mots de passe, tokens). Les deux sont injectés en variables d'environnement via envFrom dans le Deployment. Modifier un ConfigMap ne redémarre pas automatiquement les Pods — il faut kubectl rollout restart deployment/flask. Secret ≠ chiffré — c'est juste du base64 ; pour du vrai chiffrement en prod : Vault, External Secrets Operator.

Pipeline d'observabilité — comment Prometheus trouve Flask :

Pod Flask
  ↓ expose
endpoint /metrics (prometheus-flask-exporter, debug=False)
  ↓ exposé par
Service "flask" (port nommé "http")
  ↓ ciblé par
ServiceMonitor "flask-monitor"        ← CRD installée par kube-prometheus-stack
  ↓ découvert par
Prometheus Operator
  ↓ configure automatiquement
Prometheus Server                     ← scrape toutes les 15s
  ↓ requêtes PromQL
Grafana                               ← dashboards et alertes

Concepts à retenir : personne ne touche à prometheus.yml. Le ServiceMonitor est un objet Kubernetes qui décrit quoi scraper ; l'Operator se charge de reconfigurer Prometheus. Chaîne de labels critique : ServiceMonitor → Service → Pod, expliquée en détail en section 09. Si Prometheus n'arrive pas à scraper : kubectl get servicemonitor + Prometheus UI → Status → Targets. Section 06 · Debugging détaille la méthode.

Endpoints API

MéthodeRouteDescription
GET/api/tasksListe toutes les tâches
POST/api/tasksCrée une tâche (JSON: title)
PUT/api/tasks/<id>Met à jour titre + statut
DELETE/api/tasks/<id>Supprime une tâche
POST/api/tasks/<id>/fileAttache un fichier (multipart)
GET/api/tasks/<id>/fileTélécharge le fichier joint
DELETE/api/tasks/<id>/fileSupprime le fichier joint
GET/metricsEndpoint Prometheus (debug=False obligatoire)
03 / 14

PHASES

Le parcours s'est déroulé en quatre actes, du plus simple au plus complexe. D'abord l'appli native — Flask + Postgres installés directement sur le poste, pour valider que le code fonctionne avant d'introduire Docker. Puis Docker pur — chaque service lancé à la main, un terminal par commande docker run, pour comprendre ce que chaque argument fait. Ensuite Compose — tout rassemblé en un fichier déclaratif, avec healthchecks et ordre de démarrage. Enfin Kubernetes — déploiement sur un vrai cluster local (minikube), avec rolling updates, monitoring Prometheus/Grafana, et routing via Traefik. Chaque phase était une brique posée sur la précédente, jamais un saut dans le vide.
PHASE 0 L'appli en local, sans Docker ~1h

Objectif : avoir Flask + Postgres qui tournent nativement. Si quelque chose casse en Phase 1, on sait que c'est Docker, pas le code.

  • Installation Postgres native (apt install postgresql), création base + user
  • Init projet Python avec uv init, ajout dépendances
  • Code Flask : app/db.py, app/routes.py, app/__init__.py
  • Front HTML + JS vanilla dans app/static/index.html
  • Lancement : uv run python main.py — accès sur http://127.0.0.1:5000

Point clé uv : pyproject.toml = manifeste des dépendances. uv.lock = versions exactes de toutes les dépendances transitives.

uv
Gestionnaire de projets Python ultra-rapide (Rust). Remplace pip + venv + pip-tools en une commande. uv init crée le projet, uv add installe et verrouille, uv run exécute dans le venv automatiquement.
pyproject.toml / uv.lock
pyproject.toml déclare les dépendances directes (ce que vous voulez). uv.lock fige les versions exactes de toute la chaîne transitive (ce qui sera réellement installé). Ne jamais éditer uv.lock à la main.
Application factory (Flask)
Pattern create_app() dans __init__.py : instancie Flask, enregistre les blueprints, initialise les extensions. Permet d'instancier plusieurs fois l'appli (tests, config différente) sans effets de bord.
uv init / add / run pyproject.toml psycopg2 Flask factory pattern
PHASE 1 Docker pur — un conteneur à la fois ~2h

Objectif : conteneuriser Flask à la main, comprendre chaque commande docker run. Pas de Compose — on construit la stack pièce par pièce, dans 3 terminaux.

  • Écriture du Dockerfile ligne par ligne — FROM, COPY, RUN, CMD, EXPOSE
  • docker build -t todo-flask . — construction de l'image en couches
  • Premier piège : localhost dans le conteneur ≠ la machine hôte
  • Deuxième piège : Flask doit écouter sur 0.0.0.0
  • Réseau Docker nommé todo-net, volume nommé todo-pgdata
  • Traefik avec accès au socket Docker pour la découverte automatique des conteneurs
  • Flask avec labels Traefik pour le routing HTTP
  • Tag + push sur Docker Hub : dmab44/todo-flask:v5

Cache Docker : copier pyproject.toml avant le code source = les dépendances ne sont réinstallées que si elles changent. Le code change à chaque commit, les dépendances rarement.

Image Docker
Recette figée en couches (layers) décrivant un environnement d'exécution. Immutable une fois buildée. Chaque instruction Dockerfile crée une couche mise en cache.
VM analogue : template de VM / snapshot figé
Conteneur
Instance en cours d'exécution d'une image. Process isolé qui partage le noyau de l'hôte. Pas un OS complet. Démarrage en millisecondes. Son filesystem est éphémère par défaut.
VM analogue : instance de VM — sans OS complet ni hyperviseur dédié
Réseau Docker nommé
Réseau virtuel isolé dans lequel les conteneurs se parlent par leur nom (todo-postgres), pas par IP. Sur le réseau par défaut, la résolution DNS par nom ne fonctionne pas.
VM analogue : VLAN privé entre VMs
Volume nommé
Stockage persistant géré par Docker. Survit à la mort ou à la suppression du conteneur. Chemin physique géré par Docker (/var/lib/docker/volumes/), opaque pour l'utilisateur.
VM analogue : disque attaché à une VM, indépendant de son cycle de vie
Bind mount
Montage d'un dossier spécifique de l'hôte dans le conteneur. Modifications visibles des deux côtés en temps réel. Utilisé pour les uploads en développement.
VM analogue : VMware Shared Folders
Registry (Docker Hub)
Entrepôt d'images Docker public ou privé. docker push publie une image. docker pull (ou Kubernetes) la récupère. Tag versionné obligatoire (:v5 et non :latest).
VM analogue : bibliothèque de templates VM partagée
Dockerfile layers docker run / build / push réseau nommé volume nommé Traefik labels Docker Hub registry
PHASE 2 Docker Compose — tout en un fichier ~45min

Objectif : rassembler les 3 conteneurs dans un docker-compose.yml. Ce que la Phase 1 gérait en 3 terminaux, Compose le fait en 1 fichier déclaratif.

  • Services : traefik, postgres, flask
  • Healthcheck Postgres : pg_isready — Flask ne démarre qu'une fois Postgres healthy
  • depends_on: condition: service_healthy — ordre de démarrage garanti
  • Compose préfixe automatiquement les ressources avec le nom du projet
  • docker compose down sans -v conserve les volumes

Limite de Compose : orchestrateur mono-machine. Pas de scheduling inter-nœuds, pas de self-healing si le serveur tombe. C'est la raison du passage à Kubernetes en Phase 3.

Healthcheck
Sonde exécutée périodiquement dans le conteneur pour vérifier qu'il est fonctionnel (pas juste "démarré"). Postgres peut être démarré mais pas encore prêt à accepter des connexions. pg_isready vérifie que le socket PostgreSQL répond.
depends_on: condition: service_healthy
Attend que le service référencé soit dans l'état healthy (healthcheck passant) avant de démarrer. Plus robuste que depends_on seul qui attend juste que le conteneur soit démarré, pas prêt.
Déclaratif vs impératif
Approche impérative (Phase 1) : on dit comment faire — 3 commandes docker run. Approche déclarative (Phase 2 + K8s) : on dit quoi vouloir, l'outil calcule le chemin. Kubernetes pousse cette logique à l'extrême avec sa boucle de réconciliation.
docker-compose.yml healthcheck depends_on: condition déclaratif
PHASE 3 Kubernetes via minikube ~3-4h

Objectif : déployer l'appli sur un cluster Kubernetes local avec monitoring. Ce que Compose fait sur 1 machine, Kubernetes le fait sur N nœuds avec self-healing, rolling updates, et observabilité.

Pour approfondir les concepts mis en œuvre ici — hiérarchie Deployment/ReplicaSet/Pod, self-healing, probes readiness/liveness, stateless vs stateful — voir section 04 · Modèle mental Kubernetes. Pour approfondir les concepts mis en œuvre ici — hiérarchie Deployment/ReplicaSet/Pod, self-healing, probes readiness/liveness, stateless vs stateful — basculer en 📚 Manuel complet en haut de page pour accéder à la section Modèle mental Kubernetes.

  • Démarrage minikube : minikube start --memory=6g --cpus=4
  • Namespace dédié : kubectl create namespace todo
  • Image Flask pullée depuis Docker Hub (dmab44/todo-flask:v5) — flux prod réel
  • Deployment Flask + Deployment Postgres + PVC 1Gi (RWO)
  • Services : ClusterIP pour Postgres, NodePort pour Flask
  • Rolling update et rollback (kubectl rollout undo) — démo avec v99 inexistante
  • Traefik installé via Helm + IngressRoute (CRD)
  • Secret Postgres (base64) + ConfigMap Flask
  • kube-prometheus-stack via Helm — Prometheus + Grafana + AlertManager
  • Flask instrumenté : prometheus-flask-exporter, endpoint /metrics
  • ServiceMonitor créé — Prometheus scrape Flask automatiquement via l'Operator
Pod
Unité de base Kubernetes. Groupe de 1 à N conteneurs partageant le même réseau et stockage. Éphémère — ne jamais créer un Pod seul à la main, toujours via un Deployment.
VM analogue : proche d'une VM, mais sans OS complet, géré par le scheduler
Deployment
Objet K8s qui déclare N replicas d'un Pod et gère leur cycle de vie : rolling update, rollback, self-healing. Si un Pod meurt, le Deployment en recrée un.
VM analogue : Auto Scaling Group AWS
ReplicaSet
Créé automatiquement par un Deployment. Maintient le nombre souhaité de Pods. Chaque rolling update crée un nouveau ReplicaSet (conservé pour rollback possible).
Service
Point d'accès stable (IP fixe + DNS) devant un groupe de Pods. L'IP d'un Pod change à chaque redémarrage — le Service, jamais. Types : ClusterIP (interne), NodePort (port sur le nœud), LoadBalancer (IP externe cloud).
VM analogue : VIP HAProxy / load balancer devant un pool de VMs
PVC / PV / StorageClass
PVC = demande de stockage par l'appli. PV = volume physique provisionné. StorageClass = provisioner automatique. minikube crée le PV automatiquement. Modes d'accès : RWO (1 writer), RWX (N writers — nécessite CephFS/NFS).
VM analogue : ticket infra pour un disque + disque provisionné
Secret / ConfigMap
ConfigMap = config non sensible (DB_HOST, DB_NAME). Secret = config sensible en base64 — attention, base64 ≠ chiffrement. Injectés en variables d'env via envFrom.
VM analogue : fichier de config + coffre-fort d'entreprise
CRD (Custom Resource Definition)
Étend le vocabulaire Kubernetes avec de nouveaux types d'objets (IngressRoute, ServiceMonitor...). Installés par les Helm charts. kubectl get crds pour lister.
Operator
CRD + Controller. Code la connaissance opérationnelle dans une boucle de réconciliation. L'Operator Prometheus surveille les ServiceMonitor et recharge Prometheus automatiquement sans redémarrage.
VM analogue : runbook automatisé intégré au cluster
Helm
Gestionnaire de paquets Kubernetes. Un chart = ensemble de manifestes YAML paramétrables. Gère installation, mise à jour, rollback. helm install, helm upgrade, helm list.
VM analogue : apt/yum mais pour des applications K8s entières
Rolling update
K8s démarre le nouveau Pod, attend qu'il soit Ready (readinessProbe passante), puis arrête l'ancien. Zéro downtime si bien configuré. Chaque update incrémente le numéro de révision du Deployment.
VM analogue : blue/green manuel avec downtime
ServiceMonitor
CRD Prometheus. Indique à Prometheus quels Services scraper et sur quel endpoint (/metrics). Le port doit être référencé par son nom (http), pas son numéro. Chaîne : ServiceMonitor.matchLabels → Service.labels → Service.selector → Pod.labels.
VM analogue : entrée manuelle dans prometheus.yml
Deployment / ReplicaSet Service ClusterIP / NodePort PVC / PV Secret / ConfigMap Rolling update / Rollback Helm CRD / Operator ServiceMonitor
04 / 14

MODÈLE MENTAL KUBERNETES

Docker Compose et Kubernetes ressemblent tous les deux à du YAML qui orchestre des conteneurs. C'est trompeur. Compose décrit une stack sur une machine : il lance ce qu'on lui demande, redémarre si restart: always, et s'arrête là. Kubernetes décrit un état désiré dans un cluster, et un contrôleur boucle en permanence pour faire converger la réalité vers cet état. Tout le reste — self-healing, scaling, rolling update — découle de ce changement de paradigme. Cette section reprend les concepts qui n'apparaissent qu'en filigrane dans les phases, et les pose explicitement.

4.1Replicas, ReplicaSet, Deployment — la hiérarchie

Dans Compose, on lance un conteneur. Dans Kubernetes, on déclare combien d'instances doivent exister. Trois objets s'empilent pour porter cette idée, et il faut savoir à quel niveau on agit.

Hiérarchie
Deployment            # ce qu'on écrit, ce qu'on déploie
  └── ReplicaSet      # créé automatiquement, un par révision
        └── Pod(s)    # créés et surveillés par le ReplicaSet
              └── Container(s)   # 1 à N conteneurs par Pod
Replica

Une instance d'un Pod. Si replicas: 3, il y a trois Pods identiques qui tournent en parallèle.

ReplicaSet

L'objet qui maintient le nombre demandé de Pods. Si un Pod disparaît, il en recrée un. Si replicas passe de 3 à 5, il en crée deux de plus.

Deployment

L'objet de plus haut niveau, celui qu'on écrit. Il pilote les ReplicaSets et permet les rolling updates et rollbacks. Chaque mise à jour = nouveau ReplicaSet.

Pod nu — à éviter

Créer un Pod directement (kind: Pod) : possible mais à proscrire. Si le Pod meurt, personne ne le recrée. Toujours passer par un Deployment.

⚠ Point de bascule mental

En VM : « je lance ce service ». En Compose : « je décris cette stack ». En Kubernetes : « je déclare l'état désiré et je laisse le contrôleur faire converger ». Cette dernière formulation change tout — c'est ce qui permet self-healing, scaling, et rolling update sans rien ajouter au modèle.

4.2Self-healing — la boucle de réconciliation en action

Le self-healing n'est pas une fonctionnalité ajoutée, c'est la conséquence directe de la boucle de réconciliation. Le ReplicaSet compare en permanence l'état réel (X Pods existent) à l'état désiré (replicas: 3), et corrige l'écart.

Démo
# Tuer un Pod manuellement
kubectl delete pod flask-7c9b4f-x2p9q -n todo

# Regarder Kubernetes en recréer un (option -w = watch)
kubectl get pods -n todo -w

# Sortie attendue :
# flask-7c9b4f-x2p9q   1/1   Terminating   0   5m
# flask-7c9b4f-k4m7r   0/1   Pending       0   0s   ← nouveau Pod
# flask-7c9b4f-k4m7r   1/1   Running       0   3s
VM

Le processus tombe ? Il faut systemd, un superviseur, ou une intervention humaine. La récupération est locale à la machine.

Docker Compose

restart: always redémarre le conteneur. Mais limité à une machine — si la machine tombe, tout tombe avec elle.

Kubernetes

Le contrôleur recrée le Pod, et le scheduler peut le placer sur un autre nœud si celui d'origine a disparu. Self-healing à l'échelle du cluster.

4.3Scaling horizontal — replicas à la demande

Une fois la notion de replicas posée, le scaling devient trivial — à condition que l'application soit stateless.

Bash
# Monter Flask à 3 replicas
kubectl scale deployment flask --replicas=3 -n todo

# Vérifier que 3 Pods tournent
kubectl get pods -n todo -l app=flask

# Revenir à 1
kubectl scale deployment flask --replicas=1 -n todo
⚠ Le pré-requis silencieux : stateless

Scaler Flask à 3 replicas n'a de sens que si l'application est sans état local. Si les fichiers uploadés sont stockés dans le filesystem du Pod, chaque Pod aura sa propre copie — incohérent. Si les sessions sont en mémoire locale, l'utilisateur sera déconnecté à chaque requête routée vers un autre Pod. Externaliser l'état : Postgres pour les données, S3 / volume RWX pour les fichiers, Redis pour les sessions.

4.4Stateless vs Stateful — deux workloads, deux logiques

C'est probablement la distinction la plus importante quand on conçoit un déploiement Kubernetes. Tous les conteneurs ne se traitent pas de la même façon.

Flask — stateless

Pas d'état durable local. Configuration injectée par variables d'env, ConfigMap ou Secret. On peut supprimer et recréer un Pod sans perte fonctionnelle.

Deployment, scaling horizontal libre, replicas: N sans restriction.

Postgres — stateful

Porte des données. Besoin d'un stockage persistant (PVC). Ne se réplique pas trivialement avec replicas: 3 — une base de données nécessite une stratégie dédiée (réplication, backup, restore, PITR).

StatefulSet pour l'identité stable, ou base managée, ou Operator dédié (CloudNativePG, Zalando, Crunchy).

StatefulSet — quand l'identité du Pod compte

Un Deployment crée des Pods interchangeables avec des noms aléatoires (flask-7c9b4f-x2p9q). Un StatefulSet crée des Pods avec une identité stable : postgres-0, postgres-1, postgres-2. Chaque Pod garde son PVC associé même après redémarrage, et l'ordre de création/suppression est contrôlé. Nécessaire pour les clusters de bases, Kafka, Elasticsearch, ZooKeeper.

📌 Choix du POC assumé — Postgres en Deployment + PVC RWO

Dans ce POC, Postgres tourne en Deployment à 1 replica + PVC en mode ReadWriteOnce — c'est-à-dire la plus mauvaise des quatre options listées ci-dessous pour une base de données. Ce choix est volontaire et pédagogique : il permet de voir le mécanisme PV/PVC, le binding StorageClass, et le comportement de kubectl delete pod postgres qui recrée un Pod réutilisant le même volume. Mais il ne tient pas en production.

Limites du choix POC : avec un seul replica, toute mise à jour cause une interruption (le Pod est tué avant que le nouveau démarre, contrairement à Flask en rolling update). En cas de panne du nœud, le Pod est replanifié ailleurs mais le PVC RWO ne suit pas si le storage n'est pas réseau. Aucune réplication, aucune sauvegarde automatisée, aucun PITR.

Ce qu'il faudrait en production, par ordre de préférence décroissante :

  • 1. Base managée externe (RDS, Cloud SQL, Azure Database) — laisser quelqu'un d'autre gérer le HA, les backups, les upgrades.
  • 2. Operator dédié dans le cluster (CloudNativePG, Zalando postgres-operator, Crunchy) — réplication primaire/standby, backup automatisé, failover géré.
  • 3. StatefulSet nu avec PVC + scripts maison — possible mais fragile.
  • 4. Deployment + PVC RWO — ce qui est fait ici. POC seulement.

↗ Détail en section 7.3 · Ce qu'il faut durcir pour passer en prod.

4.5Service — load balancer interne devant les replicas

Si Flask a trois Pods, comment Traefik leur parle ? Pas en pointant vers chacun — leurs IP changent à chaque redémarrage. Le Service est l'abstraction qui rend les replicas adressables.

Chaîne de résolution
Ingress / Traefik
      ↓ (route HTTP)
Service flask            # IP stable, DNS stable : flask.todo.svc.cluster.local
      ↓ (sélection par label app=flask)
Pod flask-7c9b4f-x2p9q   # IP éphémère
Pod flask-7c9b4f-k4m7r   # IP éphémère
Pod flask-7c9b4f-r3n2v   # IP éphémère
Sans Service

Trois Pods, trois IP qui changent. Chaque appelant doit découvrir et tracker les Pods. Impossible à maintenir.

Avec Service

Un nom DNS stable, une IP virtuelle. Le Service équilibre le trafic en round-robin (ou via session affinity) sur les Pods sains.

⚠ Endpoints vides = selector cassé

Le Service ne route que vers les Pods dont les labels matchent son selector. Si kubectl get endpoints -n todo ne montre aucune IP pour un Service, aucun Pod ne matche son selector — vérifier les labels avec kubectl get pods --show-labels. C'est l'une des pannes silencieuses les plus classiques de Kubernetes.

4.6Readiness / Liveness — deux questions différentes

Un Pod en Running n'est pas forcément prêt à recevoir du trafic. Et un Pod qui répond peut être bloqué dans une boucle inutile. Deux probes pour deux questions distinctes.

readinessProbe — « peut-il recevoir du trafic ? »

Si la probe échoue, le Pod est retiré du Service (pas de trafic vers lui), mais il n'est pas redémarré. Sert au démarrage lent (chargement de cache, connexion DB) et au rolling update sans downtime.

livenessProbe — « faut-il le redémarrer ? »

Si la probe échoue, Kubernetes tue et redémarre le conteneur. Sert à récupérer d'un blocage (deadlock, fuite mémoire qui rend l'appli muette).

extrait flask-deployment.yaml
spec:
  containers:
  - name: flask
    image: dmab44/todo-flask:v5
    ports:
    - name: http
      containerPort: 5000
    readinessProbe:
      httpGet:
        path: /health
        port: 5000
      initialDelaySeconds: 5     # attendre 5s avant la première vérif
      periodSeconds: 10            # puis vérifier toutes les 10s
    livenessProbe:
      httpGet:
        path: /health
        port: 5000
      initialDelaySeconds: 15    # plus tardive — laisser le temps de démarrer
      periodSeconds: 20
⚠ readinessProbe = condition du rolling update zéro-downtime

Sans readinessProbe, Kubernetes considère qu'un Pod en Running est prêt — même si l'appli met 10 secondes à charger. Pendant ces 10 secondes, le trafic arrive et échoue. La readinessProbe est ce qui rend le rolling update réellement sans interruption.

4.7Rolling update — la mécanique des deux ReplicaSets

Quand on passe de v5 à v6, Kubernetes ne remplace pas brutalement tous les Pods. Il crée un nouveau ReplicaSet et fait progressivement basculer les replicas de l'ancien vers le nouveau.

Déroulé
Deployment flask  (replicas: 3)
  ├── ReplicaSet v5   replicas: 3 → 2 → 1 → 0   # ancien, vidé progressivement
  └── ReplicaSet v6   replicas: 0 → 1 → 2 → 3   # nouveau, monté progressivement

À tout instant : nb Pods total entre (3 - maxUnavailable) et (3 + maxSurge)
strategy dans le Deployment
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1   # max 1 Pod indisponible pendant la transition
      maxSurge: 1         # max 1 Pod en surnombre temporaire
maxUnavailable

Combien de Pods on accepte de perdre temporairement. 0 = aucune dégradation, mais update plus lent (il faut créer avant de tuer).

maxSurge

Combien de Pods en plus on autorise pendant la transition. Plus élevé = update plus rapide, mais pic de ressources.

Les anciens ReplicaSets sont conservés (par défaut 10, paramétrable via revisionHistoryLimit) — c'est ce qui permet kubectl rollout undo.

4.8Requests / Limits — déclarer ses besoins en ressources

En VM, on alloue une machine de 2 vCPU / 4 Go et on espère que l'appli rentre dedans. En Kubernetes, on déclare deux choses différentes : ce dont l'appli a besoin pour démarrer (requests) et ce qu'on l'autorise à consommer au max (limits).

extrait Deployment
spec:
  containers:
  - name: flask
    resources:
      requests:
        cpu: "100m"      # 0.1 vCPU réservé — le scheduler en a besoin pour placer le Pod
        memory: "128Mi"
      limits:
        cpu: "500m"      # 0.5 vCPU max — au-delà, throttling
        memory: "512Mi"   # au-delà, OOMKilled
requests = pacte avec le scheduler

Le scheduler somme les requests de tous les Pods d'un nœud et refuse d'en placer un nouveau si le total dépasse la capacité. Sous-estimer les requests = nœuds surchargés en réalité.

limits = sécurité dure

Dépasser limits.cpu = throttling (l'appli ralentit). Dépasser limits.memory = OOMKilled (le conteneur est tué net). Pas de marge de manœuvre.

⚠ Sans requests/limits, le comportement du cluster est imprévisible

Un Pod sans requests peut être placé n'importe où, et venir étrangler ses voisins. Un Pod sans limits peut consommer toute la RAM du nœud et provoquer des évictions en cascade. En cluster partagé, requests/limits sont obligatoires — souvent imposés par des LimitRange ou des ResourceQuota au niveau du namespace.

4.9Labels et selectors — la colle invisible

Kubernetes relie ses objets par labels, pas par référence directe. Un Service ne pointe pas vers une liste de Pods — il déclare « je veux tous les Pods qui ont app=flask ». C'est puissant, mais une typo dans un label suffit à provoquer une panne silencieuse.

Chaîne de selectors
Deployment.spec.template.metadata.labels    →  app: flask, version: v5
       ↓ crée des Pods avec ces labels
Pod.metadata.labels                          →  app: flask, version: v5
       ↑ sélectionnés par
Service.spec.selector                        →  app: flask
       ↑ sélectionné par
ServiceMonitor.spec.selector.matchLabels     →  app: flask
       ↑ sélectionné par
NetworkPolicy.spec.podSelector               →  app: flask
Bash — diagnostic
# Voir tous les labels des Pods
kubectl get pods -n todo --show-labels

# Voir le selector d'un Service
kubectl describe service flask -n todo

# Voir si le Service trouve effectivement des Pods
kubectl get endpoints -n todo
# Si vide → selector cassé, aucun Pod ne matche

4.10HPA — le scaling automatique

Une suite naturelle de replicas : laisser Kubernetes ajuster le nombre de Pods en fonction de la charge réelle, sans intervention manuelle.

Bash
# Auto-scaler Flask entre 1 et 5 replicas selon CPU
kubectl autoscale deployment flask -n todo \
  --cpu-percent=70 --min=1 --max=5

# Voir le HPA
kubectl get hpa -n todo

# NAME    REFERENCE          TARGETS    MINPODS  MAXPODS  REPLICAS  AGE
# flask   Deployment/flask   34%/70%    1        5        2         3m
replicas manuel

kubectl scale --replicas=3 — nombre fixe, décidé par l'opérateur.

HPA

Nombre ajusté automatiquement selon CPU, mémoire ou métriques custom (via Prometheus Adapter). Nécessite metrics-server installé dans le cluster.

⚠ Prérequis silencieux — sans metrics-server, le HPA reste muet

La commande kubectl autoscale crée bien l'objet HPA mais il reste indéfiniment en TARGETS: <unknown>/70% et REPLICAS: 0 tant que metrics-server n'est pas installé dans le cluster — c'est lui qui fournit l'API metrics.k8s.io sur laquelle le HPA s'appuie pour lire le CPU/mémoire des Pods. Sur minikube : minikube addons enable metrics-server. Sur EKS / GKE / AKS : généralement déjà installé ou disponible en un clic. Sur cluster on-premise via kubeadm : à installer manuellement (manifeste officiel ou Helm chart). Pour des métriques custom (RPS, latence, profondeur de queue), il faut en plus Prometheus Adapter qui expose custom.metrics.k8s.io alimenté par les métriques Prometheus.

Test rapide : kubectl top pods -n todo. Si la commande renvoie une erreur « Metrics API not available », le HPA ne fonctionnera pas non plus. Si elle affiche CPU/RAM, le HPA est opérationnel.

4.11Synthèse — ce qui découle de « état désiré »

Une seule idée, sept conséquences

Tous les concepts de cette section dérivent d'un même mécanisme : déclarer un état désiré, laisser un contrôleur faire converger. C'est le glissement central par rapport au monde VM ou Compose.

  • Self-healing — convergence après perte d'un Pod
  • Scaling horizontal — convergence après changement de replicas
  • Rolling update — convergence d'un ReplicaSet vers un autre
  • Service stable — abstraction nécessaire pour adresser des replicas changeants
  • Readiness probes — condition pour qu'un replica entre dans la rotation
  • Liveness probes — déclencheur de convergence quand un Pod est cassé sans le savoir
  • Requests/limits — données d'entrée du scheduler pour faire converger le placement

Compose lance des choses. Kubernetes garde des choses vraies. Cette différence est petite à écrire et énorme à exploiter.

05 / 14

LE CONTROL PLANE

Jusqu'ici, tout ce qu'on a écrit est déclaratif : on a posé des manifestes, et « Kubernetes » a fait converger. Mais qui est ce « Kubernetes » concrètement ? Quand on tape kubectl apply -f deployment.yaml, où va le YAML ? Qui décide sur quel nœud placer le Pod ? Qui recrée un Pod qui meurt ? Cette section ouvre le capot — pas pour devenir administrateur de cluster, mais pour ne plus traiter le control plane comme une boîte noire. C'est la question évidente qu'un esprit issu du monde VM finit par poser : « OK, l'orchestrateur orchestre, mais l'orchestrateur, c'est qui ? »

5.1Les cinq composants du control plane

Un cluster Kubernetes se sépare en deux plans. Le control plane décide. Les nœuds workers exécutent. Tout ce qu'on a fait jusqu'ici parlait à un seul composant — kube-apiserver — qui orchestre les quatre autres.

Architecture
# CONTROL PLANE (master node)
┌─────────────────────────────────────────────────────────────┐
│                                                             │
│   kubectl ──► kube-apiserver  ◄──► etcd                     │
│                    ▲                (stockage clé-valeur)   │
│                    │                                        │
│              ┌─────┴─────┐                                  │
│              ▼           ▼                                  │
│       kube-scheduler   kube-controller-manager              │
│       (placement)      (boucles de réconciliation)          │
│                                                             │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼ (instructions)
┌─────────────────────────────────────────────────────────────┐
│  WORKER NODES                                               │
│                                                             │
│   kubelet ───► container runtime (containerd, CRI-O)        │
│      │              ▼                                       │
│      │         Pods (vos conteneurs)                        │
│      ▼                                                      │
│   kube-proxy (règles iptables / IPVS pour les Services)     │
│                                                             │
└─────────────────────────────────────────────────────────────┘
ComposantRôleÉquivalent VM
▼ Composants critiques — sans eux le cluster est aveugle ou inerte
kube-apiserverPoint d'entrée unique du cluster. Reçoit toutes les requêtes (kubectl, controllers, kubelet), valide, persiste dans etcd. Sans lui, le cluster est inutilisable.API vCenter / API CloudStack
etcdBase clé-valeur distribuée. Source de vérité unique de l'état du cluster. Tous les manifestes, secrets, état des Pods y sont stockés. Cluster Raft (3 ou 5 nœuds en prod).Base de configuration de vCenter
kube-schedulerDécide sur quel nœud placer un nouveau Pod. Filtre les nœuds éligibles (taints, ressources, affinités) puis score les survivants. Ne déplace pas les Pods existants.DRS (Distributed Resource Scheduler) VMware
kube-controller-managerHôte de toutes les boucles de réconciliation : Deployment controller, ReplicaSet controller, Node controller, Endpoints controller... Chacun observe son objet et corrige l'écart entre réel et désiré.Watchdogs systemd + scripts cron + supervisor
kubeletAgent sur chaque nœud worker. Reçoit les specs de Pods de l'apiserver, demande au container runtime de les exécuter, remonte l'état. Ne parle qu'à l'apiserver, jamais entre kubelets.Agent VMware Tools / agent d'hyperviseur
▼ Composants techniques — indispensables au fonctionnement, mais transparents au quotidien
kube-proxySur chaque nœud, programme les règles iptables (ou IPVS) qui implémentent les Services. Quand vous joignez postgres.todo.svc, c'est kube-proxy qui a installé la règle DNAT.Configuration HAProxy / Nginx upstream automatisée
container runtimeCe qui lance réellement les conteneurs sur chaque nœud. containerd (par défaut) ou CRI-O. Docker n'est plus utilisé directement depuis K8s 1.24.Hyperviseur (ESXi, KVM)
cloud-controller-managerInterface avec l'API du cloud sous-jacent (AWS, GCP, Azure). Crée les load balancers cloud quand un Service de type LoadBalancer est demandé, gère les routes. Optionnel hors cloud.Plugin de provisioning du cloud

5.2Le trajet d'un kubectl apply

Pour fixer les idées, voici ce qui se passe réellement quand vous lancez kubectl apply -f flask-deployment.yaml sur un cluster vierge.

1
kubectl → kube-apiserver

kubectl lit le YAML, l'envoie en HTTPS à l'apiserver (TLS mutuel via le kubeconfig). L'apiserver authentifie (cert, token, OIDC...), autorise (RBAC), valide le schéma, exécute les admission controllers.

2
kube-apiserver → etcd

L'apiserver persiste l'objet Deployment dans etcd. Rien d'autre ne s'est encore passé — aucun Pod n'existe. Le manifeste est juste enregistré comme « état désiré ».

3
Deployment controller observe

Le Deployment controller (dans controller-manager) regarde l'apiserver via un watch. Il voit le nouveau Deployment, vérifie qu'aucun ReplicaSet ne correspond, et en crée un. Toujours via l'apiserver, qui persiste dans etcd.

4
ReplicaSet controller observe

À son tour, le ReplicaSet controller voit qu'il existe un ReplicaSet avec replicas: 3 et 0 Pod. Il crée 3 objets Pod via l'apiserver. Les Pods sont créés mais n'ont aucun nœud assigné (nodeName vide).

5
kube-scheduler observe

Le scheduler voit les Pods sans nodeName. Pour chacun, il filtre les nœuds éligibles, score les survivants, choisit le meilleur, et met à jour le Pod avec nodeName: worker-2. Il n'a rien démarré, juste annoté le Pod.

6
kubelet observe

Sur worker-2, le kubelet voit qu'un Pod lui est assigné. Il demande au container runtime (containerd) de pull l'image, créer les namespaces Linux, démarrer les conteneurs. Il remonte ensuite l'état (Pending → ContainerCreating → Running) à l'apiserver.

7
Endpoints controller + kube-proxy

Quand le Pod passe Ready, l'Endpoints controller met à jour l'objet Endpoints du Service. kube-proxy sur chaque nœud voit le changement et installe les règles iptables qui routent le trafic du Service vers ce Pod.

Ce qu'il faut retenir

Tous les composants ne se parlent pas entre eux. Ils parlent tous à l'apiserver. Le scheduler ne dit pas au kubelet « démarre ce Pod » — il annote le Pod, et le kubelet voit l'annotation. Cette architecture en étoile autour de l'apiserver est ce qui rend Kubernetes recomposable : on peut remplacer le scheduler par un custom, ajouter un controller, sans rien casser. C'est aussi pourquoi perdre etcd = perdre le cluster.

5.3Ce que minikube cache

Dans le POC, minikube tourne tout dans un seul conteneur Docker — control plane et worker sur la même VM. C'est pratique pour apprendre, trompeur pour penser la production.

minikube — tout sur 1 nœud

1 process containerd. 1 etcd. 1 scheduler. Tout co-localisé. Si la VM minikube tombe, tout tombe. Aucune haute disponibilité possible.

Cluster prod — control plane séparé

3 ou 5 nœuds master (etcd en quorum Raft). N nœuds workers. Si un master tombe, les autres prennent le relais. Si un worker tombe, ses Pods sont reprogrammés ailleurs. C'est la séparation des deux plans qui rend ça possible.

06 / 14

DEBUGGING RUNTIME

Le parcours montre comment déployer sur Kubernetes. Cette section montre comment diagnostiquer quand le déploiement échoue. Parce qu'il échouera. Un Pending qui ne devient jamais Running, un CrashLoopBackOff récurrent, un ImagePullBackOff mystérieux — ce sont les états que l'on rencontre avant que l'appli ne fonctionne, pas après. La méthode est toujours la même : partir du symptôme, traverser les couches.

6.1Les cinq commandes à connaître

Cinq verbes kubectl couvrent 90 % du debugging. Dans cet ordre logique : voir l'état, comprendre le contexte, lire les sorties, vérifier en direct, intervenir en dernier recours.

1. kubectl get

Vue d'ensemble : quels objets existent, dans quel état ?

kubectl get pods -n todo
kubectl get pods -n todo -o wide
kubectl get all -n todo
2. kubectl describe

Détails et événements attachés à un objet. La section Events: en bas est ce qu'on lit en premier.

kubectl describe pod <nom> -n todo
3. kubectl events

Vue chronologique des événements récents du cluster. Souvent le premier réflexe : « que s'est-il passé ces 5 dernières minutes ? » avant même de cibler un Pod.

kubectl get events -n todo \
  --sort-by=.lastTimestamp
kubectl events -n todo --for pod/<nom>
4. kubectl logs

Logs stdout/stderr du conteneur. -f pour suivre, --previous pour les logs du conteneur précédent (essentiel après un crash).

kubectl logs <pod> -n todo -f
kubectl logs <pod> -n todo --previous
5. kubectl exec

Shell dans le conteneur, pour vérifier le filesystem, tester la connectivité, lire la config réelle. Dernier recours, pas premier réflexe.

kubectl exec -it <pod> -n todo -- sh

6.2Table des symptômes courants

Le statut d'un Pod n'est jamais aléatoire — chaque état correspond à une étape précise du trajet section 5.2 qui a échoué.

StatutCe que ça veut direPremière commande
PendingLe scheduler n'a pas trouvé de nœud, ou le Pod attend une ressource (PVC non bound, ImagePullSecret manquant, ressources insuffisantes)describe pod (Events)
ContainerCreatingLe kubelet prépare le Pod : pull d'image, montage des volumes, création des namespaces Linux. Normal pendant quelques secondes ; suspect au-delà de 30s.describe pod (Events)
ImagePullBackOffL'image n'a pas pu être pullée. Mauvais tag, registry privé sans imagePullSecret, ou pas de connectivité vers le registry.describe pod
ErrImagePullVariante de ImagePullBackOff, premier échec. Backoff exponentiel ensuite.describe pod
CrashLoopBackOffLe conteneur démarre puis crashe en boucle. Kubernetes attend de plus en plus longtemps entre chaque tentative.logs --previous
OOMKilledLe conteneur a dépassé limits.memory et a été tué par le kernel. Visible dans describe pod → Last State.describe pod
Running mais non ReadyLe conteneur tourne mais la readinessProbe échoue. Le Pod n'est pas dans le pool du Service.logs + describe pod
EvictedLe nœud était sous pression (mémoire, disque) et le kubelet a tué le Pod pour libérer des ressources.describe pod + describe node
CompletedPas une erreur — le conteneur s'est terminé proprement (code 0). Anormal pour Flask, normal pour un Job ou un initContainer.logs
Terminating (bloqué)Le Pod refuse de se supprimer. Souvent un finalizer ou un volume qui ne se démonte pas. Forcer avec --grace-period=0 --force.describe pod

6.3Méthode — du symptôme à la cause

Trois cas typiques, déroulés étape par étape.

Cas 1 — Pod en CrashLoopBackOff

Symptôme : kubectl get pods montre 0/1 CrashLoopBackOff avec un nombre de restarts qui grimpe.

Étape 1 : kubectl logs <pod> --previous — lire les logs du conteneur juste avant le crash. C'est là que se trouve la stack trace ou le message d'erreur. --previous est essentiel — sans, on lit les logs du conteneur encore en démarrage qui n'a rien produit.

Étape 2 : Si les logs sont vides : kubectl describe pod <pod>, section Last State. On y voit le code de sortie (Exit Code: 137 = OOMKilled, Exit Code: 1 = erreur applicative, Exit Code: 139 = segfault).

Étape 3 : Selon la cause — augmenter limits.memory, corriger la config (DB_HOST mauvais, secret manquant), corriger le code applicatif.

Cas 2 — Pod bloqué en Pending

Symptôme : kubectl get pods montre 0/1 Pending, sans progression.

Étape 1 : kubectl describe pod <pod> — la section Events: tout en bas dit pourquoi. Messages typiques : « 0/3 nodes are available: 3 Insufficient memory » (pas assez de RAM réservable sur le cluster), « waiting for first consumer to be created before binding » (PVC en attente), « no nodes available to schedule pods » (tous les nœuds NotReady).

Étape 2 : kubectl get nodes pour vérifier que les nœuds sont Ready. Si non : kubectl describe node <nom> côté Conditions.

Étape 3 : Selon la cause — baisser les requests du Pod, débloquer le PVC (StorageClass manquante ?), redémarrer un nœud NotReady.

Cas 3 — Service qui ne répond pas (Endpoints vide)

Symptôme : curl http://flask depuis un autre Pod renvoie « connection refused » ou timeout, alors que les Pods Flask sont en Running.

Étape 1 : kubectl get endpoints -n todo. Si la colonne ENDPOINTS du Service flask est <none> ou vide : le selector ne matche aucun Pod.

Étape 2 : kubectl get pods --show-labels -n todo puis kubectl get service flask -o yaml | grep -A3 selector. Comparer ligne à ligne. Une typo dans app: flask vs app: Flask suffit.

Étape 3 : Si les labels matchent mais Endpoints reste vide : vérifier que la readinessProbe passe (un Pod non-Ready n'entre pas dans Endpoints). kubectl describe pod → conditions.

⚠ kubectl logs sans --previous après un crash = perte d'information

Quand un Pod est en CrashLoopBackOff, le conteneur visible est celui qui vient de démarrer et n'a probablement rien encore produit. La stack trace, l'erreur de connexion, le secret manquant — tout ça est dans les logs du conteneur précédent. Sans --previous, on regarde le mauvais endroit et on conclut à tort que « les logs sont vides ».

07 / 14

PASSER EN PRODUCTION

Le parcours montre que K8s est traversable — une journée suffit pour déployer une appli avec rolling updates, monitoring et self-healing. Ce que le parcours ne montre pas, c'est ce qu'il faut payer ensuite, en continu, pour que ça reste vrai en production. Cette section décline ce qui manque entre « ça marche en minikube » et « ça tourne en production réelle ». Pas pour décourager — pour chiffrer la dette.
Note de ton · Cette section adopte un registre plus normatif que les précédentes — verdicts tranchés, chiffres explicites, recommandations directes. Les décisions d'engagement sur Kubernetes méritent un avis clair plutôt qu'une description neutre. Les seuils proposés (≥ 20 applications pour rentabiliser, ~6 mois de courbe d'apprentissage, ~10 mois entre upgrades) sont des ordres de grandeur défendables dans la plupart des contextes, à ajuster selon votre cloud, votre équipe et vos contraintes.

7.1Ce que K8s coûte vraiment (TCO)

Kubernetes n'est pas « plus efficace que des VM ». C'est une plateforme plus puissante qui a un coût d'entrée non-trivial. L'illusion d'efficacité vient de la compression CPU/RAM par bin-packing — réelle, mais qui n'apparaît qu'au-delà d'une certaine taille.

Coût plancher du control plane
infrastructure

Un cluster prod minimal demande 3 nœuds master (etcd en quorum), chacun avec ~4 vCPU / 8 Go pour absorber les charges. Soit ~12 vCPU / 24 Go rien que pour le control plane, sans qu'aucune appli ne tourne. Sur AWS EKS / GKE / AKS, le control plane est managé (~75 $/mois par cluster) mais les workers restent à votre charge. Sous ~10 applications, K8s coûte plus cher qu'une VM par appli.

Courbe d'apprentissage opérationnelle
humain

Six mois est l'ordre de grandeur pour qu'une équipe SI productive en VM devienne productive en K8s. Pas pour « faire tourner un Pod » — pour diagnostiquer un endpoint vide, comprendre pourquoi un PVC ne se bound pas, déboguer une NetworkPolicy qui bloque un flux. Ce n'est pas une affaire de tutoriels, c'est une affaire de cas réels rencontrés et résolus. Pendant cette période, la productivité chute avant de remonter.

Dette opérationnelle continue
durée

K8s release une version mineure tous les ~4 mois. Chaque version est supportée ~14 mois. Vous devez upgrader tous les ~10 mois en moyenne, sous peine de vous retrouver sur une version sans patch sécurité. Chaque upgrade : tester les CRD, valider les Helm charts, valider les workloads, planifier une fenêtre. À cela s'ajoutent les CVE container runtime, les CVE kernel sur les nœuds, le drift YAML entre environnements, et la dérive lente des Helm values custom.

Le seuil de rentabilité
à partir de

K8s devient économiquement intéressant quand on consolide 20 à 50 workloads minimum sur le même cluster. En dessous, le coût du control plane et de l'équipe ops dépasse le gain de bin-packing. Au-dessus, le gain devient massif — autant côté ressources que côté outillage standardisé (CI/CD, observabilité, secrets, RBAC partagés).

7.2Le namespace comme frontière — et ce qui le complète

En production, K8s est rarement mono-tenant. Plusieurs équipes, plusieurs métiers, parfois plusieurs lignes de défense partagent le même cluster. Le namespace est la frontière logique de base, mais à lui seul il ne protège rien — il faut quatre couches pour qu'un namespace soit réellement une frontière de sécurité.

1. Namespace — la délimitation

Sépare les objets par nom. Deux Pods flask dans deux namespaces ne se voient pas par défaut par nom DNS court (mais se voient par IP — le namespace n'est pas une frontière réseau).

2. RBAC — qui peut quoi

Role + RoleBinding définissent ce qu'un utilisateur ou un ServiceAccount peut faire dans un namespace. Sans RBAC strict, n'importe quel développeur peut lire tous les Secrets du cluster.

3. NetworkPolicy — qui parle à qui

Par défaut dans K8s, tous les Pods peuvent parler à tous les Pods, peu importe le namespace. Une NetworkPolicy applique des règles type pare-feu. Sans NetworkPolicy, la segmentation est illusoire.

4. ResourceQuota — combien peut-on consommer

Limite la somme des requests et limits d'un namespace. Sans quota, un namespace mal configuré peut consommer toute la capacité du cluster et étrangler les autres.

⚠ Le piège du namespace par défaut

Beaucoup de POC (dont celui-ci, en partie) utilisent un namespace dédié mais sans NetworkPolicy. Le namespace todo n'est isolé que par nom. N'importe quel Pod dans le cluster — y compris dans kube-system ou dans un namespace compromis — peut joindre postgres.todo.svc. En production, la règle est : default-deny au niveau du namespace, puis whitelist explicite des flux entrants et sortants.

exemple — NetworkPolicy default-deny
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: todo
spec:
  podSelector: {}              # s'applique à tous les Pods du namespace
  policyTypes:
    - Ingress
    - Egress
  # Aucune règle = tout bloqué
  # Il faudra ensuite ajouter des NetworkPolicy spécifiques
  # pour autoriser flask → postgres, traefik → flask, etc.

7.3Ce qu'il faut durcir pour passer en prod

Le tableau ci-dessous reprend les simplifications volontaires du POC et indique ce que chacune devient en production réelle.

Choix POCEn production
Flask en debug=False mais sans WSGIgunicorn ou uvicorn devant Flask, multi-workers, timeouts configurés
Pas de TLS — HTTP en clairTLS partout — cert-manager + Let's Encrypt (ou PKI interne), mTLS entre services via service mesh (Istio, Linkerd) si exigé
Secrets en base64Sealed Secrets, External Secrets Operator avec HashiCorp Vault / AWS Secrets Manager / Azure Key Vault, chiffrement etcd au repos
Postgres en Deployment + PVC RWOBase managée (RDS, Cloud SQL) — toujours préférer. Sinon Operator dédié (CloudNativePG, Zalando, Crunchy) avec backup automatisé, PITR, réplication.
Pas de NetworkPolicydefault-deny par namespace, whitelist explicite des flux. Audit régulier via Calico / Cilium policies.
Pas de RBAC restrictifUn ServiceAccount dédié par Deployment, Role minimal (principe du moindre privilège), revue d'accès régulière.
Pas de requests/limits strictsLimitRange + ResourceQuota imposés au namespace. Pas de Pod admis sans requests/limits.
Image :v5 sur Docker Hub publicRegistry privé (Harbor, ECR, GAR). Image signing (cosign / Sigstore). Scan de vulnérabilités à chaque push (Trivy, Grype). PodSecurity Admission « restricted ».
Pas de PSP / Pod Security StandardsPodSecurity Admission en mode restricted — pas de privilégié, pas de hostNetwork, pas de capabilities ajoutées.
kube-prometheus-stack tel quelPrometheus haute dispo (Thanos ou Cortex pour la rétention longue), alertes routées vers PagerDuty/Opsgenie, runbooks documentés par alerte.
Logs uniquement via kubectl logsCollecte centralisée (Loki, ELK, Splunk). Rétention conforme aux exigences applicables (souvent 1 à 5 ans en environnement régulé).
Aucune politique de backupVelero pour les objets K8s + PV. Tests de restore réguliers. Backup d'etcd séparé et chiffré.

7.4Les questions à poser avant un projet K8s

Avant de proposer K8s comme cible d'architecture, ces six questions calibrent si l'effort en vaut la chandelle.

Combien d'applications vont consolider sur ce cluster ?

Moins de 10 : K8s est probablement disproportionné. 20–50 : la zone où l'investissement commence à se rentabiliser. Plus de 50 : K8s devient quasi-incontournable.

Y a-t-il une équipe qui peut maintenir le cluster en astreinte ?

K8s en astreinte demande un savoir-faire spécifique. Sans cette équipe, le risque de panne longue est réel. À défaut : cluster managé (EKS, GKE, AKS, ou équivalent on-premise type OpenShift avec support Red Hat).

Les workloads cibles sont-ils réellement stateless ?

Si la moitié des applis sont des bases de données ou des middlewares stateful complexes, K8s apporte peu et complique beaucoup. K8s brille sur du stateless, est correct sur du stateful simple (avec Operators), et redevient discutable sur du stateful exotique.

Quelle est la maturité CI/CD préalable ?

K8s suppose des images conteneurs versionnées, un pipeline de build, un registry. Si le SI est encore en mode « copie de WAR sur un Tomcat », K8s n'est pas la prochaine étape — la conteneurisation l'est.

Quelles sont les contraintes réglementaires sur les données et les flux ?

Données client en cluster mutualisé ? Logs avec PII ? Flux sortants vers internet ? Toutes ces questions ont des réponses techniques en K8s (NetworkPolicy, audit logs, sealed secrets), mais chaque réponse coûte du temps et de la complexité.

Quel est le plan de sortie ?

K8s est un standard, mais les choix d'écosystème (Helm charts, Operators, CRD custom, Service Mesh) créent une dette de migration. Penser dès le départ comment on sortirait d'un Operator ou d'un Service Mesh donné est un exercice utile, même si on ne sort jamais.

7.5Quand K8s est le bon choix — et quand il ne l'est pas

Bon terrain pour K8s

Plateforme multi-équipes avec 20+ workloads. Microservices stateless. Besoin de déploiements fréquents zéro-downtime. CI/CD mature. Équipe ops dédiée ou cluster managé.

Terrain discutable

Monolithe historique unique. 2-3 applis seulement. Équipe ops VM sans temps formation. Workloads majoritairement stateful. Contraintes de conformité fortes mal cartographiées.

Mauvais choix

Une seule appli mono-tenant. Pas de pipeline de build. Pas d'équipe pour maintenir. Migration « parce que tout le monde le fait ». K8s n'est pas une fin en soi — c'est une réponse à un problème de plateforme. Sans le problème, pas la peine de payer la réponse.

Conclusion lucide

Ce POC montre qu'on peut traverser Docker → Compose → Kubernetes en une journée. C'est vrai, et c'est important — la barrière technique d'entrée est plus basse qu'on ne le dit souvent. Mais la barrière opérationnelle est ailleurs : six mois de formation d'équipe, cycles d'upgrade tous les dix mois, dette de configuration qui s'accumule, sécurité à durcir couche par couche. Le POC fait gagner du temps sur l'apprentissage initial, pas sur le coût de possession. Garder cette asymétrie en tête quand on présente K8s comme cible d'architecture.

08 / 14

CODE SOURCE

Ces fichiers sont nés lors de la Phase 0, quand l'appli tournait encore en local sans Docker. app/db.py a été écrit en premier — la connexion à Postgres, la création de la table. Puis app/routes.py avec les endpoints CRUD et la gestion des uploads. Puis app/__init__.py pour assembler le tout avec le pattern Application Factory de Flask. Quand tout fonctionnait sur http://127.0.0.1:5000, le Dockerfile a été ajouté — une seule question : comment empaqueter exactement cet environnement pour qu'il tourne partout pareil ? Le .dockerignore est venu juste après, pour ne pas copier .venv et uploads/ dans l'image. main.py n'a nécessité qu'un seul changement critique entre la Phase 0 et la Phase 3 : passer debug=False pour que /metrics fonctionne correctement.

Dockerfile

Dockerfile
# Image de base légère Python 3.10
FROM python:3.10-slim

# Répertoire de travail dans le conteneur
WORKDIR /app

# Installer uv
RUN pip install uv

# Copier le manifeste de dépendances EN PREMIER
# → Si le code change mais pas les dépendances, cette couche est cachée
COPY pyproject.toml uv.lock ./

# Installer les dépendances dans le venv
RUN uv sync --frozen --no-dev

# Copier le reste du code
COPY app/ ./app/
COPY main.py ./

# Créer le dossier uploads
RUN mkdir -p /app/uploads

# Port exposé (documentation — ne publie pas le port à l'hôte)
EXPOSE 5000

# Commande de démarrage
# debug=False obligatoire pour que /metrics fonctionne
CMD ["uv", "run", "python", "main.py"]

.dockerignore

.dockerignore
.git
.venv
__pycache__
*.pyc
*.pyo
uploads/
*.env
.env*

main.py

main.py
from app import create_app

app = create_app()

if __name__ == "__main__":
    # debug=False OBLIGATOIRE
    # Flask debug=True lance 2 process (reloader + worker)
    # prometheus-flask-exporter n'est init que dans 1 des 2 → /metrics cassé
    app.run(host="0.0.0.0", port=5000, debug=False)

app/__init__.py

app/__init__.py
from flask import Flask
from prometheus_flask_exporter import PrometheusMetrics
from .db import init_db
from .routes import bp

def create_app():
    app = Flask(__name__, static_folder="static", static_url_path="")

    # Exposition des métriques sur /metrics
    PrometheusMetrics(app)

    # Initialisation de la base de données
    with app.app_context():
        init_db()

    # Enregistrement des routes
    app.register_blueprint(bp)

    return app

app/db.py

app/db.py
import os
import psycopg2
from psycopg2.extras import RealDictCursor

def get_conn():
    # Toutes les variables lues depuis l'environnement
    # → injectées par ConfigMap + Secret en K8s, par -e en Docker
    return psycopg2.connect(
        host=os.environ["DB_HOST"],
        port=os.environ.get("DB_PORT", "5432"),
        dbname=os.environ["DB_NAME"],
        user=os.environ["DB_USER"],
        password=os.environ["DB_PASS"],
        cursor_factory=RealDictCursor,
    )

def init_db():
    with get_conn() as conn, conn.cursor() as cur:
        cur.execute("""
            CREATE TABLE IF NOT EXISTS tasks (
                id SERIAL PRIMARY KEY,
                title TEXT NOT NULL,
                done BOOLEAN DEFAULT FALSE,
                filename TEXT
            )
        """)
        conn.commit()

app/routes.py

app/routes.py
import os
from flask import Blueprint, request, jsonify, send_from_directory
from .db import get_conn

UPLOAD_DIR = "/app/uploads"
bp = Blueprint("api", __name__, url_prefix="/api")

# ── CRUD ──────────────────────────────────────────────────────────────

@bp.route("/tasks", methods=["GET"])
def list_tasks():
    with get_conn() as conn, conn.cursor() as cur:
        cur.execute("SELECT * FROM tasks ORDER BY id")
        return jsonify(cur.fetchall())

@bp.route("/tasks", methods=["POST"])
def create_task():
    data = request.get_json()
    with get_conn() as conn, conn.cursor() as cur:
        cur.execute("INSERT INTO tasks (title) VALUES (%s) RETURNING *", (data["title"],))
        conn.commit()
        return jsonify(cur.fetchone()), 201

@bp.route("/tasks/<int:task_id>", methods=["PUT"])
def update_task(task_id):
    data = request.get_json()
    with get_conn() as conn, conn.cursor() as cur:
        cur.execute("UPDATE tasks SET title=%s, done=%s WHERE id=%s RETURNING *",
                    (data["title"], data["done"], task_id))
        conn.commit()
        return jsonify(cur.fetchone())

@bp.route("/tasks/<int:task_id>", methods=["DELETE"])
def delete_task(task_id):
    with get_conn() as conn, conn.cursor() as cur:
        cur.execute("DELETE FROM tasks WHERE id=%s", (task_id,))
        conn.commit()
    return "", 204

# ── FICHIERS ──────────────────────────────────────────────────────────

@bp.route("/tasks/<int:task_id>/file", methods=["POST"])
def upload_file(task_id):
    f = request.files["file"]
    path = os.path.join(UPLOAD_DIR, f"{task_id}_{f.filename}")
    f.save(path)
    with get_conn() as conn, conn.cursor() as cur:
        cur.execute("UPDATE tasks SET filename=%s WHERE id=%s", (f.filename, task_id))
        conn.commit()
    return jsonify({"filename": f.filename}), 201

@bp.route("/tasks/<int:task_id>/file", methods=["GET"])
def download_file(task_id):
    with get_conn() as conn, conn.cursor() as cur:
        cur.execute("SELECT filename FROM tasks WHERE id=%s", (task_id,))
        row = cur.fetchone()
    return send_from_directory(UPLOAD_DIR, f"{task_id}_{row['filename']}")

@bp.route("/tasks/<int:task_id>/file", methods=["DELETE"])
def delete_file(task_id):
    with get_conn() as conn, conn.cursor() as cur:
        cur.execute("SELECT filename FROM tasks WHERE id=%s", (task_id,))
        row = cur.fetchone()
        path = os.path.join(UPLOAD_DIR, f"{task_id}_{row['filename']}")
        if os.path.exists(path):
            os.remove(path)
        cur.execute("UPDATE tasks SET filename=NULL WHERE id=%s", (task_id,))
        conn.commit()
    return "", 204

docker-compose.yml

docker-compose.yml
version: "3.9"

services:

  traefik:
    image: traefik:v3.3
    ports:
      - "80:80"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.web.address=:80

  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: todouser
      POSTGRES_PASSWORD: todopass
      POSTGRES_DB: tododb
    volumes:
      - todo-pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "todouser", "-d", "tododb"]
      interval: 5s
      timeout: 3s
      retries: 5

  flask:
    build: .
    environment:
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_NAME: tododb
      DB_USER: todouser
      DB_PASS: todopass
    volumes:
      - ./uploads:/app/uploads
    depends_on:
      postgres:
        condition: service_healthy
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.flask.rule=Host(`localhost`)"
      - "traefik.http.routers.flask.entrypoints=web"
      - "traefik.http.services.flask.loadbalancer.server.port=5000"

volumes:
  todo-pgdata:
09 / 14

MANIFESTES KUBERNETES ANNOTÉS

Arrivé en Phase 3, les trois fichiers Docker (Dockerfile, Compose, code Flask) existaient déjà. Kubernetes ne les remplace pas — il s'y ajoute. Le dossier k8s/ a été créé from scratch. L'ordre d'écriture et d'application a suivi une logique stricte : d'abord les prérequis (Secret et ConfigMap), puis le stockage (PVC), puis les workloads (Deployments), puis l'exposition réseau (Services), puis le routing HTTP (IngressRoute), et enfin le monitoring (ServiceMonitor). On ne peut pas créer un Deployment qui référence un Secret qui n'existe pas encore. L'ordre compte.

postgres-secret.yaml

k8s/postgres-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: postgres-secret
  namespace: todo
type: Opaque
data:
  # Valeurs encodées en base64 — PAS chiffrées
  # echo -n "todouser" | base64  →  dG9kb3VzZXI=
  DB_USER: dG9kb3VzZXI=
  DB_PASS: dG9kb3Bhc3M=

flask-configmap.yaml

k8s/flask-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: flask-config
  namespace: todo
data:
  # Variables non sensibles
  # DB_HOST = nom du Service Postgres (DNS interne K8s)
  DB_HOST: postgres
  DB_PORT: "5432"
  DB_NAME: tododb

postgres-pvc.yaml

k8s/postgres-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-pvc
  namespace: todo
spec:
  accessModes:
    # RWO = ReadWriteOnce : 1 seul nœud peut monter en écriture
    # Adapté à Postgres (1 writer). RWX nécessiterait CephFS/NFS
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi

postgres-deployment.yaml

k8s/postgres-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
  namespace: todo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: postgres:16
        env:
        - name: POSTGRES_USER
          valueFrom:
            secretKeyRef:
              name: postgres-secret
              key: DB_USER
        - name: POSTGRES_PASSWORD
          valueFrom:
            secretKeyRef:
              name: postgres-secret
              key: DB_PASS
        - name: POSTGRES_DB
          value: tododb
        ports:
        - containerPort: 5432
        volumeMounts:
        - name: pgdata
          mountPath: /var/lib/postgresql/data
      volumes:
      - name: pgdata
        persistentVolumeClaim:
          claimName: postgres-pvc

postgres-service.yaml

k8s/postgres-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: postgres   ← DNS interne : postgres.todo.svc.cluster.local
  namespace: todo
spec:
  selector:
    app: postgres  ← doit correspondre aux labels du Pod Postgres
  ports:
  - port: 5432
    targetPort: 5432
  type: ClusterIP  ← interne uniquement, jamais exposé à l'extérieur

flask-deployment.yaml

k8s/flask-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: flask
  namespace: todo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: flask       ← ① colle Deployment → ReplicaSet → Pods
  template:
    metadata:
      labels:
        app: flask     ← ① même valeur
    spec:
      containers:
      - name: flask
        image: dmab44/todo-flask:v5  ← ② tag versionné, jamais :latest
        imagePullPolicy: IfNotPresent
        ports:
        - containerPort: 5000
        envFrom:
        - configMapRef:
            name: flask-config     ← ③ DB_HOST, DB_PORT, DB_NAME
        - secretRef:
            name: postgres-secret  ← ③ DB_USER, DB_PASS

flask-service.yaml

k8s/flask-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: flask
  namespace: todo
  labels:
    app: flask   ← ④ label du Service lui-même, ciblé par le ServiceMonitor
spec:
  selector:
    app: flask   ← ① route vers les Pods flask
  ports:
  - name: http   ← ⑤ OBLIGATOIRE — le ServiceMonitor référence ce nom
    port: 80
    targetPort: 5000
    nodePort: 30000
  type: NodePort

flask-ingressroute.yaml

k8s/flask-ingressroute.yaml
apiVersion: traefik.io/v1alpha1
kind: IngressRoute   ← CRD Traefik, plus puissant que l'Ingress K8s natif
metadata:
  name: flask
  namespace: todo
spec:
  entryPoints:
    - web
  routes:
  - match: Host(`localhost`) || Host(`127.0.0.1`)
    kind: Rule
    services:
    - name: flask
      port: 80

flask-servicemonitor.yaml

k8s/flask-servicemonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor   ← CRD installé par kube-prometheus-stack
metadata:
  name: flask
  namespace: todo
spec:
  selector:
    matchLabels:
      app: flask   ← ④ cible le Service dont labels.app=flask
  endpoints:
  - port: http     ← ⑤ nom du port, pas le numéro
    path: /metrics
    interval: 15s
Chaîne de labels complète (à ne pas briser)
ServiceMonitor.spec.selector.matchLabels Service.metadata.labels Service.spec.selector Pod.metadata.labels
Si un maillon est cassé (typo, casse), Prometheus ne scrape pas Flask. Vérifier : Prometheus → Status → Targets.

7 — NetworkPolicy : default-deny + whitelist (non appliqué dans le POC, projection prod)

Par défaut, Kubernetes laisse tous les Pods se parler — y compris entre namespaces. Pour passer en production, on applique d'abord un default-deny-all au niveau du namespace, puis on whitelist les flux légitimes. Ces deux manifestes sont la version minimale d'une segmentation réseau pour la stack todo. Pas dans le POC (minikube avec CNI par défaut ne supporte pas NetworkPolicy ; il faut Calico ou Cilium), mais à connaître dès maintenant.

default-deny-all.yaml — bloquer tout par défaut
k8s/networkpolicy-default-deny.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: todo            ← ① scope au namespace
spec:
  podSelector: {}              ← ② {} = TOUS les Pods du namespace
  policyTypes:
    - Ingress                  ← ③ filtrer aussi le trafic entrant
    - Egress                   ← ④ filtrer aussi le trafic sortant
  # Aucune règle ingress: ni egress: déclarée
  # → tout est bloqué jusqu'à autorisation explicite
allow-flask-to-postgres.yaml — whitelist du flux principal
k8s/networkpolicy-flask-postgres.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-flask-to-postgres
  namespace: todo
spec:
  podSelector:
    matchLabels:
      app: postgres            ← ① cette policy s'applique aux Pods postgres
  policyTypes:
    - Ingress
  ingress:
    - from:
      - podSelector:
          matchLabels:
            app: flask          ← ② autorise seulement les Pods flask
      ports:
      - protocol: TCP
        port: 5432             ← ③ et seulement sur le port Postgres
Chaîne de raisonnement complète

Avec ces deux manifestes appliqués : Postgres n'accepte plus aucune connexion sauf celles venant d'un Pod labellisé app=flask dans le même namespace, sur le port 5432. Un Pod compromis dans kube-system, ou dans n'importe quel autre namespace, ne peut plus joindre Postgres — même par son IP directe. À reproduire pour chaque flux légitime : traefik → flask (port 5000), flask → DNS (port 53 UDP egress), prometheus → flask:5000/metrics.

⚠ minikube avec CNI par défaut ignore les NetworkPolicy

Le CNI livré par défaut avec minikube n'enforce pas les NetworkPolicy — vos manifestes seront acceptés sans effet réel. Pour tester : minikube start --cni=calico ou installer Cilium. En production, le CNI choisi (Calico, Cilium, Weave) détermine non seulement si NetworkPolicy fonctionne, mais aussi les performances réseau et les capacités avancées (observabilité L7, mTLS).

10 / 14

MONITORING — HELM, OPERATOR, PROMETHEUS

Une fois l'appli Flask déployée sur Kubernetes, la question s'est posée : comment savoir ce qui se passe dedans ? En monde VM, on installe Prometheus à la main, on édite prometheus.yml, on redémarre le service. En Kubernetes, on fait mieux : on installe un Operator qui gère Prometheus pour nous, et on lui dit quoi scraper via de simples objets YAML. Le résultat : Flask apparaît dans Prometheus sans jamais toucher à un fichier de configuration Prometheus.

Helm — le gestionnaire de paquets Kubernetes

Avant d'installer quoi que ce soit, il a fallu comprendre Helm. En world VM, on installe un service avec apt install ou un playbook Ansible. En Kubernetes, on utilise Helm.

Comment fonctionne Helm
  • Un chart Helm est un paquet : une collection de manifestes YAML paramétrables (templates) qui décrivent une application complète
  • Un repo est un index de charts (comme apt a ses sources.list). On en ajoute avec helm repo add
  • Une release est une instance installée d'un chart dans un namespace. On peut installer le même chart deux fois avec des noms différents
  • Les values sont les paramètres qu'on passe à l'installation (--set clé=valeur). Helm génère les YAMLs finaux à partir du template + values, puis les applique
bash
# Ajouter les repos (à faire une seule fois)
helm repo add traefik https://traefik.github.io/charts
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

# Installer kube-prometheus-stack
# Ce chart installe : Prometheus + Grafana + AlertManager + node-exporter + kube-state-metrics
# + l'Operator Prometheus + toutes les CRDs associées
helm install prometheus prometheus-community/kube-prometheus-stack \
  --namespace todo \
  --set grafana.adminPassword=admin \
  --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false
# ↑ Ce flag est critique : sans lui, l'Operator ignore les ServiceMonitors
#   qui ne portent pas le label Helm de la release. Il faut le passer à false
#   pour que l'Operator scrape TOUS les ServiceMonitors du namespace.

# Vérifier ce que Helm a déployé
helm list -n todo
kubectl get all -n todo

Le concept d'Operator — la vraie nouveauté de Kubernetes

Un Operator n'est pas juste un déploiement applicatif. C'est un contrôleur Kubernetes qui comprend une application.

La boucle de réconciliation — le cœur de tout Operator
  • Observer — l'Operator surveille en permanence les objets Kubernetes qui l'intéressent (ici les ServiceMonitor)
  • Comparer — il compare l'état actuel (quelle config Prometheus est chargée) avec l'état désiré (ce que les ServiceMonitors déclarent)
  • Agir — si les deux divergent, il régénère la configuration Prometheus et la recharge, sans redémarrer le Pod

C'est exactement ce que ferait un admin système compétent — mais codé, en boucle infinie, en quelques millisecondes.

Ce que kube-prometheus-stack installe concrètement
ComposantRôleType K8s
prometheus-operatorLe contrôleur — surveille les CRDs et gère PrometheusDeployment
prometheusLe serveur de métriques — scrape, stocke, évalue les alertesStatefulSet (via CRD)
grafanaDashboards — visualisation des métriques PrometheusDeployment
alertmanagerGestion des alertes — routing vers Slack, PagerDuty, etc.StatefulSet (via CRD)
node-exporterMétriques système de chaque nœud (CPU, mémoire, disque)DaemonSet
kube-state-metricsMétriques K8s (pods running, deployments, PVC...)Deployment
ServiceMonitor, PrometheusRule...Les CRDs — le nouveau vocabulaire que l'Operator comprendCRD

Comment Flask est instrumenté

Côté application, une seule dépendance et deux lignes de code.

app/__init__.py (extrait)
from prometheus_flask_exporter import PrometheusMetrics

def create_app():
    app = Flask(__name__, ...)
    PrometheusMetrics(app)  # expose /metrics automatiquement
    ...

prometheus-flask-exporter expose sur /metrics les métriques HTTP par défaut : nombre de requêtes par endpoint, codes HTTP, latences. Sans configuration supplémentaire.

La chaîne complète : de Flask à Grafana

Flask /metrics expose les métriques HTTP au format Prometheus
↓ scrape toutes les 15s
ServiceMonitor dit à l'Operator : "scrape le Service flask, port http, path /metrics"
↓ l'Operator lit le ServiceMonitor et régénère la config
Prometheus Operator recharge la config de Prometheus sans redémarrage
↓ Prometheus scrape Flask selon la config générée
Prometheus stocke les time series, évalue les règles d'alerte
↓ datasource
Grafana interroge Prometheus et affiche les dashboards

Vérifier que le monitoring fonctionne

bash
# 1. Vérifier que Flask expose bien /metrics
#    (depuis un shell dans le Pod Flask)
kubectl exec -it -n todo deployment/flask -- curl localhost:5000/metrics

# 2. Vérifier que le ServiceMonitor est reconnu par l'Operator
kubectl get servicemonitor -n todo

# 3. Ouvrir Prometheus et vérifier que Flask est dans les targets
kubectl port-forward -n todo svc/prometheus-kube-prometheus-prometheus 9090:9090
# http://127.0.0.1:9090/targets → chercher "flask"
# État attendu : UP. Si DOWN ou absent : vérifier la chaîne de labels

# 4. Ouvrir Grafana
kubectl port-forward -n todo svc/prometheus-grafana 3000:80
# http://127.0.0.1:3000 — admin / admin
# Explore → datasource Prometheus → requête : flask_http_request_total
⚠ serviceMonitorSelectorNilUsesHelmValues=false — à ne pas oublier

Par défaut, l'Operator Prometheus installé via Helm ne surveille que les ServiceMonitors portant le même label Helm que sa release. Sans serviceMonitorSelectorNilUsesHelmValues=false, le ServiceMonitor qu'on crée manuellement pour Flask est tout simplement ignoré — et Flask n'apparaît jamais dans les targets Prometheus. Aucun message d'erreur, juste le silence.

⚠ debug=False — rappel critique

Flask en mode debug lance deux processus (reloader + worker). PrometheusMetrics(app) n'est initialisé que dans l'un des deux. Le endpoint /metrics répond depuis l'autre — qui n'a pas de métriques. Résultat : des métriques vides ou intermittentes. Toujours debug=False dans main.py dès qu'on utilise prometheus-flask-exporter.

11 / 14

COMMANDES

En Phase 0, une seule commande suffisait : uv run python main.py. En Phase 1, il en fallait une dizaine. En Phase 2, Compose les a toutes réduites à docker compose up -d. En Phase 3, kubectl a repris la main avec ses propres verbes. Deux entrées dans cette section : « Par phase » pour suivre la progression du POC, « Par outil » pour utiliser la page comme cheat-sheet de référence.

Phase 0 — Setup local

bash
# Installer Postgres
sudo apt update && sudo apt install -y postgresql postgresql-contrib

# Créer base et utilisateur
sudo -u postgres psql -c "CREATE USER todouser WITH PASSWORD 'todopass';"
sudo -u postgres psql -c "CREATE DATABASE tododb OWNER todouser;"

# Init projet uv
uv init todo-app && cd todo-app
uv add flask psycopg2-binary prometheus-flask-exporter

# Lancer l'appli
uv run python main.py

Phase 1 — Docker pur

bash
# Build de l'image Flask
docker build -t todo-flask .

# Créer le réseau dédié
docker network create todo-net

# Lancer Postgres avec volume persistant
docker run --rm -d \
  --name todo-postgres \
  --network todo-net \
  -e POSTGRES_USER=todouser \
  -e POSTGRES_PASSWORD=todopass \
  -e POSTGRES_DB=tododb \
  -v todo-pgdata:/var/lib/postgresql/data \
  postgres:16

# Lancer Traefik
docker run -d \
  --name todo-traefik \
  --network todo-net \
  -p 80:80 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  traefik:v3.3 \
  --providers.docker=true \
  --providers.docker.exposedbydefault=false \
  --entrypoints.web.address=:80

# Lancer Flask avec labels Traefik
docker run --rm -d \
  --name todo-flask \
  --network todo-net \
  -e DB_HOST=todo-postgres \
  -e DB_PORT=5432 \
  -e DB_NAME=tododb \
  -e DB_USER=todouser \
  -e DB_PASS=todopass \
  --label "traefik.enable=true" \
  --label "traefik.http.routers.flask.rule=Host(\`localhost\`)" \
  --label "traefik.http.routers.flask.entrypoints=web" \
  --label "traefik.http.services.flask.loadbalancer.server.port=5000" \
  todo-flask

# Tag + push sur Docker Hub
docker tag todo-flask:latest dmab44/todo-flask:v5
docker push dmab44/todo-flask:v5

Phase 2 — Docker Compose

bash
# Démarrer tous les services
docker compose up -d

# Voir les logs en temps réel
docker compose logs -f flask

# Arrêter (volumes conservés)
docker compose down

# Arrêter ET supprimer les volumes
docker compose down -v

# Rebuilder l'image Flask
docker compose build flask

# Shell dans un service
docker compose exec flask bash

Phase 3 — Kubernetes

bash
# Démarrer minikube
minikube start --memory=6g --cpus=4

# Créer le namespace
kubectl create namespace todo

# Appliquer tous les manifestes
kubectl apply -f k8s/

# Vérifier l'état du cluster
kubectl get all -n todo

# Accéder à l'appli via Traefik
minikube service traefik -n todo --url

# Logs d'un Pod Flask
kubectl logs -n todo deployment/flask

# Logs du Pod mort précédent (debug crash)
kubectl logs -n todo deployment/flask --previous

# Shell dans un Pod
kubectl exec -it -n todo deployment/flask -- /bin/bash

# Rolling update
kubectl set image deployment/flask flask=dmab44/todo-flask:v6 -n todo
kubectl rollout status deployment/flask -n todo

# Rollback à la révision précédente
kubectl rollout undo deployment/flask -n todo

# Historique des révisions
kubectl rollout history deployment/flask -n todo

# Port-forward Grafana (admin/admin)
kubectl port-forward -n todo svc/prometheus-grafana 3000:80

# Port-forward Prometheus
kubectl port-forward -n todo svc/prometheus-kube-prometheus-prometheus 9090:9090

Helm

bash
# Ajouter les repos
helm repo add traefik https://traefik.github.io/charts
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

# Installer Traefik
helm install traefik traefik/traefik \
  --namespace todo \
  --set service.type=NodePort \
  --set ports.web.nodePort=30080

# Installer kube-prometheus-stack
helm install prometheus prometheus-community/kube-prometheus-stack \
  --namespace todo \
  --set grafana.adminPassword=admin \
  --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false

# Lister les releases Helm
helm list -n todo

# Désinstaller
helm uninstall traefik -n todo

Workflow de mise à jour (rebuild complet)

bash
# 1. Modifier le code source
vim app/routes.py

# 2. Rebuilder et pousser (incrémenter le tag !)
docker build -t dmab44/todo-flask:v6 .
docker push dmab44/todo-flask:v6

# 3. Mettre à jour le Deployment
kubectl set image deployment/flask flask=dmab44/todo-flask:v6 -n todo

# 4. Surveiller le rolling update
kubectl rollout status deployment/flask -n todo

# 5. Vérifier les Pods
kubectl get pods -n todo -w

Récupérer le cluster (session suivante)

bash
# Vérifier que minikube tourne encore
minikube status

# Si arrêté, redémarrer (les données sont conservées)
minikube start --memory=6g --cpus=4

# Vérifier les Pods
kubectl get pods -n todo

# Accéder à l'appli (garder le terminal ouvert)
minikube service traefik -n todo --url

# Accéder à Grafana : http://127.0.0.1:3000 — admin / admin
kubectl port-forward -n todo svc/prometheus-grafana 3000:80

# Accéder à Prometheus : http://127.0.0.1:9090/targets
kubectl port-forward -n todo svc/prometheus-kube-prometheus-prometheus 9090:9090
uv
uv init <nom>Initialise un projet Python (pyproject.toml + .venv)
uv add <lib>Ajoute une dépendance et met à jour uv.lock
uv syncInstalle les dépendances depuis uv.lock
uv run <cmd>Exécute une commande dans le venv
uv lockRégénère le lockfile
Docker
docker build -t <nom> .Construit une image depuis le Dockerfile
docker run --rm -dLance un conteneur en arrière-plan, supprimé à l'arrêt
docker psListe les conteneurs actifs
docker imagesListe les images locales
docker logs <nom>Affiche les logs d'un conteneur
docker exec -it <nom> bashShell dans un conteneur
docker stop <nom>Arrête un conteneur
docker volume inspect <vol>Inspecte un volume (chemin physique, etc.)
docker network create <nom>Crée un réseau Docker nommé
docker system pruneSupprime conteneurs arrêtés + images orphelines
Docker Compose
docker compose up -dDémarre tous les services en arrière-plan
docker compose downArrête et supprime les conteneurs (volumes conservés)
docker compose down -vArrête ET supprime les volumes
docker compose logs -f <svc>Logs en temps réel d'un service
docker compose psÉtat des services
docker compose build <svc>Rebuild l'image d'un service
docker compose exec <svc> bashShell dans un service
kubectl
kubectl get pods -n <ns>Liste les Pods d'un namespace
kubectl get all -n <ns>Tous les objets d'un namespace
kubectl describe pod <nom> -n <ns>Détails et événements d'un Pod
kubectl logs -n <ns> <pod>Logs d'un Pod
kubectl logs -n <ns> <pod> --previousLogs du Pod mort précédent (crash debug)
kubectl exec -it <pod> -n <ns> -- bashShell dans un Pod
kubectl get events -n <ns> --sort-by=.lastTimestampÉvénements récents triés
kubectl apply -f <fichier>Applique un manifeste YAML
kubectl delete -f <fichier>Supprime les ressources d'un manifeste
kubectl port-forward svc/<svc> <local>:<remote>Tunnel vers un service
kubectl rollout restart deployment/<nom>Redémarre un Deployment
kubectl rollout undo deployment/<nom>Rollback à la révision précédente
kubectl get crdsListe les Custom Resource Definitions
kubectl get secret <nom> -o jsonpath='{.data.KEY}' | base64 -dDécode une valeur de Secret
minikube
minikube start --memory=6g --cpus=4Démarre le cluster
minikube statusÉtat du cluster
minikube stopArrête le cluster (données conservées)
minikube deleteSupprime le cluster
minikube service <svc> -n <ns> --urlTunnel d'accès à un service NodePort
eval $(minikube docker-env)Bascule le terminal sur le daemon Docker minikube
eval $(minikube docker-env -u)Revient au daemon Docker local
minikube addons enable metrics-serverActive le metrics-server (prérequis HPA)
helm
helm repo add <nom> <url>Ajoute un repo de charts
helm repo updateMet à jour les index de repos
helm install <release> <chart>Installe un chart
helm upgrade <release> <chart>Met à jour une release
helm uninstall <release> -n <ns>Désinstalle une release
helm list -n <ns>Liste les releases installées
helm show values <chart>Affiche les valeurs configurables d'un chart
helm get values <release> -n <ns>Valeurs appliquées à une release
12 / 14

COMPARAISON & MATRICE DE DÉCISION

À la fin de la journée, on avait déployé la même application trois fois, de trois façons différentes. Mais le bon choix de couche ne se déduit pas d'un tableau de features — il se déduit du contexte. Cette section commence par la matrice de décision (que choisir selon le nombre d'applis, le type de workload, la maturité de l'équipe), puis donne le tableau de référence concept-par-concept pour ceux qui veulent comparer techniquement.

Matrice de décision — quelle couche pour quelle situation ?

Choisir entre Docker, Compose et Kubernetes n'est pas un choix technique abstrait — c'est un choix qui dépend de trois axes interdépendants : combien d'applications, quelle nature de workload, et quelle maturité opérationnelle de l'équipe. Le tableau ci-dessous donne le verdict pour les configurations les plus courantes.

Situation Nombre d'applis Workload Équipe Choix recommandé
Dev local — exploration / prototype 1 peu importe 1 dev Docker pur
apprentissage des couches
Dev local — stack récurrente reproductible 1–5 stateless ou stateful simple 1–3 devs Docker Compose
confort maximal, pas de cluster
Test / intégration — environnement éphémère 1–5 stateless équipe avec CI Docker Compose
spinning up ad-hoc par CI
Production simple — application unique critique 1–3 peu importe ops VM existante VM + Docker Compose
K8s prématuré, dette injustifiée
Production — multi-applis stateless 10–20 majoritairement stateless ops + CI/CD mature K8s envisageable
point de bascule — chiffrer
Production — plateforme multi-équipes 20–50+ majoritairement stateless équipe plateforme dédiée Kubernetes
le bon outil pour le job
Production — workloads stateful complexes peu importe majoritairement stateful peu importe K8s discutable
Operators + base managée souvent meilleurs
Production — pas de CI/CD préalable peu importe peu importe pipeline immature Pas K8s
conteneuriser d'abord, orchestrer ensuite
Production — projet d'évangélisation isolé 1–2 peu importe équipe non formée Pas K8s
dette opérationnelle > valeur
Comment lire cette matrice

Trois axes, hiérarchisés. L'axe le plus discriminant est la maturité de l'équipe, pas le nombre d'applis. Une équipe qui maîtrise Compose, CI/CD, et conteneurs, peut absorber K8s pour 10 applis. Une équipe sans culture conteneur ne s'en sortira pas avec K8s même pour 50 applis. Le seuil chiffré (20+) est une heuristique à équipe constituée, pas un permis de migration universel.

Le piège classique : migrer 1-3 applis legacy vers K8s « pour se former ». Résultat habituel — dette opérationnelle sans contrepartie, équipe frustrée, retour aux VM 18 mois plus tard. Si vous voulez vous former : faire un POC isolé (comme ce parcours), pas migrer du critique.

Comparaison technique concept-par-concept

Pour comparer feature-par-feature ce que chaque couche apporte concrètement. Utile en référence quand on conçoit un déploiement et qu'on veut situer rapidement un concept connu de Compose dans son équivalent K8s.

Configuration — comment on déclare la stack
ConceptDocker purDocker ComposeKubernetes
Définition stack3 docker rundocker-compose.ymlk8s/*.yaml
Réseau entre services--network todo-netréseau auto par projetService ClusterIP + DNS
Persistance données-v todo-pgdata:/datavolumes: todo-pgdataPVC → PV (StorageClass)
Config applicative-e VAR=valenvironment: VAR: valConfigMap + envFrom
Config sensible-e PASSWORD=xxxenvironment: PASSWORDSecret + secretRef
Ressources allouées--memory --cpusdeploy.resources (Swarm)requests / limits par conteneur
Opérations — ce que la plateforme fait pour vous
CapacitéDocker purDocker ComposeKubernetes
Self-healing❌ aucunrestart: always (1 machine)ReplicaSet + scheduler (cluster)
Rolling update zéro-downtime✓ via Deployment + readinessProbe
Rollback en 1 commande❌ ré-exécuter ancien tag❌ ré-exécuter ancienne imagekubectl rollout undo
Scaling horizontaldocker run × N (manuel)--scale svc=N (manuel)replicas: N + HPA automatique
Health checks actionnablesHEALTHCHECK (statut seul)healthcheck: (statut seul)readinessProbe + livenessProbe (actions)
Workload statefulvolume nommé seulvolumes + depends_onStatefulSet + PVC, ou Operator
Multi-nœuds❌ (Swarm en option)✓ natif
Monitoring intégréPrometheus + Grafana via Helm
Coût d'entrée — ce que ça vous demande en retour
DimensionDocker purDocker ComposeKubernetes
RAM minimale du contrôle~0 (daemon Docker)~0 (daemon Docker)~2 Go (control plane mini)
Courbe d'apprentissage1–2 semaines+ quelques jours~6 mois pour opérer
Cadence d'upgradeopportunisteopportunistetous les ~10 mois (obligatoire)
Seuil de rentabilité1 appli1–5 applis≥ 20 applis consolidées
13 / 14

PIÈGES VM→CONTENEUR

Ces pièges ne sont pas théoriques — ils ont tous été rencontrés pendant le parcours. Le premier à tomber : Flask qui ne répondait pas malgré un port mapping correct (0.0.0.0 manquant). Le deuxième : Postgres injoignable depuis Flask parce que DB_HOST=localhost pointait vers le conteneur Flask lui-même. En Phase 3 : le ServiceMonitor qui ne scrapait pas — port référencé par numéro au lieu du nom. Et /metrics vide à cause de debug=True. Chaque piège a coûté du temps ; ils sont documentés ici pour ne pas les payer deux fois.
⚠ localhost ≠ la machine hôte

localhost dans un conteneur désigne le conteneur lui-même, pas la machine hôte. Pour atteindre l'hôte depuis un conteneur : host.docker.internal. Pour qu'un conteneur en atteigne un autre : utiliser le nom du conteneur sur le réseau Docker nommé (todo-postgres, etc.).

⚠ 0.0.0.0 obligatoire pour le port mapping

Sans host="0.0.0.0", Flask écoute sur 127.0.0.1 — le loopback interne au conteneur. Le port mapping Docker -p 5000:5000 devient inutile : le trafic entre mais ne trouve personne.

⚠ Le conteneur n'est pas une mini-VM

Pas d'OS complet, pas de systemd, pas de SSH natif. On ne "rentre pas" dans un conteneur en prod — on lit les logs (docker logs, kubectl logs), on utilise exec ponctuellement pour déboguer.

⚠ Sans volume, les données disparaissent

Le filesystem d'un conteneur est éphémère. Arrêter un conteneur sans volume = perdre toutes les données. En prod : volumes nommés pour les bases de données, PVC en Kubernetes.

⚠ Ne jamais utiliser :latest en Kubernetes

Kubernetes met l'image en cache sur le nœud. Si le tag est identique (latest), il ne re-pull pas même si l'image a changé sur le registry. Toujours versionner : :v1, :v2. Ou forcer imagePullPolicy: Always — mais c'est lent.

⚠ base64 ≠ chiffrement

Les Kubernetes Secrets sont encodés en base64, pas chiffrés. N'importe qui avec accès au cluster peut les lire en clair : kubectl get secret <nom> -o jsonpath='{.data.PASSWORD}' | base64 -d. En production : HashiCorp Vault, AWS Secrets Manager, Sealed Secrets ou équivalent.

⚠ L'IP d'un Pod est éphémère

Ne jamais se connecter directement à l'IP d'un Pod — elle change à chaque redémarrage. Toujours passer par un Service, qui expose une IP fixe et un nom DNS stable (postgres.todo.svc.cluster.local).

⚠ debug=False obligatoire pour /metrics

Flask en mode debug lance 2 processus (reloader + worker). prometheus-flask-exporter n'est initialisé que dans un des deux. Les métriques apparaissent à moitié ou pas du tout. Toujours debug=False dans main.py.

⚠ Le port du ServiceMonitor doit être nommé

Le ServiceMonitor référence le port Flask par son nom (port: http), pas par son numéro. Sans name: http dans le Service, le ServiceMonitor ne sait pas où scraper. Vérifier dans Prometheus → Status → Targets.

⚠ /var/run/docker.sock = droits root

Monter le socket Docker dans un conteneur (Traefik) donne un accès complet au daemon Docker — équivalent des droits root sur l'hôte. En prod, restreindre via un socket proxy dédié.

⚠ Scaler une appli stateful avec replicas

Mettre replicas: 3 sur un Deployment Postgres ne crée pas un cluster Postgres — cela crée trois Postgres indépendants qui se battent pour le même PVC en mode RWO (deux d'entre eux planteront). Une base de données nécessite un StatefulSet et un Operator dédié, pas un simple Deployment scalé.

⚠ Pas de readinessProbe = rolling update qui ment

Sans readinessProbe, Kubernetes considère un Pod en Running comme prêt — même s'il met 10 secondes à charger ses dépendances. Pendant ces 10 secondes, le Service y route du trafic, qui échoue. Le « zéro downtime » du rolling update repose entièrement sur la readinessProbe.

⚠ Endpoints vide = selector cassé

kubectl get endpoints -n todo doit montrer des IP pour chaque Service. Si la colonne ENDPOINTS est <none>, c'est qu'aucun Pod ne matche le selector du Service. Typo dans un label, mauvaise valeur dans matchLabels — panne silencieuse classique. Vérifier avec kubectl get pods --show-labels.

⚠ Pas de requests/limits en cluster partagé

Un Pod sans requests peut être placé par le scheduler sur un nœud déjà saturé en réalité — le scheduler ne voit que ce qui est déclaré, pas ce qui est réellement consommé. Un Pod sans limits peut consommer toute la RAM du nœud et provoquer des évictions en cascade. En cluster partagé, l'un et l'autre sont obligatoires — souvent imposés par LimitRange / ResourceQuota au niveau du namespace.

14 / 14

GLOSSAIRE COMPLET

Les définitions qui apparaissent dans les phases sous forme de panneaux dépliables sont rassemblées ici en référence complète. Chaque terme est accompagné de son équivalent dans le monde VM — parce qu'un concept nouveau s'apprend mieux en partant de ce qu'on connaît déjà. Cliquer sur un terme pour afficher la comparaison.
Image Docker +VM
Recette figée en couches décrivant un environnement d'exécution. Immutable une fois buildée. Stockée sur un registry.
Équivalent VM
Template de VM / snapshot figé
Conteneur +VM
Instance en cours d'exécution d'une image. Process isolé partageant le noyau de l'hôte. Filesystem éphémère par défaut.
Équivalent VM
Instance de VM — sans OS complet ni hyperviseur dédié
Volume nommé +VM
Stockage persistant géré par Docker, survit à la mort du conteneur. Chemin opaque géré par Docker.
Équivalent VM
Disque attaché à une VM, indépendant de son cycle de vie
Bind mount +VM
Montage d'un dossier hôte dans le conteneur. Modifications visibles des deux côtés en temps réel.
Équivalent VM
VMware Shared Folders
Pod +VM
Unité de base K8s. 1 à N conteneurs partageant le même réseau et stockage. Éphémère — toujours géré via un Deployment.
Équivalent VM
Proche d'une VM, sans OS complet, géré par le scheduler
Deployment +VM
Gère N replicas d'un Pod. Assure rolling update, rollback, self-healing. Si un Pod meurt, en recrée un.
Équivalent VM
Auto Scaling Group AWS
ReplicaSet +VM
Créé automatiquement par un Deployment. Maintient N replicas. Chaque rolling update crée un nouveau ReplicaSet (conservé pour rollback).
Équivalent VM
Pas d'équivalent direct — invisible en temps normal
Service +VM
IP fixe + DNS permanent devant un groupe de Pods. Types : ClusterIP (interne), NodePort (port nœud), LoadBalancer (IP externe cloud).
Équivalent VM
VIP HAProxy / load balancer devant un pool de VMs
Ingress / IngressRoute +VM
Règles de routing HTTP par URL/host. Nécessite un Ingress Controller (Traefik, Nginx). IngressRoute = CRD Traefik.
Équivalent VM
vhosts Nginx / ACL HAProxy
PVC / PV +VM
PVC = demande de stockage. PV = volume physique provisionné (auto via StorageClass). RWO : 1 writer. RWX : N writers simultanés.
Équivalent VM
Ticket infra pour un disque + disque provisionné
ConfigMap +VM
Config non sensible injectée en env vars ou volume. Séparation code / config.
Équivalent VM
Fichier /etc/app/config ou variables d'environnement système
Secret +VM
Config sensible en base64 — PAS chiffrée. En production : coupler avec HashiCorp Vault, AWS Secrets Manager, Sealed Secrets ou équivalent.
Équivalent VM
Fichier chmod 600 + coffre-fort d'entreprise
etcd +VM
Base clé-valeur distribuée — source de vérité du cluster. Si perdu sans backup : le cluster est perdu.
Équivalent VM
Base de configuration de l'hyperviseur (vCenter DB)
kubelet +VM
Agent sur chaque nœud worker. Reçoit les instructions de l'API server et gère les Pods localement.
Équivalent VM
Agent VMware Tools / agent de gestion
CNI +VM
Plugin réseau qui assigne une IP à chaque Pod et gère le routage inter-Pods. minikube utilise bridge CNI.
Équivalent VM
vSwitch VMware, réseau virtuel de l'hyperviseur
CRD +VM
Étend le vocabulaire Kubernetes avec de nouveaux types d'objets. Ex: ServiceMonitor, IngressRoute, PrometheusRule.
Équivalent VM
Pas d'équivalent — spécifique à K8s
Operator +VM
CRD + Controller. Code la connaissance opérationnelle dans une boucle de réconciliation (Prometheus, Postgres, Kafka...).
Équivalent VM
Runbook automatisé intégré au cluster
Helm +VM
Gestionnaire de paquets K8s. Chart = manifestes YAML paramétrables. Gère installation, mise à jour, rollback.
Équivalent VM
apt/yum — mais pour des applications K8s entières
ServiceMonitor +VM
CRD Prometheus. Indique quels Services scraper et sur quel endpoint. Le port doit être référencé par son nom.
Équivalent VM
Entrée manuelle dans prometheus.yml
Rolling update +VM
K8s démarre le nouveau Pod, attend qu'il soit Ready, puis arrête l'ancien. Zéro downtime si readinessProbe configurée.
Équivalent VM
Blue/green manuel avec downtime
replicas +VM
Nombre d'instances désirées d'un Pod. replicas: 3 = K8s maintient en permanence 3 Pods identiques. Modifier ce champ déclenche un scaling automatique.
Équivalent VM
Desired capacity d'un Auto Scaling Group
Self-healing +VM
Conséquence directe de la boucle de réconciliation : si un Pod disparaît, le ReplicaSet en recrée un automatiquement pour respecter replicas: N. Pas une feature ajoutée, un effet du modèle déclaratif.
Équivalent VM
Auto Scaling Group avec health checks + remplacement automatique
readinessProbe +VM
« Le Pod peut-il recevoir du trafic ? » Si la probe échoue : Pod retiré du Service (sans redémarrage). Condition du rolling update zéro-downtime.
Équivalent VM
Health check d'un load balancer qui retire la VM du pool
livenessProbe +VM
« Le conteneur est-il bloqué ? » Si la probe échoue : K8s tue et redémarre le conteneur. Sert à récupérer d'un deadlock ou d'une fuite mémoire qui rend l'appli muette.
Équivalent VM
Watchdog systemd qui redémarre un service bloqué
Stateless vs Stateful +VM
Stateless (Flask) : pas d'état local, scaling horizontal libre via replicas. Stateful (Postgres) : porte des données, nécessite stockage persistant + stratégie dédiée (réplication, backup).
Équivalent VM
App server vs serveur de base de données — même dichotomie
StatefulSet +VM
Variante du Deployment pour workloads stateful. Pods avec identité stable (postgres-0, postgres-1), PVC associé persistant, ordre de création/suppression contrôlé. Pour Kafka, ZooKeeper, clusters de bases.
Équivalent VM
Cluster de VMs avec noms fixes et disques attachés
requests / limits +VM
requests = ce que le scheduler réserve pour placer le Pod. limits = plafond strict (CPU = throttling, mémoire = OOMKilled). Obligatoires en cluster partagé.
Équivalent VM
Reservation + limit dans vSphere, ou taille fixe d'une VM
Labels / selectors +VM
Kubernetes relie ses objets par labels, pas par référence directe. Un Service déclare « je veux les Pods app=flask ». Une typo = panne silencieuse (Endpoints vide).
Équivalent VM
Tags AWS + filtres sur tags pour cibler des instances
HPA — Horizontal Pod Autoscaler +VM
Ajuste automatiquement replicas selon CPU, mémoire ou métriques custom. Nécessite metrics-server (ou Prometheus Adapter). kubectl autoscale deployment ... --min=1 --max=5 --cpu-percent=70.
Équivalent VM
Auto Scaling Policy CloudWatch / Azure VMSS autoscale
Endpoints +VM
Objet K8s qui liste les IP réelles des Pods sélectionnés par un Service. kubectl get endpoints permet de vérifier qu'un Service trouve effectivement ses Pods. Vide = selector cassé.
Équivalent VM
Liste des backends d'un pool HAProxy
kube-apiserver +VM
Point d'entrée unique du cluster. Reçoit toutes les requêtes (kubectl, controllers, kubelet), valide, persiste dans etcd. Sans lui, le cluster est aveugle.
Équivalent VM
API vCenter / API CloudStack
kube-scheduler +VM
Décide sur quel nœud placer un Pod nouvellement créé. Filtre les nœuds éligibles, score les survivants, choisit le meilleur. Ne déplace pas les Pods existants.
Équivalent VM
DRS (Distributed Resource Scheduler) VMware
kube-controller-manager +VM
Hôte de toutes les boucles de réconciliation : Deployment, ReplicaSet, Node, Endpoints controllers. Chacun observe son objet et corrige l'écart réel/désiré.
Équivalent VM
Watchdogs systemd + scripts cron + supervisor
kube-proxy +VM
Sur chaque nœud, programme les règles iptables (ou IPVS) qui implémentent les Services. C'est lui qui fait que postgres.todo.svc mène à un Pod réel.
Équivalent VM
Configuration HAProxy / Nginx upstream automatisée
container runtime +VM
Ce qui lance réellement les conteneurs sur chaque nœud. containerd (par défaut depuis K8s 1.24) ou CRI-O. Docker n'est plus le runtime direct de K8s.
Équivalent VM
Hyperviseur (ESXi, KVM)
NetworkPolicy +VM
Règles type pare-feu intra-cluster. Par défaut, tous les Pods se voient — il faut explicitement default-deny + whitelist pour vraiment isoler. Implémenté par le CNI (Calico, Cilium).
Équivalent VM
Security Groups AWS / règles iptables sur les VMs
RBAC +VM
Role-Based Access Control. Role + RoleBinding définissent qui (utilisateur, ServiceAccount) peut faire quoi (verbes sur ressources) où (namespace). Principe du moindre privilège.
Équivalent VM
Permissions vCenter / IAM AWS
ServiceAccount +VM
Identité d'un Pod auprès de l'apiserver. Chaque Pod tourne avec un ServiceAccount (par défaut : default, à proscrire en prod). Cible des RoleBindings.
Équivalent VM
Compte de service système / instance profile AWS
ResourceQuota / LimitRange +VM
ResourceQuota limite la somme des requests/limits d'un namespace. LimitRange impose un minimum/maximum par Pod. Outils de protection en cluster mutualisé.
Équivalent VM
Resource pool vSphere avec limites
CrashLoopBackOff +VM
Statut d'un Pod dont le conteneur démarre puis crashe en boucle. Backoff exponentiel entre tentatives. Toujours diagnostiquer avec kubectl logs --previous — le conteneur visible est le nouveau, pas celui qui a crashé.
Équivalent VM
Service qui ne démarre pas et que systemd retente