Cours 2 · ConteneurisationLeçon 2 sur 2
Composition multi-services
6 h de lecture8 sections Version PDF
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.
Une application dépend d'une base via depends_on, et échoue pourtant au démarrage avec « connexion refusée ». Pourquoi ?
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.
Dans un fichier Compose, la base de données ne publie aucun port. L'application s'y connecte pourtant. Comment ?
À 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.
Reproduisez la course au démarrage, comparez les trois correctifs, puis ordonnez le graphe de dépendances.
// ── 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"));
En travaux pratiques
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.
- L'image du TP 3, qui se construit et se lance
- Docker Compose disponible (docker compose version)
- 1. É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.
- 2. 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.
- 3. 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.
- 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 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.
- 6. 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.
- 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
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
Vous avez parcouru les 8 sections.
Marquez-la terminée pour faire avancer votre parcours, ou revenez sur un point avant de passer à la suite.