04 - Écrire un Dockerfile
Ce que tu vas apprendre
- Chaque instruction du Dockerfile et son rôle
- La différence entre CMD et ENTRYPOINT
- La différence entre COPY et ADD
- ARG vs ENV
- Un Dockerfile complet pour une app Bun/Node
Prerequisites
- Avoir lu l'article 02 - Architecture
- Docker installe et fonctionnel
- Notions de base en Node.js ou Bun (pour l'exemple)
Mon premier Dockerfile, c'etait un desastre
Mon premier Dockerfile faisait 40 lignes, installait curl, wget, vim, htop, et lanceait l'app avec npm start en root. L'image faisait 1.8 Go. Mon collegue a regarde ca et m'a dit : "C'est pas un conteneur, c'est un serveur de dev." Il avait raison.
Un bon Dockerfile est minimaliste. Il contient ce dont l'application a besoin pour tourner et rien d'autre. Chaque instruction compte.
FROM : le point de depart
Tout Dockerfile commence par FROM. C'est l'image de base sur laquelle tu construis.
dockerfileFROM node:22-slim
Cette ligne dit : "pars de l'image officielle Node.js 22, variante slim." On verra les différentes variantes dans l'article 08.
Tu peux aussi partir de zero :
dockerfileFROM scratch
scratch est une image vide. Utile pour les binaires Go compiles statiquement. Pas pour Node.
WORKDIR : le répertoire de travail
dockerfileWORKDIR /app
WORKDIR créé le répertoire s'il n'existe pas et s'y place. Toutes les instructions suivantes s'executent depuis ce répertoire. Sans WORKDIR, tout se passe a la racine /, ce qui est sale.
COPY vs ADD
dockerfileCOPY package.json package-lock.json ./
COPY . .
COPY copie des fichiers depuis ton build context vers l'image. C'est l'instruction que tu utiliseras 99% du temps.
ADD fait la meme chose, plus deux extras :
- Elle decompresse automatiquement les archives
.tar.gz - Elle peut telecharger des fichiers depuis une URL
dockerfile# Decompresse l'archive dans /app
ADD archive.tar.gz /app/
# Telecharge depuis une URL (mauvaise pratique)
ADD https://example.com/file.txt /app/
La recommandation officielle : utilise COPY sauf si tu as besoin de la decompression automatique. ADD avec des URLs est déconseillé parce que ca créé une couche non cacheable.
RUN : exécuter des commandes
dockerfileRUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN exécuté une commande dans le conteneur au moment du build. Chaque RUN créé une nouvelle couche dans l'image.
Le && est la pour chainer les commandes dans une seule couche. Si tu separes en plusieurs RUN, tu obtiens plus de couches et une image plus grosse :
dockerfile# Mauvais : 3 couches
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# Bon : 1 couche
RUN apt-get update && apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
Le rm -rf /var/lib/apt/lists/* nettoie le cache apt dans la meme couche. Si tu le fais dans un RUN séparé, les fichiers existent toujours dans la couche précédente.
ARG vs ENV
dockerfileARG NODE_ENV=production
ENV NODE_ENV=$NODE_ENV
ARG definit une variable disponible uniquement au moment du build. Elle disparaît du conteneur final.
ENV definit une variable d'environnement permanente, presente au build et au runtime.
bash# Surcharger un ARG au build
docker build --build-arg NODE_ENV=development -t mon-app .
Piege classique : ne mets jamais de secrets dans ARG ou ENV. Ils sont visibles dans l'historique de l'image (docker history).
EXPOSE : documenter les ports
dockerfileEXPOSE 3000
EXPOSE ne publie pas le port. Il sert de documentation pour indiquer quel port l'application écoûte. Le mapping réel se fait au docker run :
bash# -p hote:conteneur
docker run -p 8080:3000 mon-app
USER : ne pas tourner en root
dockerfileRUN addgroup --system app && adduser --system --ingroup app app
USER app
Par défaut, tout tourne en root dans un conteneur. C'est un risque de sécurité. USER change l'utilisateur pour les instructions suivantes et pour le runtime.
CMD vs ENTRYPOINT
C'est la source de confusion numero un.
CMD definit la commande par défaut. Elle peut etre surchargee au docker run.
dockerfileCMD ["node", "server.js"]
bash# Lance node server.js
docker run mon-app
# Surcharge : lance un shell
docker run mon-app sh
ENTRYPOINT definit l'executable principal. Les arguments du docker run sont passes en paramètres.
dockerfileENTRYPOINT ["node"]
CMD ["server.js"]
bash# Lance node server.js
docker run mon-app
# Lance node --inspect server.js
docker run mon-app --inspect server.js
Forme exec vs forme shell :
dockerfile# Forme exec (recommandee) - le processus recoit les signaux
CMD ["node", "server.js"]
# Forme shell - lance /bin/sh -c "node server.js"
CMD node server.js
La forme shell lance un shell intermediaire. Ton processus Node ne sera pas PID 1 et ne recevra pas les signaux SIGTERM pour un arrêt propre. Utilise toujours la forme exec.
LABEL : les metadonnees
dockerfileLABEL org.opencontainers.image.source="https://github.com/user/repo"
LABEL org.opencontainers.image.description="Mon API"
LABEL maintainer="nicolas@example.com"
Les labels ajoutent des metadonnees a l'image. C'est utile pour le tracking et l'automatisation.
Exemple complet : app Bun
Voici un Dockerfile realiste pour une API Bun :
dockerfileFROM oven/bun:1.1-slim AS base
WORKDIR /app
# Copier d'abord les fichiers de dependances (cache)
COPY package.json bun.lockb ./
# Installer les dependances
RUN bun install --frozen-lockfile --production
# Copier le code source
COPY src/ ./src/
# Creer un utilisateur non-root
RUN addgroup --system app && adduser --system --ingroup app app
USER app
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]
Et pour une app Node.js équivalente :
dockerfileFROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
RUN addgroup --system app && adduser --system --ingroup app app
USER app
EXPOSE 3000
CMD ["node", "src/index.js"]
Construire et lancer :
bashdocker build -t mon-api .
docker run -d -p 3000:3000 mon-api
curl http://localhost:3000
Sur paltemps.fr, chaque service a un Dockerfile qui suit ce schema. Les dépendances d'abord, le code ensuite, un utilisateur non-root, et la forme exec pour la commande.
Les erreurs courantes
- Oublier le
.dockerignore: tonnode_modulesde 500 Mo est envoye au daemon. Voir l'article 06. - Copier tout avant d'installer les deps :
COPY . .avantnpm installinvalide le cache a chaque changement de code. - Tourner en root : ca marche, mais c'est un risque.
- Utiliser
latest: ton build n'est plus reproductible. Epingle une version. - Installer des outils de debug en prod :
vim,curl,htopn'ont rien a faire dans une image de production.
Résumé
FROMdefinit l'image de base,WORKDIRle répertoire de travailCOPYpour les fichiers,RUNpour les commandes,&&pour grouper les couchesCMDest surchargeable,ENTRYPOINTne l'est pas (ou difficilement)- Toujours la forme exec
["cmd", "arg"]pour recevoir les signaux USERpour ne pas tourner en rootARGpour le build,ENVpour le runtime
Precedent : 03 - Desktop & Engine | Suivant : 05 - Layers & cache