Docker Compose — Orchestrer plusieurs conteneurs#

Hide code cell source

import matplotlib
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.patheffects as pe
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch, Arc
import numpy as np
import pandas as pd
import seaborn as sns
import json
import re

sns.set_theme(style="whitegrid", palette="muted")
plt.rcParams.update({
    "figure.dpi": 120,
    "font.family": "sans-serif",
    "axes.spines.top": False,
    "axes.spines.right": False,
})

Pourquoi Docker Compose ?#

Imaginez que vous construisez une application web moderne. Vous avez besoin :

  • d’un serveur web (nginx) qui reçoit les requêtes HTTP

  • d’une application (Flask, Django, Node.js…) qui traite la logique métier

  • d’une base de données (PostgreSQL) qui stocke les données

  • d’un cache (Redis) qui accélère les lectures fréquentes

Sans Docker Compose, vous devriez lancer chaque conteneur à la main, créer les réseaux manuellement, gérer les variables d’environnement, vous souvenir des dizaines d’options docker run… C’est fastidieux et source d’erreurs.

Docker Compose résout ce problème : vous décrivez toute votre stack dans un fichier compose.yml, et une seule commande lance tout l’ensemble.

Analogie — La recette de cuisine

Docker Compose, c’est comme une recette de cuisine pour un repas complet. Plutôt que d’avoir des instructions séparées pour l’entrée, le plat et le dessert, la recette décrit les ingrédients de chaque plat, l’ordre de préparation et comment les servir ensemble. Le fichier compose.yml est votre recette ; docker compose up est le moment où vous mettez tout au four.

Le fichier compose.yml#

Le fichier compose.yml (anciennement docker-compose.yml) utilise le format YAML. Voici sa structure générale :

# compose.yml — Structure générale annotée
name: mon-projet          # Nom du projet (préfixe des ressources créées)

services:                 # Les conteneurs de votre application
  web:                    # Nom du service
    image: nginx:alpine   # Image à utiliser
    ports:
      - "80:80"           # hôte:conteneur

  app:
    build: .              # Construire depuis le Dockerfile local
    environment:
      DATABASE_URL: postgres://db/myapp

  db:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data  # Volume nommé

volumes:                  # Volumes nommés déclarés ici
  pgdata:

networks:                 # Réseaux personnalisés (optionnel)
  frontend:
  backend:

Les instructions clés des services#

Voici les instructions les plus importantes que vous rencontrerez dans un compose.yml :

Instruction

Rôle

Exemple

image

Image Docker à utiliser

postgres:16-alpine

build

Construire depuis un Dockerfile

build: ./app

ports

Publier des ports hôte:conteneur

"8080:80"

volumes

Monter volumes ou répertoires

./data:/app/data

environment

Variables d’environnement

DEBUG: "true"

env_file

Charger depuis un fichier .env

env_file: .env

depends_on

Ordre de démarrage / healthcheck

voir ci-dessous

healthcheck

Vérifier si le service est prêt

voir ci-dessous

restart

Politique de redémarrage

unless-stopped

profiles

Grouper des services optionnels

profiles: [debug]

deploy

Ressources et réplicas (Swarm/Compose)

replicas: 3

networks

Rattacher à des réseaux

[frontend, backend]

depends_on avec condition service_healthy#

Un problème classique : votre application Flask essaie de se connecter à PostgreSQL avant que PostgreSQL ne soit prêt. depends_on avec condition: service_healthy résout ça proprement :

services:
  app:
    image: myapp:latest
    depends_on:
      db:
        condition: service_healthy   # Attend que db soit "healthy"
      redis:
        condition: service_started   # Attend juste que redis soit démarré

  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s      # Vérifier toutes les 5 secondes
      timeout: 5s       # Timeout par vérification
      retries: 5        # Nombre de tentatives avant "unhealthy"
      start_period: 10s # Délai avant la première vérification

Les trois conditions de depends_on

  • service_started : le conteneur a été lancé (mais peut ne pas être prêt)

  • service_healthy : le healthcheck retourne succès — le service est vraiment opérationnel

  • service_completed_successfully : pour les tâches (migrations, seeds) qui doivent finir avant d’autres services

Réseaux dans Compose#

Le réseau par défaut#

Docker Compose crée automatiquement un réseau pour votre projet. Tous les services qui n’ont pas de configuration réseau explicite y sont attachés et peuvent se contacter par leur nom de service :

services:
  app:
    image: myapp
    # app peut joindre "db" via http://db:5432

  db:
    image: postgres:16

À l’intérieur du conteneur app, l’hôte db résout automatiquement vers le conteneur PostgreSQL. C’est la magie du DNS interne de Docker.

Réseaux nommés et isolation#

Pour des architectures plus complexes, vous pouvez créer plusieurs réseaux et contrôler quels services peuvent se voir :

services:
  nginx:
    image: nginx:alpine
    networks:
      - frontend        # nginx peut parler à "app"
      - public          # nginx est accessible de l'extérieur

  app:
    image: myapp
    networks:
      - frontend        # app peut parler à nginx et db
      - backend

  db:
    image: postgres:16
    networks:
      - backend         # db n'est accessible QUE depuis app — pas depuis nginx

networks:
  frontend:
  backend:
  public:

Volumes dans Compose#

Volume nommé vs bind mount#

services:
  db:
    image: postgres:16
    volumes:
      # Volume nommé : Docker gère l'emplacement (recommandé pour les données)
      - pgdata:/var/lib/postgresql/data

  app:
    image: myapp
    volumes:
      # Bind mount : vous liez un dossier de l'hôte (recommandé pour le dev)
      - ./src:/app/src
      # Volume nommé en lecture seule (sécurité)
      - config:/etc/app:ro

volumes:
  pgdata:        # Docker choisit /var/lib/docker/volumes/pgdata/
  config:

Quand utiliser quoi ?

Volumes nommés → données persistantes (bases de données, uploads). Docker les gère, ils survivent aux docker compose down.

Bind mounts → développement local : vos modifications de code sont immédiatement visibles dans le conteneur sans rebuild.

docker compose down -v supprime aussi les volumes nommés — attention aux données !

Profiles : services optionnels#

Les profiles permettent de démarrer seulement certains services selon le contexte :

services:
  app:
    image: myapp         # Pas de profile = toujours démarré

  db:
    image: postgres:16   # Pas de profile = toujours démarré

  adminer:               # Interface web pour la DB
    image: adminer
    profiles: [tools]    # Démarré seulement avec --profile tools

  mailhog:               # Capture des emails en dev
    image: mailhog/mailhog
    profiles: [dev]

  prometheus:
    image: prom/prometheus
    profiles: [monitoring]
# Démarrer uniquement app + db
docker compose up

# Démarrer avec les outils de dev
docker compose --profile dev up

# Démarrer avec monitoring
docker compose --profile monitoring up

# Tout démarrer (tous les profiles)
docker compose --profile dev --profile tools --profile monitoring up

Les commandes Compose essentielles#

# Démarrer tous les services (en arrière-plan avec -d)
docker compose up -d

# Démarrer en reconstruisant les images
docker compose up -d --build

# Arrêter et supprimer les conteneurs (garder les volumes)
docker compose down

# Arrêter, supprimer conteneurs ET volumes nommés
docker compose down -v

# Voir l'état des services
docker compose ps

# Suivre les logs de tous les services
docker compose logs -f

# Logs d'un service spécifique
docker compose logs -f app

# Exécuter une commande dans un service en cours
docker compose exec app bash
docker compose exec db psql -U postgres

# Construire (ou reconstruire) les images
docker compose build

# Télécharger les images sans démarrer
docker compose pull

# Redémarrer un service
docker compose restart app

# Scaler un service (plusieurs réplicas)
docker compose up -d --scale app=3

# Voir la configuration fusionnée (après substitution des variables)
docker compose config

Visualisation : architecture multi-services#

Hide code cell source

fig, ax = plt.subplots(1, 1, figsize=(14, 9))
ax.set_xlim(0, 14)
ax.set_ylim(0, 9)
ax.axis("off")
ax.set_facecolor("#f8f9fa")
fig.patch.set_facecolor("#f8f9fa")

# Titre
ax.text(7, 8.5, "Architecture Docker Compose — Stack web complète",
        ha="center", va="center", fontsize=14, fontweight="bold", color="#2c3e50")

# === Réseau frontend ===
frontend_box = FancyBboxPatch((0.3, 4.2), 6.2, 3.8, boxstyle="round,pad=0.15",
                               facecolor="#e8f4f8", edgecolor="#3498db", linewidth=2, linestyle="--")
ax.add_patch(frontend_box)
ax.text(3.4, 7.85, "réseau : frontend", ha="center", va="center",
        fontsize=9, color="#2980b9", style="italic")

# === Réseau backend ===
backend_box = FancyBboxPatch((7.3, 4.2), 6.2, 3.8, boxstyle="round,pad=0.15",
                              facecolor="#fef9e7", edgecolor="#f39c12", linewidth=2, linestyle="--")
ax.add_patch(backend_box)
ax.text(10.4, 7.85, "réseau : backend", ha="center", va="center",
        fontsize=9, color="#e67e22", style="italic")

# === Réseau commun (app est dans les deux) ===
common_box = FancyBboxPatch((5.8, 4.7), 2.5, 2.8, boxstyle="round,pad=0.1",
                             facecolor="#e8f8e8", edgecolor="#27ae60", linewidth=2, linestyle=":")
ax.add_patch(common_box)
ax.text(7.05, 7.35, "app\n(les deux)", ha="center", va="center",
        fontsize=7.5, color="#1e8449", style="italic")

def draw_service(ax, x, y, w, h, name, image, color, icon=""):
    box = FancyBboxPatch((x, y), w, h, boxstyle="round,pad=0.1",
                          facecolor=color, edgecolor="#555", linewidth=1.5)
    ax.add_patch(box)
    ax.text(x + w/2, y + h*0.68, icon + " " + name,
            ha="center", va="center", fontsize=11, fontweight="bold", color="#2c3e50")
    ax.text(x + w/2, y + h*0.3, image,
            ha="center", va="center", fontsize=8.5, color="#555",
            bbox=dict(boxstyle="round,pad=0.2", facecolor="white", edgecolor="#ccc", alpha=0.8))

# Nginx
draw_service(ax, 0.6, 5.0, 2.4, 2.2, "nginx", "nginx:alpine", "#d6eaf8", "🌐")
# App Flask
draw_service(ax, 5.9, 5.0, 2.3, 2.2, "app", "flask:3.0", "#d5f5e3", "🐍")
# PostgreSQL
draw_service(ax, 8.0, 5.0, 2.5, 2.2, "db", "postgres:16", "#fdebd0", "🗄️")
# Redis
draw_service(ax, 10.8, 5.0, 2.3, 2.2, "redis", "redis:7-alpine", "#fadbd8", "⚡")

# Healthcheck badge
for xb, yb, label in [(8.0, 4.7, "✓ healthcheck"), (10.8, 4.7, "✓ healthcheck")]:
    ax.text(xb + 1.25, yb + 0.05, label, ha="center", va="center",
            fontsize=7.5, color="white",
            bbox=dict(boxstyle="round,pad=0.2", facecolor="#27ae60", edgecolor="none"))

# Flèches de dépendances
arrow_style = dict(arrowstyle="-|>", color="#555", lw=1.8,
                   connectionstyle="arc3,rad=0.0")

# nginx -> app
ax.annotate("", xy=(5.9, 6.1), xytext=(3.0, 6.1),
            arrowprops=dict(arrowstyle="-|>", color="#3498db", lw=2))
ax.text(4.45, 6.3, "depends_on", ha="center", fontsize=8, color="#3498db")

# app -> db
ax.annotate("", xy=(8.0, 6.1), xytext=(8.2, 6.1),
            arrowprops=dict(arrowstyle="-|>", color="#f39c12", lw=2))
ax.annotate("", xy=(8.0, 6.1), xytext=(8.25, 6.1),
            arrowprops=dict(arrowstyle="-|>", color="#f39c12", lw=2))
ax.annotate("", xy=(10.8, 6.1), xytext=(8.2, 6.1),
            arrowprops=dict(arrowstyle="-|>", color="#f39c12", lw=2))
ax.text(9.5, 6.3, "depends_on", ha="center", fontsize=8, color="#f39c12")

# app -> redis
ax.annotate("", xy=(10.8, 5.5), xytext=(8.2, 5.5),
            arrowprops=dict(arrowstyle="-|>", color="#e74c3c", lw=2, connectionstyle="arc3,rad=-0.3"))
ax.text(9.4, 5.1, "depends_on", ha="center", fontsize=8, color="#e74c3c")

# Internet -> nginx
ax.annotate("", xy=(0.6, 6.1), xytext=(-0.1 + 0.7, 6.1),
            arrowprops=dict(arrowstyle="-|>", color="#8e44ad", lw=2.5))
ax.text(0.55, 6.4, "Internet\n:80", ha="center", fontsize=8, color="#8e44ad",
        bbox=dict(boxstyle="round,pad=0.2", facecolor="#f9ebff", edgecolor="#8e44ad"))

# Volumes (bas)
ax.text(7, 3.9, "Volumes persistants", ha="center", va="center",
        fontsize=11, fontweight="bold", color="#2c3e50")

for x_v, name_v, color_v in [(3.5, "pgdata\n(postgres data)", "#fdebd0"),
                               (7.0, "redisdata\n(redis AOF/RDB)", "#fadbd8"),
                               (10.5, "uploads\n(fichiers app)", "#d5f5e3")]:
    vol = FancyBboxPatch((x_v - 1.2, 2.7), 2.4, 1.0, boxstyle="round,pad=0.1",
                          facecolor=color_v, edgecolor="#555", linewidth=1.3, linestyle="-")
    ax.add_patch(vol)
    ax.text(x_v, 3.2, name_v, ha="center", va="center", fontsize=8.5, color="#2c3e50")

# Flèches vers volumes
for sx, sy, ex in [(9.25, 5.0, 3.5), (11.95, 5.0, 7.0), (7.05, 5.0, 10.5)]:
    ax.annotate("", xy=(ex, 3.7), xytext=(sx, sy),
                arrowprops=dict(arrowstyle="-", color="#999", lw=1.2, linestyle="dotted"))

# Légende réseau
legend_items = [
    mpatches.Patch(facecolor="#e8f4f8", edgecolor="#3498db", linestyle="--", label="réseau frontend"),
    mpatches.Patch(facecolor="#fef9e7", edgecolor="#f39c12", linestyle="--", label="réseau backend"),
    mpatches.Patch(facecolor="#e8f8e8", edgecolor="#27ae60", linestyle=":", label="app (les deux réseaux)"),
]
ax.legend(handles=legend_items, loc="lower left", fontsize=8.5, framealpha=0.9)

plt.tight_layout()
plt.savefig("compose_architecture.png", dpi=120, bbox_inches="tight")
plt.show()
/tmp/ipykernel_22743/4212682099.py:112: UserWarning: Glyph 127760 (\N{GLOBE WITH MERIDIANS}) missing from font(s) DejaVu Sans.
  plt.tight_layout()
/tmp/ipykernel_22743/4212682099.py:112: UserWarning: Glyph 128013 (\N{SNAKE}) missing from font(s) DejaVu Sans.
  plt.tight_layout()
/tmp/ipykernel_22743/4212682099.py:112: UserWarning: Glyph 128452 (\N{FILE CABINET}) missing from font(s) DejaVu Sans.
  plt.tight_layout()
/tmp/ipykernel_22743/4212682099.py:113: UserWarning: Glyph 127760 (\N{GLOBE WITH MERIDIANS}) missing from font(s) DejaVu Sans.
  plt.savefig("compose_architecture.png", dpi=120, bbox_inches="tight")
/tmp/ipykernel_22743/4212682099.py:113: UserWarning: Glyph 128013 (\N{SNAKE}) missing from font(s) DejaVu Sans.
  plt.savefig("compose_architecture.png", dpi=120, bbox_inches="tight")
/tmp/ipykernel_22743/4212682099.py:113: UserWarning: Glyph 128452 (\N{FILE CABINET}) missing from font(s) DejaVu Sans.
  plt.savefig("compose_architecture.png", dpi=120, bbox_inches="tight")
/home/loc/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 127760 (\N{GLOBE WITH MERIDIANS}) missing from font(s) DejaVu Sans.
  fig.canvas.print_figure(bytes_io, **kw)
/home/loc/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 128013 (\N{SNAKE}) missing from font(s) DejaVu Sans.
  fig.canvas.print_figure(bytes_io, **kw)
/home/loc/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 128452 (\N{FILE CABINET}) missing from font(s) DejaVu Sans.
  fig.canvas.print_figure(bytes_io, **kw)
_images/14efdaf7077498d1a21270836da5df3ebef291a4b3d314841edc1654ecdb874f.png

Visualisation : ordre de démarrage et dépendances#

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# --- Graphe de dépendances ---
ax1 = axes[0]
ax1.set_xlim(0, 10)
ax1.set_ylim(0, 7)
ax1.axis("off")
ax1.set_title("Graphe de dépendances des services", fontsize=12, fontweight="bold", pad=10)
ax1.set_facecolor("#f8f9fa")

services_pos = {
    "nginx": (5, 5.5),
    "app": (5, 3.5),
    "db": (2.5, 1.5),
    "redis": (7.5, 1.5),
    "migration": (2.5, 3.5),
}
services_color = {
    "nginx": "#aed6f1",
    "app": "#a9dfbf",
    "db": "#f9e79f",
    "redis": "#f1948a",
    "migration": "#d7bde2",
}

for name, (x, y) in services_pos.items():
    circle = plt.Circle((x, y), 0.7, color=services_color[name], ec="#555", lw=1.5, zorder=3)
    ax1.add_patch(circle)
    ax1.text(x, y, name, ha="center", va="center", fontsize=9.5, fontweight="bold", zorder=4)

# Dépendances : (from, to, condition)
deps = [
    ("nginx", "app", "started"),
    ("app", "db", "healthy"),
    ("app", "redis", "started"),
    ("app", "migration", "completed"),
    ("migration", "db", "healthy"),
]
cond_colors = {"started": "#3498db", "healthy": "#27ae60", "completed": "#8e44ad"}

for frm, to, cond in deps:
    x1, y1 = services_pos[frm]
    x2, y2 = services_pos[to]
    dx, dy = x2 - x1, y2 - y1
    dist = (dx**2 + dy**2)**0.5
    ux, uy = dx/dist, dy/dist
    ax1.annotate("",
        xy=(x2 - ux*0.72, y2 - uy*0.72),
        xytext=(x1 + ux*0.72, y1 + uy*0.72),
        arrowprops=dict(arrowstyle="-|>", color=cond_colors[cond], lw=2.0))
    mx, my = (x1+x2)/2 + uy*0.25, (y1+y2)/2 - ux*0.25
    ax1.text(mx, my, cond, ha="center", va="center", fontsize=7.5,
             color=cond_colors[cond], fontweight="bold",
             bbox=dict(boxstyle="round,pad=0.15", facecolor="white", edgecolor=cond_colors[cond], alpha=0.85))

legend_dep = [mpatches.Patch(color=c, label=f"condition: service_{l}")
              for l, c in cond_colors.items()]
ax1.legend(handles=legend_dep, loc="upper left", fontsize=8)

# --- Ordre de démarrage ---
ax2 = axes[1]
ax2.set_facecolor("#f8f9fa")
ax2.set_title("Ordre de démarrage (timeline)", fontsize=12, fontweight="bold", pad=10)
ax2.set_xlabel("Temps (secondes)", fontsize=10)
ax2.set_xlim(-0.5, 30)
ax2.set_ylim(-0.5, 5)
ax2.set_yticks([])
ax2.spines["left"].set_visible(False)

timeline_services = ["db", "redis", "migration", "app", "nginx"]
colors_tl = ["#f9e79f", "#f1948a", "#d7bde2", "#a9dfbf", "#aed6f1"]
# (start, healthcheck_end, ready)
timings = [
    (0, 8, 12),     # db
    (0, 2, 3),      # redis
    (12, 14, 15),   # migration (attend db healthy)
    (15, 17, 18),   # app (attend db healthy + migration completed)
    (18, 19, 20),   # nginx (attend app started)
]

for i, (svc, col, (start, hc_end, ready)) in enumerate(zip(timeline_services, colors_tl, timings)):
    y = i + 0.1
    # Démarrage
    ax2.barh(y, hc_end - start, left=start, height=0.7, color=col, edgecolor="#555", lw=1.2, label=svc)
    # Prêt (après healthcheck)
    ax2.barh(y, ready - hc_end, left=hc_end, height=0.7, color=col, edgecolor="#27ae60", lw=2, alpha=0.6, hatch="///")
    ax2.text(start + (ready - start)/2, y + 0.35, svc,
             ha="center", va="center", fontsize=9.5, fontweight="bold", color="#2c3e50")
    # Marqueur "prêt"
    ax2.axvline(x=ready, ymin=(y-0.1)/5, ymax=(y+0.8)/5, color="#27ae60", lw=1.2, ls="--", alpha=0.5)

ax2.axvspan(0, 0.1, alpha=0, label="démarrage")
ax2.text(29, 4.6, "■ démarrage\n⁄ healthcheck OK", ha="right", va="top", fontsize=8, color="#555")
ax2.set_xlabel("Temps (secondes)", fontsize=10)
ax2.spines["bottom"].set_visible(True)

plt.tight_layout()
plt.show()
_images/0a33b091be7a5399eacf5848279bced69b80d6c75cda9d98ac7b02e87deda1a9.png

Exemple complet commenté : stack web#

Voici un exemple de stack complète avec nginx, Flask, PostgreSQL et Redis. C’est le type de fichier que vous utiliserez en production :

# compose.yml — Stack web production-ready
name: webapp

services:

  # ── Proxy inverse ────────────────────────────────────────────
  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro   # Config en lecture seule
      - ./nginx/ssl:/etc/nginx/ssl:ro
      - static_files:/srv/static:ro                   # Fichiers statiques de Flask
    depends_on:
      app:
        condition: service_healthy
    restart: unless-stopped
    networks:
      - frontend

  # ── Application Flask ─────────────────────────────────────────
  app:
    build:
      context: ./app
      dockerfile: Dockerfile
      target: production          # Multi-stage : stage "production"
    env_file:
      - .env                      # Secrets hors du compose.yml
    environment:
      DATABASE_URL: postgresql://appuser:${DB_PASSWORD}@db:5432/appdb
      REDIS_URL: redis://redis:6379/0
      FLASK_ENV: production
    volumes:
      - static_files:/app/static  # Partager les statics avec nginx
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
      migration:
        condition: service_completed_successfully
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 20s
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
    networks:
      - frontend
      - backend

  # ── Migrations de base de données ─────────────────────────────
  migration:
    build:
      context: ./app
      target: production
    command: flask db upgrade     # Alembic/Flask-Migrate
    env_file: .env
    environment:
      DATABASE_URL: postgresql://appuser:${DB_PASSWORD}@db:5432/appdb
    depends_on:
      db:
        condition: service_healthy
    restart: "no"                 # Ne redémarre pas : c'est une tâche unique
    networks:
      - backend

  # ── Base de données PostgreSQL ────────────────────────────────
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 15s
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 256M
    networks:
      - backend

  # ── Cache Redis ───────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped
    networks:
      - backend

  # ── Outils de développement (profile "dev") ───────────────────
  adminer:
    image: adminer:latest
    ports:
      - "8080:8080"
    profiles: [dev]
    depends_on:
      - db
    networks:
      - backend

  redis-commander:
    image: rediscommander/redis-commander:latest
    environment:
      REDIS_HOSTS: "local:redis:6379"
    ports:
      - "8081:8081"
    profiles: [dev]
    depends_on:
      - redis
    networks:
      - backend

volumes:
  pgdata:
  redisdata:
  static_files:

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
    internal: true          # Pas d'accès Internet direct depuis le backend

La variable ${DB_PASSWORD}

Les ${VARIABLE} dans le compose.yml sont remplacées par les variables d’environnement de votre shell ou du fichier .env dans le même répertoire. Ne mettez jamais de mots de passe en dur dans le compose.yml — utilisez des variables d’environnement ou les secrets Docker Compose.

Code Python : parseur et validateur de compose.yml#

Le code suivant simule la lecture et la validation d’un fichier compose.yml avec PyYAML, et vérifie qu’il n’y a pas de cycles dans les dépendances (un problème qui bloquerait le démarrage) :

import yaml
import json
from collections import defaultdict, deque

# Simulation d'un compose.yml en mémoire (sans fichier sur disque)
COMPOSE_YAML = """
name: webapp
services:
  nginx:
    image: nginx:1.25-alpine
    depends_on:
      app:
        condition: service_healthy
    networks: [frontend]

  app:
    build: ./app
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
      migration:
        condition: service_completed_successfully
    networks: [frontend, backend]

  migration:
    build: ./app
    depends_on:
      db:
        condition: service_healthy
    networks: [backend]

  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 10
    networks: [backend]

  redis:
    image: redis:7-alpine
    networks: [backend]

networks:
  frontend:
  backend:
    internal: true
"""

def parse_compose(yaml_str: str) -> dict:
    """Parse un compose.yml et retourne la structure Python."""
    return yaml.safe_load(yaml_str)

def extract_dependencies(compose: dict) -> dict[str, list[str]]:
    """Extrait le graphe de dépendances {service: [dépendances]}."""
    services = compose.get("services", {})
    graph = {}
    for name, cfg in services.items():
        deps = cfg.get("depends_on", {})
        if isinstance(deps, list):
            # Format court : depends_on: [db, redis]
            graph[name] = deps
        elif isinstance(deps, dict):
            # Format long avec conditions
            graph[name] = list(deps.keys())
        else:
            graph[name] = []
    return graph

def topological_sort(graph: dict[str, list[str]]) -> tuple[list[str], bool]:
    """
    Tri topologique (algorithme de Kahn).
    Retourne (ordre_démarrage, cycle_détecté).
    """
    in_degree = {node: 0 for node in graph}
    for node, deps in graph.items():
        for dep in deps:
            if dep in in_degree:
                in_degree[node] = in_degree.get(node, 0)
            # dep → node : dep doit démarrer AVANT node
    # Recalcul correct
    in_degree = defaultdict(int)
    for node in graph:
        in_degree[node] += 0
    # Pour chaque service, ses dépendances doivent le précéder
    reverse_graph = defaultdict(list)
    for node, deps in graph.items():
        for dep in deps:
            reverse_graph[dep].append(node)
            in_degree[node] += 1  # node dépend de dep → in_degree de node augmente

    # Correction : reset et calcul propre
    in_degree = {node: 0 for node in graph}
    for node, deps in graph.items():
        for dep in deps:
            in_degree[node] = in_degree.get(node, 0) + 1

    queue = deque([n for n, d in in_degree.items() if d == 0])
    order = []
    visited = set()

    while queue:
        node = queue.popleft()
        if node in visited:
            continue
        visited.add(node)
        order.append(node)
        # Chercher les services qui dépendent de ce nœud
        for n, deps in graph.items():
            if node in deps and n not in visited:
                in_degree[n] -= 1
                if in_degree[n] == 0:
                    queue.append(n)

    has_cycle = len(order) < len(graph)
    return order, has_cycle

def validate_compose(compose: dict) -> list[str]:
    """Valide un compose et retourne les avertissements."""
    warnings = []
    services = compose.get("services", {})

    for name, cfg in services.items():
        # Vérification healthcheck sur les services avec depends_on healthy
        deps = cfg.get("depends_on", {})
        if isinstance(deps, dict):
            for dep_name, dep_cfg in deps.items():
                if dep_cfg.get("condition") == "service_healthy":
                    dep_service = services.get(dep_name, {})
                    if "healthcheck" not in dep_service:
                        warnings.append(
                            f"⚠️  '{name}' attend '{dep_name}' (service_healthy) "
                            f"mais '{dep_name}' n'a pas de healthcheck défini !"
                        )

        # Vérification restart policy
        restart = cfg.get("restart", "no")
        if restart == "no" and name not in ["migration"]:
            warnings.append(
                f"ℹ️  '{name}' : restart policy est 'no' — "
                f"le service ne redémarrera pas automatiquement"
            )

        # Image ou build requis
        if "image" not in cfg and "build" not in cfg:
            warnings.append(f"❌ '{name}' : ni 'image' ni 'build' défini !")

    return warnings

# === Exécution ===
compose = parse_compose(COMPOSE_YAML)
graph = extract_dependencies(compose)
order, has_cycle = topological_sort(graph)
warnings = validate_compose(compose)

print("=" * 55)
print(f"  Projet : {compose.get('name', 'sans nom')}")
print(f"  Services : {len(compose.get('services', {}))}")
print(f"  Réseaux  : {len(compose.get('networks', {}))}")
print("=" * 55)

print("\n📊 Graphe de dépendances :")
for svc, deps in graph.items():
    conditions = {}
    raw_deps = compose["services"][svc].get("depends_on", {})
    if isinstance(raw_deps, dict):
        conditions = {k: v.get("condition", "?") for k, v in raw_deps.items()}
    if deps:
        dep_str = ", ".join(f"{d} [{conditions.get(d, 'started')}]" for d in deps)
        print(f"  {svc:12}{dep_str}")
    else:
        print(f"  {svc:12} ← (aucune dépendance)")

print(f"\n{'✅' if not has_cycle else '❌'} Ordre de démarrage :")
for i, svc in enumerate(order, 1):
    print(f"  {i}. {svc}")

if has_cycle:
    print("  ⚠️  CYCLE DÉTECTÉ — Compose ne pourra pas démarrer !")

print(f"\n{'⚠️  Avertissements' if warnings else '✅ Aucun avertissement'} :")
for w in warnings:
    print(f"  {w}")
=======================================================
  Projet : webapp
  Services : 5
  Réseaux  : 2
=======================================================

📊 Graphe de dépendances :
  nginx        ← app [service_healthy]
  app          ← db [service_healthy], redis [service_started], migration [service_completed_successfully]
  migration    ← db [service_healthy]
  db           ← (aucune dépendance)
  redis        ← (aucune dépendance)

✅ Ordre de démarrage :
  1. db
  2. redis
  3. migration
  4. app
  5. nginx

⚠️  Avertissements :
  ⚠️  'nginx' attend 'app' (service_healthy) mais 'app' n'a pas de healthcheck défini !
  ℹ️  'nginx' : restart policy est 'no' — le service ne redémarrera pas automatiquement
  ℹ️  'app' : restart policy est 'no' — le service ne redémarrera pas automatiquement
  ℹ️  'db' : restart policy est 'no' — le service ne redémarrera pas automatiquement
  ℹ️  'redis' : restart policy est 'no' — le service ne redémarrera pas automatiquement
# Visualisation des réseaux et de l'isolation
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# --- Résumé des services par réseau ---
ax1 = axes[0]
services_data = compose.get("services", {})
network_members = defaultdict(list)
for svc_name, svc_cfg in services_data.items():
    nets = svc_cfg.get("networks", [])
    if isinstance(nets, list):
        for net in nets:
            network_members[net].append(svc_name)
    elif isinstance(nets, dict):
        for net in nets:
            network_members[net].append(svc_name)

networks_info = compose.get("networks", {})
net_names = list(network_members.keys())
svc_names = list(services_data.keys())

# Matrice d'appartenance
matrix = np.zeros((len(svc_names), len(net_names)))
for j, net in enumerate(net_names):
    for i, svc in enumerate(svc_names):
        if svc in network_members[net]:
            matrix[i, j] = 1

im = ax1.imshow(matrix, cmap="YlGn", aspect="auto", vmin=0, vmax=1)
ax1.set_xticks(range(len(net_names)))
ax1.set_yticks(range(len(svc_names)))
ax1.set_xticklabels(net_names, fontsize=11, fontweight="bold")
ax1.set_yticklabels(svc_names, fontsize=11)
ax1.set_title("Appartenance aux réseaux", fontsize=12, fontweight="bold", pad=10)

for i in range(len(svc_names)):
    for j in range(len(net_names)):
        txt = "✓" if matrix[i, j] else "✗"
        color = "white" if matrix[i, j] else "#ccc"
        ax1.text(j, i, txt, ha="center", va="center", fontsize=14, color=color)

# Annotations "internal"
for j, net in enumerate(net_names):
    if networks_info.get(net, {}) and networks_info[net] and networks_info[net].get("internal"):
        ax1.text(j, len(svc_names) - 0.1, "🔒 internal",
                 ha="center", va="bottom", fontsize=8, color="#c0392b", style="italic")

# --- Statistiques des services ---
ax2 = axes[1]
ax2.set_facecolor("#f8f9fa")
ax2.axis("off")
ax2.set_title("Résumé de la configuration", fontsize=12, fontweight="bold", pad=10)

rows = []
for svc_name, svc_cfg in services_data.items():
    rows.append({
        "Service": svc_name,
        "Source": "build" if "build" in svc_cfg else svc_cfg.get("image", "?")[:20],
        "Healthcheck": "✓" if "healthcheck" in svc_cfg else "—",
        "Restart": svc_cfg.get("restart", "no"),
        "Réseaux": len(svc_cfg.get("networks", [])),
    })

df = pd.DataFrame(rows)
col_labels = df.columns.tolist()
cell_text = df.values.tolist()

tbl = ax2.table(
    cellText=cell_text,
    colLabels=col_labels,
    loc="center",
    cellLoc="center",
)
tbl.auto_set_font_size(False)
tbl.set_fontsize(9)
tbl.scale(1.2, 1.6)

for j in range(len(col_labels)):
    tbl[(0, j)].set_facecolor("#2c3e50")
    tbl[(0, j)].set_text_props(color="white", fontweight="bold")

for i in range(1, len(rows) + 1):
    bg = "#f2f3f4" if i % 2 == 0 else "white"
    for j in range(len(col_labels)):
        tbl[(i, j)].set_facecolor(bg)

plt.tight_layout()
plt.show()
/tmp/ipykernel_22743/3066436115.py:86: UserWarning: Glyph 128274 (\N{LOCK}) missing from font(s) DejaVu Sans.
  plt.tight_layout()
/home/loc/legacy_workspace/notebooks/.venv/lib/python3.13/site-packages/IPython/core/pylabtools.py:170: UserWarning: Glyph 128274 (\N{LOCK}) missing from font(s) DejaVu Sans.
  fig.canvas.print_figure(bytes_io, **kw)
_images/6916222ae0c7db127950af7e12f19d7a1cddadae50f65f24318d57a4227d6eb5.png

Points clés à retenir#

Résumé du chapitre

Docker Compose en une phrase : un fichier YAML qui décrit toute votre stack, une commande qui la lance.

Les concepts essentiels :

  1. compose.yml décrit services, réseaux et volumes

  2. depends_on + condition: service_healthy garantit l’ordre de démarrage

  3. Réseaux nommés isolent les services entre eux (le backend n’est pas accessible depuis Internet)

  4. Volumes nommés pour les données persistantes, bind mounts pour le développement

  5. Profiles pour activer des services optionnels (--profile dev)

  6. Variables d’environnement (.env) pour les secrets — jamais en dur dans le YAML

  7. docker compose up -d pour démarrer, docker compose down pour tout arrêter proprement