Optimiser ses Dockerfiles : multi-stage et bonnes pratiques¶
Docker te permet de créer un env isolé de ton application. Tu vas pouvoir la lancer ou tu veux, tu peux être sur qu'elle fonctionnera. C'est donc pratique pour distribuer. C'est d'ailleurs le moyen utilisé si tu veux deployer ton application sur un cluster Kubernetes, ou via Helm pour faire des templates très utile, ou encore via les outils des cloud provider (AWS Fargate, Azure container apps, Google cloud run, etc.)
Tools pré-requis¶
- Docker desktop (ou Rancher desktop)
Créer un simple Dockerfile¶
Frontend app en mode dév¶
Ici un basique docker file qui expose une application en React. Celle-ci est servie en mode développement, donc pour des tests internes seulement. Tu ne dois pas distribuer cette application.
- L1. On défini l'image sur lequel on travaille. Bossant sur une application React, il nous faut obligatoirement NodeJS.
- L2. Ici on défini dans quel répertoire de l'image définie précédemment on bosse
- L4. On copy les fichiers package.json et package-lock.json qui nous permettent de savoir quel module node on a besoin pour faire fonctionner le projet
- L5. On lance un install des modules nécéssaire
- L6. On copie le projet dans notre workdir de notre image
- L8. On expose un port pour notre app docker (5173, le port par défaut du serveur de dev Vite)
- L10. Commande lancé au lancement de notre container, ici on veut simplement lancer notre app via le script dev. L'option
--hostfait écouter Vite sur toutes les interfaces, sinon il n'est pas joignable depuis l'extérieur du container
Frontend app en mode prod¶
Je te propose ici de faire une seconde version que l'on va build en mode production.
- L8. On rajoute ici la commande pour builder notre application afin de faire une prod
- L10. On installe serve, en mode global. Cela nous permet de servir notre build de production.
Ici je te propose de servir ton application via le module serve. Libre à toi de partir sur un autre comme http-server pour ne citer qu'un exemple. Une autre méthode encore : tu peux aussi partir d'une autre image, FROM nginx:alpine par exemple, et servir avec les bons arguments dans CMD ["nginx", "-g", "daemon off;"] pour servir ton application depuis un NGINX.
Backend app¶
Si tu fais un backend basique sous ExpressJS par exemple, tu n'as pas besoin, comme pour le frontend, de build ton application, étant donné que celle-ci est faite en pur JS et non en TypeScript ou en fichiers .tsx qui contiennent des fonctionnalités propres à React. Tu as donc juste à lancer le main de ton serveur via node index.js
- L2 :
NODE_ENV=productionactive les optimisations de production d'Express et de pas mal de libs (messages d'erreur moins verbeux, cache des vues, etc.) - L5 : on copie les fichiers package.json et package-lock.json
- L6 : on copie le fichier d'entrée de notre serveur
- L7 : on copie notre OpenAPI qui spec tous nos endpoints du serveur pour validation des requêtes et réponses
- L9 : on installe uniquement les dépendances de prod, sans tes outils de lint ou de test (
--omit=dev) - L11 : on bascule sur le user
node(uid 1000), un user non privilégié déjà présent dans l'image officielle. Sans cette ligne, ton container tourne en root : c'est exactement ce que Trivy te remonte lors de l'analyse de ton Dockerfile - L13 : on ouvre le port 5000 de notre container
- L15 : on lance le main de notre serveur
Info
Attention, ici je code mon backend en mode CommonJS (cjs) qui ne nécessite donc pas de build. Mais pour profiter des nouvelles features et des imports style ES, tu peux très bien coder façon EcmaScript Module (esm). Pas besoin de bundler pour autant : NodeJS exécute nativement l'ESM, il suffit d'ajouter "type": "module" dans ton package.json (ou d'utiliser l'extension .mjs). Un bundler (esbuild, Rollup, etc.) ne devient utile que si tu écris en TypeScript ou souhaites produire un fichier unique.
Optimiser son Dockerfile¶
Choix de l'image : Alpine Linux, Distroless, Scratch...¶
Tu penses faire un bon choix de partir sur une image NodeJS pure si tu fais une application React comme vu précédemment. Mais il existe des images qui embarque à la fois NodeJS, mais qui se veulent bien plus légère en plus d'être d'avantage maintenu en terme de sécurité. Je pense notemment aux distribution Alpine Linux.
Example:
- node:24 => ~410mo (taille compressée sur Docker Hub, amd64)
- node:24-alpine => ~60mo
Soit une image environ 6 fois plus légère.
Tu peux aussi partir de l'image scratch. Celle-ci est une image complètement vide. Pratique si tu dois faire des binaires, comme par exemple développer une application en Golang (Go)
Entre les deux, tu as les images Distroless de Google : elles n'embarquent que le runtime de ton langage et ses dépendances système, sans shell ni package manager. Moins de binaires, c'est moins de CVE remontées par ton scanner et une surface d'attaque réduite. Pour NodeJS, c'est par exemple gcr.io/distroless/nodejs24-debian12 (~53mo compressée en amd64, basée sur Debian donc glibc, là où Alpine utilise musl). Son tag :nonroot tourne directement avec un user non privilégié (uid 65532).
Pas de shell en Distroless
L'ENTRYPOINT de l'image est déjà node : ton CMD ne contient que le script à lancer, par exemple CMD ["index.js"]. Et sans shell, oublie le docker exec -it <container> sh pour débugger : utilise le tag :debug (ou :debug-nonroot), qui embarque un shell busybox.
Multi-stage¶
Tu peux encore optimiser la taille de ton image en utilisant le multi-staging. Le but est de composer ton Dockerfile en une multitude d'étapes. Cela permet de n'embarquer dans ton image finale que ce dont tu as besoin comme tools, framework et binaire. Tu évites ainsi tout cache des package managers quand tu ajoutes une extension, ou autre fichier temporaire que tu aurais pu oublier.
On repprend l'example d'une application React servi par un ExpressJS :
- L1-6 : Un premier stage client-builder qui permet d'installer les dépendances et de build notre application en production
- L9-13 : Un second stage server-builder qui permet d'installer que les dépendances de prod pour servir le front
- L16-22 : On sélectionne que le strict nécessaire pour le client-builder, à savoir le dossier /dist. On récupère dans le server-builder seulement le index.js qui est le serveur Express, son openapi.yaml (chargé au démarrage par le validateur OpenAPI, sans lui chaque requête part en erreur 500) et ses dépendances de prod.
- L24 : On bascule sur le user non privilégié
node, comme pour le backend - L26 : On ouvre le port 5000 où tourne notre application
- L28 : On sert ton app React via ExpressJS
Variante Distroless
Tu peux remplacer le stage final par une image Distroless. Plus besoin de USER : le tag :nonroot s'en charge.
Note
Pas besoin pour le client-builder de définir à la main la récupération des dépendances de prod. Celles-ci sont transpilées et packagées dans le build par ton bundler (Vite, webpack, etc.).
Pour aller plus loin¶
- Les slides Introduction à Docker et au Dockerfile et leur TP pour conteneuriser une app Node.js et la builder dans GitLab CI
- Le build de ton image en CI, Docker in Docker vs Kaniko
- L'optimisation des builds Docker sur Kubernetes avec BuildKit et Buildx
- L'analyse de sécurité de ton Dockerfile avec Trivy