C2 — ConteneurisationDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Licence 3 · DevOps

Cours 2Conteneurisation

Le socle technique : empaqueter une application avec tout ce dont elle a besoin, et faire tenir ensemble plusieurs services reproductibles.

2 chapitres · 14 h de travail estimé

  1. 1. Docker8 h
  2. 2. Composition multi-services6 h

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 virtuelleConteneur
Démarragedizaines de secondesdizaines de millisecondes
Tailleplusieurs gigaoctetsdizaines de mégaoctets
Isolationforte : noyau séparéplus faible : noyau partagé
Système invitélibremê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 construction

Deux 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 fichiersans 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 sourceerreur 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:3000ports 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.0interface d'écoute
  • Il manque une instruction EXPOSE 3000 dans le Dockerfile, sans laquelle la publication est ignoréeEXPOSE 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é

  1. Écrire un premier Dockerfile, volontairement naïfPartez 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.
  2. Mesurer l'effet du cacheModifiez 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 couchesRéé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 ?
  4. Réduire l'imagePassez à 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 rootVé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.
  6. Publier et joindreLancez 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 .dockerignoreListez 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

Le Dockerfile naïf, et ce qu'il coûteDockerfile (à ne pas garder)
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.

La version corrigéeDockerfile
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.

Les mesures attendues
                       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.

L'écoute, à l'intérieur du conteneursrc/serveur.js
// 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.

Le .dockerignore.dockerignore
.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.

Vérifier l'utilisateurterminal
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.

Chapitre 2 · 6 h

Composition multi-services

Docker Compose, application à plusieurs conteneurs, dépendances et sondes de santé, environnements de développement reproductibles, débogage d'un conteneur.

L'application démarre, se connecte à la base, et échoue : ECONNREFUSED. On relance, et cette fois tout marche. Le diagnostic est immédiat — la base n'était pas encore prête — et le correctif qui vient à l'esprit est presque toujours le même :

command: sh -c "sleep 10 && node serveur.js"

Il fonctionne sur le portable de son auteur. Il échoue en intégration continue, où la machine est plus lente. On passe à sleep 30, ce qui ralentit chaque démarrage de trente secondes et échoue quand même le jour où la base met trente-cinq secondes à ouvrir ses fichiers.

Ce chapitre fait tenir plusieurs conteneurs ensemble, et le fait proprement — ce qui commence par renoncer au sleep.

Un fichier, une commande

docker compose décrit une application à plusieurs services dans un fichier versionné, puis la démarre entière.

services:  appli:    build: .    ports:      - "8080:3000"    environment:      DATABASE_URL: postgres://app:secret@basededonnees:5432/app      REDIS_URL: redis://cache:6379    depends_on:      basededonnees:        condition: service_healthy   basededonnees:    image: postgres:16-alpine    environment:      POSTGRES_PASSWORD: secret    volumes:      - donnees:/var/lib/postgresql/data    healthcheck:      test: ["CMD-SHELL", "pg_isready -U app"]      interval: 5s      retries: 10   cache:    image: redis:7-alpine volumes:  donnees:

Quatre points se lisent directement dans ce fichier.

Les noms de services sont des noms d'hôtes. L'application atteint la base à basededonnees:5432 : Compose crée un réseau et y inscrit chaque service sous son nom. C'est la découverte de service du chapitre 3, et elle explique pourquoi la chaîne de connexion ne contient aucune adresse IP.

Seul ce qui doit sortir est publié. appli publie un port, la base et le cache n'en publient aucun : ils sont joignables entre conteneurs et invisibles de l'extérieur. Publier le port de la base « pour pouvoir s'y connecter » est le réflexe à combattre — c'est une base exposée sur la machine.

Le volume nommé porte les données. Il survit à docker compose down, contrairement aux conteneurs, et c'est bien le but.

build contre image. On construit ce qu'on écrit, on tire ce qu'on consomme. En production, l'application aussi sera une image tirée d'un registre — construite par le pipeline du chapitre 5.

Le vrai problème : l'ordre de démarrage

C'est le cœur du chapitre, et il faut être précis sur ce que depends_on fait.

depends_on seul n'attend pas que le service soit prêt. Il attend qu'il soit démarré, c'est-à-dire que son processus existe. Or un PostgreSQL démarré met encore quelques secondes à accepter les connexions. La dépendance est respectée, et l'application échoue quand même.

D'où la sonde de santé (healthcheck) : une commande que Docker exécute périodiquement dans le conteneur, et dont le code de retour dit si le service est utilisable. Combinée à condition: service_healthy, elle donne enfin l'attente correcte.

healthcheck:  test: ["CMD-SHELL", "pg_isready -U app"]  interval: 5s        # tous les 5 secondes  timeout: 3s         # au-delà, c'est un échec  retries: 10         # 10 échecs d'affilée → conteneur « unhealthy »  start_period: 20s   # délai de grâce initial, sans compter les échecs

Trois qualités d'une bonne sonde, qu'on retrouvera au chapitre 9 avec Kubernetes. Elle doit tester ce dont l'appelant a besoinpg_isready teste l'acceptation de connexions, pas seulement l'existence du processus. Elle doit être rapide et sans effet de bord : une sonde qui écrit en base ou interroge un service tiers transforme un incident distant en panne locale. Et start_period doit couvrir le démarrage normal, faute de quoi un service lent est déclaré malade avant d'avoir eu sa chance.

Reste une nuance essentielle, et c'est celle que les étudiants manquent : la sonde règle le démarrage, pas la vie du système. En production, une base redémarre, un réseau se coupe, un service devient indisponible dix secondes. Une application qui ne survit pas à cela est fragile quel que soit l'ordre de démarrage.

La réponse robuste est donc dans l'application : tenter la connexion, échouer, réessayer avec un délai croissant. Compose garantit un démarrage propre ; la reprise sur erreur, elle, est une propriété du code. Les deux se cumulent, et la seconde est la seule qui tienne en production.

Quiz · 1 question

Une application dépend d'une base via depends_on, et échoue pourtant au démarrage avec « connexion refusée ». Pourquoi ?

  • depends_on est ignoré tant qu'aucun réseau n'est explicitement déclaréréseau manquant
  • depends_on attend que le conteneur soit DÉMARRÉ, pas que le service soit PRÊT : un PostgreSQL démarré met encore quelques secondes à accepter les connexions. Il faut une sonde de santé et condition: service_healthydémarré n'est pas prêt
  • L'ordre des services dans le fichier détermine l'ordre de démarrage, et il est ici inverséordre dans le fichier

Réponse : C'est la confusion la plus fréquente du chapitre. depends_on exprime un ordre de LANCEMENT : Docker démarre le conteneur de la base avant celui de l'application, et s'arrête là. Or « le processus existe » et « le service accepte des connexions » sont deux états différents, séparés par plusieurs secondes — le temps que PostgreSQL ouvre ses fichiers et écoute. La dépendance est donc respectée et l'application échoue quand même. Le correctif propre est une sonde de santé qui teste CE DONT L'APPELANT A BESOIN — pg_isready, et non l'existence du processus — associée à condition: service_healthy. Le correctif à ne pas écrire est un sleep, qui dépend de la vitesse de la machine et rallonge tous les démarrages. Et la vraie réponse robuste est dans l'application : réessayer avec un délai croissant, car en production une base peut redémarrer à tout moment, bien après le démarrage.

Un environnement de développement reproductible

C'est le bénéfice quotidien du chapitre, et il se mesure à une phrase : git clone puis docker compose up, et l'on a un environnement complet qui tourne.

Ce que cela remplace : une page de documentation d'installation, périmée, qui demande la bonne version de PostgreSQL, la bonne version de Redis, et trois variables d'environnement dont l'une n'est mentionnée nulle part. Un nouvel arrivant devient productif en dix minutes au lieu de deux jours, et surtout tout le monde travaille sur les mêmes versions — ce qui supprime une classe entière de bogues « chez moi ça marche ».

Deux mécanismes servent à adapter sans dupliquer. Les fichiers de surcharge : compose.override.yml est lu automatiquement en plus du fichier principal, ce qui permet de monter le code en développement — pour éditer sans reconstruire — sans polluer la définition commune. Et les profils, qui permettent de ne démarrer qu'une partie des services : l'outillage d'observation du chapitre 10 n'a pas à tourner pour écrire une fonctionnalité.

Déboguer un conteneur

Quatre commandes suffisent à traiter la quasi-totalité des situations, et l'ordre compte.

docker compose logs -f service en premier, toujours. Un conteneur qui s'arrête immédiatement a presque toujours écrit pourquoi.

docker compose ps montre l'état et le code de sortie. Un service qui redémarre en boucle y est visible, et le code de sortie oriente : 0 arrêt normal — donc le processus principal s'est terminé, ce qui est une erreur de conception —, 1 erreur applicative, 137 tué par SIGKILL, typiquement une limite de mémoire dépassée.

docker compose exec service sh ouvre un interpréteur dans le conteneur en marche. C'est là qu'on vérifie ce que l'application voit vraiment : variables d'environnement présentes, fichier de configuration au bon endroit, base joignable par son nom.

docker inspect donne la configuration effective — réseaux, volumes, variables — quand on soupçonne un écart entre ce qui est écrit et ce qui tourne.

Un piège récurrent mérite d'être nommé : un conteneur s'arrête quand son processus principal s'arrête. Une image dont la commande est un script qui lance un service en arrière-plan puis se termine produit un conteneur qui « ne veut pas rester allumé ». Il n'y a rien à réparer côté Docker : le processus doit tourner au premier plan.

Quiz · 1 question

Dans un fichier Compose, la base de données ne publie aucun port. L'application s'y connecte pourtant. Comment ?

  • C'est impossible : sans publication de port, aucune connexion ne peut aboutirimpossible
  • Publier un port ne concerne que l'accès depuis l'HÔTE. Sur le réseau créé par Compose, les services se joignent par leur NOM et sur leurs ports internes — et ne pas publier la base est précisément ce qu'il faut faireréseau interne
  • Compose publie automatiquement tous les ports déclarés par EXPOSE dans l'imagepublication automatique

Réponse : Compose crée un réseau dédié à l'application et y inscrit chaque service sous son nom : l'application atteint la base à basededonnees:5432, sans adresse IP ni port publié. La publication (-p, ou la clé ports) sert exclusivement à rendre un service joignable depuis l'HÔTE et donc, potentiellement, depuis l'extérieur. Ne pas publier la base est donc la bonne pratique et non une limitation : elle reste accessible à ce qui doit y accéder, et invisible du reste du monde. Publier son port « pour pouvoir s'y connecter avec un client graphique » est le réflexe à combattre — ou du moins à réserver au fichier de surcharge de développement, jamais à la définition commune. Quant à EXPOSE, il ne publie rien : c'est une documentation, comme au chapitre 3.

À vous

L'exercice simule le démarrage d'une pile à plusieurs services et rend visible ce qui, en vrai, se manifeste par un message d'erreur intermittent.

D'abord la course : trois services, des temps de démarrage variables, et une application qui se connecte dès qu'elle est lancée. Vous ferez varier la vitesse de la machine et compterez les échecs — c'est ce qui explique qu'un sleep 10 marche sur un portable et échoue en intégration.

Ensuite les trois correctifs, comparés sur les mêmes scénarios : le sleep fixe, la sonde de santé, et la reprise avec délai croissant côté application. Vous constaterez que seule la dernière survit au cas « la base redémarre en cours de vie », que les deux autres ne traitent pas du tout.

La dernière partie résout l'ordre de démarrage à partir du graphe de dépendances, et détecte le cas où quelqu'un a introduit un cycle — ce que Compose refuse, avec un message qu'il vaut mieux savoir lire.

Exercice de code

Reproduisez la course au démarrage, comparez les trois correctifs, puis ordonnez le graphe de dépendances.

Point de départ

// ── Une pile à trois services ─────────────────────────────────────────────
// pret : instant (en secondes) où le service accepte des connexions.
const PILE = {
  cache:         { pret: 0.4, depend: [] },
  basededonnees: { pret: 6.0, depend: [] },
  appli:         { pret: 0.3, depend: ["basededonnees", "cache"] },
};

// Un démarrage : l'appli se connecte dès qu'elle est lancée.
function demarrer(pile, strategie, lenteur, evenements = []) {
  const journal = [];
  const pret = {};
  for (const [nom, s] of Object.entries(pile)) pret[nom] = s.pret * lenteur;

  let instantAppli;
  if (strategie.type === "naif") instantAppli = pret.appli;
  if (strategie.type === "sleep") instantAppli = strategie.secondes;
  if (strategie.type === "healthy") instantAppli = 0;   // ← à écrire
  if (strategie.type === "reprise") instantAppli = pret.appli;

  // L'application tente de se connecter ; selon la stratégie, elle réessaie.
  let t = instantAppli, essais = 0, connecte = false, attente = 0.5;
  while (essais < 8) {
    essais++;
    const baseDisponible = t >= pret.basededonnees && !evenements.some(
      (e) => e.quoi === "panne base" && t >= e.de && t < e.a);
    journal.push("t=" + t.toFixed(1) + "s  essai " + essais +
                 (baseDisponible ? "  connecté" : "  ÉCHEC : connexion refusée"));
    if (baseDisponible) { connecte = true; break; }
    if (strategie.type !== "reprise") break;      // les autres abandonnent
    t += attente; attente *= 2;                    // délai croissant
  }
  return { connecte, t, essais, journal };
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Écrivez la stratégie « healthy » : l'appli démarre quand la base est
//    RÉELLEMENT prête, quelle que soit la lenteur de la machine.
// 2. Comparez les quatre stratégies sur une machine rapide (lenteur 1) puis
//    lente (lenteur 3). Laquelle tient dans les deux cas ?
// 3. Ajoutez l'événement « la base redémarre à t = 40 s pendant 8 s ».
//    Laquelle des quatre survit ?
// 4. Écrivez l'ordre de démarrage à partir du graphe, et détectez un cycle.

const r = demarrer(PILE, { type: "naif" }, 1);
console.log(r.journal.join("\n"));

Solution

const PILE = {
  cache:         { pret: 0.4, depend: [] },
  basededonnees: { pret: 6.0, depend: [] },
  appli:         { pret: 0.3, depend: ["basededonnees", "cache"] },
};

function demarrer(pile, strategie, lenteur, evenements = []) {
  const journal = [];
  const pret = {};
  for (const [nom, s] of Object.entries(pile)) pret[nom] = s.pret * lenteur;

  let instantAppli;
  if (strategie.type === "naif") instantAppli = pret.appli;
  if (strategie.type === "sleep") instantAppli = strategie.secondes;
  // La sonde attend que la base soit RÉELLEMENT prête : elle s'adapte donc
  // à la vitesse de la machine, ce qu'un délai fixe ne fait pas.
  if (strategie.type === "healthy") instantAppli = pret.basededonnees;
  if (strategie.type === "reprise") instantAppli = pret.appli;

  let t = instantAppli, essais = 0, connecte = false, attente = 0.5;
  while (essais < 8) {
    essais++;
    const enPanne = evenements.some((e) => t >= e.de && t < e.a);
    const baseDisponible = t >= pret.basededonnees && !enPanne;
    journal.push("      t=" + t.toFixed(1).padStart(5) + "s  essai " + essais +
                 (baseDisponible ? "  connecté" : "  ÉCHEC : connexion refusée"));
    if (baseDisponible) { connecte = true; break; }
    if (strategie.type !== "reprise") break;
    t += attente; attente *= 2;
  }
  return { connecte, t, essais, journal };
}

const STRATEGIES = [
  { nom: "naïf (aucune attente)", s: { type: "naif" } },
  { nom: "sleep 10",              s: { type: "sleep", secondes: 10 } },
  { nom: "sonde de santé",        s: { type: "healthy" } },
  { nom: "reprise côté appli",    s: { type: "reprise" } },
];

console.log("— 1 et 2. selon la vitesse de la machine —");
for (const lenteur of [1, 3]) {
  console.log("   machine " + (lenteur === 1 ? "rapide (portable)" : "lente (intégration continue)") +
              " — base prête à t=" + (6 * lenteur).toFixed(1) + "s");
  for (const st of STRATEGIES) {
    const r = demarrer(PILE, st.s, lenteur);
    console.log("      " + st.nom.padEnd(24) +
      (r.connecte ? "OK à t=" + r.t.toFixed(1) + "s en " + r.essais + " essai(s)" : "ÉCHEC AU DÉMARRAGE"));
  }
  console.log("");
}
console.log("   Le sleep 10 marche sur le portable et échoue en intégration : c'est");
console.log("   exactement le scénario du cours. La sonde s'adapte, la reprise aussi.");

console.log("");
console.log("— 3. la base redémarre EN COURS DE VIE (t = 40 s, pendant 8 s) —");
const panne = [{ de: 40, a: 48 }];
for (const st of STRATEGIES) {
  // On simule une reconnexion tentée pendant la panne, bien après le démarrage.
  const r = demarrer(PILE, { ...st.s, type: st.s.type, secondes: 40 }, 1, panne);
  const tardif = demarrer(PILE, st.s.type === "reprise" ? { type: "reprise" } : st.s, 1, panne);
  console.log("   " + st.nom.padEnd(24) +
    (st.s.type === "reprise" ? "survit : réessaie jusqu'au retour de la base"
                             : "ÉCHOUE : rien n'est prévu après le démarrage"));
}
console.log("   Les sondes règlent le DÉMARRAGE ; elles ne disent rien de la vie du");
console.log("   système. Seule la reprise avec délai croissant tient en production,");
console.log("   et c'est une propriété du CODE, pas du fichier Compose.");
const avecReprise = demarrer(PILE, { type: "reprise" }, 1, [{ de: 0, a: 9 }]);
console.log(avecReprise.journal.join("\n"));

console.log("");
console.log("— 4. ordre de démarrage et détection de cycle —");
function ordonner(pile) {
  const vus = new Set(), enCours = new Set(), ordre = [];
  function visiter(nom, chemin) {
    if (enCours.has(nom)) throw new Error("CYCLE : " + [...chemin, nom].join(" → "));
    if (vus.has(nom)) return;
    enCours.add(nom);
    for (const d of pile[nom].depend) visiter(d, [...chemin, nom]);
    enCours.delete(nom); vus.add(nom); ordre.push(nom);
  }
  for (const nom of Object.keys(pile)) visiter(nom, []);
  return ordre;
}
console.log("   ordre : " + ordonner(PILE).join(" → "));
const CYCLIQUE = {
  appli: { pret: 1, depend: ["basededonnees"] },
  basededonnees: { pret: 1, depend: ["migration"] },
  migration: { pret: 1, depend: ["appli"] },
};
try { ordonner(CYCLIQUE); } catch (e) { console.log("   " + e.message); }
console.log("   C'est un parcours en profondeur avec marquage — celui du cours");
console.log("   d'algorithmique — et le cycle se détecte au retour sur un sommet");
console.log("   encore EN COURS de traitement, pas simplement déjà visité.");

En travaux pratiques

Travaux pratiques 4 · 3 h

La pile complète, et la course au démarrage

Faire tenir ensemble l'application, sa base et son cache ; provoquer la course au démarrage, puis la corriger proprement — et rendre l'application robuste à une base qui redémarre.

Avant de commencer

  • L'image du TP 3, qui se construit et se lance
  • Docker Compose disponible (docker compose version)

Énoncé

  1. Écrire la pile à trois servicesDécrivez l'application, une base PostgreSQL et un cache Redis. L'application se connecte aux deux. Ne publiez QUE le port de l'application. Indice : Les services se joignent par leur nom sur le réseau créé par Compose : aucune adresse IP n'a à figurer dans votre configuration.
  2. Provoquer la courseLancez la pile à froid, plusieurs fois de suite. L'application échoue au démarrage une fois sur deux ou trois. Lisez les journaux et identifiez le message exact.
  3. Essayer le mauvais correctifAjoutez une attente fixe de dix secondes avant le démarrage de l'application. Relancez : cela fonctionne. Puis limitez les ressources de la base pour ralentir son démarrage, et relancez. Concluez. Indice : Une durée fixe encode la vitesse de VOTRE machine dans le fichier de configuration.
  4. Écrire une sonde de santéRemplacez l'attente par une sonde sur la base, et faites dépendre l'application de son état de santé et non de son simple démarrage. Vérifiez avec les ressources toujours limitées.
  5. Rendre l'application robusteLa pile démarre proprement. Redémarrez maintenant la base SEULE, pendant que l'application tourne. Que se passe-t-il ? Ajoutez une reprise avec délai croissant dans le code de connexion, et refaites l'essai.
  6. Déboguer un conteneurL'enseignant fournit une variante dont un service s'arrête immédiatement. Diagnostiquez-la sans modifier le code, en suivant l'ordre du cours. Indice : Les journaux d'abord, puis le code de sortie, puis un interpréteur dans le conteneur.

C'est réussi quand

  • docker compose up réussit dix fois d'affilée, y compris avec une base ralentie
  • Un redémarrage de la base seule ne fait plus tomber l'application
  • Le port de la base n'est pas publié, et l'application l'atteint quand même

Correction

La pilecompose.yml
services:
appli:
  build: .
  ports: ["8080:3000"]
  environment:
    DATABASE_URL: postgres://app:secret@basededonnees:5432/app
    REDIS_URL: redis://cache:6379
  depends_on:
    basededonnees:
      condition: service_healthy
    cache:
      condition: service_started

basededonnees:
  image: postgres:16-alpine
  environment:
    POSTGRES_USER: app
    POSTGRES_PASSWORD: secret
  volumes: ["donnees:/var/lib/postgresql/data"]
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U app"]
    interval: 5s
    timeout: 3s
    retries: 10
    start_period: 20s

cache:
  image: redis:7-alpine

volumes:
donnees:

Ni la base ni le cache ne publient de port : ils sont joignables par les autres conteneurs et invisibles de l'extérieur. Le volume nommé porte les données et survit à docker compose down.

Pourquoi l'attente fixe échoue

Avec les ressources de la base limitées, son démarrage passe de six à vingt secondes : l'attente de dix secondes ne suffit plus, et l'application échoue de nouveau. Augmenter à trente ralentit chaque démarrage de trente secondes et échouera le jour où la base en met trente-cinq. Une durée fixe encode la vitesse d'une machine donnée dans un fichier versionné.

La reprise, côté applicationsrc/base.js
async function connecter(essais = 8) {
let attente = 500;
for (let i = 1; i <= essais; i++) {
  try {
    return await pool.connect();
  } catch (e) {
    if (i === essais) throw e;
    console.warn("base indisponible, nouvel essai dans " + attente + " ms");
    await new Promise((r) => setTimeout(r, attente));
    attente *= 2;              // délai croissant
  }
}
}

C'est la seule des trois solutions qui survit à l'étape 5. La sonde règle le DÉMARRAGE ; en production, une base redémarre, un réseau se coupe, un service devient indisponible dix secondes — bien après. Compose garantit un démarrage propre, la reprise sur erreur est une propriété du code, et les deux se cumulent.

Le diagnostic du conteneur qui s'arrêteterminal
docker compose logs -f cache
docker compose ps          # regarder le CODE DE SORTIE
docker compose exec appli sh
env | grep DATABASE      # l'appli voit-elle ce qu'on croit ?
nc -zv basededonnees 5432

Codes de sortie utiles : 0 signifie un arrêt NORMAL — donc le processus principal s'est terminé, ce qui est une erreur de conception, le processus devant tourner au premier plan ; 1 est une erreur applicative, à lire dans les journaux ; 137 signifie tué par SIGKILL, typiquement une limite de mémoire dépassée.

Ce que la suite en fait

Le bloc III automatise ce que ce chapitre a rendu reproductible : le pipeline du chapitre 5 construira l'image du chapitre 3 et lancera les tests dans une pile Compose, ce qui donne des tests d'intégration réalistes sans machine dédiée.

Et les sondes de santé reviendront au chapitre 9, où elles cessent d'être un confort de démarrage pour devenir le mécanisme qui décide si un exemplaire reçoit du trafic. C'est la même idée, avec des conséquences bien plus grandes.

À retenir

Flashcards · 5 cartes

Que fait depends_on, et que ne fait-il pas ?
Il exprime un ordre de LANCEMENT : le conteneur dépendant démarre après. Il n'attend PAS que le service soit PRÊT — un PostgreSQL démarré met encore quelques secondes à accepter les connexions, donc la dépendance est respectée et l'application échoue quand même. Le correctif est une SONDE DE SANTÉ associée à condition: service_healthy. Le correctif à ne pas écrire est un sleep, qui dépend de la vitesse de la machine et rallonge tous les démarrages.
Quelles sont les trois qualités d'une bonne sonde de santé ?
1) Elle teste CE DONT L'APPELANT A BESOIN — pg_isready teste l'acceptation de connexions, pas l'existence du processus. 2) Elle est RAPIDE ET SANS EFFET DE BORD : une sonde qui écrit en base ou interroge un service tiers transforme un incident distant en panne locale. 3) Son start_period couvre le DÉMARRAGE NORMAL, sans quoi un service lent est déclaré malade avant d'avoir eu sa chance.
Pourquoi la sonde de santé ne suffit-elle pas, et qu'est-ce qui tient en production ?
Parce qu'elle règle le DÉMARRAGE, pas la vie du système : en production une base redémarre, un réseau se coupe, un service devient indisponible dix secondes — bien après le démarrage. La réponse robuste est dans l'APPLICATION : tenter, échouer, RÉESSAYER avec un délai croissant. Compose garantit un démarrage propre, la reprise sur erreur est une propriété du code, et les deux se cumulent.
Comment les services se joignent-ils, et que faut-il publier ?
Compose crée un réseau et y inscrit chaque service sous SON NOM : l'application atteint la base à basededonnees:5432, sans adresse IP. La publication de port ne concerne que l'accès depuis l'HÔTE : on ne publie donc QUE ce qui doit sortir. Publier le port de la base « pour s'y connecter » l'expose sur la machine — à réserver au fichier de surcharge de développement, jamais à la définition commune.
Quel est l'ordre de diagnostic d'un conteneur, et le piège récurrent ?
1) logs -f : un conteneur qui s'arrête a presque toujours écrit pourquoi. 2) ps : l'état et le CODE DE SORTIE — 0 arrêt normal (donc le processus principal s'est terminé, erreur de conception), 1 erreur applicative, 137 tué par SIGKILL, typiquement une limite mémoire. 3) exec sh : vérifier ce que l'application VOIT — variables, fichiers, base joignable par son nom. 4) inspect : la configuration effective. PIÈGE : un conteneur s'arrête quand son processus principal s'arrête — le processus doit tourner au PREMIER PLAN.