DevOps · C2 Conteneurisation · 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 échecsTrois qualités d'une bonne sonde, qu'on retrouvera au chapitre 9 avec Kubernetes. Elle doit
tester ce dont l'appelant a besoin — pg_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_healthy — dé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 aboutir — impossible
- 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 faire — réseau interne
- Compose publie automatiquement tous les ports déclarés par EXPOSE dans l'image — publication 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é
- Écrire la pile à trois services — Dé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.
- Provoquer la course — Lancez 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.
- Essayer le mauvais correctif — Ajoutez 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.
- É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.
- Rendre l'application robuste — La 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.
- Déboguer un conteneur — L'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
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.
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é.
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.
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.