Formations/Fondations Docker, Compose et Swarm/3.3 Optimiser son Dockerfile pour la production
Leçon 15m

3.3 Optimiser son Dockerfile pour la production

Optimiser son Dockefile pour le rendre moins vulnérable en production

Pour améliorer notre Dockerfile, nous allons utiliser des techniques subtiles mais qui, bout à bout, vont lourdement peser dans la balance.

L'amélioration aura pour but de sécuriser un peu l'image grâce à une réduction des paquets préinstallés, de prendre une image à jour, de créer un build multi-stage pour rendre l'image finale encore plus légère (ce qui réduit d'autant plus la surface d'attaque), et enfin, de changer d'utilisateur (car par défaut, Docker utilise l'utilisateur root, ce qui est une très mauvaise pratique en sécurité).

Nos missions seront donc :

  • Optimiser la taille de l'image.
  • Ajouter une étape multi-stage.
  • Changer d'utilisateur (ne plus être root).

Ça sera déjà pas mal ! Et croyez-moi, ça sera déjà beaucoup mieux que d'énormément d'images déployées en production par certains devs... 😉

Étape 1 : Le choix de l'image de base

Par défaut, nous avons utilisé une image fournie par Python qui tourne généralement sous Debian. C'est très bien pour tester, mais cela embarque énormément d'outils systèmes dont on n'a absolument pas besoin pour faire tourner notre API. Résultat : ça alourdit la facture (taille du fichier) et ça augmente la surface d'attaque pour les pirates. Sa augmente également le temps de build donc le temps de pipeline et ce qui s'en suit !

Notre but est donc de trouver une image avec un packaging beaucoup plus léger. Dans l'écosystème Docker, il existe principalement 3 grandes "familles" d'images :

  • Standard (ex: bookworm, trixie) : C'est la version complète de Debian. Très lourde, elle contient tous les outils classiques d'un OS (compilateurs, librairies...).
  • Alpine : Une version ultra-allégée basée sur un OS minimaliste. Elle pèse à peine ~5 Mo ! C'est génial, mais attention avec Python : Alpine n'utilise pas les mêmes librairies fondamentales que Debian (musl au lieu de glibc), ce qui peut causer des bugs de compatibilité ou ralentir l'installation de certains paquets Python complexes.
  • Slim : Une version allégée de Debian. Elle garde la compatibilité parfaite de Debian, mais on lui a retiré tout le superflu (documentation, compilateurs C++, etc.). C'est le compromis parfait pour Python.

Optimization_Tip

Les images "Hardened"

Il existe aussi des images dites "Hardened". Ce sont des images testées et vérifiées par des professionnels de la cybersécurité, qui ne contiennent que des paquets sûrs et validés.

Cependant, beaucoup d'entre elles sont payantes ou réservées aux entreprises, et ne sont généralement pas disponibles gratuitement pour le grand public. On ne va donc pas se baser dessus pour notre exercice. Mais si vous avez l'occasion d'en utiliser une plus tard dans votre carrière, c'est une excellente chose !

L'astuce de pro : Sur le Docker Hub, vous pouvez souvent voir le nombre de CVE (Common Vulnerabilities and Exposures, c'est-à-dire les failles de sécurité connues) présentes dans les différentes images. C'est un super indicateur qui donne un aperçu immédiat de la fiabilité et du niveau de sécurité de l'image avant même de la télécharger.

On va donc aller faire un tour sur le Docker Hub et chercher une image Slim. Notre choix va se porter sur : python:3.14-slim.

Je ne l'ai pas choisie par magie, mais en me basant sur plusieurs critères stricts :

  1. Le poids de base de l'image : Environ 17.5 MB (compressée) dans notre cas, ce qui est extrêmement léger par rapport aux 300 MB de la version standard !
  2. La fiabilité (le nombre de téléchargements) : Actuellement plus de 26 millions de pulls en une semaine. C'est un excellent indicateur de stabilité.
  3. La compatibilité : Étant basée sur Debian, on est sûr que nos paquets Python s'installeront sans erreur bizarre.
  4. La documentation : La documentation fournie sur le Hub est claire (et j'aime bien comprendre ce que j'utilise !).

Étape 2 : Le Multi-Stage Build (L'art du tri sélectif)

Le multi-stage build va nous permettre de créer plusieurs "boîtes virtuelles" successives de manière très simple.

Il faut s'imaginer que l'installation de paquets prend du temps, nécessite des outils de compilation, et peut causer des effets de bord : ça alourdit l'image et ça la ralentit. Pour éviter ça, on utilise plusieurs étapes (les fameux stages). Cela permet d'isoler la phase d'installation, puis de transférer uniquement ce qui est strictement utile vers notre image définitive, sans la contaminer avec les outils parasites.

Imaginons la logique pour notre application Python : on va installer les paquets dans une première étape (qu'on appellera le build), puis récupérer uniquement le résultat de cette installation pour le mettre dans notre image finale, sans le surplus.

Voici à quoi ressemble notre Dockerfile mis à jour :

# --- ÉTAPE 1 : Le Build (L'atelier de construction) ---
FROM python:3.14-slim AS build-stage

# On ajoute notre futur environnement virtuel au PATH
ENV PATH="/app/venv/bin:$PATH"

WORKDIR /app

COPY requirements.txt .

# On crée un environnement virtuel propre
RUN python -m venv /app/venv

# On installe les dépendances DANS l'environnement virtuel
RUN pip install --no-cache-dir -r requirements.txt


# --- ÉTAPE 2 : Le Runtime (L'image finale toute propre) ---
FROM python:3.14-slim AS runtime-stage

# On s'assure d'utiliser le bon PATH pour Python
ENV PATH="/app/venv/bin:$PATH"

WORKDIR /app

# L'INSTRUCTION MAGIQUE : On copie l'environnement virtuel depuis l'étape 1 !
COPY --from=build-stage /app/venv /app/venv

# On copie notre code applicatif
COPY main.py .

CMD ["fastapi", "dev", "--host", "0.0.0.0"]

Que s'est-il passé ici ?

Dans notre cas, on utilise deux fois la même image de base (python:3.14-slim), mais elles ont des rôles très différents :

  1. La première (build-stage) : On y installe les paquets. Elle sera donc "polluée" par les outils d'installation et les caches temporaires. Pour regrouper toutes nos dépendances au même endroit, on crée un environnement virtuel Python (venv).
  2. La seconde (runtime-stage) : C'est notre image définitive, toute propre. Grâce à l'instruction --from=build-stage, on vient récupérer uniquement le dossier /app/venv (qui contient nos paquets installés) pour l'injecter dans cette nouvelle image vierge.

Étape 3 : Changer d'utilisateur (Adieu Root !)

Voici un secret de polichinelle dans le monde de la conteneurisation : par défaut, Docker exécute tous les conteneurs en tant que root (l'administrateur suprême).

C'est une énorme faille de sécurité. Imaginez qu'un pirate trouve une vulnérabilité dans votre code Python ou dans FastAPI. S'il arrive à rentrer dans le conteneur, il aura les pleins pouvoirs à l'intérieur. S'il est root, il pourrait même tenter ce qu'on appelle une "évasion de conteneur" (container escape) pour aller attaquer votre vrai serveur (votre machine hôte).

À l'inverse, si l'application tourne avec un utilisateur standard qui n'a aucun droit, le pirate se retrouvera coincé dans une boîte avec les mains liées.

Notre mission est donc de créer un utilisateur basique et de dire à Docker de l'utiliser.

Le Dockerfile Ultime (Sécurisé et Optimisé)

Voici notre fichier final, qui combine notre image Slim, notre Multi-stage build, et notre changement d'utilisateur. C'est ce qu'on appelle un fichier prêt pour la production !

# --- ÉTAPE 1 : Le Build (L'atelier) ---
FROM python:3.14-slim AS build-stage

ENV PATH="/app/venv/bin:$PATH"
WORKDIR /app

COPY requirements.txt .

RUN python -m venv /app/venv
RUN pip install --no-cache-dir -r requirements.txt


# --- ÉTAPE 2 : Le Runtime (La production) ---
FROM python:3.14-slim AS runtime-stage

ENV PATH="/app/venv/bin:$PATH"
WORKDIR /app

# 1. On crée un utilisateur sans privilèges nommé "appuser"
RUN useradd -m appuser

# 2. On copie l'environnement virtuel en donnant la propriété à appuser
COPY --chown=appuser:appuser --from=build-stage /app/venv /app/venv

# 3. On copie notre code applicatif en donnant la propriété à appuser
COPY --chown=appuser:appuser main.py .

# 4. INSTRUCTION MAGIQUE : On bascule sur cet utilisateur pour la suite
USER appuser

# 5. On lance le serveur
CMD ["fastapi", "dev", "--host", "0.0.0.0"]

Qu'avons-nous ajouté ?

  1. RUN useradd -m appuser : Cette commande Linux basique crée un nouvel utilisateur nommé appuser.
  2. L'astuce de pro (--chown=appuser:appuser) : Quand on utilise l'instruction COPY, par défaut, Docker donne les fichiers à root. En ajoutant ce petit drapeau (flag), on dit à Docker de donner immédiatement la propriété de ces fichiers à notre nouvel utilisateur.
  3. USER appuser : À partir de cette ligne, toutes les instructions suivantes (y compris le démarrage du serveur avec CMD) seront exécutées sans les droits administrateur.

Et voilà ! En quelques lignes, vous êtes passés d'un Dockerfile lourd et vulnérable à une image ultra-légère, propre, et sécurisée comme un coffre-fort.