Déployer une application sur Kubernetes : le projet complet

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.

9 min de lecturekubernetesdockerconteneurs

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.

Architecture du projet : du code au navigateur, en passant par l’image Docker, le Deployment, trois Pods, le ConfigMap et le Service.

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à.

L’arborescence

text
demo-k8s/
├── app/
│   ├── app.py
│   ├── requirements.txt
│   └── Dockerfile
└── k8s/
    ├── configmap.yaml
    ├── deployment.yaml
    └── service.yaml

Deux dossiers, et la frontière est exactement celle des deux outils : app/ est le territoire de Docker, k8s/ celui de Kubernetes.

1. L’application

python
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.

text
flask==3.1.0

2. Le Dockerfile

dockerfile
FROM 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.

Le serveur de développement de Flask en production ?

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 :

dockerfile
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.

3. Construire l’image

bash
docker build -t demo-k8s:1.0 ./app
docker images | grep demo-k8s
text
demo-k8s   1.0   a3f1c8d2e4b6   142MB

Testez-la immédiatement, avant d’impliquer Kubernetes :

bash
docker run --rm -p 5000:5000 -e APP_TITLE="Test local" demo-k8s:1.0

Si 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.

4. Donner l’image au cluster

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.

bash
minikube image load demo-k8s:1.0        # minikube
kind load docker-image demo-k8s:1.0     # kind

Avec 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.

5. Le ConfigMap

yaml
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.

6. Le Deployment

yaml
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: 128Mi

C’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 ;
  • les deux blocs d’étiquettes 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.

7. Le Service

yaml
apiVersion: v1
kind: Service
metadata:
  name: demo-web
spec:
  type: NodePort
  selector:
    app: demo-web
  ports:
    - port: 80
      targetPort: 5000
      nodePort: 30080

Le 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.

8. Tout appliquer

bash
kubectl apply -f k8s/
kubectl rollout status deployment demo-web
kubectl get pods,svc,configmap
text
NAME                            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/TCP

Puis l’ouverture, qui dépend de votre cluster :

bash
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.

La première preuve : le Service répartit

bash
for i in {1..6}; do curl -s http://localhost:30080 | grep Pod; done
text
servi 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.

La seconde preuve : le ConfigMap, et son piège

Changez la couleur :

yaml
BG_COLOR: '#7f1d1d'
bash
kubectl apply -f k8s/configmap.yaml

Rechargez 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é.

bash
kubectl rollout restart deployment demo-web
kubectl rollout status deployment demo-web

Maintenant 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é.

La troisième preuve : le Deployment répare

bash
kubectl delete pod -l app=demo-web --field-selector=status.phase=Running
kubectl get pods -l app=demo-web -w

Les 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.

Quand ça ne marche pas

SymptômeCause la plus probableVérification
ImagePullBackOffimage absente du clusterminikube image load ou kind load
CrashLoopBackOffl’application plante au démarragekubectl logs <pod>
Pod Running mais 0/1la sonde de disponibilité échouekubectl describe pod <pod>
Connexion refuséel’application écoute sur 127.0.0.1host='0.0.0.0' dans le code
Délai d’attentele Service ne trouve aucun Podkubectl get endpoints demo-web
Valeur de config vide# non protégé par des guillemets en YAMLkubectl 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.

Ranger

bash
kubectl delete -f k8s/

Ce qu’il faut retenir

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.

Ce sujet fait partie d’un cours complet

Développement et déploiement de solutions de données — les premiers modules sont en accès libre.

Voir le plan du cours

Continuer sur le même sujet