Une application Flask, un Dockerfile, trois fichiers YAML. Le trajet du code au navigateur, avec les vérifications qui prouvent que chaque objet fait son travail.
Les objets Kubernetes s’expliquent mal séparément. Ils se comprennent quand on les voit fonctionner ensemble sur une application minuscule, et qu’on les casse un par un pour voir ce qui manque.
Voici le projet complet : une page web qui affiche un titre, une version, une couleur de fond et le nom du Pod qui l’a servie. Rien de plus — mais ces quatre informations suffisent à démontrer tout ce qui compte.

Cinq étapes numérotées, et cinq objets à leur place : le code et son Dockerfile deviennent l’image demo-k8s:1.0 ; le Deployment demo-web en maintient trois Pods qui écoutent sur le port 5000 ; le ConfigMap demo-config leur fournit APP_TITLE, APP_VERSION et BG_COLOR en variables d’environnement ; le Service demo-web expose l’ensemble sur le NodePort 30080.
Si la répartition des rôles entre Docker et Kubernetes n’est pas encore claire, commencez par là.
demo-k8s/
├── app/
│ ├── app.py
│ ├── requirements.txt
│ └── Dockerfile
└── k8s/
├── configmap.yaml
├── deployment.yaml
└── service.yamlDeux dossiers, et la frontière est exactement celle des deux outils : app/ est le territoire de Docker, k8s/ celui de Kubernetes.
import os
import socket
from flask import Flask
app = Flask(__name__)
TITLE = os.environ.get('APP_TITLE', 'Application sans titre')
VERSION = os.environ.get('APP_VERSION', '0.0.0')
BG_COLOR = os.environ.get('BG_COLOR', '#1f2937')
@app.get('/')
def home():
return f'''<!doctype html>
<html lang="fr">
<head><meta charset="utf-8"><title>{TITLE}</title></head>
<body style="background:{BG_COLOR};color:#f8fafc;
font-family:system-ui;text-align:center;padding:4rem">
<h1>{TITLE}</h1>
<p>version {VERSION}</p>
<p>servi par le Pod <code>{socket.gethostname()}</code></p>
</body>
</html>'''
@app.get('/health')
def health():
return {'status': 'ok'}
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)Trois choix méritent d’être expliqués, parce que chacun correspond à une erreur classique.
os.environ.get avec une valeur par défaut. L’application lit sa configuration dans l’environnement, jamais dans une constante. C’est ce qui rendra le ConfigMap utile. Et la valeur par défaut permet de la lancer sans Kubernetes, ce qui est indispensable pour développer.
host='0.0.0.0'. Le défaut de Flask est 127.0.0.1, qui n’accepte que les connexions venant de l’intérieur du conteneur. Un conteneur qui écoute sur 127.0.0.1 est injoignable, et le symptôme — une connexion refusée alors que le Pod tourne — envoie chercher très loin de la cause.
socket.gethostname(). Dans un Pod, le nom d’hôte est le nom du Pod. C’est la ficelle qui va rendre visible le travail du Service.
flask==3.1.0FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5000
CMD ["python", "app.py"]L’ordre des instructions n’est pas décoratif : les dépendances sont copiées et installées avant le code. Comme Docker met en cache chaque couche et n’invalide que celles qui suivent un changement, modifier app.py ne réinstalle pas Flask. Inverser ces deux blocs, et chaque build reprend à zéro.
EXPOSE est purement documentaire — il n’ouvre rien. Il annonce l’intention, ce qui a de la valeur pour la personne qui lira le fichier.
Non, et Flask lui-même l’écrit dans les journaux au démarrage. app.run() est mono-processus et ne tient pas la charge. En production, on passe par un serveur d’application :
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "app:app"]Le reste du déploiement Kubernetes est identique, ce qui est le point intéressant : le cluster ne sait pas et n’a pas à savoir quel serveur tourne dans le conteneur.
docker build -t demo-k8s:1.0 ./app
docker images | grep demo-k8sdemo-k8s 1.0 a3f1c8d2e4b6 142MBTestez-la immédiatement, avant d’impliquer Kubernetes :
docker run --rm -p 5000:5000 -e APP_TITLE="Test local" demo-k8s:1.0Si http://localhost:5000 ne répond pas ici, ce n’est pas un problème Kubernetes, et le chercher dans les manifestes fera perdre une heure. Cette vérification intermédiaire est la meilleure habitude à prendre.
L’étape qui bloque tout le monde la première fois. docker build a rangé l’image dans le dépôt de votre machine ; le cluster cherche dans le sien.
minikube image load demo-k8s:1.0 # minikube
kind load docker-image demo-k8s:1.0 # kindAvec Kubernetes intégré à Docker Desktop, il n’y a rien à faire : le démon est partagé. Sur un vrai cluster, l’image doit être poussée dans un registre que les nœuds peuvent atteindre.
Sans cette étape, le Pod reste en ErrImagePull puis ImagePullBackOff, et Kubernetes a raison : il cherche une image qui n’existe pas chez lui.
apiVersion: v1
kind: ConfigMap
metadata:
name: demo-config
labels:
app: demo-web
data:
APP_TITLE: 'Bonjour depuis Kubernetes'
APP_VERSION: '1.0.0'
BG_COLOR: '#0f172a'Trois valeurs, hors de l’image. Les guillemets autour de '#0f172a' sont obligatoires : sans eux, YAML lit le # comme un début de commentaire et la valeur devient vide.
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo-web
spec:
replicas: 3
selector:
matchLabels:
app: demo-web
template:
metadata:
labels:
app: demo-web
spec:
containers:
- name: demo-web
image: demo-k8s:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5000
envFrom:
- configMapRef:
name: demo-config
readinessProbe:
httpGet:
path: /health
port: 5000
initialDelaySeconds: 2
periodSeconds: 5
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128MiC’est ici que les briques se rejoignent :
image: demo-k8s:1.0 désigne ce que Docker a fabriqué ;envFrom verse toutes les clés du ConfigMap dans l’environnement des conteneurs ;readinessProbe s’appuie sur la route /health écrite plus haut, et décide quand le Pod entre dans le Service ;app: demo-web doivent correspondre, sans quoi Kubernetes refuse le manifeste.imagePullPolicy: IfNotPresent est explicite à dessein : il dit au cluster d’utiliser l’image locale plutôt que d’aller la chercher au loin.
apiVersion: v1
kind: Service
metadata:
name: demo-web
spec:
type: NodePort
selector:
app: demo-web
ports:
- port: 80
targetPort: 5000
nodePort: 30080Le selector reprend l’étiquette du Deployment. Le Service ne nomme aucun Pod : il déclare une condition, et Kubernetes tient la liste à jour.
Les trois ports, dans l’ordre du trajet :
Le seul port imposé par l’application est targetPort. Les deux autres sont des choix d’exposition.
kubectl apply -f k8s/
kubectl rollout status deployment demo-web
kubectl get pods,svc,configmapNAME READY STATUS RESTARTS
pod/demo-web-7c9f8b6d54-2xk9p 1/1 Running 0
pod/demo-web-7c9f8b6d54-8vnqt 1/1 Running 0
pod/demo-web-7c9f8b6d54-mz4rl 1/1 Running 0
NAME TYPE PORT(S)
service/demo-web NodePort 80:30080/TCPPuis l’ouverture, qui dépend de votre cluster :
minikube service demo-web # minikube ouvre le navigateur
open http://localhost:30080 # Docker Desktop, ou kind bien configuréAvec minikube, localhost:30080 ne répond pas directement : le cluster tourne dans sa propre machine virtuelle, et il faut passer par minikube service. Avec kind, le port doit avoir été déclaré dans extraPortMappings à la création du cluster. C’est la deuxième cause d’abandon après l’image absente, et elle n’a rien à voir avec vos manifestes.
for i in {1..6}; do curl -s http://localhost:30080 | grep Pod; doneservi par le Pod <code>demo-web-7c9f8b6d54-2xk9p</code>
servi par le Pod <code>demo-web-7c9f8b6d54-mz4rl</code>
servi par le Pod <code>demo-web-7c9f8b6d54-8vnqt</code>
servi par le Pod <code>demo-web-7c9f8b6d54-2xk9p</code>
...Des noms différents : le Service distribue bien. À faire avec curl, qui ouvre une connexion neuve à chaque appel ; dans un navigateur, la connexion persistante vous renvoie vers le même Pod et donne la fausse impression que la répartition ne marche pas.
Changez la couleur :
BG_COLOR: '#7f1d1d'kubectl apply -f k8s/configmap.yamlRechargez la page. Elle n’a pas changé.
Rien n’est cassé. Les variables d’environnement d’un processus sont fixées à son démarrage et ne peuvent plus changer — c’est vrai de tout processus, sur tout système. Kubernetes a mis à jour le ConfigMap, ce qu’on lui a demandé ; il n’a pas redémarré les Pods, ce qu’on ne lui a pas demandé.
kubectl rollout restart deployment demo-web
kubectl rollout status deployment demo-webMaintenant la page est rouge. Et surtout : aucune image n’a été reconstruite. C’est tout l’intérêt du ConfigMap, et la démonstration valait la peine de passer par la surprise. Le détail des deux modes de consommation — variables ou fichiers montés — est dans l’article dédié.
kubectl delete pod -l app=demo-web --field-selector=status.phase=Running
kubectl get pods -l app=demo-web -wLes Pods sont détruits, et de nouveaux apparaissent en quelques secondes, sans que personne n’intervienne. Le Deployment maintient l’état déclaré ; c’est le sujet de son propre article.
| Symptôme | Cause la plus probable | Vérification |
|---|---|---|
ImagePullBackOff | image absente du cluster | minikube image load ou kind load |
CrashLoopBackOff | l’application plante au démarrage | kubectl logs <pod> |
Pod Running mais 0/1 | la sonde de disponibilité échoue | kubectl describe pod <pod> |
| Connexion refusée | l’application écoute sur 127.0.0.1 | host='0.0.0.0' dans le code |
| Délai d’attente | le Service ne trouve aucun Pod | kubectl get endpoints demo-web |
| Valeur de config vide | # non protégé par des guillemets en YAML | kubectl get configmap demo-config -o yaml |
Les deux commandes qui règlent la majorité des cas sont kubectl describe pod et kubectl logs. La première dit ce que le cluster a tenté et pourquoi il a échoué ; la seconde dit ce que l’application a répondu.
kubectl delete -f k8s/Ce projet tient en sept fichiers, et il contient déjà tout le vocabulaire : une image fabriquée une fois, un Deployment qui maintient trois exemplaires, un ConfigMap qui les règle sans reconstruction, un Service qui donne une adresse stable et répartit le trafic.
Les trois vérifications valent mieux que les définitions. Voir des noms de Pods différents, voir une couleur changer sans docker build, et voir des Pods détruits revenir tout seuls — ce sont ces trois moments qui font que Kubernetes cesse d’être une liste d’objets.
La suite naturelle est la version suivante. Reconstruire l’image en 2.0, changer le tag, réappliquer — et découvrir que mettre à jour sans coupure impose un ordre précis dès qu’une base de données entre dans le décor.
Pour aller plus loin : le Service et son diagnostic, et l’allègement de l’image, qui ferait passer celle-ci bien en dessous de 142 Mo.
Développement et déploiement de solutions de données — les premiers modules sont en accès libre.