0%
Docker Compose

Compose

Une application entière décrite dans un seul fichier.

10-15 min

Docker Compose

Une application réelle n’est presque jamais un conteneur. C’est une API, une base de données, un cache, parfois un worker asynchrone et un proxy en frontal. Cinq conteneurs, donc cinq docker run — avec les bons ports, les bonnes variables, le bon réseau, dans le bon ordre, et en se souvenant de tout cela demain matin.

Ça tient un temps, à coups de scripts shell recopiés d’un projet à l’autre. Puis ça se dégrade toujours de la même manière : le script du collègue n’a pas la même version de Postgres, personne ne sait plus pourquoi le worker porte telle option obscure, l’arrivée d’un nouveau développeur coûte une demi-journée, et le mot de passe de la base traîne dans l’historique de trois terminaux. Le problème n’est pas le nombre de commandes. C’est qu’il n’existe aucune description unique et vérifiable de ce que « l’application » signifie.

Docker Compose répond exactement à ce manque. Un fichier YAML déclare l’ensemble des services, leurs images, leurs variables, leurs volumes, leurs dépendances. Ce fichier vit dans le dépôt Git, à côté du code. Cloner le projet et taper une seule commande suffit à obtenir la pile complète, identique pour tout le monde — et git log explique pourquoi telle option a été ajoutée.

Un mot sur la syntaxe avant de commencer, parce qu’elle est source de confusion. La forme moderne est docker compose, en deux mots : c’est un plugin intégré au client Docker. L’ancienne forme docker-compose, avec un tiret, est un binaire Python séparé, la « v1 », dont le développement est arrêté depuis 2023. Les deux se ressemblent, mais la v1 ignore une partie de la syntaxe actuelle. Tout ce qui suit utilise docker compose.

Un premier fichier, service par service

Le fichier s’appelle compose.yaml à la racine du projet. C’est le nom recommandé par la spécification Compose ; docker-compose.yml reste reconnu pour compatibilité, et vous le rencontrerez souvent dans des projets existants.

# compose.yaml
# Pas de cle "version:" en tete : elle est obsolete et sans effet.

services:
  # ---------- L'application ----------
  api:
    build:
      context: .
      dockerfile: Dockerfile
    image: mon-api:dev          # nom attribue a l'image construite
    ports:
      - "3000:3000"             # hote:conteneur
    environment:
      NODE_ENV: development
      # "db" et "cache" sont des noms de SERVICES : ils sont resolus
      # en DNS sur le reseau de la pile. Aucune adresse IP a ecrire.
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/appdb
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped

  # ---------- La base de donnees ----------
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: appdb
    volumes:
      # Volume nomme : les donnees survivent a la suppression du conteneur
      - db-data:/var/lib/postgresql/data
    healthcheck:
      # Le double $$ echappe l'interpolation Compose : la variable doit
      # etre resolue DANS le conteneur, pas par Compose.
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s
    restart: unless-stopped

  # ---------- Le cache ----------
  cache:
    image: redis:7-alpine
    command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
    volumes:
      - cache-data:/data
    restart: unless-stopped

volumes:
  db-data:
  cache-data:

Deux détails de ce fichier méritent d’être relevés tout de suite.

La base de données ne publie aucun port : aucune clé ports sous db. Elle reste pourtant parfaitement joignable par l’API à l’adresse db:5432, parce que les deux services partagent un réseau interne créé automatiquement par Compose. Publier 5432:5432 exposerait votre base sur toutes les interfaces de la machine, ce qui n’est nécessaire que pour y brancher un client graphique depuis l’hôte.

Et les services se désignent par leur nom, jamais par une adresse IP. Les IP des conteneurs changent à chaque recréation ; les noms, non. La leçon suivante détaille cette mécanique réseau.

Le cycle de commandes quotidien

Compose fonctionne par convergence : vous décrivez l’état voulu, il calcule les actions à mener. up ne relance pas tout, il ne touche que ce qui a changé.

docker compose up -d              # demarrer en arriere-plan
docker compose up -d --build      # reconstruire les images locales avant
docker compose up -d --wait       # attendre que les healthchecks soient verts
docker compose ps                 # etat de la pile, avec la sante
docker compose logs -f            # journaux agreges, en direct
docker compose logs -f --tail 100 api   # un seul service

# Ouvrir un shell dans un service qui TOURNE
docker compose exec api sh
docker compose exec db psql -U app -d appdb

# Lancer une commande dans un conteneur JETABLE.
# run cree un nouveau conteneur, exec entre dans celui qui tourne.
docker compose run --rm api npm run migrate

docker compose restart api

L’arrêt mérite qu’on distingue soigneusement trois commandes, parce que l’une détruit des données :

# Arreter sans rien supprimer. Reversible par "start".
docker compose stop

# Arreter ET supprimer conteneurs et reseaux.
# Les VOLUMES sont conserves : vos donnees survivent.
docker compose down

# Idem, plus la suppression des volumes. Vos donnees sont PERDUES.
docker compose down -v

# Supprimer aussi les conteneurs orphelins d'une ancienne version du fichier
docker compose down --remove-orphans

Enfin, la commande de diagnostic la plus rentable :

# Affiche le fichier tel que Compose le comprend REELLEMENT : variables
# interpolees, fichiers fusionnes, valeurs par defaut appliquees.
# Le premier reflexe quand une variable "ne passe pas".
docker compose config

Un mot sur le nom de projet. Compose préfixe tous les objets qu’il crée par un nom dérivé du dossier courant : le volume db-data devient monprojet_db-data. C’est ce qui permet de faire tourner deux piles indépendantes sur une même machine, et c’est aussi ce qui explique qu’un dossier renommé fasse apparemment « perdre » les données — elles sont toujours là, sous l’ancien préfixe.

docker compose -p mon-projet up -d
# ou, de facon persistante, dans .env : COMPOSE_PROJECT_NAME=mon-projet

image ou build : consommer ou produire

image seul tire une image existante d’un registre : c’est le cas de Postgres, Redis ou Nginx, que vous ne construisez jamais. build construit depuis un Dockerfile : c’est le cas de votre code. Combiner les deux clés donne un nom à l’image construite, ce qui la rend poussable dans un registre.

services:
  api:
    build:
      context: .                        # comme le "." de docker build
      dockerfile: docker/Dockerfile.api # relatif au contexte
      target: runtime                   # etape d'un Dockerfile multi-stage
      args:                             # equivaut a --build-arg
        NODE_VERSION: "22"
    image: registre.exemple.com/equipe/mon-api:dev

Le piège de débutant est ici : up ne reconstruit pas une image existante. Vous modifiez votre Dockerfile, vous relancez up -d, rien ne change. Compose réutilise l’image déjà construite ; il faut le demander explicitement.

docker compose build api             # reconstruire un service
docker compose build --no-cache api  # sans cache
docker compose up -d --build         # reconstruire puis demarrer

Les dépendances : depends_on seul ne suffit pas

C’est la source d’erreur la plus fréquente et la plus déroutante, parce que le symptôme est intermittent.

La forme courte de depends_on garantit uniquement un ordre de démarrage. Compose attend que le conteneur db soit lancé, pas que Postgres accepte des connexions. Or Postgres met deux à dix secondes à initialiser son cluster au premier démarrage. Votre API démarre pendant ce temps, échoue sur connection refused, et sort. Sur une machine rapide ça passe parfois : d’où l’impression d’un bug aléatoire.

# Insuffisant : garantit l'ORDRE, pas la DISPONIBILITE
services:
  api:
    depends_on:
      - db

La solution correcte associe un healthcheck sur le service dont on dépend et une condition sur le service dépendant :

services:
  api:
    depends_on:
      db:
        condition: service_healthy   # attend le healthcheck au vert
      cache:
        condition: service_started   # le demarrage suffit
      migrate:
        condition: service_completed_successfully  # attend une sortie en code 0

  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s       # frequence des verifications
      timeout: 3s        # au-dela, la verification est un echec
      retries: 10        # echecs consecutifs avant le statut "unhealthy"
      start_period: 10s  # periode de grace : les echecs n'y sont pas comptes

  migrate:
    build: .
    command: ["npm", "run", "migrate"]
    depends_on:
      db:
        condition: service_healthy
    restart: "no"        # un travail ponctuel, pas un service

La troisième condition, service_completed_successfully, résout élégamment le problème des migrations de schéma : le service migrate s’exécute une fois, sort en code 0, et l’API ne démarre qu’ensuite. Plus besoin de scripts d’attente artisanaux.

# La colonne STATUS affiche (healthy), (unhealthy) ou (health: starting)
docker compose ps

# Historique detaille des verifications, avec les sorties de commande
docker inspect --format '{{json .State.Health}}' $(docker compose ps -q db)

Une réserve honnête : depends_on n’intervient qu’au démarrage. Si la base redémarre en cours de vie, votre application doit savoir se reconnecter. Une logique de reconnexion avec temporisation progressive dans le code reste indispensable ; depends_on supprime le bruit au démarrage, il ne remplace pas la robustesse.

Variables d’environnement : trois mécanismes distincts

La confusion vient de ce que trois choses différentes portent presque le même nom.

Le fichier .env à côté du compose.yaml alimente l’interpolation dans le YAML lui-même : ses valeurs remplacent les ${...} avant que Compose ne traite quoi que ce soit. La clé environment définit les variables reçues par le processus dans le conteneur. La clé env_file charge un fichier de variables directement dans le conteneur, sans passer par l’interpolation.

# .env  -- a exclure de Git via .gitignore
DB_PASSWORD=motdepasse-de-dev
API_PORT=3000
TAG=dev
services:
  api:
    image: mon-api:${TAG}         # interpole AVANT le demarrage
    ports:
      - "${API_PORT}:3000"
    env_file:
      - ./config/api.env          # injecte tel quel dans le conteneur
    environment:
      LOG_LEVEL: ${LOG_LEVEL:-info}   # valeur par defaut si absente
      # Deux-points + point d'interrogation : Compose ECHOUE si la
      # variable est absente ou vide. A utiliser pour tout secret.
      JWT_SECRET: ${JWT_SECRET:?JWT_SECRET est obligatoire}

La forme ${VAR:?message} est le meilleur garde-fou disponible : elle transforme un secret oublié en erreur immédiate et explicite, plutôt qu’en une application qui démarre avec une chaîne vide et échoue à l’authentification trois heures plus tard.

Deux règles de sécurité pour finir. Un .env contenant de vrais secrets n’entre jamais dans Git : versionnez un .env.example documenté et sans valeurs. Et en production, les secrets ne passent pas par des variables lisibles dans docker inspect : on utilise la section secrets, qui les monte comme fichiers sous /run/secrets/, l’application lisant alors un chemin plutôt qu’une valeur.

Profils : des services optionnels dans la même pile

Une pile de développement finit par accumuler des outils dont personne n’a besoin en permanence : une interface d’administration de base, un faux serveur SMTP, un injecteur de charge. Les démarrer systématiquement gaspille de la mémoire ; les mettre dans un second fichier duplique la configuration. Un service affecté à un profil ne démarre que si ce profil est demandé.

services:
  api:
    build: .
    ports:
      - "3000:3000"
    # Aucun profil : ce service demarre toujours

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}

  adminer:                    # administration de la base
    image: adminer:4
    profiles: ["debug"]
    ports:
      - "8080:8080"

  mailpit:                    # faux serveur SMTP, dans DEUX profils
    image: axllent/mailpit:latest
    profiles: ["debug", "mail"]
    ports:
      - "8025:8025"

  k6:                         # tests de charge, jamais par accident
    image: grafana/k6:latest
    profiles: ["perf"]
    command: ["run", "/scripts/charge.js"]
    volumes:
      - ./perf:/scripts:ro    # :ro monte en lecture seule
docker compose up -d                                # api + db seulement
docker compose --profile debug up -d                # + adminer + mailpit
docker compose --profile debug --profile perf up -d # profils cumules
COMPOSE_PROFILES=debug docker compose up -d         # via l'environnement
docker compose --profile debug config --services    # lister sans demarrer

Superposer plusieurs fichiers

Le même projet doit souvent tourner dans des contextes différents : code monté et rechargement à chaud en développement, image figée et limites de ressources en production. Compose superpose les fichiers plutôt que de les dupliquer. Un fichier nommé compose.override.yaml est chargé automatiquement en plus de compose.yaml.

# compose.yaml -- la base commune, valable partout
services:
  api:
    image: registre.exemple.com/equipe/mon-api:${TAG:-latest}
    environment:
      NODE_ENV: production
    restart: unless-stopped
# compose.override.yaml -- charge automatiquement, pour le developpement
services:
  api:
    build: .                   # on construit localement
    environment:
      NODE_ENV: development
      DEBUG: "app:*"
    volumes:
      # Le code de l'hote remplace celui de l'image : une modification
      # est visible sans reconstruire.
      - ./src:/app/src
      # Volume anonyme : protege le node_modules de l'image de
      # l'ecrasement par celui de l'hote.
      - /app/node_modules
    command: ["npm", "run", "dev"]
    ports:
      - "9229:9229"            # port du debogueur Node
# compose.prod.yaml -- charge explicitement
services:
  api:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
# Developpement : compose.yaml + compose.override.yaml, automatiquement
docker compose up -d

# Production : on nomme les fichiers, l'override n'est PAS charge
docker compose -f compose.yaml -f compose.prod.yaml up -d

# Toujours verifier le resultat de la fusion avant de deployer
docker compose -f compose.yaml -f compose.prod.yaml config

La règle de fusion est à connaître : pour les valeurs simples, le dernier fichier gagne ; pour les listes comme ports ou volumes, les entrées sont cumulées. Une surprise fréquente, que docker compose config révèle immédiatement.

Ce que Compose ne fait pas

Compose orchestre des conteneurs sur un seul hôte. Il ne répartit rien sur plusieurs machines, ne redémarre pas un service ailleurs quand un serveur tombe, n’assure aucun déploiement sans coupure. C’est le domaine de Kubernetes ou de Docker Swarm.

Cela ne le disqualifie pas pour la production : un serveur unique piloté par Compose est une architecture parfaitement défendable pour un service à trafic modéré, et beaucoup plus simple à opérer qu’un cluster. La leçon 6 traite précisément ce cas.

La mise à l’échelle locale existe cependant, et rend service pour tester un worker :

# Trois instances du service worker
docker compose up -d --scale worker=3

Elle est incompatible avec une publication de port fixe : trois conteneurs ne peuvent pas se lier au port 3000 de l’hôte. Retirez la clé ports du service concerné, ou laissez Docker choisir des ports libres.

À retenir

  • Un compose.yaml versionné remplace une collection de docker run fragiles : la définition de l’application devient un document unique, relu en revue et reproductible pour toute l’équipe.
  • Utilisez docker compose en deux mots. docker-compose avec un tiret est la v1, abandonnée, qui ignore une partie de la syntaxe actuelle. La clé version: en tête de fichier est obsolète et sans effet.
  • Les services se joignent par leur nom grâce au DNS interne. Ne publiez avec ports que ce qui doit être atteignable depuis l’extérieur : une base de données n’en fait généralement pas partie.
  • depends_on seul ne garantit qu’un ordre de démarrage. Pour attendre une réelle disponibilité, ajoutez un healthcheck et condition: service_healthy ; service_completed_successfully règle proprement le cas des migrations.
  • docker compose up ne reconstruit pas vos images : pensez à --build après toute modification d’un Dockerfile. Et docker compose down -v supprime les volumes, donc les données.
  • ${VAR:?message} transforme un secret manquant en erreur immédiate. En cas de doute sur une variable, docker compose config affiche le fichier réellement interprété.
  • Les profils isolent les outils optionnels et les fichiers de surcharge adaptent la même pile au développement ou à la production, sans dupliquer la configuration.

Conseil : prenez l’habitude de lancer docker compose config avant tout up sur une configuration modifiée. La plupart des heures perdues sur Compose se jouent entre ce que vous croyez avoir écrit et ce que l’interpolation a réellement produit.

Commentaires

Les commentaires sont alimentés par GitHub Discussions

Connectez-vous avec GitHub pour participer à la discussion

Lien copié !