Module 6 — Conteneurisation d'un modèle
Le modèle churn-telecom@champion du registre pèse 12 Mo. L'installation
Python qui le charge en pèse 900. Le conteneur qu'on livre en production
doit contenir ces 912 Mo, pas 4 Go, doit démarrer en moins de deux
secondes, et ne doit pas embarquer de faille connue au moment de la
livraison. Ce module fait passer d'une image bricolée à une image qu'on
ose signer.
Le mauvais Dockerfile qui marche quand même
FROM python:3.11
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "servir.py"]
Cette image démarre, sert le modèle, répond aux requêtes. Elle a pourtant
cinq défauts que le module va corriger un par un : image de base trop
grosse, absence d'ordre de couches, dépendances non figées, utilisateur
root, chargement du modèle à chaque requête.
Image de base minimale et étagée
Une image python:3.11 de base pèse environ 950 Mo. python:3.11-slim
pèse 130 Mo, avec les mêmes capacités pour un service typique. Les
variantes alpine descendent encore, mais posent des problèmes de
compatibilité avec les paquets qui utilisent glibc (dont numpy,
scipy, torch) : la règle prudente est de partir de slim par défaut.
L'étage de construction (multi-stage build) permet de compiler les
dépendances dans une image lourde et de ne copier que le résultat dans
l'image finale :
FROM python:3.11-slim@sha256:ab12... AS constructeur
WORKDIR /construction
COPY pyproject.toml uv.lock ./
RUN pip install --user --no-deps -r <(uv export --format requirements-txt)
FROM python:3.11-slim@sha256:ab12... AS service
RUN useradd --uid 10001 --create-home appli
WORKDIR /app
COPY --from=constructeur /root/.local /home/appli/.local
COPY --chown=appli:appli src/ ./src/
COPY --chown=appli:appli modele/ ./modele/
USER appli
ENV PATH=/home/appli/.local/bin:$PATH
ENV MODELE_URI=models:/churn-telecom@champion
EXPOSE 8000
CMD ["uvicorn", "src.servir:app", "--host", "0.0.0.0", "--port", "8000"]
Cette image finale ne contient plus les outils de compilation, pas de
gestionnaire de paquets, et tourne sous un utilisateur non privilégié.
Elle passe de 4 Go à environ 400 Mo pour un service FastAPI type.
Charger le modèle une seule fois
L'erreur qui coûte le plus cher au débutant est de charger le modèle à chaque requête :
# À éviter
@app.post("/predire")
def predire(req):
modele = mlflow.pyfunc.load_model(URI) # 300 ms
return modele.predict(req.donnees)
Le modèle doit être chargé une seule fois, au démarrage du processus, et gardé en mémoire :
from contextlib import asynccontextmanager
from fastapi import FastAPI
import mlflow.pyfunc, os
@asynccontextmanager
async def cycle_de_vie(app: FastAPI):
app.state.modele = mlflow.pyfunc.load_model(os.environ["MODELE_URI"])
yield
app = FastAPI(lifespan=cycle_de_vie)
@app.post("/predire")
def predire(req: RequetePrediction):
return {"proba": float(app.state.modele.predict(req.en_dataframe())[0])}
Le service prend deux secondes à démarrer, puis répond en quelques millisecondes. Sans ce patron, la première requête après un redémarrage prend une seconde et déclenche une alerte de latence.
Variables d'environnement pour tout ce qui change
L'image est immuable. Ce qui change d'un environnement à l'autre (chemin du registre, alias du modèle, seuil de décision, chaîne de connexion) passe par des variables d'environnement.
MODELE_URI:models:/churn-telecom@championen production,models:/churn-telecom@candidaten préproduction.MLFLOW_TRACKING_URI: le serveurMLflowselon l'environnement.SEUIL_DECISION:0.5par défaut, ajustable par les opérations.
Une image qui tourne dans trois environnements sans être reconstruite est un signe de bonne conception. Une image qu'on reconstruit pour changer un seuil est un signe qu'il faut sortir la configuration.
Taille de l'image, sous contrôle
Trois commandes valent mieux qu'un long discours :
# Taille des couches, par ordre décroissant
docker history mon-image:v7
# Contenu et poids des dépendances Python
docker run --rm mon-image:v7 pip list --format=freeze
# Faille de sécurité connues
trivy image mon-image:v7
Une image de service ne devrait embarquer ni git, ni curl, ni
build-essential : ces outils appartiennent à l'étage de construction. Une
faille dans curl sur un service qui n'utilise pas curl reste une
faille inscrite au registre des vulnérabilités et donc bloquante pour
plusieurs politiques de sécurité.
Épingler l'image de base par sha256:... protège de la republication
silencieuse d'un python:3.11-slim mis à jour. Signer l'image livrée
(cosign) donne au déploiement la certitude qu'elle provient bien de la
chaîne d'intégration officielle. Ces deux gestes coûtent une commande et
évitent toute une classe d'attaques par supply chain.
En résumé
- Partez d'une image
slimet utilisez un étage de construction pour ne pas embarquer les outils de compilation. - Faites tourner l'image sous un utilisateur non privilégié ; épinglez
la base par
sha256:.... - Chargez le modèle une fois au démarrage via le cycle de vie du serveur, jamais à chaque requête.
- Sortez la configuration en variables d'environnement ; scannez la faille
connue avec
trivyavant de publier.
Le module 7 automatise la construction et le déploiement de cette image depuis un dépôt Git, avec les bons tests le long du chemin.