cursus.

Cours 2 · ConteneurisationLeçon 1 sur 2

Docker

8 h de lecture10 sections Version PDF

À la fin de cette leçon, vous saurez

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 · vérifiez votre compréhension Sans réponse

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.

Quiz · vérifiez votre compréhension Sans réponse

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.

Exercice · JavaScript · à vous de jouer

Simulez le cache de couches, mesurez la construction multi-étapes, puis relisez quatre Dockerfile.

En attente
// ── 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"]);

Console de sortie
Le résultat s'affiche dans la console

En travaux pratiques

Travaux pratiques 3 · sur machine

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é.

4 h
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
  1. 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. 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. 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. 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. 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. 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. 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.

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

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 · 1 / 5Toucher pour retourner
Fin de la leçon

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.