Aller au contenu principal

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@champion en production, models:/churn-telecom@candidat en préproduction.
  • MLFLOW_TRACKING_URI : le serveur MLflow selon l'environnement.
  • SEUIL_DECISION : 0.5 par 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é.

Signez et épinglez

É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 slim et 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 trivy avant 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.