Docker, Compose et Kubernetes
« Ça marche chez moi. » Ce module traite l'outil qui a rendu cette phrase obsolète : le conteneur. On part de ce qu'est réellement un conteneur — et de ce qui le distingue d'une machine virtuelle —, puis on construit une image pour un projet Python, on orchestre une pile complète avec Docker Compose, et on termine par ce qu'il faut savoir de Kubernetes pour lire un manifeste et comprendre ce que fait une équipe plateforme.
📋 Au programme
1. Le problème que Docker résout
Un projet Python ne dépend pas que de Python. Il dépend d'une version de l'interpréteur, de bibliothèques compilées (BLAS, libpq, ffmpeg), de paquets système, de variables d'environnement, parfois d'un pilote GPU et d'une version de CUDA. Un environnement virtuel (module 1) fige les bibliothèques Python ; il ne fige rien de tout le reste. C'est pourquoi le même code fonctionne sur un portable et échoue sur le serveur.
Un conteneur emporte tout ce dont l'application a besoin — code, dépendances Python, bibliothèques système, fichiers de configuration — dans une unité qui s'exécute à l'identique partout où tourne un moteur de conteneurs. Le bénéfice ne se limite pas au déploiement : il rend un résultat reproductible dans six mois, ce qui est exactement ce qu'on cherche en data science (module 26).
2. Conteneur ou machine virtuelle ?
Les deux isolent une application, mais à des niveaux très différents.
MACHINE VIRTUELLE CONTENEUR ┌───────────────────────────┐ ┌───────────────────────────┐ │ App A │ App B │ │ App A │ App B │ ├───────────┼───────────────┤ ├───────────┼───────────────┤ │ Biblioth. │ Biblioth. │ │ Biblioth. │ Biblioth. │ ├───────────┼───────────────┤ ├───────────┴───────────────┤ │ Noyau OS │ Noyau OS │ ←──┐ │ moteur de conteneurs │ │ invité │ invité │ │ ├───────────────────────────┤ ├───────────┴───────────────┤ │ │ NOYAU OS DE L'HÔTE │ ← partagé │ Hyperviseur │ │ ├───────────────────────────┤ ├───────────────────────────┤ │ │ Matériel │ │ Noyau OS hôte │ │ └───────────────────────────┘ ├───────────────────────────┤ │ │ Matériel │ └── chaque VM embarque un système └───────────────────────────┘ d'exploitation complet
Une machine virtuelle simule un ordinateur entier : l'hyperviseur lui présente un processeur, de la mémoire et des disques virtuels, sur lesquels on installe un système d'exploitation complet avec son propre noyau. Isolation très forte, mais coût élevé — plusieurs gigaoctets sur disque, plusieurs centaines de mégaoctets de RAM, des dizaines de secondes de démarrage.
Un conteneur ne simule rien. C'est un processus ordinaire du système hôte, à qui le noyau ment sur ce qu'il voit :
- les namespaces cloisonnent sa vision — il ne voit que ses propres processus, sa propre arborescence de fichiers, ses propres interfaces réseau ;
- les cgroups (control groups) limitent ce qu'il consomme — tant de CPU, tant de mémoire.
Puisqu'il n'y a pas de noyau invité, le coût est celui d'un processus : quelques mégaoctets, un démarrage en quelques dizaines de millisecondes. L'analogie la plus juste : une VM est une maison individuelle avec ses propres fondations ; un conteneur est un appartement dans un immeuble — murs séparés, mais fondations et structure partagées.
3. Image, conteneur, couche, registre
- Une image est un modèle figé, en lecture seule : un système de fichiers complet plus des métadonnées (commande à lancer, variables d'environnement, port exposé). C'est l'équivalent d'une classe.
- Un conteneur est une instance en cours d'exécution de cette image, avec une fine couche inscriptible par-dessus. C'est l'équivalent d'un objet. On peut lancer vingt conteneurs à partir de la même image.
- Une couche (layer) est une strate de l'image, produite par une instruction du Dockerfile. Les couches sont empilées, partagées entre images et mises en cache individuellement — c'est ce qui rend les reconstructions rapides et les téléchargements légers.
- Un registre est un entrepôt d'images : Docker Hub (public), GitHub Container Registry, ou un registre privé d'entreprise.
4. Les commandes du quotidien
# lancer un conteneur (télécharge l'image si nécessaire) docker run hello-world # interactif : un shell dans un conteneur Python jetable docker run -it --rm python:3.12-slim bash # │ └─ --rm : supprime le conteneur à la sortie (sinon il s'accumule) # └─ -it : mode interactif avec terminal # en arrière-plan, avec un nom et une redirection de port docker run -d --name mon-api -p 8000:8000 mon-image:1.0 # │ └─ port 8000 de la MACHINE → port 8000 du CONTENEUR # └─ -d : detached, rend la main immédiatement # voir ce qui tourne docker ps # conteneurs en cours docker ps -a # y compris ceux qui sont arrêtés docker stats # consommation CPU / mémoire en direct # lire les journaux docker logs mon-api # tout depuis le début docker logs -f --tail 50 mon-api # suivre en direct les 50 dernières lignes # entrer dans un conteneur qui tourne (LE réflexe de débogage) docker exec -it mon-api bash docker exec mon-api python -c "import torch; print(torch.cuda.is_available())" # inspecter en détail (configuration, réseaux, montages) docker inspect mon-api docker port mon-api # quelles redirections de ports sont actives
# construire une image à partir du Dockerfile du répertoire courant docker build -t mon-api:1.0 . docker build -t mon-api:1.0 --build-arg VERSION=1.0 -f docker/Dockerfile . # lister, étiqueter, publier docker images docker tag mon-api:1.0 ghcr.io/nils/mon-api:1.0 docker login ghcr.io && docker push ghcr.io/nils/mon-api:1.0 docker pull postgres:16 # arrêter et supprimer docker stop mon-api && docker rm mon-api docker rmi mon-api:1.0 # faire de la place (Docker consomme beaucoup de disque avec le temps) docker system df # ce qui occupe l'espace docker container prune # conteneurs arrêtés docker image prune -a # images non utilisées docker system prune -a --volumes # grand nettoyage — supprime aussi les VOLUMES
5. Écrire un Dockerfile
Un Dockerfile est une recette : chaque instruction produit une couche. Voici les instructions à connaître, avant l'exemple complet.
FROM python:3.12-slim image de départ ("slim" = sans les outils de compilation)
WORKDIR /app répertoire de travail ; le crée si besoin
COPY source destination copie depuis la machine vers l'image
RUN commande exécute PENDANT LA CONSTRUCTION (installer des paquets…)
ENV CLE=valeur variable d'environnement, présente à l'exécution
ARG CLE=valeur variable disponible seulement pendant la construction
EXPOSE 8000 documentation du port utilisé (ne publie rien par lui-même)
USER app change d'utilisateur : ne pas rester root
HEALTHCHECK … commande que Docker lance pour vérifier la santé
CMD ["uvicorn", "…"] commande lancée AU DÉMARRAGE du conteneur
ENTRYPOINT ["python"] exécutable fixe ; CMD fournit alors ses argumentsLe cache des couches : la règle qui divise les temps de build par dix
Docker met en cache chaque couche et la réutilise tant que ni l'instruction ni les fichiers qu'elle copie n'ont changé. Dès qu'une couche est invalidée, toutes les suivantes le sont aussi. D'où la règle d'or : ordonner du plus stable au plus volatil. Les dépendances changent une fois par mois, le code change à chaque commit. Si l'on copie tout le projet avant d'installer les dépendances, la moindre modification d'une ligne de code réinstalle l'intégralité de l'environnement — plusieurs minutes à chaque build. En copiant d'abord pyproject.toml et uv.lock, cette couche reste valide tant que les dépendances ne bougent pas.
# ════════ étape 1 : construire l'environnement ════════
FROM python:3.12-slim AS builder
# on récupère le binaire uv depuis son image officielle
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
# ── couche STABLE : les dépendances ──
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
# --frozen : interdit de modifier le lock (échoue si désynchronisé)
# --no-dev : pas de pytest/ruff dans l'image de production
# ── couche VOLATILE : le code ──
COPY src/ src/
RUN uv sync --frozen --no-dev
# ════════ étape 2 : image finale, sans outils de build ════════
FROM python:3.12-slim
WORKDIR /app
# on ne recopie que le résultat : l'environnement et le code
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/src /app/src
ENV PATH="/app/.venv/bin:$PATH"
# un utilisateur non privilégié : si l'application est compromise,
# l'attaquant n'est pas root dans le conteneur
RUN useradd --create-home app && chown -R app /app
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "mon_projet.api:app", "--host", "0.0.0.0", "--port", "8000"]La construction en deux étapes (multi-stage) est le point clé : compilateurs, caches et fichiers intermédiaires restent dans l'étape jetable, et seul le résultat utile est recopié dans l'image finale. On passe typiquement de 1,2 Go à 200 Mo — ce qui accélère les déploiements et réduit la surface d'attaque.
Deux détails qui comptent : --host 0.0.0.0 est obligatoire, car écouter sur 127.0.0.1 depuis un conteneur signifie « uniquement depuis l'intérieur du conteneur », et la redirection de port ne verrait rien. Et le fichier .dockerignore est indispensable :
.git .venv data/ notebooks/ models/ __pycache__/ *.parquet *.csv .pytest_cache .ruff_cache
Sans lui, la commande docker build envoie l'intégralité du répertoire au démon Docker avant même de commencer — y compris les 10 Go de données et l'historique Git.
6. Volumes : faire survivre les données
Un conteneur est éphémère : tout ce qu'il écrit dans son système de fichiers disparaît quand on le supprime. C'est voulu — un conteneur doit être jetable et remplaçable — mais il faut donc un mécanisme pour les données qui doivent survivre. Deux formes de montage :
# VOLUME NOMMÉ : géré par Docker, stocké dans son espace. Pour les DONNÉES. docker volume create donnees-pg docker run -d --name pg -v donnees-pg:/var/lib/postgresql/data postgres:16 # └─ le volume survit à la suppression du conteneur docker volume ls docker volume inspect donnees-pg docker volume rm donnees-pg # là seulement, les données disparaissent # BIND MOUNT : un dossier de la MACHINE est monté dans le conteneur. # Pour le DÉVELOPPEMENT : le code modifié est visible immédiatement. docker run -it --rm -v "$(pwd)":/app -w /app python:3.12-slim python script.py # montage en lecture seule : un jeu de données que le conteneur ne doit pas modifier docker run -v /data/brut:/data:ro mon-image
La distinction pratique : volume nommé pour ce que produit l'application (base de données, modèles entraînés, fichiers téléversés) ; bind mount pour ce qui vient de la machine (code source en développement, données brutes en lecture seule). Et attention aux permissions : un fichier créé par le conteneur appartient à l'utilisateur du conteneur, ce qui peut donner des fichiers root sur la machine hôte.
7. Réseaux et variables d'environnement
Chaque conteneur reçoit sa propre interface réseau. Trois choses à savoir :
# créer un réseau : les conteneurs qui y sont attachés se voient PAR LEUR NOM docker network create app-net docker run -d --name base --network app-net postgres:16 docker run -d --name api --network app-net -p 8000:8000 mon-api:1.0 # depuis "api", la base est joignable à l'adresse "base:5432" — Docker fournit # une résolution DNS interne, il n'y a aucune adresse IP à connaître # publier un port vers l'extérieur : -p HOTE:CONTENEUR docker run -p 8080:8000 mon-api # http://localhost:8080 → port 8000 du conteneur docker run -p 127.0.0.1:8000:8000 mon-api # accessible uniquement en local docker network ls docker network inspect app-net
Sans réseau explicite, les conteneurs sont sur le réseau bridge par défaut, où la résolution par nom ne fonctionne pas : c'est la cause la plus fréquente des « connection refused » entre deux conteneurs.
docker run -e DATABASE_URL="postgresql://user:pass@base:5432/db" mon-api docker run --env-file .env mon-api # toutes les variables d'un fichier # GPU (nécessite le NVIDIA Container Toolkit sur l'hôte) docker run --gpus all nvidia/cuda:12.4.0-base nvidia-smi
ENV ou copiée dans un fichier reste dans les couches de l'image : quiconque la télécharge peut l'extraire, même si une instruction ultérieure a « supprimé » le fichier — les couches précédentes sont toujours là. Les secrets passent par des variables d'environnement à l'exécution, un fichier monté, ou un gestionnaire de secrets. Et pour le GPU : le pilote reste sur la machine hôte, seules les bibliothèques CUDA sont dans l'image — c'est ce qui explique les incompatibilités de version entre image et pilote, comme dans mon projet NAS.
8. Docker Compose
À quoi ça sert
Une application réelle n'est jamais un seul conteneur : une API, une base de données, un cache, parfois un serveur de tâches de fond. Les lancer à la main impose de retenir l'ordre de démarrage, de créer le réseau, de nommer les volumes, de recopier les bonnes variables d'environnement — une longue série de commandes fragiles, à refaire à chaque redémarrage.
Docker Compose décrit toute cette pile dans un fichier unique, versionné avec le code. Une commande démarre l'ensemble, une autre l'arrête. C'est l'outil qui permet à quelqu'un de cloner un dépôt et d'avoir l'application complète qui tourne en une minute — y compris la base de données, avec le bon schéma.
services:
api:
build: . # construire depuis le Dockerfile local
# image: ghcr.io/nils/mon-api:1.0 ← en production, on utilise une image publiée
ports:
- "8000:8000" # exposé sur la machine
environment:
DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@base:5432/annonces
REDIS_URL: redis://cache:6379/0
LOG_LEVEL: info
depends_on:
base:
condition: service_healthy # attend que la base réponde VRAIMENT
cache:
condition: service_started
volumes:
- ./src:/app/src # bind mount : rechargement à chaud en dev
restart: unless-stopped # redémarre en cas de crash, sauf arrêt manuel
base:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # lu dans le fichier .env
POSTGRES_DB: annonces
volumes:
- donnees-pg:/var/lib/postgresql/data # LES DONNÉES SURVIVENT ICI
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro # schéma au 1er démarrage
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d annonces"]
interval: 5s
timeout: 3s
retries: 10
# pas de "ports:" → la base n'est PAS exposée à l'extérieur, seulement à l'api
cache:
image: redis:7-alpine
command: redis-server --save 60 1 --loglevel warning
volumes:
donnees-pg: # volume nommé, géré par Dockerdocker compose up -d # démarre toute la pile en arrière-plan docker compose up --build # reconstruit les images avant de démarrer docker compose ps # état des services docker compose logs -f api # suivre les journaux d'un service docker compose exec api bash # ouvrir un shell dans le service "api" docker compose exec base psql -U app annonces # un client SQL dans la base docker compose restart api docker compose stop # arrête sans supprimer docker compose down # arrête ET supprime conteneurs + réseau docker compose down -v # … et SUPPRIME LES VOLUMES (donc les données) docker compose run --rm api pytest # lancer une commande ponctuelle docker compose --profile dev up # démarrer un sous-ensemble de services
Trois points méritent d'être soulignés dans ce fichier. Le healthcheck combiné à depends_on: condition: service_healthy évite l'erreur la plus courante : l'API démarre avant que PostgreSQL n'accepte les connexions et plante immédiatement. L'absence de section ports sur la base signifie qu'elle n'est joignable que depuis le réseau interne — une bonne pratique de sécurité, et suffisante puisque l'API l'atteint par le nom base. Enfin, le script monté dans /docker-entrypoint-initdb.d/ est exécuté automatiquement au tout premier démarrage : c'est ainsi qu'on crée le schéma SQL du module 4 sans intervention manuelle. C'est exactement la structure utilisée dans le projet final (module 28).
9. Kubernetes
Le problème que Compose ne résout pas
Compose orchestre des conteneurs sur une machine. Dès qu'il faut plusieurs machines, la question change de nature : que se passe-t-il si un serveur tombe ? Comment répartir la charge ? Comment déployer une nouvelle version sans coupure ? Comment redémarrer automatiquement ce qui a planté à 3 h du matin ?
Kubernetes (souvent abrégé « K8s ») répond à cela par un principe simple à énoncer : on lui décrit l'état souhaité — « je veux trois exemplaires de cette image, chacun avec 2 Go de mémoire, accessibles derrière une adresse stable » — et il s'emploie en permanence à faire correspondre l'état réel à cette description. Si un serveur meurt, il replace ailleurs les conteneurs perdus, sans intervention. C'est ce qu'on appelle une approche déclarative : on ne donne pas d'ordres, on décrit une cible.
L'architecture, en deux moitiés
PLAN DE CONTRÔLE (le cerveau) NŒUDS DE TRAVAIL (les bras) ┌────────────────────────────┐ ┌──────────────┐ ┌──────────────┐ │ API server ← tout passe │ │ kubelet │ │ kubelet │ │ par lui │◄───────►│ │ │ │ │ │ │ etcd ← l'état │ │ ┌──┴───┐ │ │ ┌──┴───┐ │ │ souhaité │ │ │ Pod │ │ │ │ Pod │ │ │ scheduler ← place les │ │ │ Pod │ │ │ │ Pod │ │ │ pods │ │ └──────┘ │ │ └──────┘ │ │ controllers ← corrigent │ │ kube-proxy │ │ kube-proxy │ │ les écarts │ └──────────────┘ └──────────────┘ └────────────────────────────┘
On parle au cluster via l'API server, presque toujours avec la commande kubectl. L'état souhaité est stocké dans etcd. Le scheduler décide sur quel nœud placer chaque pod en fonction des ressources demandées. Les contrôleurs comparent en boucle l'état réel à l'état souhaité et agissent pour combler l'écart — c'est là que se trouve toute l'intelligence du système.
Les objets, du plus petit au plus grand
Pod — la plus petite unité déployable. Un pod contient un ou plusieurs conteneurs qui partagent la même adresse IP et les mêmes volumes ; ils sont toujours placés ensemble sur le même nœud. Dans 90 % des cas, un pod = un conteneur ; les cas à plusieurs conteneurs servent au motif sidecar (un conteneur annexe qui collecte les journaux, par exemple). Point essentiel : un pod est jetable. Il n'a pas d'adresse stable, il peut être détruit et recréé ailleurs à tout moment. On ne crée donc jamais un pod à la main.
ReplicaSet — maintient un nombre donné de pods identiques. Si l'un disparaît, il en recrée un. On ne le manipule pas directement non plus.
Deployment — l'objet que l'on écrit réellement. Il gère un ReplicaSet et, surtout, les mises à jour progressives : lors d'un changement de version, il crée les nouveaux pods, attend qu'ils soient prêts, puis retire les anciens, un par un. Pas de coupure de service, et retour arrière en une commande.
Service — puisque les pods vont et viennent avec des adresses changeantes, le Service fournit une adresse et un nom stables devant un groupe de pods, et répartit la charge entre eux. Trois variantes : ClusterIP (joignable uniquement dans le cluster, le défaut), NodePort (ouvre un port sur chaque nœud), LoadBalancer (demande un répartiteur au fournisseur cloud).
Ingress — l'entrée HTTP depuis l'extérieur : routage par nom de domaine et par chemin, terminaison TLS. C'est le reverse proxy du cluster.
ConfigMap et Secret — la configuration, séparée de l'image : paramètres non sensibles pour le premier, identifiants et clés pour le second. Cela permet de déployer la même image en test et en production avec des réglages différents.
PersistentVolumeClaim — une demande de stockage durable, l'équivalent du volume nommé de Compose.
Job et CronJob — une tâche qui se termine (un entraînement, un import) et sa version planifiée (un ré-entraînement hebdomadaire, module 26).
Namespace — un cloisonnement logique du cluster, pour séparer les équipes ou les environnements.
HorizontalPodAutoscaler — ajuste automatiquement le nombre de répliques selon la charge CPU ou une métrique personnalisée.
apiVersion: apps/v1
kind: Deployment
metadata:
name: model-api
labels: { app: model-api }
spec:
replicas: 3 # trois exemplaires en permanence
selector:
matchLabels: { app: model-api } # quels pods ce Deployment gère-t-il
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # au plus 1 pod en trop pendant la mise à jour
maxUnavailable: 0 # aucun pod indisponible : zéro coupure
template: # ── le modèle de pod à créer ──
metadata:
labels: { app: model-api } # doit correspondre au selector ci-dessus
spec:
containers:
- name: api
image: ghcr.io/nils/model-api:1.4.2 # JAMAIS :latest en production
ports:
- containerPort: 8000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef: { name: db-credentials, key: url }
resources:
requests: # RÉSERVÉ : sert au scheduler pour le placement
cpu: "500m" # 500 millicœurs = 0,5 cœur
memory: "1Gi"
limits: # PLAFOND : au-delà, le conteneur est bridé (CPU)
cpu: "2" # ou tué (mémoire : OOMKilled)
memory: "4Gi"
nvidia.com/gpu: 1 # un GPU entier (non partageable par défaut)
readinessProbe: # "puis-je recevoir du trafic ?"
httpGet: { path: /health, port: 8000 }
initialDelaySeconds: 20 # laisse le temps de charger le modèle
periodSeconds: 10
livenessProbe: # "suis-je encore vivant ?" sinon → redémarrage
httpGet: { path: /health, port: 8000 }
initialDelaySeconds: 60
periodSeconds: 30
---
apiVersion: v1
kind: Service # l'adresse stable devant les 3 pods
metadata:
name: model-api
spec:
selector: { app: model-api } # sélectionne les pods par leur étiquette
ports:
- port: 80 # port du Service
targetPort: 8000 # port du conteneur
type: ClusterIPLes trois points qui comptent vraiment pour du ML
requestscontrelimits— la première est une réservation utilisée pour décider où placer le pod, la seconde un plafond appliqué à l'exécution. Un modèle qui charge 3 Go de poids doit le déclarer, sinon il sera tué (OOMKilled) dès le premier pic de charge.readinessProbe— sans elle, Kubernetes envoie du trafic à un pod dont le modèle n'est pas encore chargé en mémoire, et les premières requêtes échouent à chaque déploiement.livenessProbe— elle redémarre un conteneur bloqué. Mal réglée (délai trop court), elle redémarre en boucle un service qui démarre lentement : c'est la cause classique du CrashLoopBackOff.
kubectl apply -f deployment.yaml # applique l'état souhaité (créé ou mis à jour) kubectl get pods # lister ; -w pour suivre en direct kubectl get all -n mon-namespace kubectl describe pod model-api-7d9f # événements : LA commande de diagnostic kubectl logs -f model-api-7d9f # journaux ; --previous après un crash kubectl exec -it model-api-7d9f -- bash kubectl port-forward svc/model-api 8080:80 # tester en local sans exposer kubectl rollout status deploy/model-api # suivre une mise à jour kubectl rollout undo deploy/model-api # RETOUR ARRIÈRE immédiat kubectl scale deploy/model-api --replicas=5 kubectl top pods # consommation réelle kubectl delete -f deployment.yaml
En avez-vous besoin ?
Honnêtement, rarement pour un projet personnel ou un prototype : la complexité opérationnelle est réelle, et un conteneur derrière un reverse proxy couvre la grande majorité des besoins. Kubernetes devient pertinent lorsqu'il faut plusieurs machines, de l'autoscaling, des déploiements sans coupure, ou le partage d'un parc de GPU entre plusieurs équipes. La valeur immédiate, pour un data scientist, est de savoir lire un manifeste et déclarer correctement ses ressources : c'est la langue commune des équipes plateforme. Pour s'exercer sans cluster, k3s, kind ou minikube font tourner un cluster complet sur un portable, et kubectl s'y comporte exactement de la même façon.
Ce qu'il faut retenir du module
- Un conteneur est un processus isolé par le noyau de l'hôte (namespaces + cgroups) ; une VM embarque un noyau complet.
- Image = modèle figé, conteneur = instance ; les couches sont mises en cache, d'où l'ordre du plus stable au plus volatil.
- Construction en deux étapes, utilisateur non root,
.dockerignore, jamais de secret dans l'image,--host 0.0.0.0. - Volume nommé pour les données produites, bind mount pour le code en développement.
- Un réseau Docker explicite permet aux conteneurs de se joindre par leur nom.
- Compose décrit une pile complète dans un fichier ;
healthcheck+depends_onévitent les démarrages ratés. - Kubernetes maintient un état déclaré : Deployment pour les répliques et les mises à jour, Service pour l'adresse stable, probes et ressources pour un service de modèle qui tient debout.