---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# 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.

```yaml
# 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.

```{admonition} Limites des anchors YAML
:class: note
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.

```yaml
# 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
```

```yaml
# 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
```

```bash
# 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.

```yaml
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
```

```{admonition} deploy: avec Docker Compose standalone
:class: important
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.

```yaml
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
```

```bash
# 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.

```yaml
# 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
```

```bash
# 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

```yaml
# 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.

```yaml
# 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
```

```bash
# 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.

```bash
# 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.

```{admonition} Isolation des réseaux par projet
:class: tip
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

```{admonition} Kompose — migration automatique
:class: tip
`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

```{code-cell} python3
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()
```

## Radar chart : Compose vs Kubernetes vs Swarm

```{code-cell} python3
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()
```

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

```{code-cell} python3
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()
```

## 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.
