Chapitre 14 — Docker Compose en production#

Ce chapitre approfondit Docker Compose au-delà des bases — services, réseaux, volumes, healthchecks, depends_on — déjà couverts dans le chapitre Docker. L’objectif est de maîtriser les fonctionnalités avancées qui rendent Compose utilisable sur des projets complexes : réduction de la duplication YAML, gestion fine du cycle de vie des conteneurs, intégration CI/CD et transition vers Kubernetes.

YAML anchors et merges pour un Compose DRY#

Les fichiers compose.yml sur des projets réels deviennent rapidement répétitifs : chaque service partage des variables d’environnement, des labels de logging, des réseaux. Les YAML anchors permettent de factoriser cette configuration.

# compose.yml — configuration commune factorisée avec anchors
x-service-base: &service-base
  restart: unless-stopped
  networks:
    - backend
  logging:
    driver: "json-file"
    options:
      max-size: "10m"
      max-file: "3"
  labels:
    - "prometheus.io/scrape=true"

x-env-common: &env-common
  TZ: Europe/Paris
  LOG_LEVEL: info
  OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317

services:
  api:
    <<: *service-base
    image: myapp/api:${VERSION:-latest}
    ports:
      - "8080:8080"
    environment:
      <<: *env-common
      DATABASE_URL: postgresql://db:5432/app

  worker:
    <<: *service-base
    image: myapp/worker:${VERSION:-latest}
    environment:
      <<: *env-common
      QUEUE_URL: redis://redis:6379/0
    deploy:
      replicas: 3

  scheduler:
    <<: *service-base
    image: myapp/scheduler:${VERSION:-latest}
    environment:
      <<: *env-common

&service-base déclare l’ancre, *service-base la référence, <<: fusionne le contenu dans la map courante. Les clés définies localement écrasent celles de l’ancre.

Limites des anchors YAML

Les anchors YAML sont une fonctionnalité du parseur YAML, pas de Docker Compose. Ils ne fonctionnent qu’au sein d’un même fichier. Pour partager la configuration entre fichiers, utiliser extends: (voir section suivante). De plus, docker compose config résout les anchors et affiche le YAML aplati — utile pour déboguer.

extends: entre fichiers Compose#

Le mot-clé extends: permet d’hériter la définition d’un service défini dans un autre fichier, sans le copier. C’est le mécanisme DRY inter-fichiers de Compose.

# compose.base.yml — définitions partagées entre tous les environnements
services:
  api:
    image: myapp/api:${VERSION:-latest}
    environment:
      LOG_LEVEL: info
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 10s
      timeout: 5s
      retries: 3
# compose.prod.yml — surcharge pour la production
services:
  api:
    extends:
      file: compose.base.yml
      service: api
    environment:
      LOG_LEVEL: warn
      DATABASE_URL: "${PROD_DATABASE_URL}"
    deploy:
      replicas: 4
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
# Lancer avec le fichier production
docker compose -f compose.prod.yml up -d

Section deploy: — ressources et politiques#

La section deploy: est activée nativement par Docker Compose (depuis Compose v2) pour configurer le comportement de déploiement.

services:
  api:
    image: myapp/api:latest
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: "0.5"
          memory: 256M
        reservations:
          cpus: "0.1"
          memory: 64M
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
        window: 120s
      update_config:
        parallelism: 1          # Mettre à jour 1 replica à la fois
        delay: 10s              # Délai entre chaque replica mis à jour
        failure_action: rollback
        monitor: 30s            # Durée d'observation avant de valider la mise à jour
        max_failure_ratio: 0.1
      rollback_config:
        parallelism: 0          # Rollback de tous les replicas simultanément
        delay: 0s
        failure_action: pause
        monitor: 30s

deploy: avec Docker Compose standalone

Certaines options deploy: (comme replicas) ne sont pleinement honorées qu’avec Docker Swarm. Avec docker compose up, les resources (limits/reservations) sont appliquées, mais replicas crée plusieurs conteneurs préfixés par le nom du projet. Ce comportement est suffisant pour le développement local et les tests d’intégration CI.

Compose Watch — synchronisation en développement#

watch: (stable depuis Compose 2.22) remplace les volumes bind-mount pour le rechargement à chaud. Il surveille les fichiers locaux et synchronise les changements dans le conteneur, ou reconstruit l’image selon les règles définies.

services:
  frontend:
    build: ./frontend
    ports:
      - "3000:3000"
    develop:
      watch:
        - action: sync
          path: ./frontend/src
          target: /app/src
          ignore:
            - node_modules/
        - action: rebuild
          path: ./frontend/package.json
        - action: rebuild
          path: ./frontend/Dockerfile

  backend:
    build: ./backend
    develop:
      watch:
        - action: sync+restart
          path: ./backend/src
          target: /app/src
# Lancer avec le mode watch actif
docker compose watch

# Ou via up (depuis Compose 2.26)
docker compose up --watch

Les trois actions disponibles :

  • sync : copie les fichiers modifiés dans le conteneur sans redémarrer (rechargement à chaud via le process applicatif)

  • sync+restart : synchronise puis redémarre le conteneur

  • rebuild : reconstruit l’image et recrée le conteneur (pour les changements de dépendances)

Compose et tests d’intégration CI#

Compose est particulièrement utile en CI pour démarrer un environnement complet et exécuter des tests d’intégration. Les options dédiées permettent d’automatiser proprement ce workflow.

# compose.test.yml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test -d testdb"]
      interval: 5s
      timeout: 3s
      retries: 10

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  tests:
    build: .
    command: pytest tests/integration/ -v --tb=short
    environment:
      DATABASE_URL: postgresql://test:test@db:5432/testdb
      REDIS_URL: redis://redis:6379/0
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
# En CI (GitHub Actions, GitLab CI, etc.)

# Démarrer tous les services et attendre qu'ils soient healthy
docker compose -f compose.test.yml up --wait

# Lancer les tests et récupérer le code de sortie du service "tests"
docker compose -f compose.test.yml run --exit-code-from tests tests

# Alternative : up avec exit-code-from (démarre + attend + retourne le code)
docker compose -f compose.test.yml up --abort-on-container-exit --exit-code-from tests

# Cleanup systématique (important en CI pour libérer les ressources)
docker compose -f compose.test.yml down --volumes --remove-orphans

--exit-code-from SERVICE fait retourner à compose up le code de sortie du service spécifié — essentiel pour que le CI détecte les échecs de tests.

Secrets Compose vs secrets Kubernetes#

# Secrets dans compose.yml — deux sources possibles
services:
  api:
    image: myapp/api
    secrets:
      - db_password
      - api_key

secrets:
  db_password:
    # Source 1 : fichier local (développement)
    file: ./secrets/db_password.txt

  api_key:
    # Source 2 : variable d'environnement (CI/CD)
    environment: API_KEY_SECRET

Les secrets Compose sont montés en lecture seule dans /run/secrets/<secret_name> dans le conteneur. C’est plus sécurisé que les variables d’environnement (non visibles dans docker inspect), mais limité comparé aux secrets Kubernetes.

Comparaison avec les secrets Kubernetes :

  • Kubernetes Secrets : chiffrés au repos (avec KMS), RBAC granulaire, rotation sans redéploiement via external-secrets-operator

  • Compose secrets : simples et portables, mais pas de chiffrement natif, pas de rotation automatique

  • Pour la production sérieuse, utiliser Vault, AWS Secrets Manager ou Kubernetes External Secrets même avec Compose

Override files et multi-environnements#

Le mécanisme d’override files permet de maintenir une base commune et de surcharger uniquement ce qui diffère par environnement.

# compose.yml — base commune
services:
  api:
    image: myapp/api:${VERSION}
    environment:
      LOG_LEVEL: info

# compose.override.yml — chargé AUTOMATIQUEMENT en développement local
# (Docker Compose fusionne automatiquement ce fichier si présent)
services:
  api:
    build: .            # Construire localement au lieu de tirer l'image
    volumes:
      - ./src:/app/src  # Code source monté pour le rechargement à chaud
    environment:
      LOG_LEVEL: debug
    ports:
      - "8080:8080"

# compose.staging.yml — staging (chargé explicitement)
services:
  api:
    environment:
      LOG_LEVEL: warn
      DATABASE_URL: "${STAGING_DATABASE_URL}"
    deploy:
      replicas: 2
# Développement local : compose.yml + compose.override.yml (automatique)
docker compose up

# Staging : compose.yml + compose.staging.yml (explicite)
docker compose -f compose.yml -f compose.staging.yml up -d

# Production : compose.yml + compose.prod.yml
docker compose -f compose.yml -f compose.prod.yml up -d

Multi-projet et namespaces Compose#

COMPOSE_PROJECT_NAME isole les ressources (conteneurs, réseaux, volumes) de différents projets Compose sur un même hôte.

# Définir le nom de projet explicitement
COMPOSE_PROJECT_NAME=myapp-prod docker compose up -d
COMPOSE_PROJECT_NAME=myapp-staging docker compose up -d

# Ou via le flag --project-name
docker compose --project-name myapp-prod up -d

# Ou dans le fichier .env
echo "COMPOSE_PROJECT_NAME=myapp" >> .env

Les conteneurs, réseaux et volumes sont préfixés par le nom de projet (myapp-prod_api_1, myapp-staging_api_1), ce qui permet de faire coexister plusieurs instances du même compose.yml sans collision.

Isolation des réseaux par projet

Deux projets Compose distincts créent des réseaux Docker séparés. Les services d’un projet ne peuvent pas communiquer directement avec ceux d’un autre projet sans configuration explicite de réseaux partagés (external: true). C’est une bonne garantie d’isolation pour les environnements de test en parallèle.

Limites de Compose vs Kubernetes#

Compose est excellent pour le développement local et les déploiements simples sur un seul hôte. Ses limites apparaissent dès qu’on vise une infrastructure de production robuste.

Critère

Docker Compose

Kubernetes

Multi-hôte natif

Non (Swarm pour ça)

Oui

Autoscaling

Manuel

HPA, KEDA

Self-healing avancé

Basique (restart_policy)

Liveness/readiness probes, PodDisruptionBudget

Rolling update sans downtime

Limité

Natif

Gestion des secrets

Basique

External Secrets, Vault

Service discovery

DNS Docker

kube-dns + Service

Observabilité

Via intégrations manuelles

Prometheus Operator, Grafana

Migrer vers Kubernetes quand :

  • Le service doit tourner sur plusieurs hôtes pour la haute disponibilité

  • L’autoscaling automatique est requis (trafic variable)

  • L’équipe dépasse 5-10 développeurs avec des déploiements fréquents

  • Des exigences de compliance nécessitent une gestion des secrets avancée

Kompose — migration automatique

kompose convert traduit automatiquement un compose.yml en manifestes Kubernetes (Deployments, Services, PVCs). Le résultat nécessite des ajustements manuels mais accélère la migration initiale.

Graphe de dépendances entre services#

import networkx as nx
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

# Graphe de dépendances d'un Compose complexe (e-commerce)
G = nx.DiGraph()

services = {
    "nginx":      {"color": "#4C72B0", "layer": 0},
    "api":        {"color": "#DD8452", "layer": 1},
    "worker":     {"color": "#DD8452", "layer": 1},
    "scheduler":  {"color": "#DD8452", "layer": 1},
    "postgres":   {"color": "#55A868", "layer": 2},
    "redis":      {"color": "#55A868", "layer": 2},
    "minio":      {"color": "#55A868", "layer": 2},
    "mailhog":    {"color": "#C44E52", "layer": 2},
    "prometheus": {"color": "#8172B2", "layer": 3},
    "grafana":    {"color": "#8172B2", "layer": 3},
}

# depends_on (condition: service_healthy)
edges = [
    ("nginx", "api"),
    ("api", "postgres"),
    ("api", "redis"),
    ("api", "minio"),
    ("api", "mailhog"),
    ("worker", "postgres"),
    ("worker", "redis"),
    ("scheduler", "postgres"),
    ("scheduler", "redis"),
    ("prometheus", "api"),
    ("prometheus", "postgres"),
    ("grafana", "prometheus"),
]

G.add_nodes_from(services.keys())
G.add_edges_from(edges)

# Disposition hiérarchique par couche
pos = {}
layer_nodes = {}
for svc, attrs in services.items():
    layer = attrs["layer"]
    layer_nodes.setdefault(layer, []).append(svc)

for layer, nodes in layer_nodes.items():
    n = len(nodes)
    for i, node in enumerate(nodes):
        pos[node] = (i - (n - 1) / 2, -layer)

node_colors = [services[n]["color"] for n in G.nodes()]

fig, ax = plt.subplots(figsize=(13, 7))

nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=1800, ax=ax, alpha=0.9)
nx.draw_networkx_labels(G, pos, font_size=9, font_color="white", font_weight="bold", ax=ax)
nx.draw_networkx_edges(G, pos, edge_color="#555555", arrows=True,
                       arrowsize=18, width=1.5, ax=ax,
                       connectionstyle="arc3,rad=0.05",
                       node_size=1800)

legend_handles = [
    mpatches.Patch(color="#4C72B0", label="Reverse proxy"),
    mpatches.Patch(color="#DD8452", label="Services applicatifs"),
    mpatches.Patch(color="#55A868", label="Stockage / Infra"),
    mpatches.Patch(color="#C44E52", label="Outils dev"),
    mpatches.Patch(color="#8172B2", label="Observabilité"),
]
ax.legend(handles=legend_handles, loc="lower right", fontsize=9)
ax.set_title("Graphe de dépendances entre services Compose\n(flèches = depends_on)", fontsize=12, fontweight="bold")
ax.axis("off")

plt.show()
_images/a3ef474f72a03b5877eb6da9a00618198533e0ad1ddec2f6ac86d9c766a53dac.png

Radar chart : Compose vs Kubernetes vs Swarm#

import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

categories = [
    "Simplicité\nd'utilisation", "Fonctionnalités\nprod", "Multi-hôte",
    "Autoscaling", "Ecosystème\noutils", "Courbe\nd'apprentissage\n(inverse)"
]
N = len(categories)

# Scores /10
compose  = [10, 5,  2,  2,  7, 10]
swarm    = [ 8, 6,  8,  5,  5,  8]
kube     = [ 4, 10, 10, 10, 10,  3]

def close(lst):
    return lst + [lst[0]]

angles = [n / float(N) * 2 * np.pi for n in range(N)]
angles_p = angles + [angles[0]]

fig, ax = plt.subplots(figsize=(8, 8), subplot_kw=dict(polar=True))

ax.plot(angles_p, close(compose), "o-", linewidth=2, label="Docker Compose", markersize=5)
ax.fill(angles_p, close(compose), alpha=0.15)

ax.plot(angles_p, close(swarm),   "s-", linewidth=2, label="Docker Swarm",   markersize=5)
ax.fill(angles_p, close(swarm),   alpha=0.15)

ax.plot(angles_p, close(kube),    "^-", linewidth=2, label="Kubernetes",     markersize=5)
ax.fill(angles_p, close(kube),    alpha=0.15)

ax.set_xticks(angles)
ax.set_xticklabels(categories, size=9)
ax.set_ylim(0, 10)
ax.set_yticks([2, 4, 6, 8, 10])
ax.set_yticklabels(["2", "4", "6", "8", "10"], size=8)
ax.set_title("Compose vs Swarm vs Kubernetes\n(prod-readiness et complexité)", size=12, fontweight="bold", pad=20)
ax.legend(loc="upper right", bbox_to_anchor=(1.35, 1.15), fontsize=11)

plt.show()
_images/026333cd5cccdc513a1e49aca830773e6e8bee8fbd5e1023e9dd2ef4f6af4171.png

Simulation de l’ordre de démarrage avec healthchecks#

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import time as time_mod

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

# Machine à états simulant le démarrage de services avec healthchecks
# États : WAITING → STARTING → HEALTHY / UNHEALTHY

import numpy as np

np.random.seed(7)

services_def = [
    {"name": "postgres",   "start_t": 0,  "healthy_t": 8,  "depends": []},
    {"name": "redis",      "start_t": 0,  "healthy_t": 3,  "depends": []},
    {"name": "api",        "start_t": 8,  "healthy_t": 14, "depends": ["postgres", "redis"]},
    {"name": "worker",     "start_t": 8,  "healthy_t": 12, "depends": ["postgres", "redis"]},
    {"name": "nginx",      "start_t": 14, "healthy_t": 16, "depends": ["api"]},
    {"name": "prometheus", "start_t": 14, "healthy_t": 17, "depends": ["api"]},
    {"name": "grafana",    "start_t": 17, "healthy_t": 19, "depends": ["prometheus"]},
]

# Palette de couleurs
palette = sns.color_palette("muted", len(services_def))

fig, ax = plt.subplots(figsize=(13, 6))

y_labels = []
for i, svc in enumerate(services_def):
    name = svc["name"]
    s = svc["start_t"]
    h = svc["healthy_t"]
    y_labels.append(name)
    color = palette[i]

    # Phase WAITING (gris) avant démarrage
    if s > 0:
        ax.barh(i, s, left=0, height=0.5, color="#cccccc", alpha=0.8)
        ax.text(s / 2, i, "waiting", ha="center", va="center", fontsize=7, color="#555555")

    # Phase STARTING (orange) : entre start_t et healthy_t
    ax.barh(i, h - s, left=s, height=0.5, color="#DD8452", alpha=0.85)
    ax.text(s + (h - s) / 2, i, "starting", ha="center", va="center", fontsize=7, color="white")

    # Phase HEALTHY (vert) : après healthy_t
    ax.barh(i, 22 - h, left=h, height=0.5, color="#55A868", alpha=0.85)
    ax.text(h + (22 - h) / 2, i, "healthy", ha="center", va="center", fontsize=7, color="white")

    # Repère du moment healthy
    ax.axvline(x=h, color=color, linestyle=":", alpha=0.4, linewidth=1)

    # Dépendances
    if svc["depends"]:
        deps_str = "dépend de : " + ", ".join(svc["depends"])
        ax.text(22.2, i, deps_str, va="center", fontsize=7, color="#333333")

ax.set_yticks(range(len(services_def)))
ax.set_yticklabels(y_labels)
ax.set_xlabel("Temps (secondes)")
ax.set_title("Ordre de démarrage avec depends_on condition: service_healthy", fontsize=12, fontweight="bold")
ax.set_xlim(0, 30)

legend_handles = [
    mpatches.Patch(color="#cccccc", label="En attente (waiting)"),
    mpatches.Patch(color="#DD8452", label="Démarrage (starting)"),
    mpatches.Patch(color="#55A868", label="Prêt (healthy)"),
]
ax.legend(handles=legend_handles, loc="lower right", fontsize=9)

plt.show()
_images/f74c555a8059ec55a8da1c60068971c9f6142420be7367202de0c62fd24ce4f6.png

Résumé#

  1. Les YAML anchors (&anchor, <<: *merge) éliminent la duplication intra-fichier ; extends: partage la configuration entre fichiers Compose distincts.

  2. La section deploy: contrôle le comportement de déploiement : nombre de replicas, limites CPU/mémoire, politique de redémarrage, stratégie de mise à jour et de rollback.

  3. Compose Watch (develop.watch:) remplace les volumes bind-mount pour le développement local en synchronisant les fichiers sans redémarrer le conteneur — ou en reconstruisant l’image si les dépendances changent.

  4. En CI, --wait attend que tous les services soient healthy avant de lancer les tests ; --exit-code-from propage le code de sortie du service de test au pipeline.

  5. Les override files (compose.override.yml chargé automatiquement, -f compose.staging.yml chargé explicitement) permettent de gérer plusieurs environnements avec un seul socle de configuration.

  6. COMPOSE_PROJECT_NAME isole les ressources de projets distincts sur un même hôte, évitant les collisions de noms lors de tests parallèles en CI.

  7. Les secrets Compose (/run/secrets/) sont plus sûrs que les variables d’environnement, mais n’ont pas le chiffrement, le RBAC et la rotation automatique des secrets Kubernetes.

  8. La migration vers Kubernetes est justifiée quand apparaissent des besoins multi-hôtes, d’autoscaling, ou de haute disponibilité que Compose ne peut pas satisfaire seul.

  9. kompose convert automatise la traduction initiale d’un compose.yml vers des manifestes Kubernetes, réduisant le coût de migration.

  10. L’ordre de démarrage avec depends_on condition: service_healthy garantit que chaque service attend réellement que ses dépendances soient opérationnelles — critère indispensable pour les tests d’intégration fiables.