Machine virtuelle contre conteneur ; image, conteneur, registre ; Dockerfile et ses bonnes pratiques — couches, cache, image minimale, utilisateur non-root ; volumes, réseaux et secrets.
« Ça marche sur ma machine. »
La phrase est devenue une plaisanterie, mais elle décrit un vrai problème : une application dépend de bien plus que de son code. D'une version d'interpréteur, de bibliothèques système, de variables d'environnement, d'un fichier de configuration édité six mois plus tôt et que personne n'a documenté. Reproduire cet ensemble sur un serveur, à l'identique, relève de l'archéologie.
Le conteneur répond en changeant l'unité de livraison : on ne livre plus le code, on livre le code et tout ce dont il a besoin pour s'exécuter. Ce qui a été testé et ce qui tourne en production sont alors le même objet, au bit près.
Machine virtuelle contre conteneur
Le chapitre 8 du cours de systèmes a posé le tableau de l'isolation ; voici la ligne qui nous intéresse.
MACHINES VIRTUELLES CONTENEURS┌──────┬──────┬──────┐ ┌──────┬──────┬──────┐│ app │ app │ app │ │ app │ app │ app │├──────┼──────┼──────┤ ├──────┴──────┴──────┤│ noyau│ noyau│ noyau│ ← chacun │ moteur de conteneurs │├──────┴──────┴──────┤ le sien └────────────────────┤│ hyperviseur │ │ noyau PARTAGÉ │├────────────────────┤ ├────────────────────┤│ noyau de l'hôte │ │ noyau de l'hôte │└────────────────────┘ └────────────────────┘Toute la différence tient là : une machine virtuelle embarque son propre noyau, un conteneur partage celui de l'hôte. Les conséquences se déduisent toutes de ce fait.
| Machine virtuelle | Conteneur | |
|---|---|---|
| Démarrage | dizaines de secondes | dizaines de millisecondes |
| Taille | plusieurs gigaoctets | dizaines de mégaoctets |
| Isolation | forte : noyau séparé | plus faible : noyau partagé |
| Système invité | libre | même noyau que l'hôte |
L'isolation n'est pas un détail à passer sous silence. Un conteneur repose sur deux mécanismes du noyau Linux que le cours de systèmes a nommés : les espaces de noms, qui limitent ce qu'un processus voit — ses processus, son réseau, ses montages — et les cgroups, qui limitent ce qu'il consomme. Ce sont de bons mécanismes, mais une faille du noyau les traverse tous. Un conteneur sépare des services qu'on maîtrise ; il ne suffit pas à exécuter du code hostile, ce que fait une machine virtuelle.
Image, conteneur, registre
Trois mots à ne jamais confondre, et une analogie qui tient : l'image est au conteneur ce que la classe est à l'objet.
Une image est un modèle en lecture seule, empilement de couches immuables. Elle ne s'exécute pas.
Un conteneur est une instance d'une image : les mêmes couches, plus une couche d'écriture par-dessus, et un processus qui tourne. Dix conteneurs de la même image partagent les couches en lecture seule — d'où leur coût dérisoire.
Un registre est l'endroit où les images sont publiées : Docker Hub, ou un registre privé. On y pousse une image, on l'en tire.
Une image se désigne par un nom et une étiquette : node:20-alpine. Une mise en garde
immédiate : l'étiquette latest ne signifie pas « la dernière » mais « celle qu'on n'a pas
étiquetée », et elle change sous vos pieds. Une construction reproductible fixe une version
précise, et l'on emploie l'empreinte (@sha256:...) quand la reproductibilité doit être
absolue.
Le Dockerfile, et la question des couches
FROM node:20-alpineWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=devCOPY . .EXPOSE 3000USER nodeCMD ["node", "serveur.js"]Chaque instruction crée une couche. Docker met chaque couche en cache et la réutilise tant que ni l'instruction ni son contenu n'ont changé. Mais — et c'est le point pratique le plus rentable du chapitre — dès qu'une couche est invalidée, toutes les suivantes le sont aussi.
D'où la règle d'ordonnancement : du moins volatil au plus volatil.
C'est ce qui explique la séparation des deux COPY ci-dessus. Le fichier de dépendances change
rarement ; le code source change à chaque commit. En copiant d'abord le seul
package.json, on obtient un npm ci — l'étape longue — qui reste en cache tant que les
dépendances ne bougent pas.
L'ordre naïf produit l'effet inverse :
COPY . . # une virgule dans le code invalide cette couche…RUN npm ci --omit=dev # …et donc celle-ci, à chaque constructionDeux minutes de reconstruction à chaque modification, contre quelques secondes. Sur un pipeline déclenché vingt fois par jour, c'est le premier gain à aller chercher.
Trois autres instructions demandent une précision. CMD donne la commande par défaut, qu'on
peut remplacer au lancement, là où ENTRYPOINT est fixe. EXPOSE ne publie rien : c'est
une documentation, la publication se fait au lancement avec -p. Et WORKDIR crée et se place
dans un répertoire, plutôt que d'enchaîner des cd qui ne survivent pas d'une couche à l'autre.
Quatre bonnes pratiques
Construire en plusieurs étapes. Compiler demande des outils qui n'ont rien à faire en production.
FROM node:20 AS constructionWORKDIR /appCOPY . .RUN npm ci && npm run build FROM node:20-alpineWORKDIR /appCOPY --from=construction /app/dist ./distCOPY --from=construction /app/node_modules ./node_modulesCMD ["node", "dist/serveur.js"]Seule la dernière étape devient l'image finale. On passe couramment de 1,2 Go à 120 Mo, et chaque mégaoctet économisé est du temps de transfert à chaque déploiement — et de la surface d'attaque en moins, comme le rappellera le chapitre 11.
Ne pas tourner en root. Par défaut, le processus d'un conteneur s'exécute avec l'identifiant
0. Combiné à une faille d'évasion, c'est root sur l'hôte. Une ligne USER node suffit, et c'est
le principe de moindre privilège du cours de systèmes.
Écrire un .dockerignore. Sans lui, COPY . . embarque .git, node_modules local et les
fichiers de configuration personnels — image plus grosse, cache invalidé plus souvent, et
parfois des secrets.
Un processus par conteneur. Un conteneur qui fait tourner l'application et la base et un cron n'est plus déployable indépendamment, ni observable, ni redémarrable séparément. C'est le chapitre suivant qui les fera cohabiter.
Un Dockerfile place COPY . . avant RUN npm ci. Quelle est la conséquence ?
Persistance et réseau
La couche d'écriture d'un conteneur est éphémère. Elle disparaît avec lui, et c'est délibéré : un conteneur doit pouvoir être détruit et recréé sans conséquence. Une base de données qui écrit dans cette couche perd tout au premier redémarrage.
Deux mécanismes de persistance, aux usages distincts. Un volume nommé est géré par Docker, stocké hors du conteneur, et c'est le choix pour des données de production. Un montage d'hôte (bind mount) fait apparaître un répertoire de la machine dans le conteneur : idéal en développement, pour éditer son code sans reconstruire l'image, et à éviter en production où il crée une dépendance à l'agencement de la machine.
Côté réseau, un conteneur reçoit sa propre pile et sa propre adresse. Deux points suffisent à débloquer la plupart des situations.
Publier un port n'est pas l'exposer. -p 8080:3000 relie le port 8080 de l'hôte au port
3000 du conteneur. Sans cette publication, le service est joignable depuis les autres
conteneurs du même réseau, et de nulle part ailleurs.
Écouter sur 127.0.0.1 rend le service injoignable. À l'intérieur du conteneur, cette
adresse ne désigne que le conteneur lui-même. Une application doit écouter sur 0.0.0.0 pour
recevoir le trafic publié — c'est le premier bogue de tout le monde, et il ressemble à s'y
méprendre à un problème de pare-feu.
Enfin, sur un réseau Docker défini par l'utilisateur, les conteneurs se joignent par leur
nom : l'application atteint la base à l'adresse basededonnees:5432. C'est la découverte de
service, et c'est ce qui rend le chapitre suivant possible.
Configuration et secrets
La règle générale est celle du DevOps tout entier : l'image est identique dans tous les environnements, seule la configuration change. Une image construite pour la recette et une autre pour la production ruinent la garantie « ce qui a été testé est ce qui tourne ».
D'où le passage par des variables d'environnement : DATABASE_URL, LOG_LEVEL, PORT. On
les injecte au lancement, jamais dans l'image.
Et une mise en garde qui vaut le chapitre 11 : les variables d'environnement ne sont pas un
mécanisme de secret. Elles apparaissent dans docker inspect, dans la liste des processus,
et souvent dans les journaux d'erreur. Pire, un secret écrit dans un Dockerfile avec ENV
reste dans l'historique de l'image, même s'il est effacé par une instruction ultérieure —
les couches sont immuables, et quiconque tire l'image peut les lire.
Les vrais mécanismes — coffres-forts, secrets montés en fichier, secrets d'orchestrateur — arrivent aux chapitres 8 et 9.
Une application dans un conteneur écoute sur 127.0.0.1:3000. Le port est publié par -p 8080:3000, et pourtant rien ne répond sur le port 8080 de l'hôte. Pourquoi ?
À vous
L'exercice simule le cache de construction, et c'est le plus rentable du bloc.
Vous donnez un Dockerfile et la liste des fichiers modifiés depuis la dernière construction ; le programme indique quelles couches sont réutilisées, lesquelles sont reconstruites, et le temps total. Vous comparerez l'ordre naïf et l'ordre optimisé sur le même scénario — modifier une ligne de code, puis ajouter une dépendance — et vous verrez le rapport.
La seconde partie mesure la construction en plusieurs étapes : taille de l'image mono-étape, taille de l'image finale, et ce que la première embarque d'inutile.
La troisième est une revue : quatre Dockerfile contenant chacun une faute du chapitre —
exécution en root, secret dans une variable de construction, latest non fixé, absence de
.dockerignore — à trouver et à corriger.
Simulez le cache de couches, mesurez la construction multi-étapes, puis relisez quatre Dockerfile.
// ── 1. Le cache de couches ──────────────────────────────────────────────── // Chaque instruction : ce dont elle DÉPEND, et ce qu'elle coûte en secondes. const NAIF = [ { instr: "FROM node:20", depend: [], cout: 0 }, { instr: "WORKDIR /app", depend: [], cout: 0 }, { instr: "COPY . .", depend: ["*"], cout: 2 }, { instr: "RUN npm ci", depend: [], cout: 95 }, { instr: "RUN npm run build", depend: [], cout: 40 }, { instr: "CMD node dist/serveur.js", depend: [], cout: 0 }, ]; const OPTIMISE = [ { instr: "FROM node:20", depend: [], cout: 0 }, { instr: "WORKDIR /app", depend: [], cout: 0 }, { instr: "COPY package*.json ./", depend: ["package.json"], cout: 1 }, { instr: "RUN npm ci", depend: [], cout: 95 }, { instr: "COPY . .", depend: ["*"], cout: 2 }, { instr: "RUN npm run build", depend: [], cout: 40 }, { instr: "CMD node dist/serveur.js", depend: [], cout: 0 }, ]; // modifies : la liste des fichiers changés depuis la dernière construction. function construire(nom, dockerfile, modifies) { let invalide = false; // une fois invalidé, TOUT ce qui suit l'est aussi let total = 0; const detail = []; for (const c of dockerfile) { const touche = c.depend.some((d) => d === "*" ? modifies.length > 0 : modifies.includes(d)); // ← à écrire : dès qu'une couche est touchée, elle et toutes les // suivantes sont reconstruites. Sinon, cache réutilisé. detail.push(c.instr.padEnd(28) + "cache"); } console.log(" " + nom + " (" + total + " s)"); for (const d of detail) console.log(" " + d); return total; } // ── 2. Construction en plusieurs étapes ─────────────────────────────────── const ETAPES = { monoEtape: [ { nom: "node:20 (base complète)", mo: 1100 }, { nom: "outils de compilation", mo: 180 }, { nom: "node_modules (dev inclus)", mo: 340 }, { nom: "code source + .git", mo: 45 }, { nom: "artefact construit", mo: 12 }, ], finale: [ { nom: "node:20-alpine", mo: 55 }, { nom: "node_modules (prod)", mo: 58 }, { nom: "artefact construit", mo: 12 }, ], }; // ── 3. Revue de Dockerfile ──────────────────────────────────────────────── const A_RELIRE = { A: ["FROM node:latest", "COPY . .", "RUN npm ci", "CMD node serveur.js"], B: ["FROM node:20-alpine", "ENV API_TOKEN=sk-prod-8f3a91", "COPY . .", "CMD node serveur.js"], C: ["FROM node:20-alpine", "COPY package*.json ./", "RUN npm ci", "COPY . .", "CMD node serveur.js"], D: ["FROM node:20-alpine", "COPY package*.json ./", "RUN npm ci", "COPY . .", "USER node", "CMD node serveur.js"], }; // ── À VOUS ──────────────────────────────────────────────────────────────── // 1. Écrivez construire(), avec l'invalidation en cascade. Comparez les deux // Dockerfile sur deux scénarios : une ligne de code modifiée, puis une // dépendance ajoutée. // 2. Calculez les deux tailles d'image et ce que la mono-étape embarque en // pur superflu. // 3. Relisez les quatre Dockerfile : chacun a une faute (sauf un). construire("naïf", NAIF, ["src/serveur.js"]);
En travaux pratiques
Conteneuriser l'application du fil rouge
Écrire un Dockerfile naïf, en mesurer les défauts, puis le corriger point par point — ordre des couches, construction en plusieurs étapes, utilisateur non privilégié.
- Docker installé et fonctionnel (docker run hello-world)
- Le dépôt tickets du TP 2, avec l'application qui démarre en local
- 1. Écrire un premier Dockerfile, volontairement naïf
Partez d'une image node complète, copiez tout le projet, installez les dépendances, lancez le serveur. Construisez, puis mesurez : temps de construction et taille de l'image.
- 2. Mesurer l'effet du cache
Modifiez une seule ligne d'un fichier source, reconstruisez, et notez le temps. Recommencez après avoir modifié package.json. Les deux durées devraient être identiques — expliquez pourquoi c'est un problème.
- 3. Réordonner les couches
Réécrivez le Dockerfile pour que l'installation des dépendances reste en cache quand seul le code change. Reprenez les deux mesures de l'étape précédente et comparez.
- 4. Réduire l'image
Passez à une construction en deux étapes : une étape qui compile, une étape finale minimale qui ne reçoit que le résultat. Comparez la taille avant et après.
- 5. Ne plus tourner en root
Vérifiez sous quel identifiant tourne le processus dans le conteneur, puis faites-le tourner sous un compte non privilégié. Vérifiez à nouveau.
- 6. Publier et joindre
Lancez le conteneur en publiant un port, et atteignez l'application depuis votre machine. Si rien ne répond, reprenez le diagnostic du TP 2 : sur quelle interface l'application écoute-t-elle À L'INTÉRIEUR du conteneur ?
- 7. Écrire le .dockerignore
Listez ce que COPY embarque et qui n'a rien à faire dans l'image. Écrivez le fichier, reconstruisez, et comparez la taille du contexte de construction.
- Une modification de code seule ne relance PAS l'installation des dépendances
- L'image finale fait moins du quart de l'image naïve
- docker exec … id ne rend pas uid=0
- curl atteint l'application depuis l'hôte
Ce que la suite en fait
Le chapitre 4 fait cohabiter plusieurs conteneurs : l'application, sa base, son cache. La découverte par nom de ce chapitre y devient le mécanisme central, et la sonde de santé y règle le problème que l'on découvrira vite — une application qui démarre plus vite que sa base.
Le chapitre 5 construira ces images dans le pipeline, et le cache de couches y devient un enjeu de temps de cycle. Le chapitre 9, enfin, ne déploiera jamais autre chose que des images : tout ce que Kubernetes orchestre a été empaqueté ici.
À retenir
Vous avez parcouru les 10 sections.
Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.