Aller au contenu
  1. Blog/

Docker Compose : orchestrer des applications multi-conteneurs

·11 mins
Sommaire
Apprendre Docker - Cet article fait partie d'une série.
Partie 3: Cet article

Lancer un conteneur isolé, c’est bien. Faire tourner une application complète avec sa base de données, son cache et son backend — le tout en une seule commande — c’est mieux. Docker Compose est l’outil qui transforme une série de docker run en une stack déclarative, versionnée et reproductible.

Dans ce chapitre, on va construire une application réaliste de bout en bout : une API Python (Flask), une base PostgreSQL et un cache Redis. Tout orchestré par un seul fichier docker-compose.yml.

Pourquoi Docker Compose ?
#

Sans Compose, démarrer une stack multi-conteneurs implique :

  • Créer manuellement un réseau Docker
  • Lancer chaque conteneur avec les bons flags (--network, --volume, -e, -p…)
  • Gérer l’ordre de démarrage à la main
  • Reproduire tout ça sur chaque machine

Docker Compose résout ces problèmes avec un fichier YAML déclaratif. Vous décrivez l’état souhaité, Compose s’occupe du reste.

Avantages concrets :

  • Un fichier = une stack — tout est versionné dans Git
  • Une commandedocker compose up lance tout
  • Isolation par projet — chaque stack a ses réseaux et volumes
  • Reproductibilité — même comportement en dev, CI et staging

Docker Compose est inclus dans Docker Desktop et dans le CLI Docker récent (plugin compose). La commande moderne est docker compose (sans tiret). L’ancien binaire docker-compose est déprécié.

Anatomie d’un docker-compose.yml
#

Un fichier Compose se structure autour de trois blocs principaux :

1
2
3
services:    # Les conteneurs à lancer
networks:    # Les réseaux internes
volumes:     # Les volumes persistants

Services
#

Chaque service correspond à un conteneur. On y définit l’image (ou le build), les ports, les volumes, les variables d’environnement et les dépendances.

1
2
3
4
5
6
7
8
9
services:
  api:
    build: ./api
    ports:
      - "5000:5000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/app
    depends_on:
      - db

Points clés :

  • Le nom du service (api) devient le hostname DNS interne — les autres conteneurs peuvent joindre ce service via http://api:5000
  • build: ./api indique le répertoire contenant le Dockerfile
  • depends_on contrôle l’ordre de démarrage (mais pas la disponibilité — on verra les healthchecks plus bas)

Networks
#

Par défaut, Compose crée un réseau bridge pour chaque projet. Tous les services y sont connectés et peuvent communiquer entre eux par leur nom de service.

Vous pouvez définir des réseaux personnalisés pour isoler certains services :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
networks:
  frontend:
  backend:

services:
  api:
    networks:
      - frontend
      - backend
  db:
    networks:
      - backend

Ici, db n’est accessible que depuis le réseau backend. Un éventuel service frontend n’y aurait pas accès.

Volumes
#

Les volumes persistent les données au-delà du cycle de vie des conteneurs. Deux syntaxes existent :

1
2
3
4
5
6
7
8
9
volumes:
  pg-data:      # Volume nommé, géré par Docker

services:
  db:
    image: postgres:16
    volumes:
      - pg-data:/var/lib/postgresql/data    # Volume nommé
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql  # Bind mount
  • Volume nommé (pg-data:) — stocké par Docker, performant, idéal pour les données persistantes
  • Bind mount (./init.sql:) — lie un fichier/répertoire de l’hôte, pratique pour le développement

Projet complet : API + PostgreSQL + Redis
#

Passons à la pratique. On va construire une stack réaliste avec :

  • api — une application Flask qui expose des endpoints REST
  • db — PostgreSQL pour le stockage persistant
  • redis — Redis pour le cache applicatif

Structure du projet
#

1
2
3
4
5
6
7
8
mon-projet/
├── docker-compose.yml
├── .env
├── api/
│   ├── Dockerfile
│   ├── requirements.txt
│   └── app.py
└── init.sql

Le Dockerfile de l’API
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# api/Dockerfile
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 5000

CMD ["python", "app.py"]
1
2
3
4
# api/requirements.txt
flask==3.1
psycopg2-binary==2.9.10
redis==5.2

L’application Flask
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# api/app.py
import os
import redis
import psycopg2
from flask import Flask, jsonify

app = Flask(__name__)

cache = redis.Redis(
    host=os.getenv("REDIS_HOST", "redis"),
    port=int(os.getenv("REDIS_PORT", 6379))
)

def get_db():
    return psycopg2.connect(os.getenv("DATABASE_URL"))

@app.route("/")
def index():
    visits = cache.incr("visits")
    return jsonify({"message": "Hello from Docker Compose!", "visits": visits})

@app.route("/health")
def health():
    try:
        cache.ping()
        conn = get_db()
        conn.close()
        return jsonify({"status": "healthy"}), 200
    except Exception as e:
        return jsonify({"status": "unhealthy", "error": str(e)}), 503

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

Le script d’initialisation SQL
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- init.sql
CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users (name, email) VALUES
    ('Alice', 'alice@example.com'),
    ('Bob', 'bob@example.com');

Le fichier docker-compose.yml complet
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    ports:
      - "${API_PORT:-5000}:5000"
    environment:
      - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      - REDIS_HOST=redis
      - REDIS_PORT=6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped
    networks:
      - app-network

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=${POSTGRES_DB}
    volumes:
      - pg-data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 10s
    restart: unless-stopped
    networks:
      - app-network

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped
    networks:
      - app-network

volumes:
  pg-data:
  redis-data:

networks:
  app-network:
    driver: bridge

Variables d’environnement et fichier .env
#

Coder des mots de passe en dur dans docker-compose.yml est une mauvaise idée. Compose charge automatiquement un fichier .env situé à la racine du projet.

Le fichier .env
#

1
2
3
4
5
# .env
POSTGRES_USER=devops
POSTGRES_PASSWORD=S3cur3P@ss!
POSTGRES_DB=appdb
API_PORT=5000

Règles importantes :

  • Le fichier .env est chargé automatiquement par Compose
  • Ajoutez .env à votre .gitignore — ne commitez jamais de secrets
  • Fournissez un .env.example avec des valeurs fictives pour documenter les variables attendues
  • La syntaxe ${VARIABLE:-default} permet de définir une valeur par défaut

Passer des variables aux conteneurs
#

Trois méthodes, par ordre de priorité :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
services:
  api:
    # Méthode 1 : inline
    environment:
      - DATABASE_URL=postgresql://user:pass@db/app

    # Méthode 2 : fichier externe
    env_file:
      - ./api/.env

    # Méthode 3 : référence à une variable de l'hôte
    environment:
      - API_KEY  # Passe la valeur de $API_KEY de l'hôte

Profiles : activer des services à la demande
#

Les profiles permettent de regrouper des services optionnels qui ne démarrent pas par défaut.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
services:
  api:
    build: ./api
    # Pas de profile = toujours démarré

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

  adminer:
    image: adminer:latest
    ports:
      - "8080:8080"
    profiles:
      - debug

  redis-commander:
    image: rediscommander/redis-commander
    ports:
      - "8081:8081"
    environment:
      - REDIS_HOSTS=local:redis:6379
    profiles:
      - debug
1
2
3
4
5
# Lancement normal — seulement api et db
docker compose up -d

# Lancement avec les outils de debug
docker compose --profile debug up -d

Les services avec un profiles: ne démarrent que si le profil est explicitement activé. Pratique pour séparer les outils de développement du runtime de production.

Healthchecks et depends_on
#

Le problème
#

depends_on sans condition garantit uniquement que le conteneur est démarré, pas que le service est prêt. PostgreSQL peut prendre plusieurs secondes à accepter des connexions après le démarrage du conteneur.

La solution : healthchecks
#

Un healthcheck est une commande exécutée périodiquement dans le conteneur pour vérifier que le service est fonctionnel :

1
2
3
4
5
6
7
8
db:
  image: postgres:16-alpine
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U devops -d appdb"]
    interval: 5s       # Fréquence du check
    timeout: 3s        # Temps max par check
    retries: 5         # Nombre d'échecs avant "unhealthy"
    start_period: 10s  # Grâce au démarrage (pas de check pendant cette période)

Combiné avec depends_on conditionnel :

1
2
3
4
5
6
api:
  depends_on:
    db:
      condition: service_healthy
    redis:
      condition: service_healthy

Avec cette configuration, le conteneur api ne démarre qu’une fois que db et redis sont en état healthy. Plus de crash au démarrage parce que la base n’est pas encore prête.

Vérifier l’état des healthchecks
#

1
docker compose ps

La colonne STATUS affiche healthy, unhealthy ou starting pour les services avec un healthcheck configuré.

Commandes essentielles
#

Démarrer la stack
#

1
2
3
4
5
6
7
8
# Lancer en arrière-plan (détaché)
docker compose up -d

# Lancer et reconstruire les images si le code a changé
docker compose up -d --build

# Lancer en forçant la recréation des conteneurs
docker compose up -d --force-recreate

Arrêter et nettoyer
#

1
2
3
4
5
6
7
8
# Stopper les conteneurs (conserve volumes et réseaux)
docker compose down

# Stopper ET supprimer les volumes (⚠️ perte de données)
docker compose down -v

# Stopper ET supprimer les images construites
docker compose down --rmi local

Consulter les logs
#

1
2
3
4
5
6
7
8
# Tous les services
docker compose logs

# Un service spécifique, en streaming
docker compose logs -f api

# Les 50 dernières lignes
docker compose logs --tail 50 api

Exécuter des commandes dans un conteneur
#

1
2
3
4
5
6
7
8
# Shell interactif dans le conteneur api
docker compose exec api sh

# Lancer une commande ponctuelle
docker compose exec db psql -U devops -d appdb

# Vérifier la connectivité Redis
docker compose exec redis redis-cli ping

Construire et reconstruire
#

1
2
3
4
5
6
7
8
# Construire les images sans lancer les conteneurs
docker compose build

# Reconstruire sans cache (utile si les dépendances ont changé)
docker compose build --no-cache

# Reconstruire un seul service
docker compose build api

Autres commandes utiles
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# État des conteneurs
docker compose ps

# Voir la configuration résolue (variables interpolées)
docker compose config

# Mettre à l'échelle un service (ex: 3 instances de l'API)
docker compose up -d --scale api=3

# Redémarrer un service sans toucher aux autres
docker compose restart api

# Voir les processus dans chaque conteneur
docker compose top

Bonnes pratiques
#

1. Un service = une responsabilité
#

Chaque service fait une seule chose. Ne mettez pas votre app et votre base dans le même conteneur.

2. Toujours des healthchecks
#

Pour tout service dont d’autres dépendent, définissez un healthcheck. C’est la seule façon fiable de gérer l’ordre de démarrage.

3. Images avec tag explicite
#

1
2
3
4
5
# ❌ Évitez
image: postgres:latest

# ✅ Préférez
image: postgres:16-alpine

Un tag explicite garantit la reproductibilité. latest peut changer à tout moment et casser votre stack.

4. Restart policies
#

1
2
3
restart: unless-stopped  # Redémarre sauf arrêt manuel
restart: on-failure       # Redémarre uniquement en cas d'erreur
restart: always           # Redémarre toujours

En développement, unless-stopped est un bon défaut. En production avec un orchestrateur, no est souvent préférable (laissez l’orchestrateur gérer).

5. Limiter les ressources
#

1
2
3
4
5
6
7
services:
  api:
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "0.5"

Empêche un conteneur de consommer toutes les ressources de l’hôte.

Exercices pratiques
#

Exercice 1 — Lancer la stack
#

  1. Créez la structure du projet décrite dans cet article
  2. Lancez la stack avec docker compose up -d --build
  3. Vérifiez que tous les services sont healthy avec docker compose ps
  4. Testez l’endpoint http://localhost:5000/ — le compteur de visites doit s’incrémenter
  5. Testez le healthcheck : http://localhost:5000/health

Exercice 2 — Explorer et débugger
#

  1. Consultez les logs de l’API : docker compose logs -f api
  2. Ouvrez un shell dans le conteneur PostgreSQL et listez les tables :
    1
    
    docker compose exec db psql -U devops -d appdb -c '\dt'
  3. Vérifiez le compteur Redis :
    1
    
    docker compose exec redis redis-cli GET visits
  4. Modifiez le code de app.py, puis relancez uniquement l’API :
    1
    
    docker compose up -d --build api

Exercice 3 — Ajouter un service
#

Ajoutez Adminer (interface web pour gérer PostgreSQL) à la stack :

  1. Ajoutez un service adminer avec le profile debug
  2. Exposez le port 8080
  3. Connectez-le au même réseau
  4. Lancez avec docker compose --profile debug up -d
  5. Accédez à http://localhost:8080 et connectez-vous à la base

Exercice 4 — Sécuriser la configuration
#

  1. Déplacez tous les secrets dans le fichier .env
  2. Créez un .env.example avec des valeurs fictives
  3. Vérifiez que la configuration est correctement résolue :
    1
    
    docker compose config
  4. Ajoutez .env à .gitignore

Exercice 5 — Simuler une panne
#

  1. Arrêtez le conteneur Redis manuellement :
    1
    
    docker compose stop redis
  2. Appelez /health — le status doit être unhealthy
  3. Observez le comportement de la politique de restart :
    1
    
    docker compose ps
  4. Relancez Redis : docker compose start redis
  5. Vérifiez que le healthcheck repasse à healthy

Résumé
#

Docker Compose transforme une collection de conteneurs en une application cohérente et reproductible. Les concepts clés de ce chapitre :

ConceptRôle
servicesDéfinir les conteneurs et leur configuration
networksIsoler la communication entre services
volumesPersister les données
.envExternaliser la configuration sensible
profilesActiver des services à la demande
healthcheckVérifier la disponibilité réelle d’un service
depends_on + conditionOrchestrer l’ordre de démarrage

Dans le prochain chapitre, on abordera la construction d’images optimisées : multi-stage builds, layer caching, sécurité et bonnes pratiques pour des images de production légères et sûres.

Apprendre Docker - Cet article fait partie d'une série.
Partie 3: Cet article

Articles connexes