Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 :

dockerfile
# 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"]
LigneRôle
# syntax=docker/dockerfile:1Demande la dernière version stable de la syntaxe des Dockerfile (facultatif, mais conseillé)
FROM node:24-slimL'image de départ : Node.js 24 sur une Debian réduite. Tout Dockerfile commence par FROM.
WORKDIR /appCrée le dossier /app et s'y place pour la suite, comme un cd
ENV NODE_ENV=productionUne 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=devExécute une commande pendant la construction : installe exactement les versions du package-lock.json, sans les outils de développement
COPY src ./srcCopie le code
USER nodeLa suite, et le conteneur, tournent sous l'utilisateur node (UID 1000), créé par l'image officielle, et non sous root
EXPOSE 3000Documente 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

bash
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.

text
[+] 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 :

text
 => 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 :

text
node_modules
.git
.env
.env.*
coverage
*.log
Dockerfile
compose.yaml
ExclusionPourquoi
node_modulesLes dépendances sont réinstallées dans l'image, compilées pour Linux ; celles de ton poste sont lourdes et parfois incompatibles
.gitLourd, 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, *.logFichiers 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

InstructionRôleExemple
FROMImage de départ ; AS nom nomme une étapeFROM node:24-slim AS build
WORKDIRDossier de travailWORKDIR /app
COPYCopie depuis le contexte ou une autre étape ; --chown règle le propriétaireCOPY --chown=node:node src ./src
ADDComme COPY, mais extrait les archives et télécharge des URL ; préfère COPY, plus prévisibleADD outil.tar.gz /opt/
RUNCommande exécutée pendant la construction ; crée une coucheRUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
ENVVariable présente à la construction et à l'exécutionENV TZ=Europe/Paris
ARGVariable de construction seulement, passée par --build-argARG VERSION=2.4.0
EXPOSEDocumente un portEXPOSE 3000
USERUtilisateur des instructions suivantes et du conteneurUSER node
CMDCommande par défaut au démarrageCMD ["node", "src/server.js"]
ENTRYPOINTProgramme toujours lancé ; CMD devient ses argumentsENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
HEALTHCHECKCommande qui vérifie la santé du serviceVoir plus bas
LABELMétadonnéesLABEL org.opencontainers.image.source="https://github.com/sorbier/sorbier-frais"
VOLUMEDéclare un dossier de données ; Docker y crée un volume anonymeVOLUME /var/lib/mysql
STOPSIGNALSignal envoyé par docker stopSTOPSIGNAL 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 directement node, qui devient le processus numéro 1 du conteneur et reçoit les signaux, dont le SIGTERM de docker 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 stop attend 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 :

Dockerfiledocker run image lancedocker 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.jsnode --version
ENTRYPOINT ["node", "src/server.js"]node src/server.jsnode 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 :

dockerfile
# 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.

Étape 1 : buildFROM node:24-slim AS buildnpm ci : toutes les dépendancesnpm run build (Vite)dist/ : fichiers statiquesÉtape 2 : image finalenginx-unprivileged:1.30-alpineNginx, sous un utilisateurordinaire, port 8080ni Node.js, ni node_modules,ni code source/usr/share/nginx/htmlCOPY --from=buildjetée : n'entre pas dans l'imageseule cette étape forme l'image
Seule la dernière étape forme l'image : les outils de construction restent derrière

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 app sur Debian, RUN addgroup -S app && adduser -S -G app app sur Alpine ;
  • les fichiers que l'application doit écrire doivent appartenir à cet utilisateur : COPY --chown=node:node ou RUN mkdir -p /app/stockage && chown node:node /app/stockage avant USER.

Vérifie avec :

bash
docker run --rm sorbier-frais:2.4.0 id
text
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.

dockerfile
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))"
OptionSens
--interval=30sUn test toutes les 30 secondes
--timeout=3sUn test qui dure plus de 3 secondes échoue
--start-period=10sLes échecs des 10 premières secondes ne comptent pas (démarrage)
--retries=3Trois é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 :

dockerfile
ARG NODE_VERSION=24
FROM node:${NODE_VERSION}-slim
bash
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 :

dockerfile
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --omit=dev
bash
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 :

dockerfile
# 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/
LigneRôle
docker-php-ext-install pdo_mysqlCompile et active l'extension PDO pour MySQL, absente par défaut
a2enmod rewriteActive la réécriture d'URL d'Apache (routeur d'un MVC)
php.ini-productionLes réglages de production : erreurs non affichées aux visiteurs
public/ et src/ séparésSeul 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

  1. Une image de base officielle, avec une étiquette précise (node:24-slim, jamais node).
  2. Les dépendances avant le code, pour profiter du cache.
  3. Un .dockerignore qui exclut au moins .git, node_modules et .env.
  4. Un multi-stage dès qu'il y a une étape de construction.
  5. Un utilisateur sans privilège (USER).
  6. La forme exec pour CMD et ENTRYPOINT.
  7. Un HEALTHCHECK.
  8. Aucun secret dans l'image : ni COPY .env, ni ENV MOT_DE_PASSE=…, ni ARG.
  9. Un seul service par conteneur : l'API dans un conteneur, la base dans un autre.
  10. 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.

dockerfile
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
dockerfile
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.

dockerfile
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
  1. FROM node prend latest : version imprévisible, image complète et lourde. → FROM node:24-slim.
  2. COPY . /app avant l'installation casse le cache et, sans .dockerignore, copie node_modules, .git et .env.
  3. ENV DB_PASSWORD=… grave un secret dans les métadonnées de l'image, lisible par docker inspect et docker history. → Le fournir au démarrage, par un secret (chapitre 11).
  4. npm install peut modifier les versions et installe les outils de développement. → npm ci --omit=dev.
  5. CMD npm start en forme shell : le processus principal ne reçoit pas le SIGTERM de docker stop, et npm s'interpose en plus. → CMD ["node", "src/server.js"].

Il manque aussi un utilisateur sans privilège. Version corrigée :

dockerfile
# 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 ?

  1. docker run --rm sauvegarde
  2. docker run --rm sauvegarde snapshots
  3. docker run --rm --entrypoint sh sauvegarde -c 'ls /'
Voir la réponseMasquer la réponse de l'exercice 4
  1. restic --help : l'ENTRYPOINT suivi du CMD par défaut.
  2. restic snapshots : les arguments donnés après l'image remplacent le CMD, pas l'ENTRYPOINT.
  3. sh -c 'ls /' : l'option --entrypoint remplace 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é ?

Chapitre terminé !

Fais les 5 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Volumes Docker : conserver les donnéesRevoir le sommaire du cours