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.
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.
| Composant | Version |
|---|---|
| OS | Ubuntu 24.04 (zaltux) |
| Docker | 29.4.3 |
| Docker Compose | v2.35.1 |
| kubectl | v1.35.5 |
| minikube | v1.35.0 · Kubernetes v1.32.0 |
| Helm | v3.21.0 |
| uv / Python | 0.9.8 / 3.10 |
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.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.~/todo-app/uploads/). Ils persistent car c'est un répertoire local normal./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.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.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é.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.docker-compose.yml au lieu de 3 commandes docker run dans 3 terminaux.pg_isready : Postgres doit répondre aux connexions, pas juste avoir démarré son processus.todo-app_default (préfixe = nom du dossier). Les services se joignent par leur nom de service (postgres, flask) — pas besoin de --network manuel.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.:30080).http — ce nom est référencé par le ServiceMonitor.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.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.
| Méthode | Route | Description |
|---|---|---|
| GET | /api/tasks | Liste toutes les tâches |
| POST | /api/tasks | Cré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>/file | Attache un fichier (multipart) |
| GET | /api/tasks/<id>/file | Télécharge le fichier joint |
| DELETE | /api/tasks/<id>/file | Supprime le fichier joint |
| GET | /metrics | Endpoint Prometheus (debug=False obligatoire) |
Objectif : avoir Flask + Postgres qui tournent nativement. Si quelque chose casse en Phase 1, on sait que c'est Docker, pas le code.
apt install postgresql), création base + useruv init, ajout dépendancesapp/db.py, app/routes.py, app/__init__.pyapp/static/index.htmluv run python main.py — accès sur http://127.0.0.1:5000Point clé uv : pyproject.toml = manifeste des dépendances. uv.lock = versions exactes de toutes les dépendances transitives.
uv init crée le projet, uv add installe et verrouille, uv run exécute dans le venv automatiquement.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.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.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.
Dockerfile ligne par ligne — FROM, COPY, RUN, CMD, EXPOSEdocker build -t todo-flask . — construction de l'image en coucheslocalhost dans le conteneur ≠ la machine hôte0.0.0.0todo-net, volume nommé todo-pgdatadmab44/todo-flask:v5Cache 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.
todo-postgres), pas par IP. Sur le réseau par défaut, la résolution DNS par nom ne fonctionne pas./var/lib/docker/volumes/), opaque pour l'utilisateur.docker push publie une image. docker pull (ou Kubernetes) la récupère. Tag versionné obligatoire (:v5 et non :latest).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.
traefik, postgres, flaskpg_isready — Flask ne démarre qu'une fois Postgres healthydepends_on: condition: service_healthy — ordre de démarrage garantidocker compose down sans -v conserve les volumesLimite 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.
pg_isready vérifie que le socket PostgreSQL répond.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.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.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.
minikube start --memory=6g --cpus=4kubectl create namespace tododmab44/todo-flask:v5) — flux prod réelkubectl rollout undo) — démo avec v99 inexistanteprometheus-flask-exporter, endpoint /metricsenvFrom.IngressRoute, ServiceMonitor...). Installés par les Helm charts. kubectl get crds pour lister.ServiceMonitor et recharge Prometheus automatiquement sans redémarrage.helm install, helm upgrade, helm list./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.replicas, pourquoi les probes, pourquoi le Service. On change de registre — moins narratif, plus dense.
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.
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
Une instance d'un Pod. Si replicas: 3, il y a trois Pods identiques qui tournent en parallèle.
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.
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.
Créer un Pod directement (kind: Pod) : possible mais à proscrire. Si le Pod meurt, personne ne le recrée. Toujours passer par un Deployment.
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.
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.
# 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
Le processus tombe ? Il faut systemd, un superviseur, ou une intervention humaine. La récupération est locale à la machine.
restart: always redémarre le conteneur. Mais limité à une machine — si la machine tombe, tout tombe avec elle.
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.
Une fois la notion de replicas posée, le scaling devient trivial — à condition que l'application soit stateless.
# 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
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.
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.
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.
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).
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.
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 :
↗ Détail en section 7.3 · Ce qu'il faut durcir pour passer en prod.
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.
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
Trois Pods, trois IP qui changent. Chaque appelant doit découvrir et tracker les Pods. Impossible à maintenir.
Un nom DNS stable, une IP virtuelle. Le Service équilibre le trafic en round-robin (ou via session affinity) sur les Pods sains.
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.
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.
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.
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).
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
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.
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.
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)
spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 1 # max 1 Pod indisponible pendant la transition maxSurge: 1 # max 1 Pod en surnombre temporaire
Combien de Pods on accepte de perdre temporairement. 0 = aucune dégradation, mais update plus lent (il faut créer avant de tuer).
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.
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).
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
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é.
Dépasser limits.cpu = throttling (l'appli ralentit). Dépasser limits.memory = OOMKilled (le conteneur est tué net). Pas de marge de manœuvre.
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.
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.
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
# 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
Une suite naturelle de replicas : laisser Kubernetes ajuster le nombre de Pods en fonction de la charge réelle, sans intervention manuelle.
# 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
kubectl scale --replicas=3 — nombre fixe, décidé par l'opérateur.
Nombre ajusté automatiquement selon CPU, mémoire ou métriques custom (via Prometheus Adapter). Nécessite metrics-server installé dans le cluster.
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.
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.
replicasCompose lance des choses. Kubernetes garde des choses vraies. Cette différence est petite à écrire et énorme à exploiter.
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.
# 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) │
│ │
└─────────────────────────────────────────────────────────────┘
| Composant | Rôle | Équivalent VM |
|---|---|---|
| ▼ Composants critiques — sans eux le cluster est aveugle ou inerte | ||
| kube-apiserver | 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 inutilisable. | API vCenter / API CloudStack |
| etcd | Base 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-scheduler | Dé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-manager | Hô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 |
| kubelet | Agent 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-proxy | Sur 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 runtime | Ce 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-manager | Interface 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 |
kubectl applyPour fixer les idées, voici ce qui se passe réellement quand vous lancez kubectl apply -f flask-deployment.yaml sur un cluster vierge.
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.
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é ».
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.
À 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).
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.
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.
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.
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.
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.
1 process containerd. 1 etcd. 1 scheduler. Tout co-localisé. Si la VM minikube tombe, tout tombe. Aucune haute disponibilité possible.
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.
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.
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
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
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>
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
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
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é.
| Statut | Ce que ça veut dire | Première commande |
|---|---|---|
| Pending | Le scheduler n'a pas trouvé de nœud, ou le Pod attend une ressource (PVC non bound, ImagePullSecret manquant, ressources insuffisantes) | describe pod (Events) |
| ContainerCreating | Le 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) |
| ImagePullBackOff | L'image n'a pas pu être pullée. Mauvais tag, registry privé sans imagePullSecret, ou pas de connectivité vers le registry. | describe pod |
| ErrImagePull | Variante de ImagePullBackOff, premier échec. Backoff exponentiel ensuite. | describe pod |
| CrashLoopBackOff | Le conteneur démarre puis crashe en boucle. Kubernetes attend de plus en plus longtemps entre chaque tentative. | logs --previous |
| OOMKilled | Le conteneur a dépassé limits.memory et a été tué par le kernel. Visible dans describe pod → Last State. | describe pod |
| Running mais non Ready | Le conteneur tourne mais la readinessProbe échoue. Le Pod n'est pas dans le pool du Service. | logs + describe pod |
| Evicted | Le nœud était sous pression (mémoire, disque) et le kubelet a tué le Pod pour libérer des ressources. | describe pod + describe node |
| Completed | Pas 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 |
Trois cas typiques, déroulés étape par étape.
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.
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.
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.
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 ».
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.
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.
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.
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.
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).
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é.
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).
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.
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.
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.
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.
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.
Le tableau ci-dessous reprend les simplifications volontaires du POC et indique ce que chacune devient en production réelle.
| Choix POC | En production |
|---|---|
| Flask en debug=False mais sans WSGI | gunicorn ou uvicorn devant Flask, multi-workers, timeouts configurés |
| Pas de TLS — HTTP en clair | TLS partout — cert-manager + Let's Encrypt (ou PKI interne), mTLS entre services via service mesh (Istio, Linkerd) si exigé |
| Secrets en base64 | Sealed Secrets, External Secrets Operator avec HashiCorp Vault / AWS Secrets Manager / Azure Key Vault, chiffrement etcd au repos |
| Postgres en Deployment + PVC RWO | Base managée (RDS, Cloud SQL) — toujours préférer. Sinon Operator dédié (CloudNativePG, Zalando, Crunchy) avec backup automatisé, PITR, réplication. |
| Pas de NetworkPolicy | default-deny par namespace, whitelist explicite des flux. Audit régulier via Calico / Cilium policies. |
| Pas de RBAC restrictif | Un ServiceAccount dédié par Deployment, Role minimal (principe du moindre privilège), revue d'accès régulière. |
| Pas de requests/limits stricts | LimitRange + ResourceQuota imposés au namespace. Pas de Pod admis sans requests/limits. |
Image :v5 sur Docker Hub public | Registry 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 Standards | PodSecurity Admission en mode restricted — pas de privilégié, pas de hostNetwork, pas de capabilities ajoutées. |
| kube-prometheus-stack tel quel | Prometheus 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 logs | Collecte centralisée (Loki, ELK, Splunk). Rétention conforme aux exigences applicables (souvent 1 à 5 ans en environnement régulé). |
| Aucune politique de backup | Velero pour les objets K8s + PV. Tests de restore réguliers. Backup d'etcd séparé et chiffré. |
Avant de proposer K8s comme cible d'architecture, ces six questions calibrent si l'effort en vaut la chandelle.
Moins de 10 : K8s est probablement disproportionné. 20–50 : la zone où l'investissement commence à se rentabiliser. Plus de 50 : K8s devient quasi-incontournable.
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).
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.
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.
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é.
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.
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é.
Monolithe historique unique. 2-3 applis seulement. Équipe ops VM sans temps formation. Workloads majoritairement stateful. Contraintes de conformité fortes mal cartographiées.
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.
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.
# 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"]
.git .venv __pycache__ *.pyc *.pyo uploads/ *.env .env*
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)
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
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()
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
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:
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=
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
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
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
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
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
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
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
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
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.
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
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
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.
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).
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.
helm repo add--set clé=valeur). Helm génère les YAMLs finaux à partir du template + values, puis les applique# 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
Un Operator n'est pas juste un déploiement applicatif. C'est un contrôleur Kubernetes qui comprend une application.
ServiceMonitor)C'est exactement ce que ferait un admin système compétent — mais codé, en boucle infinie, en quelques millisecondes.
| Composant | Rôle | Type K8s |
|---|---|---|
| prometheus-operator | Le contrôleur — surveille les CRDs et gère Prometheus | Deployment |
| prometheus | Le serveur de métriques — scrape, stocke, évalue les alertes | StatefulSet (via CRD) |
| grafana | Dashboards — visualisation des métriques Prometheus | Deployment |
| alertmanager | Gestion des alertes — routing vers Slack, PagerDuty, etc. | StatefulSet (via CRD) |
| node-exporter | Métriques système de chaque nœud (CPU, mémoire, disque) | DaemonSet |
| kube-state-metrics | Métriques K8s (pods running, deployments, PVC...) | Deployment |
| ServiceMonitor, PrometheusRule... | Les CRDs — le nouveau vocabulaire que l'Operator comprend | CRD |
Côté application, une seule dépendance et deux lignes de code.
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.
# 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
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.
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.
# 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
# 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
# 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
# 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
# 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
# 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
# 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 init <nom> | Initialise un projet Python (pyproject.toml + .venv) |
| uv add <lib> | Ajoute une dépendance et met à jour uv.lock |
| uv sync | Installe les dépendances depuis uv.lock |
| uv run <cmd> | Exécute une commande dans le venv |
| uv lock | Régénère le lockfile |
| docker build -t <nom> . | Construit une image depuis le Dockerfile |
| docker run --rm -d | Lance un conteneur en arrière-plan, supprimé à l'arrêt |
| docker ps | Liste les conteneurs actifs |
| docker images | Liste les images locales |
| docker logs <nom> | Affiche les logs d'un conteneur |
| docker exec -it <nom> bash | Shell 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 prune | Supprime conteneurs arrêtés + images orphelines |
| docker compose up -d | Démarre tous les services en arrière-plan |
| docker compose down | Arrête et supprime les conteneurs (volumes conservés) |
| docker compose down -v | Arrê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> bash | Shell dans un service |
| 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> --previous | Logs du Pod mort précédent (crash debug) |
| kubectl exec -it <pod> -n <ns> -- bash | Shell 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 crds | Liste les Custom Resource Definitions |
| kubectl get secret <nom> -o jsonpath='{.data.KEY}' | base64 -d | Décode une valeur de Secret |
| minikube start --memory=6g --cpus=4 | Démarre le cluster |
| minikube status | État du cluster |
| minikube stop | Arrête le cluster (données conservées) |
| minikube delete | Supprime le cluster |
| minikube service <svc> -n <ns> --url | Tunnel 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-server | Active le metrics-server (prérequis HPA) |
| helm repo add <nom> <url> | Ajoute un repo de charts |
| helm repo update | Met à 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 |
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 |
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.
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 | |||
|---|---|---|---|
| Concept | Docker pur | Docker Compose | Kubernetes |
| Définition stack | 3 docker run | docker-compose.yml | k8s/*.yaml |
| Réseau entre services | --network todo-net | réseau auto par projet | Service ClusterIP + DNS |
| Persistance données | -v todo-pgdata:/data | volumes: todo-pgdata | PVC → PV (StorageClass) |
| Config applicative | -e VAR=val | environment: VAR: val | ConfigMap + envFrom |
| Config sensible | -e PASSWORD=xxx | environment: PASSWORD | Secret + secretRef |
| Ressources allouées | --memory --cpus | deploy.resources (Swarm) | requests / limits par conteneur |
| Opérations — ce que la plateforme fait pour vous | |||
| Capacité | Docker pur | Docker Compose | Kubernetes |
| Self-healing | ❌ aucun | restart: 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 image | kubectl rollout undo |
| Scaling horizontal | docker run × N (manuel) | --scale svc=N (manuel) | replicas: N + HPA automatique |
| Health checks actionnables | HEALTHCHECK (statut seul) | healthcheck: (statut seul) | readinessProbe + livenessProbe (actions) |
| Workload stateful | volume nommé seul | volumes + depends_on | StatefulSet + 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 | |||
| Dimension | Docker pur | Docker Compose | Kubernetes |
| RAM minimale du contrôle | ~0 (daemon Docker) | ~0 (daemon Docker) | ~2 Go (control plane mini) |
| Courbe d'apprentissage | 1–2 semaines | + quelques jours | ~6 mois pour opérer |
| Cadence d'upgrade | opportuniste | opportuniste | tous les ~10 mois (obligatoire) |
| Seuil de rentabilité | 1 appli | 1–5 applis | ≥ 20 applis consolidées |
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.).
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.
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.
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.
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.
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.
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).
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 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.
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é.
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é.
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.
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.
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.
replicas: 3 = K8s maintient en permanence 3 Pods identiques. Modifier ce champ déclenche un scaling automatique.replicas: N. Pas une feature ajoutée, un effet du modèle déclaratif.replicas. Stateful (Postgres) : porte des données, nécessite stockage persistant + stratégie dédiée (réplication, backup).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.kubectl get endpoints permet de vérifier qu'un Service trouve effectivement ses Pods. Vide = selector cassé.postgres.todo.svc mène à un Pod réel.Role + RoleBinding définissent qui (utilisateur, ServiceAccount) peut faire quoi (verbes sur ressources) où (namespace). Principe du moindre privilège.default, à proscrire en prod). Cible des RoleBindings.kubectl logs --previous — le conteneur visible est le nouveau, pas celui qui a crashé.