← Réseaux

Docker — Dockerfile et construction d'images

Sommaire

Jusqu'ici on a utilisé des images existantes. Cette fiche porte sur la construction de ses propres images, à partir d'un Dockerfile.

1. Construire une image

mkdir mon-premier-dockerfile && cd mon-premier-dockerfile
nano Dockerfile
FROM ubuntu:22.04
CMD ["echo", "Hello, Docker!"]
docker build . -t mon_image       # construire, avec le tag mon_image
docker build -t mon_image:1.0 .   # avec un numéro de version
docker run mon_image              # exécuter

Le point final désigne le contexte de construction : le répertoire envoyé au démon. Tous les COPY y font référence, et rien en dehors n'est accessible. D'où l'utilité d'un fichier .dockerignore pour exclure node_modules, .git ou les fichiers sensibles.

2. Les instructions

InstructionRôleMoment
FROMImage de base — toujours la première ligneConstruction
RUNExécute une commande et fige le résultat dans une coucheConstruction
COPYCopie des fichiers du contexte vers l'imageConstruction
ADDComme COPY, mais décompresse les archives et accepte des URLConstruction
WORKDIRDéfinit le répertoire de travailConstruction
ENVVariable d'environnement, persistée dans l'imageConstruction + exécution
ARGVariable disponible uniquement pendant la constructionConstruction
EXPOSEDocumente le port utiliséDocumentation
USERUtilisateur d'exécution — à utiliser pour ne pas tourner en rootExécution
VOLUMEDéclare un point de montage destiné à persisterExécution
CMDCommande par défaut, remplaçable au docker runExécution
ENTRYPOINTCommande fixe, non remplaçable par un simple argumentExécution

EXPOSE ne publie rien

FROM httpd
COPY index.html /usr/local/apache2/htdocs/
EXPOSE 80
CMD ["httpd", "-D", "FOREGROUND"]

EXPOSE 80 est purement déclaratif : il documente le port attendu, rien de plus. Pour rendre le service accessible depuis l'hôte, il faut -p 80:80 au lancement. C'est la question piège classique.

3. Le conteneur vit tant que son processus vit

C'est la règle centrale de Docker, et la source de la moitié des incompréhensions.

Un conteneur exécute un processus principal (PID 1). Quand ce processus se termine, le conteneur s'arrête. Ce n'est pas une panne, c'est le comportement attendu.

FROM alpine
CMD ["sh", "-c", "echo 'Je vais disparaître dans 5 secondes'; sleep 5"]

Après 5 secondes, le sleep se termine, donc le processus principal aussi, donc le conteneur s'arrête. Même chose pour un simple echo, qui s'arrête immédiatement.

Pour qu'il reste actifCommande
Lancer un service au premier planCMD ["nginx", "-g", "daemon off;"]
Processus de veille (démonstration)CMD ["tail", "-f", "/dev/null"]
Session interactivedocker run -it mon_image bash

Corollaire : un service ne doit jamais être lancé en mode démon dans un conteneur. daemon off pour nginx, -D FOREGROUND pour Apache. Si le service passe en arrière-plan, le processus principal rend la main et le conteneur meurt aussitôt.

4. ARG et ENV

FROM alpine
ENV NOM="DockerStudent"
ARG env=PROD
RUN echo "Nom : $NOM — Environnement : $env"
docker build -t demo .
docker build --build-arg env=DEV -t demo .     # surcharge l'ARG
docker run demo
ARGENV
Disponible pendantLa construction seulementConstruction et exécution
Présent dans l'image finaleNonOui
Surchargeable--build-arg au build-e au run
UsageVersion à installer, proxy de buildConfiguration de l'application

Pourquoi un ARG n'est-il plus disponible ensuite ? Parce qu'il n'appartient pas à l'image : c'est un paramètre du processus de build, consommé pendant celui-ci et non enregistré dans les métadonnées.

Attention néanmoins : un ARG reste visible dans l'historique des couches (docker history). Ne jamais y passer un mot de passe ou un jeton. Pour les secrets de build, utiliser --secret (BuildKit).

5. ENTRYPOINT et CMD

CMD seul

FROM ubuntu:22.04
CMD ["sleep", "10"]
docker run -it mon_image          # bloque 10 secondes
docker run -it mon_image bash     # ouvre un shell : CMD est REMPLACÉ

On obtient un terminal au lieu du sleep parce que CMD est une valeur par défaut, écrasée par tout argument passé au docker run.

ENTRYPOINT avec CMD

FROM ubuntu:22.04
ENTRYPOINT ["sleep"]
CMD ["10"]
docker run -it mon_image          # sleep 10
docker run -it mon_image 20       # sleep 20

Ici, 20 ne remplace pas la commande mais l'argument par défaut : l'argument du docker run se substitue au CMD, et le tout est passé à l'ENTRYPOINT, qui reste fixe. D'où sleep 20 au lieu de sleep 10.

Configurationdocker run imgdocker run img arg
CMD ["sleep","10"]sleep 10arg (CMD écrasé)
ENTRYPOINT ["sleep"] + CMD ["10"]sleep 10sleep arg

Règle d'usage : ENTRYPOINT définit ce que fait l'image, CMD fournit ses arguments par défaut. Pour surcharger l'ENTRYPOINT malgré tout : --entrypoint.

Toujours préférer la forme exec (une liste JSON) à la forme shell : cette dernière lance un /bin/sh -c intermédiaire qui devient le PID 1 et ne transmet pas correctement les signaux — le conteneur ne répond alors plus proprement à docker stop.

6. Les couches et le cache

Chaque instruction RUN, COPY ou ADD crée une couche. Docker met chaque couche en cache : si rien n'a changé, il la réutilise au lieu de la reconstruire.

# MAUVAIS : toute modification du code réinstalle les dépendances
COPY . /app
RUN npm install

# BON : les dépendances ne sont réinstallées que si package.json change
COPY package.json package-lock.json ./
RUN npm install
COPY . /app

Principe d'optimisation : placer les instructions les plus stables en premier, les plus changeantes à la fin. Une couche invalidée invalide toutes les suivantes.

# regrouper les RUN limite le nombre de couches
RUN apt-get update && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*

Point de sécurité : les couches sont empilées, jamais effacées. Un fichier ajouté dans une couche puis supprimé dans la suivante reste présent dans l'image et récupérable. Un secret copié par erreur ne s'efface donc pas par un RUN rm.

7. Multi-stage build

Objectif : construire dans une image complète, puis ne garder que le résultat dans une image finale minimale.

# Étape 1 : construction
FROM node:18 AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install --only=production

# Étape 2 : image finale, allégée
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY server.js ./
EXPOSE 3000
CMD ["node", "server.js"]
docker build -t node-optimise .
docker run -d -p 3000:3000 node-optimise
docker images            # comparer la taille avec une image mono-étape

L'intérêt est double : l'image finale ne contient ni les outils de compilation, ni les sources, ni le cache, ce qui réduit fortement sa taille et sa surface d'attaque. Un compilateur absent est un compilateur qu'un attaquant ne peut pas utiliser.

COPY --from=builder est la clé : il récupère un chemin de l'étape précédente sans embarquer le reste. Une image Node complète pèse environ 1 Go, sa variante alpine quelques dizaines de mégaoctets.

8. Bonnes pratiques

RèglePourquoi
Partir d'une image officielle et taguéeReproductibilité et confiance
Préférer les variantes alpine ou slimTaille et surface d'attaque réduites
Un .dockerignoreNe pas envoyer .git, les secrets et les dépendances au démon
Instructions stables en premierCache efficace, builds rapides
Regrouper les RUN et nettoyer dans la même coucheMoins de couches, image plus petite
USER non privilégiéNe pas exécuter l'application en root
Forme exec pour CMD et ENTRYPOINTSignaux transmis correctement
Jamais de secret dans un ARG ou une coucheLes couches ne s'effacent pas
Multi-stage dès qu'il y a une compilationImage finale minimale

À retenir