Aller au contenu
  1. Blog/

Dockerfile : créer et optimiser ses images Docker

·11 mins
Sommaire
Apprendre Docker - Cet article fait partie d'une série.
Partie 2: Cet article

Dans le chapitre précédent, nous avons découvert les concepts fondamentaux de Docker et lancé nos premiers conteneurs. Mais exécuter des images existantes ne suffit pas : en production, vous devez créer vos propres images adaptées à vos applications.

C’est exactement le rôle du Dockerfile — un fichier texte qui décrit, instruction par instruction, comment construire une image Docker reproductible. Dans ce chapitre, nous allons décortiquer chaque instruction, maîtriser les multi-stage builds et apprendre à produire des images optimisées, légères et sécurisées.

Qu’est-ce qu’un Dockerfile ?
#

Un Dockerfile est un fichier texte (sans extension) placé à la racine de votre projet. Il contient une série d’instructions que le moteur Docker exécute séquentiellement pour assembler une image.

Chaque instruction crée un layer (couche) dans l’image finale. Ce système de couches est au cœur de l’efficacité de Docker : les layers sont mis en cache et réutilisés entre les builds, ce qui accélère considérablement les reconstructions.

Voici le cycle de vie typique :

1
Dockerfile → docker build → Image → docker run → Conteneur

Créons un premier Dockerfile minimal :

1
2
3
4
5
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl
COPY app.sh /usr/local/bin/app.sh
RUN chmod +x /usr/local/bin/app.sh
CMD ["/usr/local/bin/app.sh"]

Construction et exécution :

1
2
docker build -t mon-app:1.0 .
docker run --rm mon-app:1.0

Le flag -t attribue un tag à l’image. Le . indique le build context — le répertoire dont Docker enverra le contenu au daemon pour le build.

Anatomie d’un Dockerfile : les instructions essentielles
#

FROM — l’image de base
#

Toute image Docker part d’une base. FROM est obligatoirement la première instruction (hors commentaires et directives de syntaxe).

1
FROM node:22-alpine

Quelques conventions :

  • Utilisez toujours un tag précis (node:22-alpine plutôt que node:latest) pour garantir la reproductibilité.
  • Préférez les variantes alpine ou slim pour réduire la taille de l’image.
  • FROM scratch permet de partir d’une image vide — utile pour les binaires statiques Go ou Rust.

RUN — exécuter des commandes
#

RUN exécute une commande dans le conteneur pendant le build et enregistre le résultat dans un nouveau layer.

1
2
3
4
5
RUN apt-get update && apt-get install -y \
    curl \
    wget \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/*

Point clé : chaque RUN crée un layer. Regroupez les commandes liées avec && pour limiter le nombre de layers et réduire la taille finale (le nettoyage dans un RUN séparé ne libère pas l’espace du layer précédent).

COPY et ADD — injecter des fichiers
#

COPY copie des fichiers du build context vers l’image :

1
2
COPY package.json package-lock.json ./
COPY src/ ./src/

ADD fait la même chose, mais avec deux capacités supplémentaires :

  • Extraction automatique des archives tar (.tar, .tar.gz)
  • Téléchargement depuis une URL
1
ADD archive.tar.gz /opt/app/

En pratique : préférez systématiquement COPY sauf si vous avez explicitement besoin de l’extraction automatique. COPY est plus lisible et son comportement est prévisible.

WORKDIR — le répertoire de travail
#

WORKDIR définit le répertoire courant pour les instructions suivantes (RUN, COPY, CMD, etc.). Si le répertoire n’existe pas, Docker le crée automatiquement.

1
2
3
WORKDIR /app
COPY . .
RUN npm install

Évitez les RUN cd /quelque/part && ... à répétition — WORKDIR rend le Dockerfile plus lisible et maintenable.

EXPOSE — documenter les ports
#

EXPOSE documente le port sur lequel le conteneur écoute. C’est une métadonnée, pas une règle de réseau — il faut toujours mapper le port au lancement avec -p.

1
EXPOSE 3000
1
docker run -p 3000:3000 mon-app

ENV et ARG — les variables
#

ENV définit une variable d’environnement persistante dans l’image et les conteneurs qui en découlent :

1
ENV NODE_ENV=production

ARG définit une variable disponible uniquement pendant le build :

1
2
ARG APP_VERSION=1.0.0
RUN echo "Building version $APP_VERSION"

On peut surcharger un ARG au build : docker build --build-arg APP_VERSION=2.0.0 .

CMD et ENTRYPOINT — le point d’entrée
#

Ces deux instructions définissent ce qui s’exécute au démarrage du conteneur, mais avec des philosophies différentes.

CMD — la commande par défaut, substituable :

1
CMD ["node", "server.js"]

L’utilisateur peut la remplacer : docker run mon-app node autre-script.js

ENTRYPOINT — le point d’entrée fixe :

1
ENTRYPOINT ["node", "server.js"]

L’utilisateur ne peut pas la remplacer facilement (il faut --entrypoint).

Le combo classique — ENTRYPOINT pour le binaire, CMD pour les arguments par défaut :

1
2
ENTRYPOINT ["python"]
CMD ["app.py"]

Ainsi, docker run mon-app exécute python app.py, mais docker run mon-app script.py exécute python script.py.

Forme exec vs forme shell : Utilisez toujours la forme exec (tableau JSON ["cmd", "arg"]) plutôt que la forme shell (CMD cmd arg). La forme shell lance la commande via /bin/sh -c, ce qui empêche la bonne propagation des signaux UNIX (problème courant en production).

USER — sécurité par défaut
#

Par défaut, les processus tournent en root dans le conteneur. C’est une mauvaise pratique. Créez un utilisateur dédié :

1
2
RUN addgroup --system app && adduser --system --ingroup app app
USER app

Placez USER après l’installation des dépendances (qui nécessite souvent root) et avant le CMD.

Multi-stage builds : la puissance de la séparation
#

Le problème classique : votre image de build contient le compilateur, les dépendances de dev, les fichiers sources… tout ça se retrouve dans l’image finale, qui pèse des centaines de Mo inutilement.

Les multi-stage builds résolvent ce problème en séparant le build de l’exécution dans un seul Dockerfile.

Exemple concret : application Go
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# ── Étape 1 : Build ──
FROM golang:1.23-alpine AS builder

WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/server

# ── Étape 2 : Image finale ──
FROM gcr.io/distroless/static-debian12

COPY --from=builder /app/server /server

EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]

Résultat :

  • L’étape builder contient Go, les sources, les dépendances — ~300 Mo
  • L’image finale ne contient que le binaire statique sur une base distroless — ~8 Mo

Le mot-clé COPY --from=builder permet de copier des fichiers d’une étape vers une autre. Seule la dernière étape produit l’image finale.

Exemple : application Node.js
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# ── Étape 1 : Installation des dépendances ──
FROM node:22-alpine AS deps

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

# ── Étape 2 : Build ──
FROM node:22-alpine AS build

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ── Étape 3 : Image finale ──
FROM node:22-alpine

WORKDIR /app
ENV NODE_ENV=production

RUN addgroup --system app && adduser --system --ingroup app app

COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./

USER app
EXPOSE 3000
CMD ["node", "dist/index.js"]

Trois étapes, chacune avec un rôle précis : dépendances de production, build, et image finale allégée. Les devDependencies et les sources TypeScript restent dans les stages intermédiaires.

Optimisation des images
#

Comprendre le cache de layers
#

Docker met en cache chaque layer. Quand une instruction change, tous les layers suivants sont invalidés. L’ordre des instructions est donc crucial.

Mauvais :

1
2
COPY . .
RUN npm ci

Tout changement de code invalide le cache de npm ci et force une réinstallation complète.

Bon :

1
2
3
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

Les dépendances ne sont réinstallées que si package.json ou package-lock.json changent. C’est le pattern le plus important à retenir.

Règle générale : placez les instructions qui changent le moins souvent en premier (dépendances système, dépendances applicatives) et celles qui changent le plus souvent en dernier (code source).

Le fichier .dockerignore
#

Comme .gitignore, le .dockerignore exclut des fichiers du build context. Sans lui, Docker envoie tout le répertoire au daemon, y compris les fichiers inutiles.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# .dockerignore
node_modules
.git
.env
*.md
docker-compose*.yml
.dockerignore
Dockerfile
dist
coverage
.nyc_output

Avantages :

  • Build plus rapide — moins de données à transférer au daemon
  • Sécurité — pas de risque de copier .env ou des secrets dans l’image
  • Cache plus stable — les fichiers non pertinents ne cassent plus le cache

Choisir la bonne image de base
#

ImageTaille typiqueUsage
ubuntu:24.04~75 MoDev, debug, quand on a besoin de tout
node:22-slim~60 MoProduction Node.js sans extras
node:22-alpine~45 MoProduction, image légère
gcr.io/distroless/base~20 MoBinaires avec dépendances libc
gcr.io/distroless/static~2 MoBinaires statiques (Go, Rust)
scratch0 MoImage vide, binaires statiques uniquement

Alpine utilise musl au lieu de glibc, ce qui peut causer des incompatibilités avec certains paquets natifs. Testez votre application avant de migrer.

Distroless (maintenu par Google) ne contient ni shell, ni gestionnaire de paquets — impossible de faire un exec dans le conteneur, ce qui renforce la sécurité en production.

Réduire le nombre de layers
#

Chaque RUN, COPY et ADD crée un layer. Regroupez intelligemment :

1
2
3
4
5
6
7
8
9
# ❌ 3 layers
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# ✅ 1 layer
RUN apt-get update \
    && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*

Le nettoyage (rm -rf) doit être dans le même RUN que l’installation pour réellement réduire la taille de l’image.

Bonnes pratiques et anti-patterns
#

✅ À faire
#

  1. Un processus par conteneur — ne lancez pas nginx + php-fpm + cron dans le même conteneur. Utilisez Docker Compose.
  2. Images tagguéesFROM python:3.12-slim, jamais FROM python:latest.
  3. Utilisateur non-root — toujours définir un USER avant le CMD.
  4. Fichier .dockerignore — systématique, dès le premier build.
  5. Ordre des instructions — du moins changeant au plus changeant.
  6. Forme exec pour CMD/ENTRYPOINT["cmd", "arg"] pour la gestion des signaux.
  7. Labels pour les métadonnées — identifiez vos images :
1
2
LABEL org.opencontainers.image.source="https://github.com/user/repo"
LABEL org.opencontainers.image.version="1.2.0"

❌ Anti-patterns
#

  1. Installer des outils de debug en productionvim, curl, htop n’ont rien à faire dans l’image finale. Utilisez les multi-stage builds.
  2. Stocker des secrets dans l’image — jamais de COPY .env . ou ENV DB_PASSWORD=secret. Utilisez les variables d’environnement au runtime ou Docker Secrets.
  3. Lancer apt-get upgrade — l’image de base est votre contrat de version. Si vous avez besoin de patchs de sécurité, mettez à jour l’image de base.
  4. Utiliser ADD pour copier des fichiers locauxCOPY est plus explicite et suffisant.
  5. Ignorer le .dockerignore — copier node_modules ou .git dans l’image est un problème de taille ET de sécurité.
  6. Layer de nettoyage séparéRUN rm -rf /var/cache dans un layer séparé n’économise rien.

Analyser ses images
#

Utilisez docker history pour comprendre la composition de vos images :

1
docker history mon-app:1.0

Pour une analyse plus poussée, dive est un excellent outil open-source :

1
2
3
4
# Installation
docker run --rm -it \
  -v /var/run/docker.sock:/var/run/docker.sock \
  wagoodman/dive mon-app:1.0

Il affiche chaque layer, sa taille, et les fichiers ajoutés/modifiés/supprimés — indispensable pour traquer le bloat.

Exercices pratiques
#

Exercice 1 — Conteneuriser une app Node.js
#

Créez une application Express minimaliste et son Dockerfile optimisé :

1
2
3
4
5
6
7
8
9
// server.js
const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.json({ message: 'Hello depuis Docker !', version: '1.0' });
});

app.listen(3000, () => console.log('Serveur démarré sur le port 3000'));

Objectifs :

  1. Écrire un Dockerfile avec une base node:22-alpine
  2. Exploiter le cache en copiant package.json avant le code source
  3. Définir un utilisateur non-root
  4. Créer un .dockerignore adapté
  5. Construire et tester : l’image finale doit peser moins de 80 Mo

Exercice 2 — Multi-stage build Go
#

Créez un serveur HTTP en Go et un Dockerfile multi-stage :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// main.go
package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello depuis un conteneur optimisé !")
    })
    fmt.Println("Serveur démarré sur :8080")
    http.ListenAndServe(":8080", nil)
}

Objectifs :

  1. Étape de build avec golang:1.23-alpine
  2. Image finale avec gcr.io/distroless/static-debian12
  3. L’image finale doit peser moins de 10 Mo
  4. Comparez avec un build sans multi-stage — quelle différence de taille ?

Exercice 3 — Optimisation d’un Dockerfile existant
#

Analysez ce Dockerfile et identifiez tous les problèmes :

1
2
3
4
5
6
7
8
FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y python3 python3-pip
RUN pip3 install flask redis
COPY . .
ENV FLASK_SECRET=super-secret-key
EXPOSE 5000
CMD flask run --host=0.0.0.0

Objectifs :

  1. Listez au moins 6 problèmes dans ce Dockerfile
  2. Réécrivez-le en appliquant toutes les bonnes pratiques vues dans ce chapitre
  3. Ajoutez un .dockerignore approprié

Indice : tag imprécis, layers éclatés, secret en dur, absence de WORKDIR, forme shell du CMD, pas d’utilisateur non-root, pas de .dockerignore, pip install sans --no-cache-dir

Récapitulatif
#

Dans ce chapitre, nous avons couvert :

  • Les instructions DockerfileFROM, RUN, COPY, ADD, WORKDIR, EXPOSE, ENV, ARG, CMD, ENTRYPOINT et USER
  • Les multi-stage builds — séparer build et runtime pour des images minimales
  • L’optimisation — cache de layers, .dockerignore, choix de l’image de base, réduction des layers
  • Les bonnes pratiques — sécurité, reproductibilité, maintenabilité
  • Les anti-patterns — secrets dans l’image, latest, layers de nettoyage séparés

Un Dockerfile bien écrit, c’est la fondation d’un pipeline CI/CD fiable. Prenez le temps de l’optimiser — vos builds seront plus rapides, vos images plus légères et vos déploiements plus sûrs.

Dans le prochain chapitre, nous aborderons Docker Compose pour orchestrer des applications multi-conteneurs et gérer les volumes, réseaux et dépendances entre services.

Apprendre Docker - Cet article fait partie d'une série.
Partie 2: Cet article

Articles connexes