Formation MLOps · Dotika · Bloc 4 · 4 h

La boîte

Conteneurisation avec Docker — le système entre dans une boîte standard et devient portable : laptop, serveur, cloud, il tourne partout à l'identique.

Le fil rouge · où on en est

L'API répond — sur une seule machine

Bloc 3 : le cerveau a une adresse. POST /predict répond en quelques millisecondes, les erreurs sont propres, les tests passent. Mais tout ce théâtre vit sur votre laptop — avec votre Python, vos libs, vos réglages.

Mission du bloc 4 : mettre le système dans une boîte qui démarre à l'identique sur n'importe quelle machine.

FastAPI ✓tests ✓portable… pas encore
L'objectif du bloc

À la fin du bloc, le système démarre à l'identique sur n'importe quelle machine — en une commande

docker compose up : l'API, la base de transactions et MLflow se lèvent ensemble, connectés, sur le laptop du voisin comme sur le serveur de la banque. Fini le « ça marche chez moi ».

Bloc 4 · 4 h · théo 0h40 / prat 3h20

Une idée, puis 3h20 au clavier

0h40
Concepts Docker : images, conteneurs, registries, couchesL'idée du conteneur — de la boîte maritime au mille-feuille d'images.
théorie
1h40
Dockerfile pour l'API ML + optimisation (multi-stage, .dockerignore)Mettre notre détecteur en boîte, puis passer de 2,1 Go à moins de 500 Mo.
pratique
1h00
Docker Compose : API + base de transactions + MLflowTrois organes, un fichier YAML, une commande.
pratique
0h40
Debug & bonnes pratiques (logs, exec, non-root)Quand ça casse : diagnostiquer — puis durcir la boîte.
pratique
Acte 1

Le retour
du démon

Une histoire vraie · partout, tout le temps

Vendredi, 17 h. L'API est parfaite : les tests passent, la démo éblouit. On la copie sur le serveur de la banque. Elle ne démarre pas.

Le code est identique au caractère près. Et pourtant, rien ne marche. Vous l'avez déjà vécu — c'est la douleur n°1 du bloc 1, revenue en personne.

Rappel du bloc 1 · douleur n°1

« Ça marche
chez moi »

Votre laptop : Python 3.11, Debian récent, les bonnes libs système. Le serveur : Python 3.8, une vieille libc, pas les mêmes drivers. Le même code, deux mondes — et le modèle sérialisé n'aime que le vôtre.

Le code n'était pas le problème. Le problème, c'était tout ce qu'il y a autour du code.

$ python -m src.api.main
ModuleNotFoundError: No module named 'pydantic_core'
$ pip install pydantic-core
ERROR: requires Python >= 3.9
# serveur : Python 3.8.5 · glibc 2.17 (2012)
Le serveur de la banque, vendredi 17 h 42.
L'autopsie

Quatre différences invisibles

Votre programme ne tourne jamais seul : il repose sur un empilement que personne ne voit — jusqu'au jour où il change.

Couche 1
La version de Python
3.11 chez vous, 3.8 sur le serveur. Une syntaxe, un import, un pickle — et tout s'arrête.
Couche 2
Les bibliothèques système
libc, OpenSSL, drivers : numpy et scikit-learn s'appuient dessus, et pip ne les contrôle pas.
Couche 3
Les variables d'environnement
Chemins, ports, secrets, locales. Invisibles dans le code, décisives à l'exécution.
Couche 4
Le système lui-même
macOS chez vous, Linux là-bas. Deux noyaux, deux façons de servir les fichiers, le réseau, les processus.
La fausse solution

Le doc d'installation
de 40 pages

La riposte classique : tout documenter. Un README fleuve qui décrit chaque étape, chaque version, chaque piège. Il est faux dès la première mise à jour, et chacun le suit à sa manière — donc chacun obtient une machine différente.

Un document décrit un environnement. Il ne le garantit jamais.

# INSTALL.md — v7, « à jour »
étape 12/87 : installer libgomp1 (voir note 4)
étape 13/87 : si erreur SSL, recompiler Python
étape 14/87 : demander à Karim (parti en 2024)
# temps moyen constaté : 2 jours
Chaque équipe a ce document. Personne n'arrive au bout.
Le renversement

Et si, au lieu de décrire la machine,
on livrait la machine avec le code ?

Emballer le code et tout son empilement — Python, libs, réglages, mini-OS — dans un seul objet scellé, qu'on déplace tel quel. C'est exactement l'idée du conteneur. Et elle ne vient pas de l'informatique.

Acte 2 · la théorie du bloc vit ici · 0h40

L'idée
du conteneur

L'analogie maritime · 1956

1956. Malcom McLean, transporteur routier, regarde des dockers vider son camion caisse par caisse pendant des heures — et invente la boîte en acier standardisée.

Même taille partout, empilable, scellée, manipulable par n'importe quelle grue du monde. Le contenu peut être n'importe quoi — la boîte, elle, est toujours la même.

Ce que la boîte a changé

Une boîte, et le coût du fret divisé par 36

Avant la boîte
Charger un cargo : des jours, des centaines de dockers, chaque caisse manipulée à la main. Casse, vol, attente.
5,86 $ la tonne chargée (1956)
Après la boîte
Une grue, des heures. Le port n'a plus besoin de savoir ce qu'il y a dedans — juste de savoir soulever des boîtes.
0,16 $ la tonne — ÷ 36
La boîte n'a rien changé au contenu. Elle a standardisé la manière de le déplacer — et c'est ça qui a tout changé.
Le transfert

Docker, 2013 — la même idée, pour le logiciel

Un conteneur logiciel = votre code + son environnement complet, scellés dans une boîte standard que toute machine sait exécuter.

Le serveur n'a plus besoin de connaître Python, vos libs ni votre OS. Il n'a besoin que d'une seule compétence : savoir ouvrir des boîtes. Cette compétence s'appelle Docker — et elle est installée partout, du laptop au cloud.

Dockerle port standardisé du logiciel
Feynman · 3 minutes

L'image et le conteneur : la recette et le plat

L'image — la recette
Un fichier figé, inerte : mini-OS + libs + votre code, empilés en couches. On la construit une fois, on la partage, elle ne change jamais.
1 image, construite une fois
Le conteneur — le plat servi
L'image en train de tourner : un processus vivant, isolé du reste de la machine. On le lance, on l'arrête, on le jette — l'image reste intacte.
N conteneurs, tous identiques
Une image → mille conteneurs rigoureusement identiques. C'est ça, la reproductibilité qu'on cherchait.
Sous le capot

Une image est
un mille-feuille

Chaque instruction de la recette ajoute une strate figée par-dessus la précédente. Docker garde chaque strate en cache : ce qui n'a pas changé n'est jamais reconstruit — ni retéléchargé.

Comprendre les couches, c'est comprendre pourquoi certains builds prennent 3 secondes et d'autres 20 minutes.

FROM python:3.11-slim RUN pip install … COPY src/ ./src COPY models/model.pkl 1 instruction = 1 couche, empilées de bas en haut couche inchangée = cache = build instantané
Le mille-feuille : chaque strate est réutilisable telle quelle.
Partager les boîtes

Le registry : la bibliothèque d'images

Une image se partage comme du code — via un registry : le GitHub des boîtes. Trois gestes suffisent.

docker build
Fabriquer la boîte à partir de la recette. Chez vous, ou — bientôt — par un robot.
docker push
La déposer à la bibliothèque : Docker Hub, GitHub Container Registry, ou le registry privé de la banque.
docker pull
La récupérer depuis n'importe quelle machine du monde — et la lancer telle quelle.

Teaser : au bloc 6, c'est la CI qui fera build + push à chaque commit. La bibliothèque deviendra le point de passage obligé entre votre code et la production.

Question fréquente

« Une machine virtuelle fait pareil, non ? »

Presque — mais la différence de poids change tout à l'usage.

La VM · l'immeuble entier
Un OS complet par application
  • Des gigaoctets, des minutes à démarrer
  • Isolation maximale — au prix d'un immeuble par locataire
Le conteneur · la chambre
Le noyau de l'hôte, partagé
  • Des mégaoctets, des millisecondes à démarrer
  • Des dizaines par machine — chacun isolé dans sa chambre

C'est cette légèreté qui rend possible la suite : multiplier les répliques de l'API en quelques secondes — rendez-vous au bloc 7 avec Kubernetes.

Acte 3

Dockeriser
notre détecteur

La recette

Le Dockerfile, ligne par ligne

Le doc d'installation de 40 pages, réécrit en cinq lignes exécutables — versionnées dans Git avec le code.

FROM python:3.11-slim  # la base : mini-OS + le bon Python, garanti
COPY requirements.txt .  # d'abord la liste des dépendances
RUN pip install -r requirements.txt  # la couche lourde
COPY src/ ./src  # puis le code — ce qui change souvent
CMD ["uvicorn", "src.api.main:app", "--host", "0.0.0.0"]  # au démarrage : l'API

Chaque ligne crée une couche du mille-feuille. docker build exécute la recette ; docker run sert le plat.

Le cache · la subtilité qui compte

Pourquoi requirements avant le code ?

Parce qu'une couche modifiée invalide toutes celles du dessus. Et vous modifiez votre code cent fois par jour — vos dépendances, une fois par semaine.

Code copié d'abord
20 minutes par build
  • Chaque modification du code invalide la couche du dessus : pip install
  • Réinstallation complète des dépendances à chaque essai
requirements d'abord
3 secondes par build
  • La couche pip reste en cache tant que requirements.txt ne bouge pas
  • Seule la fine couche du code est reconstruite

La règle : du plus stable en haut du Dockerfile au plus volatil en bas. Une ligne déplacée, des heures gagnées chaque semaine.

L'oubli classique

N'embarquez pas
toute la maison

Sans garde-fou, COPY embarque tout le dossier : les 150 Mo du CSV, l'historique .git, les notebooks — et parfois le .env avec les secrets. Une image obèse, et une fuite qui attend son heure.

Dans la boîte, on met le nécessaire au voyage. Pas le bureau entier.

# .dockerignore
data/  # 150 Mo de CSV : reste dehors
.git/  # tout l'historique du projet
notebooks/
__pycache__/
.env  # les secrets, surtout pas dans l'image
Cinq lignes : des centaines de Mo — et un incident de sécurité — évités.
L'optimisation qui compte

L'histoire des 2,1 Go

Premier build de l'équipe : 2,1 Go. Le coupable ? Tout l'outillage qui a servi à construire voyage encore dans la boîte livrée.

Build naïf
Compilateurs, outils de build, caches pip : indispensables pour construire, inutiles pour exécuter — et pourtant embarqués.
2,1 Go à pousser, tirer, démarrer
Multi-stage
Deux étages dans le même Dockerfile : un atelier pour construire, une boîte propre pour exécuter. On ne livre que la seconde.
380 Mo — mêmes fonctionnalités
Moins de Go = push plus rapide, démarrage plus rapide, moins de failles embarquées. L'atelier reste à l'atelier.
Démo live · à vous de jouer

Regardez l'image se construire

0 Mo
taille de l'image
chaque instruction du Dockerfile ajoute une couche — cliquez sur docker build.
Acte 4

Le système
à trois organes

Le nouveau problème

Lundi matin : lancer l'API, puis la base de transactions, puis MLflow — dans le bon ordre, avec les bons ports. Trois terminaux, et un rituel que personne ne mémorise.

Notre système n'est plus un programme : c'est un organisme à plusieurs organes. Mettre chaque organe en boîte ne suffit pas — il faut un chef d'orchestre.

Le chef d'orchestre

Trois organes, un fichier

Docker Compose : on décrit l'état voulu du système dans un YAML — et une commande le fait exister.

services:
  api:  # notre détecteur, construit depuis le Dockerfile
    build: .    ports: ["8000:8000"]    depends_on: [db]
  db:  # la base de transactions
    image: postgres:16    volumes: [pgdata:/var/lib/postgresql/data]
  mlflow:  # la mémoire des modèles — il arrive au bloc 5
    image: ghcr.io/mlflow/mlflow:v2.14.1

Ce fichier est commité dans Git : l'architecture du système devient du texte relu en revue de code.

La magie discrète

Les services s'appellent
par leur nom

Compose crée un réseau privé pour le système : l'API joint la base à l'adresse db:5432, MLflow à mlflow:5000. Pas d'IP à connaître, pas de configuration réseau — le nom du service suffit.

« localhost », vu de l'intérieur d'un conteneur, c'est le conteneur lui-même. Premier piège du lab — vous voilà prévenus.

# dans le conteneur api
DATABASE_URL = "postgresql://dotika@db:5432/transactions"
MLFLOW_TRACKING_URI = "http://mlflow:5000"

# "db", "mlflow" = les noms des services du YAML
L'annuaire est automatique : un nom de service = une adresse.
La mémoire

Ce qui survit
au conteneur

Un conteneur est jetable : on le détruit et on le recrée sans état d'âme — c'est sa force. Mais les transactions de la banque, elles, doivent survivre. Un volume est un disque géré par Docker, branché dans le conteneur : les données y vivent, hors de la boîte.

Le conteneur meurt, le volume reste. L'éphémère calcule, le durable se souvient.

$ docker compose down  # tout est détruit…
$ docker compose up -d  # …tout redémarre
$ docker exec db psql -c "SELECT count(*) FROM transactions;"
  284807  # les données sont toujours là
pgdata:/var/lib/postgresql/data — une ligne du YAML, et la base survit.
Le résultat

docker compose up —
et le système entier respire

Nouveau collègue, nouveau serveur, machine de démo : git clone, docker compose up, café. Le doc d'installation de 40 pages est mort — remplacé par un fichier de vingt lignes qui ne ment jamais.

Acte 5

Quand
ça casse

Réflexe n°1

La boîte noire :
docker logs

Un conteneur n'a pas d'écran : si l'API meurt au démarrage, tout s'est joué en silence. docker logs rejoue tout ce que le processus a écrit — c'est l'enregistreur de vol de la boîte, et le premier geste de tout diagnostic.

Avant de soupçonner Docker, lisez ce que votre programme a dit en mourant.

$ docker compose logs api --tail 5
api | Traceback (most recent call last):
api |   File "src/api/main.py", line 6
api | ModuleNotFoundError: No module named 'joblib'
# → la ligne manquait dans requirements.txt
Le crash est daté, expliqué, rejouable. Rien à deviner.
Réflexe n°2

Entrer dans la boîte :
docker exec

Quand les logs ne suffisent pas, on ouvre un shell à l'intérieur du conteneur qui tourne : vérifier que le modèle est bien là, tester la connexion à la base, inspecter les variables d'environnement — sur place.

Le conteneur n'est pas une boîte opaque. C'est une pièce dans laquelle on peut entrer, regarder, et ressortir.

$ docker exec -it bloc4-api-1 bash
$ ls models/
model.pkl  # le modèle est bien embarqué
$ python -c "import socket; socket.create_connection(('db', 5432))"
# pas d'erreur : la base répond par son nom
Deux vérifications, trente secondes — le doute est levé.
Bonne pratique n°1 · sécurité

Le stagiaire n'a pas les clés du camion

Par défaut, le processus tourne en root dans le conteneur. Une faille dans l'API, et l'attaquant est administrateur de la boîte.

root par défaut
Toutes les clés, tout le temps
  • Une faille dans l'API = un attaquant qui peut tout écrire, tout installer
  • Le réflexe qu'aucun audit bancaire ne laisse passer
USER appuser
Le strict nécessaire
  • Deux lignes dans le Dockerfile : adduser, puis USER
  • Le processus lit son code, sert ses requêtes — et rien d'autre

L'API n'a besoin que de conduire. On lui donne le volant — pas les clés du camion, ni celles du dépôt.

Bonne pratique n°2 · reproductibilité

« latest » est un mensonge

Le tag :latest ne veut pas dire « la meilleure version ». Il veut dire « la dernière poussée, par n'importe qui, à n'importe quel moment ».

Une image :latest peut changer entre deux déploiements — et voilà le démon « ça marchait hier » de retour, dans la boîte censée l'avoir tué. En production, on épingle tout : python:3.11-slim, postgres:16, fraud-api:1.4.2. Un tag explicite, c'est la reproductibilité qu'on vient de gagner — ne la rendez pas au hasard.

fraud-api:1.4.2fraud-api:latest — jamais en prod
Avant le lab · l'essentiel

Cinq réflexes à emporter

Réflexe 1
L'image décrit, le conteneur exécute
La recette et le plat. Une image, mille conteneurs identiques : c'est ça, la portabilité.
Réflexe 2
L'ordre des couches = le cache
Du plus stable au plus volatil : requirements avant le code. 3 secondes au lieu de 20 minutes.
Réflexe 3
Une boîte légère et propre
.dockerignore dès le premier build, multi-stage pour livrer sans l'atelier.
Réflexe 4
logs, puis exec
Lire ce que le processus a dit, puis entrer vérifier sur place. Dans cet ordre.

Et le cinquième : jamais root, jamais :latest. La boîte est aussi une frontière de sécurité — et une promesse de reproductibilité.

Acte 6 · lab

À vous :
3h20 au clavier

Mission 1 / 3 · 1h40 · pratique

Mettre l'API en boîte — puis l'affûter

Écrire le Dockerfile de l'API du bloc 3 : build, run, tester POST /predict depuis l'extérieur de la boîte. Puis optimiser : .dockerignore, ordre des couches vérifié au chrono, et multi-stage — objectif : une image sous les 500 Mo.

Livrable : docker run -p 8000:8000 fraud-api → /predict répond ; image finale < 500 Mo, chronos avant/après notés.

Dockerfiledocker builddocker run -p.dockerignoremulti-stagedocker image ls

Binômes : un qui code, un qui questionne — on échange à la mi-temps.

Mission 2 / 3 · 1h00 · pratique

Le système à trois organes, en un fichier

Écrire compose.yml : l'API, la base de transactions (postgres) et MLflow. L'API joint la base par son nom de service, un volume garde les données, un healthcheck ordonne les démarrages.

Livrable : docker compose up démarre les trois services ; down puis up → les transactions ont survécu.

docker composedepends_onvolumeshealthcheckpostgres:16mlflow

Piège annoncé : « localhost » dans un conteneur, c'est le conteneur. Appelez les services par leur nom.

Mission 3 / 3 · 0h40 · pratique

Casser, diagnostiquer, durcir

On vous livre trois systèmes en panne : dépendance manquante, nom de service erroné, port occupé. Diagnostic aux logs et à l'exec — sans regarder le code d'abord. Puis on durcit votre propre image : utilisateur non-root, tags épinglés partout.

Livrable : les trois pannes expliquées par écrit (symptôme → indice → cause), et un Dockerfile durci commité.

docker compose logsdocker exec -itUSER appuserdocker inspecttags épinglés

Le but n'est pas de réparer vite — c'est de diagnostiquer proprement. La méthode servira toute la semaine.

Le fil rouge · fin du bloc 4

La boîte tourne partout

Regardez la carte : le système est scellé dans sa boîte. La même image démarre sur votre laptop, le serveur de la banque, le cloud — à l'identique, en une commande. Le démon « ça marche chez moi » est mort.

Mais une question reste sans réponse : quel modèle est dans la boîte ? Entraîné quand, sur quoi, avec quelle PR-AUC ? Personne ne s'en souvient — et c'est le prochain danger.

image ✓compose ✓mémoire… pas encore
La suite

Donnons-lui
une mémoire

Bloc 5 — Tracking & gestion de modèles avec MLflow : chaque expérience notée, chaque modèle versionné. « Le run 7, c'était quoi déjà ? » — plus jamais.

Pas à pas ↗