Aller au contenu principal

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 = 9 travailleurs ⇒ 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 donne min(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).

Un utilisateur non privilégié, toujours

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é

  • Dockerfile en plusieurs étages, base slim, utilisateur non privilégié : l'image passe de 1,2 Go à environ 400 Mo.
  • .dockerignore obligatoire, aucun secret dans l'image, tout en variables d'environnement lues par Pydantic Settings.
  • Nombre de travailleurs Uvicorn contraint par la mémoire du modèle, pas par la formule 2 × cœurs + 1 hérité du monde synchrone.
  • Charger le modèle depuis le registre MLflow au démarrage permet la promotion @champion sans 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.