Chapitre 1 · 8 h
Docker
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.
Quiz · 1 question
Un Dockerfile place COPY . . avant RUN npm ci. Quelle est la conséquence ?
- Aucune : l'ordre des instructions n'affecte que la lisibilité du fichier — sans effet
- Toute modification du code source invalide la couche COPY, donc TOUTES les suivantes — y compris l'installation des dépendances, refaite à chaque construction alors que rien n'a changé de ce côté — cache invalidé en cascade
- Les dépendances ne seront pas installées, car npm ci a besoin du code source — erreur d'installation
Réponse : Chaque instruction crée une couche, mise en cache tant que ni l'instruction ni son contenu n'ont changé — et l'invalidation est EN CASCADE : une couche invalidée invalide toutes les suivantes. Copier tout le code avant d'installer les dépendances signifie donc que la moindre virgule modifiée fait recommencer l'installation, qui est l'étape la plus longue. D'où la règle : ordonner du MOINS VOLATIL au PLUS VOLATIL. On copie d'abord le seul fichier de dépendances, on installe, puis on copie le code : l'installation reste alors en cache tant que les dépendances ne bougent pas. Sur un pipeline déclenché vingt fois par jour, c'est le premier gain à aller chercher — deux minutes contre quelques secondes.
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.
Quiz · 1 question
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 ?
- Le port publié doit être identique des deux côtés : -p 3000:3000 — ports identiques
- À l'intérieur du conteneur, 127.0.0.1 ne désigne que le conteneur lui-même : le trafic publié arrive par l'interface réseau du conteneur, que l'application n'écoute pas. Il faut écouter sur 0.0.0.0 — interface d'écoute
- Il manque une instruction EXPOSE 3000 dans le Dockerfile, sans laquelle la publication est ignorée — EXPOSE manquant
Réponse : Chaque conteneur possède sa propre pile réseau, donc sa propre boucle locale. Une application qui se lie à 127.0.0.1 n'accepte que les connexions venues de l'intérieur du conteneur ; le trafic publié depuis l'hôte, lui, arrive par l'interface réseau du conteneur, sur une autre adresse. Le service est donc parfaitement fonctionnel et parfaitement injoignable — et le symptôme ressemble à s'y méprendre à un pare-feu mal configuré, ce qui fait perdre des heures. Il faut écouter sur 0.0.0.0, c'est-à-dire toutes les interfaces. Les ports n'ont aucune raison d'être identiques des deux côtés, c'est même l'intérêt de la publication. Quant à EXPOSE, il ne publie rien : c'est une documentation, la publication se faisant au lancement avec -p.
À 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.
Exercice de code
Simulez le cache de couches, mesurez la construction multi-étapes, puis relisez quatre Dockerfile.
Point de départ
// ── 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"]);
Solution
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 },
];
function construire(nom, dockerfile, modifies, silencieux) {
let invalide = false, total = 0;
const detail = [];
for (const c of dockerfile) {
const touche = c.depend.some((d) => (d === "*" ? modifies.length > 0 : modifies.includes(d)));
// LA règle : une fois qu'une couche est invalidée, toutes les suivantes
// le sont aussi, qu'elles dépendent ou non des fichiers modifiés.
if (touche) invalide = true;
if (invalide) { total += c.cout; detail.push(c.instr.padEnd(28) + "RECONSTRUITE (" + c.cout + " s)"); }
else detail.push(c.instr.padEnd(28) + "cache");
}
if (!silencieux) {
console.log(" " + nom.padEnd(34) + total + " s");
for (const d of detail) console.log(" " + d);
}
return total;
}
console.log("— 1. modifier UNE LIGNE de code —");
const a1 = construire("Dockerfile naïf", NAIF, ["src/serveur.js"]);
const b1 = construire("Dockerfile optimisé", OPTIMISE, ["src/serveur.js"]);
console.log(" rapport : " + (a1 / b1).toFixed(1) + " fois plus long pour le naïf.");
console.log(" L'installation des dépendances a été refaite alors que rien n'avait");
console.log(" changé de ce côté : c'est l'invalidation EN CASCADE.");
console.log("");
console.log("— ajouter une dépendance —");
const a2 = construire("Dockerfile naïf", NAIF, ["package.json"], true);
const b2 = construire("Dockerfile optimisé", OPTIMISE, ["package.json"], true);
console.log(" naïf " + a2 + " s | optimisé " + b2 + " s — là, les deux reconstruisent :");
console.log(" c'est normal, les dépendances ont réellement changé.");
console.log("");
console.log("— sur vingt constructions par jour, dont 18 pour du code seul —");
const parJourNaif = 18 * a1 + 2 * a2;
const parJourOpt = 18 * b1 + 2 * b2;
console.log(" naïf : " + Math.round(parJourNaif / 60) + " min/jour d'attente");
console.log(" optimisé : " + Math.round(parJourOpt / 60) + " min/jour");
console.log(" Deux lignes déplacées dans le Dockerfile.");
console.log("");
console.log("— 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 },
],
};
const somme = (t) => t.reduce((s, x) => s + x.mo, 0);
const mono = somme(ETAPES.monoEtape), fin = somme(ETAPES.finale);
console.log(" mono-étape : " + mono + " Mo");
for (const c of ETAPES.monoEtape) console.log(" " + String(c.mo).padStart(5) + " Mo " + c.nom);
console.log(" finale : " + fin + " Mo (" + (mono / fin).toFixed(1) + " fois plus petite)");
console.log(" Superflu embarqué par la mono-étape : " + (mono - fin) + " Mo — dont les");
console.log(" outils de compilation et le .git, qui sont aussi de la surface d'attaque.");
console.log("");
console.log("— 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"],
};
function relire(lignes) {
const f = [];
if (lignes.some((l) => /:latest/.test(l))) f.push("étiquette latest : la base change sous vos pieds");
if (lignes.some((l) => /ENV .*(TOKEN|SECRET|PASSWORD|KEY)=/i.test(l)))
f.push("SECRET dans l'image : il reste dans l'historique des couches, à jamais");
const iCopieTout = lignes.findIndex((l) => /^COPY \. \./.test(l));
const iInstall = lignes.findIndex((l) => /npm ci/.test(l));
if (iCopieTout !== -1 && iInstall !== -1 && iCopieTout < iInstall)
f.push("COPY . . avant l'installation : cache invalidé à chaque commit");
if (!lignes.some((l) => /^USER /.test(l))) f.push("aucun USER : le processus tourne en root");
return f;
}
for (const [nom, lignes] of Object.entries(A_RELIRE)) {
const f = relire(lignes);
console.log(" Dockerfile " + nom + " : " + (f.length ? f.length + " faute(s)" : "correct"));
for (const x of f) console.log(" - " + x);
}
En travaux pratiques
Travaux pratiques 3 · 4 h
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é.
Avant de commencer
- Docker installé et fonctionnel (docker run hello-world)
- Le dépôt tickets du TP 2, avec l'application qui démarre en local
Énoncé
- É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. Indice : docker build affiche la durée ; docker images donne la taille.
- 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.
- 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. Indice : Une couche invalidée invalide toutes les suivantes. Que faut-il donc copier en premier ?
- 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.
- 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. Indice : docker exec conteneur id, puis l'instruction USER — attention à l'ordre par rapport aux instructions qui écrivent des fichiers.
- 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 ?
- É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.
C'est réussi quand
- 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
Correction
FROM node:20 WORKDIR /app COPY . . RUN npm ci RUN npm run build CMD ["node", "dist/serveur.js"]
Environ 1,2 Go, et près de deux minutes de reconstruction à CHAQUE modification de code : COPY . . est invalidé par la moindre virgule, donc npm ci et npm run build le sont aussi. C'est l'invalidation en cascade.
FROM node:20 AS construction WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine WORKDIR /app ENV NODE_ENV=production COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY --from=construction /app/dist ./dist USER node EXPOSE 3000 CMD ["node", "dist/serveur.js"]
Trois corrections en une. Le fichier de dépendances est copié AVANT le code, donc npm ci reste en cache tant que les dépendances ne bougent pas. La seconde étape ne reçoit que dist et les dépendances de production : les outils de compilation restent derrière. Et USER est placé après les instructions qui écrivent, sinon celles-ci échoueraient faute de droits.
naïf corrigé taille de l'image 1,18 Go 138 Mo 1re construction 118 s 121 s modification de code 115 s 9 s modification de deps 117 s 119 s
La première construction ne gagne rien — c'est normal, tout est à faire. Le gain porte sur le cas courant, qui représente dix-huit constructions sur vingt : une modification de code. Sur un pipeline déclenché vingt fois par jour, c'est une demi-heure d'attente évitée quotidiennement.
// AVANT : injoignable depuis l'hôte, même avec -p app.listen(3000, "127.0.0.1"); // APRÈS : toutes les interfaces du conteneur app.listen(3000, "0.0.0.0"); docker run -p 8080:3000 tickets curl http://localhost:8080/sante
Chaque conteneur a sa propre pile réseau, donc sa propre boucle locale : 127.0.0.1 n'y désigne que le conteneur. Le trafic publié arrive par l'interface du conteneur, que l'application n'écoutait pas. Le symptôme — connexion refusée malgré -p — ressemble à s'y méprendre à un pare-feu.
.git node_modules dist .env *.log coverage .vscode
Sans lui, COPY . . embarque l'historique Git complet, les node_modules de votre machine — compilés pour VOTRE système — et souvent un fichier .env contenant des secrets. Le contexte de construction passe couramment de 300 Mo à 2 Mo, ce qui accélère aussi chaque construction.
docker exec tickets id # avant : uid=0(root) gid=0(root) # après : uid=1000(node) gid=1000(node)
Par défaut, le processus d'un conteneur tourne avec l'identifiant 0. Combiné à une faille d'évasion, c'est root sur l'hôte. Une ligne suffit à supprimer ce cumul, et c'est le principe de moindre privilège du cours de systèmes.
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
Flashcards · 5 cartes
- Quelle est la différence de fond entre une machine virtuelle et un conteneur ?
- Une VM embarque SON PROPRE NOYAU, un conteneur PARTAGE celui de l'hôte. Tout en découle : démarrage en millisecondes contre dizaines de secondes, image de dizaines de mégaoctets contre gigaoctets — et isolation PLUS FAIBLE, puisqu'une faille du noyau traverse tous les conteneurs. Ils reposent sur les espaces de noms (ce qu'un processus VOIT) et les cgroups (ce qu'il CONSOMME). Bons pour séparer des services qu'on maîtrise, insuffisants pour exécuter du code hostile.
- Distinguez image, conteneur et registre, et méfiez-vous de quelle étiquette ?
- L'IMAGE est un modèle en lecture seule, empilement de couches immuables ; elle ne s'exécute pas. Le CONTENEUR est une instance : mêmes couches, plus une couche d'écriture, plus un processus — dix conteneurs d'une même image partagent les couches, d'où leur coût dérisoire. Le REGISTRE est l'endroit où l'on publie. L'étiquette LATEST ne signifie pas « la dernière » mais « celle qu'on n'a pas étiquetée » : elle change sous vos pieds, et une construction reproductible fixe une version, voire une empreinte sha256.
- Comment ordonner les instructions d'un Dockerfile, et pourquoi ?
- DU MOINS VOLATIL AU PLUS VOLATIL. Chaque instruction crée une couche mise en cache, et l'invalidation est EN CASCADE : une couche invalidée invalide toutes les suivantes. On copie donc d'abord le fichier de dépendances, on installe, puis on copie le code — sinon la moindre virgule modifiée fait recommencer l'installation, l'étape la plus longue. Sur un pipeline déclenché vingt fois par jour, c'est le premier gain à aller chercher.
- Citez quatre bonnes pratiques de construction d'image.
- 1) CONSTRUCTION EN PLUSIEURS ÉTAPES : les outils de compilation restent dans l'étape intermédiaire, l'image finale passe couramment de 1,2 Go à 120 Mo — du temps de transfert et de la surface d'attaque en moins. 2) NE PAS TOURNER EN ROOT : par défaut le processus a l'identifiant 0, ce qui combiné à une évasion donne root sur l'hôte ; une ligne USER suffit. 3) UN .dockerignore, sans quoi COPY embarque .git, les node_modules locaux et parfois des secrets. 4) UN PROCESSUS PAR CONTENEUR, sans quoi rien n'est déployable ni redémarrable séparément.
- Pourquoi une variable d'environnement n'est-elle pas un secret ?
- Parce qu'elle apparaît 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 si une instruction ultérieure l'efface — les couches sont immuables, et quiconque tire l'image peut les lire. Les variables d'environnement servent à la CONFIGURATION (l'image est identique partout, seule la configuration change) ; les secrets demandent un coffre-fort ou un montage en fichier.