Module 9 — Conteneurisation et déploiement
Le service FastAPI existe maintenant en huit modules d'améliorations, mais
il tourne encore sur le portable de l'équipe. Ce module l'emballe dans une
image Docker que n'importe quelle plateforme d'exécution — serveur seul,
Kubernetes, Cloud Run, App Runner — sait lancer, avec les bonnes
propriétés de taille, de sécurité et de démarrage.
Une image, deux étages
Une image Docker naïve — FROM python:3.12 puis pip install — pèse
1,2 Go et contient des compilateurs, des bibliothèques de développement,
des paquets système inutiles à l'exécution. Une image en plusieurs
étages (multi-stage build) sépare la phase de construction (qui a besoin
de tout) de la phase d'exécution (qui a besoin du strict minimum).
# ----- Étage 1 : construction -----
FROM python:3.12-slim AS constructeur
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential gcc && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# ----- Étage 2 : exécution -----
FROM python:3.12-slim AS execution
# utilisateur non privilégié
RUN useradd --create-home --shell /bin/bash app
USER app
WORKDIR /home/app
# on ne copie que les paquets Python installés, pas les compilateurs
COPY --from=constructeur --chown=app:app /root/.local /home/app/.local
ENV PATH="/home/app/.local/bin:${PATH}"
COPY --chown=app:app app/ ./app/
COPY --chown=app:app modeles/ ./modeles/
ENV PORT=8000
EXPOSE 8000
CMD ["gunicorn", "app.main:app", \
"--worker-class", "uvicorn.workers.UvicornWorker", \
"--workers", "3", \
"--bind", "0.0.0.0:8000", \
"--access-logfile", "-", \
"--timeout", "60"]
Ce fichier passe l'image de 1,2 Go à environ 400 Mo, dont 250 Mo pour
scikit-learn, pandas et leurs dépendances natives, et 100 Mo pour le
modèle sérialisé. Les 50 Mo restants sont Python et le système de base.
Pourquoi slim et pas alpine
Deux images légères existent. python:3.12-slim (Debian minimal) et
python:3.12-alpine (musl libc). Alpine est encore plus petit (~50 Mo au
départ) mais son gestionnaire de paquets et sa libc diffèrent : la
plupart des paquets Python avec code natif — numpy, pandas, scipy,
scikit-learn — n'ont pas de roues précompilées pour Alpine, pip doit
donc compiler à l'installation, ce qui prend cinq à quinze minutes
supplémentaires à chaque construction et alourdit l'image finale
(compilateur laissé en place). Sur un service ML, slim gagne presque
toujours.
Le fichier .dockerignore évite la surprise du contexte
Sans lui, docker build envoie tout le dépôt — carnets Jupyter, données
d'entraînement, .git, .venv — au démon Docker, ce qui peut monter à
plusieurs Go et gonfle l'image si des COPY . sont naïfs.
.git/
.venv/
__pycache__/
*.pyc
notebooks/
data/
.pytest_cache/
.mypy_cache/
.env
tests/
Le .env en particulier ne doit jamais être copié dans l'image : il
contient des clés. Les secrets passent par des variables d'environnement
injectées à l'exécution.
Configuration par variables d'environnement
L'application lit sa configuration via Pydantic Settings, qui charge
depuis os.environ et applique les mêmes contraintes que les modèles
Pydantic.
from pydantic_settings import BaseSettings
from pydantic import Field
class Configuration(BaseSettings):
port: int = Field(8000, ge=1024, le=65535)
modele_uri: str = "models:/churn-telecom@champion"
mlflow_tracking_uri: str = "http://mlflow:5000"
workers: int = Field(3, ge=1, le=32)
log_level: str = "INFO"
api_keys: str # séparées par des virgules, jamais dans le code
class Config:
env_prefix = "CHURN_API_"
env_file = ".env"
config = Configuration()
Le préfixe CHURN_API_ évite la collision avec d'autres services. Le
docker docker run -e CHURN_API_MODELE_URI=... image change le
comportement sans reconstruire.
Combien de travailleurs Uvicorn
gunicorn --workers N lance N processus Python indépendants. Chaque
processus charge sa propre copie du modèle (module 3) et sert des
requêtes en parallèle. La règle empirique héritée du monde synchrone est
N = 2 × nombre_de_cœurs + 1. Pour un service ML, on la corrige à cause
de la mémoire.
Sur une machine à quatre cœurs et 8 Go de RAM avec un modèle de 500 Mo :
2 × 4 + 1 = 9travailleurs ⇒ 4,5 Go de modèle en mémoire, sans compter Python et les caches numpy. Trop.- La bonne formule devient
N = min(cœurs, mémoire_disponible / (mémoire_travailleur × 1,3)). Avec 500 Mo par travailleur, ça donnemin(4, 8 / 0,65) = 4.
Deux travailleurs par cœur sont le maximum théorique quand le calcul domine (cas ML) ; on descend souvent à un par cœur pour laisser une marge au système. Le module 10 mesure la valeur exacte pour le fil rouge.
Le démarrage : ne pas mettre le modèle dans l'image
Deux écoles.
Modèle dans l'image. Simple : COPY modeles/ ./modeles/. Le service
démarre en 5 secondes. Inconvénient : chaque nouveau modèle demande une
reconstruction et un redéploiement, ce qui casse le patron @champion
promu sans redémarrage (module 5 du cours 20).
Modèle chargé depuis MLflow au démarrage. Le service télécharge
l'artefact à chaque démarrage (5 à 60 secondes selon la taille). L'image
est petite (~350 Mo), la promotion se fait par @champion sans
reconstruction. C'est l'école recommandée dès que la fréquence de
promotion dépasse une fois par semaine.
Sur le fil rouge, on charge depuis MLflow. La sonde de disponibilité (module 8) ne passe qu'après le chargement, ce qui empêche l'orchestrateur d'envoyer du trafic avant.
Choisir sa plateforme d'exécution
Trois familles s'imposent en 2026.
Un serveur seul avec docker compose convient pour un service
interne à faible trafic : simple à déployer, coûte peu, mais aucune
redondance et le redémarrage est manuel.
Un service géré (Cloud Run, ECS Fargate, App Runner, Container Apps)
gère l'échelle, TLS, la sonde de disponibilité, les journaux. On lui
donne l'URL de l'image dans un registre (ECR, GCR, ACR, Docker Hub) et une configuration. C'est le choix par défaut pour un service ML
sans particularité : opérationnel en une heure, facturé à la seconde
d'exécution.
Kubernetes couvre les besoins complexes : multi-service, plusieurs environnements, service mesh, autoscaling fin. Un déploiement Kubernetes pour un seul service est un contre-emploi tant qu'il n'y a pas d'écosystème derrière.
Scan de vulnérabilités
L'image finie passe par un scanner (trivy, grype, docker scout) qui
compare les paquets installés à la base CVE. On l'intègre dans la chaîne
d'intégration (cours 20, module 7) : une image avec une vulnérabilité
critique n'est pas poussée. La fréquence des CVE sur Debian slim est
faible, mais elle est non nulle : un scan mensuel du dépôt d'images est
indispensable.
L'image lourde qui casse le déploiement
Une image de 3 Go se pousse en 20 minutes sur un réseau lent, se tire
sur chaque nœud en 5 minutes, ralentit chaque redémarrage. Sur un service
qui doit se déployer plusieurs fois par jour (retour arrière rapide,
canari), cette lourdeur devient un obstacle réel. La cible raisonnable
est < 500 Mo ; au-delà, on cherche ce qui pèse (docker history) et on
retire (compilateurs oubliés, torch non nécessaire, données de test
copiées par erreur).
Le USER app du Dockerfile n'est pas décoratif : sans lui, le
processus tourne en root dans le conteneur, et une évasion transforme
la faille en accès root sur l'hôte. Toute image de production doit tourner
en utilisateur non privilégié, avec un système de fichiers en lecture
seule quand c'est possible (--read-only).
En résumé
Dockerfileen plusieurs étages, baseslim, utilisateur non privilégié : l'image passe de 1,2 Go à environ 400 Mo..dockerignoreobligatoire, aucun secret dans l'image, tout en variables d'environnement lues parPydantic Settings.- Nombre de travailleurs Uvicorn contraint par la mémoire du modèle,
pas par la formule
2 × cœurs + 1hérité du monde synchrone. - Charger le modèle depuis le registre MLflow au démarrage permet la
promotion
@championsans reconstruction ; service géré par défaut, Kubernetes seulement s'il y a un écosystème derrière.
Le module 10 branche Locust sur le service et calibre le dimensionnement.