DevOps, CI/CD et conteneurs

Docker Compose : applications multi-conteneurs, réseau et volumes

Intermédiaire11 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Compose décrit une application entière — le service, sa base de données, un broker de messages, un reverse proxy — dans un seul fichier que n'importe quel développeur démarre en une commande. Ce guide présente le format du fichier tel qu'il existe dans Compose v2, puis approfondit les deux sujets qui sèment le plus de confusion : comment les conteneurs se trouvent sur un réseau Compose, et comment les données survivent quand les conteneurs, eux, disparaissent.

Ce que vous allez apprendre

  • Écrire un compose.yaml décrivant plusieurs services avec build, image, ports et environment
  • Expliquer comment le réseau du projet et son DNS intégré permettent aux services de s'adresser les uns aux autres par leur nom
  • Ordonner correctement le démarrage avec des healthchecks et les conditions de depends_on
  • Choisir entre volumes nommés, bind mounts et tmpfs, et sauvegarder un volume
  • Superposer la configuration avec fichiers env, interpolation de variables, profils et fichiers d'override
  • Exécuter une stack Compose dans un job de CI sans laisser fuir d'état d'un build à l'autre

Une application moderne se résume rarement à un seul processus. Un service web a besoin d'une base de données, probablement d'un cache, souvent d'un broker de messages, parfois d'un reverse proxy et d'un intercepteur de mails pour le développement. Les lancer à la main avec docker run, c'est retenir six lignes de commande avec les bonnes options, dans le bon ordre, sur le bon réseau — exactement le genre de savoir qui ne vit que dans l'historique shell d'une seule personne.

Compose remplace tout cela par un fichier. compose.yaml décrit les services, les réseaux qui les relient et le stockage qui les porte, et docker compose up donne vie à l'ensemble. Ce guide suppose que vous êtes à l'aise avec les images et les conteneurs — le guide Les fondamentaux de Docker les couvre — et se concentre sur le format du fichier, sur le modèle réseau qui surprend les débutants et sur les choix de stockage qui décident de la survie de vos données.

Le fichier, et ce qui a changé avec la v2

Compose v2 est un plugin écrit en Go, invoqué avec docker compose (une sous-commande, sans tiret). L'ancien binaire Python docker-compose a été retiré, et avec lui deux habitudes : la clé version: de premier niveau est obsolète et déclenche un avertissement, et le nom de fichier recommandé est compose.yaml plutôt que docker-compose.yml — même si les quatre variantes sont toujours détectées.

Voici une stack complète pour un service Spring Boot avec PostgreSQL et Redis :

name: shop

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        JAR_FILE: target/app.jar
    image: acme/shop-api:dev
    ports:
      - "8080:8080"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/shop
      SPRING_DATASOURCE_USERNAME: shop
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
      SPRING_DATA_REDIS_HOST: cache
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health"]
      interval: 10s
      timeout: 3s
      retries: 6
      start_period: 40s
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: shop
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./ops/initdb:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shop -d shop"]
      interval: 5s
      timeout: 3s
      retries: 10
    ports:
      - "127.0.0.1:5432:5432"

  cache:
    image: redis:7-alpine
    command: ["redis-server", "--save", "60", "1", "--appendonly", "no"]
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:

On la démarre, on la surveille, on l'arrête :

docker compose up -d --build
docker compose ps
docker compose logs -f api
docker compose exec db psql -U shop -d shop
docker compose down          # containers and networks; volumes survive
docker compose down -v       # ...and delete the named volumes too

La clé name: fixe le nom du projet, qui préfixe chaque conteneur, réseau et volume créé par Compose. Sans elle, Compose prend le nom du répertoire — c'est pourquoi deux clones du même dépôt dans des dossiers nommés différemment se retrouvent, sans bruit, avec deux bases de données distinctes.

Le réseau : la partie qui surprend

Au démarrage d'un projet, Compose crée un réseau bridge défini par l'utilisateur, nommé <project>_default, et y attache chaque service. Docker fait tourner un serveur DNS intégré sur ce réseau, et chaque service y est résolu par son nom de service. L'API joint donc PostgreSQL sur db:5432 et Redis sur cache:6379, sans la moindre adresse IP.

Trois conséquences en découlent, et chacune correspond à une question qui revient sans cesse.

Dans un conteneur, localhost, c'est le conteneur. Une application configurée avec jdbc:postgresql://localhost:5432/shop échouera, car il n'y a pas de PostgreSQL dans le namespace réseau du conteneur de l'API. Utilisez le nom du service.

Le trafic entre conteneurs ignore ports. La clé ports publie un port du conteneur sur l'hôte, pour votre navigateur et votre client de base de données. Les services d'un même réseau communiquent directement sur le port du conteneur, qu'il soit publié ou non. C'est pour cela que l'exemple publie PostgreSQL en 127.0.0.1:5432:5432 — lié uniquement à l'interface de loopback, pour qu'un portable branché sur le Wi-Fi d'un café n'offre pas sa base de développement à toute la salle.

expose ne publie rien. Comme EXPOSE dans un Dockerfile, c'est de la documentation. Supprimez-le s'il sème la confusion.

Vous pouvez définir des réseaux supplémentaires pour segmenter le trafic, ce qui vaut la peine dès que la stack a une façade publique et un cœur privé :

services:
  proxy:
    image: nginx:1.27-alpine
    ports: ["80:80", "443:443"]
    networks: [edge]
    volumes:
      - ./ops/nginx.conf:/etc/nginx/conf.d/default.conf:ro

  api:
    build: .
    networks: [edge, backend]

  db:
    image: postgres:16-alpine
    networks: [backend]

networks:
  edge:
  backend:
    internal: true      # no route to the outside world

Désormais, le proxy ne peut plus atteindre la base du tout, et la base ne peut ouvrir aucune connexion sortante. C'est une vraie frontière de sécurité, imposée par la pile réseau, et non une simple convention.

Deux autres options reviennent souvent. aliases donne à un service des noms DNS supplémentaires sur un réseau, pratique quand une application attend un nom d'hôte précis comme kafka.internal. Et network_mode: host supprime complètement l'isolation réseau en donnant au conteneur les interfaces de l'hôte ; c'est parfois nécessaire pour de l'outillage réseau, cela casse la découverte de services et ne se comporte pas de la même façon sous Docker Desktop, donc évitez-le sauf si vous n'avez pas le choix.

Ordre de démarrage et healthchecks

Sous sa forme courte, depends_on ne contrôle que l'ordre dans lequel les conteneurs sont créés et démarrés. Il n'attend pas que ce qui tourne à l'intérieur soit prêt, ce qui explique pourquoi tant de stacks échouent au premier up et fonctionnent au second.

La forme longue règle le problème en s'appuyant sur le healthcheck de la dépendance :

depends_on:
  db:
    condition: service_healthy
  migrations:
    condition: service_completed_successfully

service_healthy attend que le HEALTHCHECK de la cible la déclare saine ; service_completed_successfully attend qu'un conteneur à usage unique — une migration de schéma, un script d'amorçage — se termine avec le code zéro. Un healthcheck remplit donc une double fonction : il vous indique l'état d'un service en cours d'exécution dans docker compose ps, et il permet aux autres services d'attendre quelque chose qui a du sens.

Écrivez des healthchecks qui testent la dépendance réelle, pas le processus. pg_isready confirme que PostgreSQL accepte les connexions ; un test TCP sur le port 5432 passerait alors que la base est encore en récupération. Accordez aux services lents un start_period généreux, pendant lequel les échecs ne comptent pas dans retries — sinon, un service JVM qui met 40 secondes à démarrer sera déclaré unhealthy et redémarré indéfiniment.

Même avec tout cela, les applications doivent retenter leur première connexion. L'ordonnancement de Compose ne couvre que le démarrage ; en cours d'exécution, un conteneur de base de données peut redémarrer à tout moment, et un service qui meurt définitivement parce que sa dépendance a eu un hoquet est fragile dans tous les environnements, pas seulement celui-ci.

Les volumes, et où vivent réellement les données

La couche inscriptible d'un conteneur disparaît quand le conteneur est supprimé. Tout ce qui doit survivre à docker compose down a besoin d'un volume. Compose en propose trois sortes.

Un volume nommé est un stockage que Docker crée et gère dans son propre répertoire. C'est le bon choix par défaut pour les bases de données et tout autre état : il survit à la recréation des conteneurs, il se sauvegarde et se déplace, et ses permissions sont gérées par l'image plutôt que par les identifiants utilisateur de votre hôte.

volumes:
  - pgdata:/var/lib/postgresql/data

Un bind mount monte un chemin de l'hôte dans le conteneur. Son contenu est exactement ce qui se trouve sur votre disque, ce qui en fait l'outil idéal pour le code source que vous voulez modifier à chaud, les fichiers de configuration et les données d'amorçage en lecture seule :

volumes:
  - ./ops/nginx.conf:/etc/nginx/conf.d/default.conf:ro
  - ./src:/app/src

Ajoutez :ro à tout ce que le conteneur n'a aucune raison de modifier. Sachez que les fichiers montés par bind mount conservent leur propriétaire sur l'hôte : un conteneur qui tourne avec un utilisateur non root peut donc ne pas pouvoir y écrire — c'est de loin la cause la plus fréquente d'erreurs de permissions dans une stack Compose. Sur les systèmes SELinux, ajoutez :z (partagé) ou :Z (privé) pour réétiqueter le chemin.

Un montage tmpfs place un répertoire dans la mémoire de l'hôte, jamais sur disque. Utilisez-le pour de l'espace de travail temporaire et pour des bases de test où la durabilité n'a aucune importance, contrairement à la vitesse :

tmpfs:
  - /tmp
  - /var/lib/postgresql/data:size=512m

Sauvegarder un volume nommé, c'est lancer un conteneur qui le monte et écrit une archive à un endroit que vous maîtrisez :

docker run --rm \
  -v shop_pgdata:/data:ro \
  -v "$PWD":/backup \
  alpine tar -czf /backup/pgdata-$(date +%F).tar.gz -C /data .

Pour une base de données, préférez son propre outil de dump — docker compose exec db pg_dump -U shop shop > shop.sql —, car une copie au niveau fichiers d'un répertoire de données en cours d'utilisation peut être incohérente.

docker compose down conserve volontairement les volumes ; -v les supprime. Cette option a effacé bien des données locales que quelqu'un croyait jetables : sachez donc laquelle des deux vous tapez par réflexe.

Superposer la configuration

Coder les valeurs en dur dans compose.yaml le rend inutilisable ailleurs que sur une seule machine. Compose vous offre plusieurs mécanismes qui se combinent.

L'interpolation de variables lit le shell et un fichier .env placé à côté du fichier Compose. La syntaxe gère les valeurs par défaut et les valeurs obligatoires :

environment:
  LOG_LEVEL: ${LOG_LEVEL:-info}                   # default when unset or empty
  DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
  TAG: ${TAG:-dev}

Ne confondez pas le fichier .env (interpolé dans le fichier Compose lui-même, au moment du parsing) et env_file: sur un service (une liste de fichiers dont le contenu devient des variables d'environnement à l'intérieur du conteneur). Ils résolvent des problèmes différents, et l'on prend sans cesse l'un pour l'autre.

services:
  api:
    env_file:
      - ./config/common.env
      - ./config/local.env      # later files win

Les fichiers d'override permettent de spécialiser une définition de base. docker compose up fusionne automatiquement compose.yaml avec compose.override.yaml s'il existe. Pour tout le reste, nommez les fichiers explicitement :

docker compose -f compose.yaml -f compose.prod.yaml up -d

Le fichier de base décrit l'application ; l'override ajoute les commodités de développement (code source en bind mount, ports de debug, intercepteur de mails) ou les réglages de production (limites de ressources, politiques de redémarrage, tag d'image figé à la place d'un build).

Les profils activent et désactivent des services optionnels au sein d'un même fichier :

services:
  mailhog:
    image: mailhog/mailhog
    profiles: ["dev"]
    ports: ["8025:8025"]
  loadtest:
    image: grafana/k6
    profiles: ["perf"]

Les services sans clé profiles démarrent toujours ; ceux qui ont un profil ne démarrent que sur demande, avec --profile dev ou COMPOSE_PROFILES=dev. Un seul fichier fait ainsi foi, au lieu de voir fleurir compose.dev.yaml, compose.demo.yaml et compagnie.

Enfin, les ancres YAML et la clé extends éliminent les répétitions entre services similaires :

x-service-defaults: &defaults
  restart: unless-stopped
  logging:
    driver: json-file
    options: { max-size: "10m", max-file: "3" }

services:
  api:    { <<: *defaults, image: acme/shop-api:dev }
  worker: { <<: *defaults, image: acme/shop-worker:dev }

Les options de logging ci-dessus valent la peine d'être reprises : le driver json-file par défaut n'a aucune limite de taille, et un conteneur bavard sur un hôte qui tourne longtemps finira par remplir le disque de logs.

Compose dans un job de CI

Compose est un excellent moyen de monter les dépendances des tests d'intégration, à condition de régler deux problèmes : les builds qui tournent en parallèle sur le même agent, et l'état résiduel.

Isolez chaque exécution avec un nom de projet unique, et rendez le nettoyage inconditionnel :

export COMPOSE_PROJECT_NAME="ci-${BUILD_NUMBER}"
trap 'docker compose down -v --remove-orphans' EXIT

docker compose up -d --wait --wait-timeout 120
docker compose run --rm tests

--wait bloque jusqu'à ce que chaque service doté d'un healthcheck soit déclaré sain, et renvoie un code non nul si l'un d'eux ne l'est pas, ce qui remplace les boucles de sleep qui encombraient autrefois les scripts de CI. run --rm démarre un conteneur ponctuel à partir d'une définition de service, s'y attache et renvoie son code de sortie — le résultat du job de CI est donc celui de la suite de tests.

Pour une suite définie comme un service, --abort-on-container-exit combiné à --exit-code-from obtient le même effet pour toute la stack :

docker compose up --abort-on-container-exit --exit-code-from tests

En CI, évitez complètement les ports publiés sur l'hôte : deux builds concurrents qui réservent tous deux le 8080 entreront en collision. Laissez plutôt le conteneur de test joindre le service par son nom sur le réseau Compose. Le guide sur les pipelines Jenkins montre comment publier ensuite les rapports produits.

Demandez-vous aussi si vous avez vraiment besoin de Compose dans le processus de test lui-même — pour les projets JVM, Testcontainers démarre les mêmes images par programmation depuis le code de test, ce qui supprime la coordination entre un script shell et une suite de tests.

Diagnostiquer une stack qui refuse de démarrer

Une courte séquence résout la plupart des problèmes :

  1. Valider la configuration fusionnée

    docker compose config affiche le fichier entièrement résolu, variables substituées et overrides fusionnés. La plupart des signalements du type « ma variable est vide » s'arrêtent là.
  2. Regarder les statuts et les codes de sortie

    docker compose ps -a montre quels services se sont arrêtés et avec quel code. 137 signifie que le conteneur a été tué, généralement faute de mémoire ; 1 signifie généralement que le processus n'a pas réussi à démarrer.
  3. Lire les logs du service en échec

    docker compose logs --tail=100 db. Les images de bases de données affichent des messages clairs à propos d'un répertoire de données initialisé avec d'autres identifiants — symptôme classique d'un volume nommé périmé.
  4. Tester la connectivité de l'intérieur

    docker compose exec api sh -c 'getent hosts db; nc -zv db 5432' distingue un problème de DNS d'un problème d'écoute et d'un problème de configuration applicative.
  5. Tout recréer de zéro

    docker compose down -v && docker compose up --build. Si cela règle le problème, c'était un état périmé, et il vaut la peine de savoir quel volume le contenait.

Les erreurs classiques des équipes

  • Croire que depends_on attend que le service soit prêt. Ajoutez des healthchecks et la condition de la forme longue.
  • Publier tous les ports sur 0.0.0.0. Liez les bases de développement à 127.0.0.1 et ne publiez que ce dont un navigateur a besoin.
  • Modifier des fichiers dans un conteneur en cours d'exécution. La modification disparaît à la recréation. Changez l'image ou montez le fichier en bind mount.
  • Garder des secrets dans compose.yaml. Le fichier est commité. Utilisez .env (ignoré par git) ou les secrets Docker, et ne réutilisez jamais les credentials de production en local.
  • Construire les images implicitement en production. Une stack de production doit référencer un tag immuable construit par la CI, pas un build: à partir du code qui traîne sur le serveur.
  • Laisser latest dans le fichier. Figez postgres:16-alpine, pas postgres. Un saut de version majeure lors d'un docker compose pull ne migrera pas vos données à votre place.
  • Ne jamais faire le ménage. docker system prune et docker volume ls méritent une place dans votre routine ; les volumes orphelins issus d'anciens noms de projet s'accumulent en silence.

Points clés à retenir

  • Compose v2 s'exécute via docker compose, préfère compose.yaml et ne veut plus de clé version: ; définissez name: pour que l'identité du projet ne dépende pas du nom du répertoire.
  • Chaque service rejoint un réseau de projet doté d'un DNS intégré : les conteneurs s'adressent les uns aux autres par nom de service et port du conteneur ; ports ne sert qu'à l'accès depuis l'hôte et expose ne fait rien.
  • Segmentez avec un second réseau et internal: true quand vous voulez une base de données réellement inaccessible depuis la façade.
  • depends_on seul ordonne le démarrage ; les healthchecks combinés à condition: service_healthy ou service_completed_successfully lui donnent enfin le sens de « prêt » — et les applications doivent quand même retenter.
  • Volumes nommés pour les données, bind mounts pour le code et la configuration (avec :ro dès que possible), tmpfs pour l'espace temporaire ; sauvegardez les bases avec leurs propres outils de dump.
  • Superposez la configuration avec l'interpolation .env, env_file, les fichiers d'override et les profils plutôt que de copier tout le fichier pour chaque environnement.
  • En CI, donnez à chaque exécution son propre nom de projet, utilisez --wait et run --rm, évitez les liaisons de ports sur l'hôte, et nettoyez avec down -v dans un trap.

Sources et lectures complémentaires

Questions fréquentes

Pourquoi mon application n'arrive-t-elle pas à se connecter à la base sur localhost:5432 ?
Dans un conteneur, localhost désigne le namespace réseau de ce conteneur, donc l'application elle-même. Les autres services du même réseau Compose se joignent par leur nom de service et par le port sur lequel le processus écoute dans son conteneur — db:5432 —, quoi que vous ayez publié sur l'hôte. Les ports publiés servent à l'accès depuis votre poste, pas au trafic entre conteneurs.
depends_on suffit-il pour que la base soit prête avant le démarrage de l'application ?
Non. Sous sa forme simple, depends_on ne fait qu'ordonner la création des conteneurs ; le processus de la base peut encore être en cours d'initialisation. Ajoutez un healthcheck au service de base de données et utilisez depends_on avec la condition service_healthy pour que Compose attende que le test passe. Même dans ce cas, les applications doivent retenter leur première connexion, car les conteneurs redémarrent et les dépendances disparaissent aussi en cours d'exécution.
Quelle différence entre un volume nommé et un bind mount ?
Un volume nommé est un stockage créé et géré par Docker, portable d'une machine à l'autre et facile à sauvegarder ou à passer d'un conteneur à un autre. Un bind mount monte un chemin précis de l'hôte dans le conteneur : son contenu et ses permissions viennent de votre système de fichiers. Utilisez des volumes nommés pour les données, comme les fichiers d'une base, et des bind mounts pour le code source que vous voulez modifier à chaud en développement.
Peut-on utiliser Compose en production ?
Pour un seul hôte, une poignée de services et une brève interruption acceptable pendant les mises à jour, oui — beaucoup de petits déploiements fonctionnent exactement ainsi. Ce que vous n'avez pas, c'est l'ordonnancement sur plusieurs machines, les rolling updates avec rollback piloté par la santé des services, l'autoscaling horizontal ni l'auto-réparation en cas de panne d'un nœud. Quand ces besoins apparaissent, les mêmes définitions de services se transposent assez directement en objets Kubernetes.