Chapitre 520 minIntermédiaire
Écrire un Dockerfile : instructions et bonnes pratiques
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- écrire le Dockerfile d'une application Node.js ou PHP ;
- ordonner les instructions pour profiter du cache de construction ;
- réduire une image avec une construction en plusieurs étapes ;
- faire tourner l'application sans root et avec un contrôle de santé.
5 exercices corrigés1 schéma
Un Dockerfile est un fichier texte qui décrit, instruction par instruction, comment construire une image : l'image de départ (FROM), les fichiers à copier (COPY), les commandes à exécuter pendant la construction (RUN) et la commande à lancer au démarrage du conteneur (CMD). Ce chapitre écrit le Dockerfile de l'API sorbier-frais, explique le cache de construction, la différence entre CMD et ENTRYPOINT, la construction en plusieurs étapes (multi-stage), l'utilisateur sans privilège et le contrôle de santé, puis donne un exemple pour une application PHP.
Un premier Dockerfile, ligne par ligne
L'API sorbier-frais est une application Node.js 24 avec Express. Son dossier contient package.json, package-lock.json et le code dans src/. Le Dockerfile, sans extension, se place à la racine du projet :
# syntax=docker/dockerfile:1
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
| Ligne | Rôle |
|---|---|
# syntax=docker/dockerfile:1 | Demande la dernière version stable de la syntaxe des Dockerfile (facultatif, mais conseillé) |
FROM node:24-slim | L'image de départ : Node.js 24 sur une Debian réduite. Tout Dockerfile commence par FROM. |
WORKDIR /app | Crée le dossier /app et s'y place pour la suite, comme un cd |
ENV NODE_ENV=production | Une variable d'environnement, présente pendant la construction et dans le conteneur |
COPY package.json package-lock.json ./ | Copie les deux manifestes depuis le contexte de construction vers /app |
RUN npm ci --omit=dev | Exécute une commande pendant la construction : installe exactement les versions du package-lock.json, sans les outils de développement |
COPY src ./src | Copie le code |
USER node | La suite, et le conteneur, tournent sous l'utilisateur node (UID 1000), créé par l'image officielle, et non sous root |
EXPOSE 3000 | Documente le port d'écoute ; ne publie rien |
CMD ["node", "src/server.js"] | La commande lancée au démarrage du conteneur |
Construire l'image : docker build
docker build -t sorbier-frais:2.4.0 .
-t donne le nom et l'étiquette ; le point final désigne le contexte de construction : le dossier dont le contenu est envoyé au moteur de construction, et le seul où COPY peut aller chercher des fichiers.
[+] Building 21.4s (10/10) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> [internal] load metadata for docker.io/library/node:24-slim 0.9s
=> [internal] load .dockerignore 0.0s
=> [1/5] FROM docker.io/library/node:24-slim@sha256:… 6.2s
=> [internal] load build context 0.1s
=> [2/5] WORKDIR /app 0.2s
=> [3/5] COPY package.json package-lock.json ./ 0.0s
=> [4/5] RUN npm ci --omit=dev 12.8s
=> [5/5] COPY src ./src 0.1s
=> exporting to image 1.1s
Le moteur de construction est BuildKit (via Buildx), le moteur par défaut. Lance ensuite l'image comme n'importe quelle autre : docker run -d -p 3000:3000 sorbier-frais:2.4.0.
Le cache : l'ordre des instructions compte
BuildKit garde le résultat de chaque étape en cache. À la construction suivante, il réutilise une étape si l'instruction et les fichiers qu'elle copie n'ont pas changé (CACHED dans la sortie). Dès qu'une étape change, toutes les étapes suivantes sont refaites.
D'où la règle : ce qui change rarement en haut, ce qui change souvent en bas. Les manifestes package*.json changent rarement, le code change à chaque commit. En copiant les manifestes et en lançant npm ci avant de copier le code, une modification de code ne relance que la dernière étape :
=> CACHED [2/5] WORKDIR /app 0.0s
=> CACHED [3/5] COPY package.json package-lock.json ./ 0.0s
=> CACHED [4/5] RUN npm ci --omit=dev 0.0s
=> [5/5] COPY src ./src 0.1s
La construction passe de 21 secondes à moins de 2.
Le fichier .dockerignore
Le fichier .dockerignore, à côté du Dockerfile, exclut des fichiers du contexte de construction, avec la même syntaxe qu'un .gitignore :
node_modules
.git
.env
.env.*
coverage
*.log
Dockerfile
compose.yaml
| Exclusion | Pourquoi |
|---|---|
node_modules | Les dépendances sont réinstallées dans l'image, compilées pour Linux ; celles de ton poste sont lourdes et parfois incompatibles |
.git | Lourd, inutile à l'exécution, et il contient tout l'historique du code |
.env, .env.* | Les secrets n'entrent jamais dans une image (chapitre 4) |
coverage, *.log | Fichiers produits localement, sans intérêt dans l'image |
Sans .dockerignore, un COPY . . embarque tout, secrets compris, et la construction envoie parfois des centaines de mégaoctets de contexte.
Les instructions du Dockerfile
| Instruction | Rôle | Exemple |
|---|---|---|
FROM | Image de départ ; AS nom nomme une étape | FROM node:24-slim AS build |
WORKDIR | Dossier de travail | WORKDIR /app |
COPY | Copie depuis le contexte ou une autre étape ; --chown règle le propriétaire | COPY --chown=node:node src ./src |
ADD | Comme COPY, mais extrait les archives et télécharge des URL ; préfère COPY, plus prévisible | ADD outil.tar.gz /opt/ |
RUN | Commande exécutée pendant la construction ; crée une couche | RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* |
ENV | Variable présente à la construction et à l'exécution | ENV TZ=Europe/Paris |
ARG | Variable de construction seulement, passée par --build-arg | ARG VERSION=2.4.0 |
EXPOSE | Documente un port | EXPOSE 3000 |
USER | Utilisateur des instructions suivantes et du conteneur | USER node |
CMD | Commande par défaut au démarrage | CMD ["node", "src/server.js"] |
ENTRYPOINT | Programme toujours lancé ; CMD devient ses arguments | ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] |
HEALTHCHECK | Commande qui vérifie la santé du service | Voir plus bas |
LABEL | Métadonnées | LABEL org.opencontainers.image.source="https://github.com/sorbier/sorbier-frais" |
VOLUME | Déclare un dossier de données ; Docker y crée un volume anonyme | VOLUME /var/lib/mysql |
STOPSIGNAL | Signal envoyé par docker stop | STOPSIGNAL SIGQUIT |
CMD, ENTRYPOINT et la forme exec
CMD et ENTRYPOINT s'écrivent sous deux formes :
- la forme exec, un tableau JSON :
CMD ["node", "src/server.js"]. Docker lance directementnode, qui devient le processus numéro 1 du conteneur et reçoit les signaux, dont leSIGTERMdedocker stop; - la forme shell, une simple chaîne :
CMD node src/server.js. Docker lance/bin/sh -c "node src/server.js". D'après la documentation de Docker, le programme n'est alors pas le processus numéro 1 et ne reçoit pas les signaux :docker stopattend 10 secondes puis tue tout (code 137), sans arrêt propre.
Écris toujours la forme exec. Quand les deux instructions sont présentes, ENTRYPOINT est le programme et CMD ses arguments par défaut, que l'utilisateur peut remplacer :
| Dockerfile | docker run image lance | docker run image --version lance |
|---|---|---|
CMD ["node", "src/server.js"] | node src/server.js | --version (et échoue : ce n'est pas un programme) |
ENTRYPOINT ["node"] et CMD ["src/server.js"] | node src/server.js | node --version |
ENTRYPOINT ["node", "src/server.js"] | node src/server.js | node src/server.js --version |
Un ENTRYPOINT sert souvent à lancer un script de préparation (attendre la base, générer une configuration) qui termine par exec "$@" pour passer la main à la commande de CMD en lui laissant le numéro 1.
La construction en plusieurs étapes (multi-stage)
Construire une application demande des outils (compilateur, dépendances de développement, Vite ou TypeScript) dont l'exécution n'a pas besoin. Un Dockerfile multi-stage enchaîne plusieurs FROM : une étape de construction, lourde, puis une étape finale qui ne récupère que le résultat avec COPY --from. Seule la dernière étape forme l'image.
Le front de sorbier-frais est une application web construite par Vite, qui produit des fichiers statiques dans dist/. Il n'a besoin de Node.js que pour la construction ; un serveur web suffit pour servir le résultat :
# syntax=docker/dockerfile:1
# Étape 1 : construire les fichiers statiques
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Étape 2 : les servir avec Nginx, sans privilège
FROM nginxinc/nginx-unprivileged:1.30-alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -q --spider http://127.0.0.1:8080/ || exit 1
L'image finale ne contient ni Node.js, ni node_modules, ni le code source : seulement Nginx et quelques fichiers HTML, CSS et JavaScript. L'image nginx-unprivileged, publiée par l'équipe de Nginx, fait tourner le serveur sous un utilisateur ordinaire et écoute sur 8080, un port supérieur à 1024, qu'un utilisateur ordinaire peut ouvrir quelles que soient les règles de la plateforme qui l'exécute.
Pour une API Node.js qui n'a rien à compiler, une première étape installe les dépendances de production et l'étape finale les copie : c'est le Dockerfile utilisé dans la CI de Sorbier (Construire une image dans la CI). Pour une application Go ou Rust, l'étape finale peut partir de scratch ou d'une image distroless et ne contenir qu'un binaire.
Ne pas tourner en root
Par défaut, le processus d'un conteneur tourne sous root (UID 0). Les espaces de noms et les capacités limitent ce root, mais une faille de l'application, ou du noyau, lui donnerait bien plus de pouvoir qu'à un utilisateur ordinaire. La règle est donc de passer à un utilisateur sans privilège avec USER :
- les images officielles de Node.js fournissent l'utilisateur
node(UID 1000) ; - pour d'autres images, crée-le :
RUN groupadd --system app && useradd --system --gid app --no-create-home appsur Debian,RUN addgroup -S app && adduser -S -G app appsur Alpine ; - les fichiers que l'application doit écrire doivent appartenir à cet utilisateur :
COPY --chown=node:nodeouRUN mkdir -p /app/stockage && chown node:node /app/stockageavantUSER.
Vérifie avec :
docker run --rm sorbier-frais:2.4.0 id
uid=1000(node) gid=1000(node) groups=1000(node)
Le contrôle de santé : HEALTHCHECK
Un conteneur peut tourner sans que le service réponde : l'API est bloquée, la base est injoignable. HEALTHCHECK fait exécuter régulièrement une commande par Docker ; selon son code de retour (0 ou 1), le conteneur est marqué healthy ou unhealthy, visible dans docker ps.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
| Option | Sens |
|---|---|
--interval=30s | Un test toutes les 30 secondes |
--timeout=3s | Un test qui dure plus de 3 secondes échoue |
--start-period=10s | Les échecs des 10 premières secondes ne comptent pas (démarrage) |
--retries=3 | Trois échecs consécutifs font passer le conteneur à unhealthy |
L'image slim ne contient pas curl : le test utilise le fetch intégré à Node.js plutôt que d'installer un outil de plus. Docker ne redémarre pas un conteneur unhealthy de lui-même, mais Compose s'en sert pour l'ordre de démarrage (chapitre 8) et la supervision pour alerter (chapitre 10).
Variables de construction et secrets de construction
ARG définit une variable qui n'existe que pendant la construction :
ARG NODE_VERSION=24
FROM node:${NODE_VERSION}-slim
docker build --build-arg NODE_VERSION=26 -t essai .
Ni ARG ni ENV ne conviennent à un secret : leurs valeurs sont visibles dans l'historique et les métadonnées de l'image. Si la construction a vraiment besoin d'un secret (un jeton pour un dépôt npm privé, par exemple), BuildKit le monte le temps d'une seule instruction, sans l'écrire dans aucune couche :
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --omit=dev
docker build --secret id=npmrc,src=$HOME/.npmrc -t sorbier-frais:2.4.0 .
Un Dockerfile pour une application PHP
En SLAM, ta réalisation est souvent une application PHP avec PDO et MySQL (cours PHP et MySQL). L'image officielle php:8.5-apache contient PHP 8.5 et Apache ; ses scripts docker-php-ext-install ajoutent les extensions :
# syntax=docker/dockerfile:1
FROM php:8.5-apache
RUN docker-php-ext-install pdo_mysql \
&& a2enmod rewrite \
&& mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY --chown=www-data:www-data public/ /var/www/html/
COPY --chown=www-data:www-data src/ /var/www/src/
| Ligne | Rôle |
|---|---|
docker-php-ext-install pdo_mysql | Compile et active l'extension PDO pour MySQL, absente par défaut |
a2enmod rewrite | Active la réécriture d'URL d'Apache (routeur d'un MVC) |
php.ini-production | Les réglages de production : erreurs non affichées aux visiteurs |
public/ et src/ séparés | Seul public/ est servi par Apache ; le code et la configuration restent hors de la racine web |
Ici, Apache démarre en root pour ouvrir le port 80, puis ses processus de travail passent sous www-data : c'est le fonctionnement prévu de cette image.
Les bonnes pratiques en une liste
- Une image de base officielle, avec une étiquette précise (
node:24-slim, jamaisnode). - Les dépendances avant le code, pour profiter du cache.
- Un
.dockerignorequi exclut au moins.git,node_moduleset.env. - Un multi-stage dès qu'il y a une étape de construction.
- Un utilisateur sans privilège (
USER). - La forme exec pour
CMDetENTRYPOINT. - Un
HEALTHCHECK. - Aucun secret dans l'image : ni
COPY .env, niENV MOT_DE_PASSE=…, niARG. - Un seul service par conteneur : l'API dans un conteneur, la base dans un autre.
- Les journaux sur la sortie standard, pas dans des fichiers.
Exercices
Exercice 1 sur 5 Remettre dans l'ordre Réussi À revoir
Ce Dockerfile fonctionne, mais chaque construction prend deux minutes, même quand on ne change qu'une ligne de code. Réécris-le pour profiter du cache.
FROM node:24-slim
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "src/server.js"]
Voir la réponseMasquer la réponse de l'exercice 1
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
USER node
CMD ["node", "src/server.js"]
COPY . . copiait tout le projet avant npm ci : le moindre changement de code invalidait cette étape, donc toutes les suivantes, et l'installation repartait de zéro. En copiant d'abord les seuls manifestes, npm ci reste en cache tant que les dépendances ne changent pas. USER node et un .dockerignore ne sont pas demandés, mais font partie d'une bonne réponse.
Tu avais trouvé ?
Exercice 2 sur 5 Les étapes refaites Réussi À revoir
Avec le Dockerfile corrigé de l'exercice précédent, le développeur ajoute une dépendance (npm install zod), ce qui modifie package.json et package-lock.json, puis il corrige aussi un fichier de src/. Quelles étapes la construction suivante refait-elle ?
Voir la réponseMasquer la réponse de l'exercice 2
FROM et WORKDIR restent en cache. COPY package.json package-lock.json ./ est refaite, puisque les fichiers ont changé, et toutes les étapes suivantes aussi : RUN npm ci, COPY src ./src, puis USER et CMD (instantanées). Une étape qui change invalide tout ce qui la suit ; la correction dans src/ n'y change rien de plus.
Tu avais trouvé ?
Exercice 3 sur 5 Trouver les erreurs Réussi À revoir
Relève les cinq défauts de ce Dockerfile et corrige-le.
FROM node
COPY . /app
WORKDIR /app
ENV DB_PASSWORD=Sorbier2026!
RUN npm install
EXPOSE 3000
CMD npm start
Voir la réponseMasquer la réponse de l'exercice 3
FROM nodeprendlatest: version imprévisible, image complète et lourde. →FROM node:24-slim.COPY . /appavant l'installation casse le cache et, sans.dockerignore, copienode_modules,.gitet.env.ENV DB_PASSWORD=…grave un secret dans les métadonnées de l'image, lisible pardocker inspectetdocker history. → Le fournir au démarrage, par un secret (chapitre 11).npm installpeut modifier les versions et installe les outils de développement. →npm ci --omit=dev.CMD npm starten forme shell : le processus principal ne reçoit pas leSIGTERMdedocker stop, etnpms'interpose en plus. →CMD ["node", "src/server.js"].
Il manque aussi un utilisateur sans privilège. Version corrigée :
# syntax=docker/dockerfile:1
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
Tu avais trouvé ?
Exercice 4 sur 5 CMD ou ENTRYPOINT Réussi À revoir
Une image d'outil de sauvegarde a ce Dockerfile (extrait) : ENTRYPOINT ["restic"] et CMD ["--help"]. Qu'exécutent les commandes suivantes ?
docker run --rm sauvegardedocker run --rm sauvegarde snapshotsdocker run --rm --entrypoint sh sauvegarde -c 'ls /'
Voir la réponseMasquer la réponse de l'exercice 4
restic --help: l'ENTRYPOINTsuivi duCMDpar défaut.restic snapshots: les arguments donnés après l'image remplacent leCMD, pas l'ENTRYPOINT.sh -c 'ls /': l'option--entrypointremplace le programme, et ce qui suit l'image devient ses arguments.
Tu avais trouvé ?
Exercice 5 sur 5 Réduire l'image du front Réussi À revoir
L'image du front de sorbier-frais pèse 1,2 Go : elle part de node:24 et lance npx vite preview pour servir le site. Propose une meilleure approche et explique ce que l'on gagne.
Voir la réponseMasquer la réponse de l'exercice 5
Un Dockerfile multi-stage : une étape FROM node:24-slim AS build qui installe les dépendances et lance npm run build, puis une étape finale FROM nginxinc/nginx-unprivileged:1.30-alpine qui ne fait que COPY --from=build /app/dist /usr/share/nginx/html (le Dockerfile complet est dans ce chapitre).
On gagne :
- une image de quelques dizaines de mégaoctets au lieu de 1,2 Go : déploiement et téléchargement plus rapides ;
- beaucoup moins de paquets, donc beaucoup moins de vulnérabilités signalées par les scanners ;
- un vrai serveur web de production, sous un utilisateur sans privilège, au lieu du serveur de prévisualisation de Vite, que sa documentation réserve au test local.
Tu avais trouvé ?