Docker : le modèle Compose qui prévient 90% des pannes en production

Cet article vous a aidé ?

Si vous avez déjà vu une pile Compose « démarrer » tandis que votre application réelle reste indisponible, vous connaissez déjà le sale secret :
le démarrage des conteneurs n’est pas la même chose que la disponibilité des services.
En production, cet écart est l’endroit où naissent les pages d’alerte.

Les défaillances sont généralement banales : une base de données qui a besoin de 12 secondes de plus, une migration exécutée deux fois, un volume obsolète, un disque de logs qui se remplit,
une optimisation « utile » qui a silencieusement supprimé des garde-fous. Compose n’est pas le coupable. Ce sont les modèles par défaut.

Le modèle : Compose comme graphe de services avec verrous et observabilité

Le modèle Compose qui prévient la plupart des pannes en production n’est pas une ligne unique dans docker-compose.yml. C’est une posture :
traitez votre stack comme un graphe de services avec une disponibilité explicite, un démarrage contrôlé, des ressources limitées, un état durable et
un comportement de défaillance prévisible.

Voici l’idée centrale :

  1. Chaque service critique a un healthcheck qui correspond à la vraie disponibilité (pas « le processus existe »).
  2. Les dépendances sont verrouillées sur la santé, pas sur la création du conteneur.
  3. Les jobs one-shot sont explicites (migrations, vérifications de schéma, bootstrap) et idempotents.
  4. L’état est isolé dans des volumes nommés (ou des bind mounts bien gérés) avec des procédures de sauvegarde/restauration.
  5. La configuration est immuable par déploiement (env + fichiers) et les changements sont délibérés.
  6. La politique de redémarrage est un choix, pas un défaut. Se crasher à l’infini n’est pas de la « haute disponibilité ».
  7. Les logs sont bornés pour que le « mode debug » ne devienne pas « disque plein ».
  8. Des limites de ressources existent pour qu’un service ne puisse pas affamer l’hôte et emporter tout le reste.
  9. Vous avez une boucle de diagnostic rapide : trois commandes pour identifier le goulot en moins de deux minutes.

Le résultat pratique : moins de défaillances en cascade, moins de tempêtes de redémarrages, moins d’incidents « ça marche sur ma machine » et bien moins
de mystères à 3h du matin causés par des problèmes d’ordre invisibles.

Une citation à garder sur votre écran : Espérer n’est pas une stratégie. — James Greene (attribué couramment dans les cercles ops).
Si vous n’êtes pas sûr de l’exactitude, considérez-la comme une paraphrase et passez à autre chose ; l’idée tient.

Blague n°1 : La bonne chose avec « ça marche sur ma machine », c’est que c’est vrai. La mauvaise chose, c’est que votre machine n’est pas la production.

Ce que ce modèle n’est pas

  • Pas « transformer tout en Kubernetes ». Compose convient pour bon nombre de systèmes en production.
  • Pas « ajoutez juste depends_on ». Sans verrouillage par health, c’est du théâtre d’ordre.
  • Pas « restart: always ». C’est ainsi que vous transformez une mauvaise configuration en boucle infinie avec d’excellentes métriques d’uptime pour le runtime de conteneurs.

Pourquoi ça marche : vous réduisez l’incertitude

La plupart des pannes dans les déploiements Compose sont de l’incertitude déguisée en commodité :
« ça démarre probablement assez vite », « le réseau sera prêt », « la migration ne s’exécutera qu’une fois », « le fichier de log ne grossira pas »,
« le volume est comme la dernière fois », « cette variable d’environnement est définie quelque part ».
Ce modèle supprime le « probablement ». Il le remplace par des vérifications et des verrous que vous pouvez inspecter.

Faits intéressants et contexte historique

  • Compose a commencé comme Fig (ère 2013–2014), un outil développeur pour définir des applications multi-conteneurs ; l’endurcissement pour la production est venu plus tard via des patterns, pas des valeurs par défaut.
  • Les healthchecks Docker ont été introduits après que les opérateurs aient inventé des scripts « wait-for-it » ; la plateforme a fini par admettre que la readiness est un besoin de première classe.
  • depends_on ne signifie pas « prêt » par défaut ; historiquement, cela signifie « démarre ce conteneur avant celui-ci », ce qui est rarement la vraie exigence.
  • Les politiques de redémarrage ne sont pas des tentatives ; ce sont des « continuer d’essayer pour toujours ». Beaucoup de post-mortems incluent une tempête de redémarrages qui a masqué la première erreur utile.
  • Les conteneurs ne contiennent pas le noyau ; les voisins bruyants existent toujours. Sans limites CPU/mémoire, un service peut dégrader l’hôte et tout ce qu’il contient.
  • Les volumes locaux sont faciles, la portabilité ne l’est pas ; les volumes nommés sont portables en définition, mais le cycle de vie des données reste votre responsabilité.
  • Les drivers de log comptent ; json-file est pratique jusqu’à ce qu’une application bavarde transforme le disque en incident qui grossit lentement.
  • Compose n’est pas un ordonnanceur ; il ne redistribuera pas les charges entre nœuds ni ne gérera les pannes de nœuds comme un orchestrateur. Votre conception doit supposer un hôte unique sauf si vous faites autrement.
  • Les « init containers » existaient comme pattern bien avant que Kubernetes popularise le terme ; les bootstraps one-shot sont un besoin universel dans les systèmes distribués.

Pourquoi les stacks Compose échouent en production (les récidivistes)

1) L’ordre de démarrage est pris pour la disponibilité des dépendances

Votre conteneur API démarre. Il essaie de se connecter à Postgres. Postgres est « up » en termes Docker (le PID existe), mais il rejoue encore le WAL,
effectue une récupération, ou n’écoute simplement pas encore. L’API échoue, redémarre, échoue à nouveau. Vous avez maintenant une panne qui ressemble à un problème d’API,
mais qui est en réalité un problème de readiness.

C’est là que healthchecks + verrouillage rentrent dans leurs frais. Vous ne voulez pas que votre API soit la sonde de disponibilité de la base de données.
C’est mauvais pour l’uptime et pire pour les logs.

2) Les migrations sont traitées comme un effet secondaire au lieu d’un job

Un anti-pattern courant : l’entrypoint du conteneur applicatif exécute les migrations, puis démarre le serveur.
Dans un seul conteneur, sur un seul hôte, avec une seule réplique, ça peut aller.
Dans la vraie vie, l’app redémarre, la migration se relance, verrouille les tables, ou applique partiellement des modifications.

Faites des migrations un service one-shot dédié. Rendez-les idempotentes. Faites en sorte qu’elles bloquent le démarrage de l’app jusqu’à leur fin.
Votre futur vous enverra une lettre de remerciement. Probablement sous la forme de moins d’alertes.

3) Les volumes sont traités comme « un répertoire quelconque »

Les services stateful ne tombent pas poliment. Ils tombent en corrompant, en remplissant, ou en étant montés avec de mauvaises permissions.
Les volumes nommés aident car ils découplent le chemin des données de la mise en page aléatoire du système de fichiers, mais ils ne remplacent pas :
les sauvegardes, les restaurations, les contrôles de capacité et le contrôle des changements.

4) Les politiques de redémarrage masquent les vraies erreurs

restart: always est un instrument brutal. Il redémarrera fidèlement un conteneur avec une faute de frappe dans une variable d’env, un fichier secret manquant,
ou une migration échouante. Votre monitoring voit du flap. Vos logs deviennent un blender. Pendant ce temps, la cause racine défile toutes les trois secondes.

Utilisez restart: unless-stopped ou on-failure intentionnellement. Combinez avec des healthchecks pour que « en cours d’exécution » ne soit pas un mensonge.

5) Pas de limites de ressources, puis la mort surprise de l’hôte

Compose vous laisse volontiers un conteneur consommer toute la mémoire, déclencher l’OOM killer, et emporter des services non liés.
Ce n’est pas théorique. C’est le plus vieux tour du livre « pourquoi tout est mort ? ».

6) Les logs mangent le disque

Le driver de logs JSON par défaut peut croître sans limite. Si le disque de l’hôte se remplit, votre base de données peut cesser d’écrire,
votre application peut cesser de créer des fichiers temporaires, et Docker lui-même peut devenir instable.

La rotation des logs bornée n’est pas un gadget ; c’est une ceinture de sécurité.

7) Des choix réseau « pratiques » créent du couplage invisible

Publier chaque port sur l’hôte paraît pragmatique. C’est aussi comme ça que l’on se retrouve avec des conflits de ports, une exposition involontaire,
et un « debug rapide » qui devient une architecture permanente.

Utilisez des réseaux internes. Publiez uniquement ce que les humains ou systèmes en amont ont besoin d’atteindre. Gardez le trafic est-ouest à l’intérieur du réseau Compose.

Fichier Compose de référence (commenté, orienté production)

C’est un pattern, pas un texte sacré. Adaptez-le. L’important est l’interaction :
healthchecks, verrouillage, jobs explicites, logs bornés et volumes durables.

cr0x@server:~$ cat docker-compose.yml
version: "3.9"

x-logging: &default-logging
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "5"

networks:
  appnet:
    driver: bridge

volumes:
  pgdata:
  redisdata:

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/pg_password
    secrets:
      - pg_password
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks: [appnet]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb -h 127.0.0.1"]
      interval: 5s
      timeout: 3s
      retries: 20
      start_period: 10s
    restart: unless-stopped
    logging: *default-logging

  redis:
    image: redis:7
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redisdata:/data
    networks: [appnet]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 20
    restart: unless-stopped
    logging: *default-logging

  migrate:
    image: ghcr.io/example/app:1.9.3
    command: ["./app", "migrate", "up"]
    environment:
      DATABASE_URL_FILE: /run/secrets/db_url
    secrets:
      - db_url
    networks: [appnet]
    depends_on:
      postgres:
        condition: service_healthy
    restart: "no"
    logging: *default-logging

  api:
    image: ghcr.io/example/app:1.9.3
    environment:
      DATABASE_URL_FILE: /run/secrets/db_url
      REDIS_URL: redis://redis:6379/0
      PORT: "8080"
    secrets:
      - db_url
    networks: [appnet]
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
    ports:
      - "127.0.0.1:8080:8080"
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz | grep -q ok"]
      interval: 10s
      timeout: 3s
      retries: 10
      start_period: 10s
    restart: unless-stopped
    logging: *default-logging
    deploy:
      resources:
        limits:
          memory: 512M

secrets:
  pg_password:
    file: ./secrets/pg_password.txt
  db_url:
    file: ./secrets/db_url.txt

Ce qu’il faut piquer dans ce fichier

  • Les healthchecks correspondent à la vraie disponibilité : pg_isready, redis-cli ping, et un endpoint HTTP qui valide que l’app sert réellement.
  • Le verrouillage inclut « completed successfully » pour les migrations. Si les migrations échouent, l’API reste arrêtée, bruyante, avec une erreur actionnable.
  • Secrets via fichiers pour que les mots de passe n’apparaissent pas dans la sortie de docker inspect ou l’historique du shell.
  • Ports liés à localhost pour la sécurité. Mettez un reverse proxy devant si vous avez besoin d’accès externe.
  • Logs bornés via rotation. Cela prévient les incidents « le log debug a mangé le disque ».
  • Volumes nommés pour les services stateful. Ce n’est pas magique, mais c’est explicite.

Ce que vous devriez personnaliser immédiatement

  • Limites mémoire/CPU en fonction de la taille de votre hôte et du comportement des services.
  • Logique des healthchecks pour correspondre au vrai état « ready » de votre application (par ex., connectivité BD + migrations appliquées).
  • Plan de sauvegarde et procédure de restauration pour les volumes. Si vous ne pouvez pas restaurer, vous n’avez pas de sauvegardes ; vous avez des vœux coûteux.

Tâches pratiques : commandes, sorties et la décision que vous prenez

Ce ne sont pas des diagnostics « sympas ». Ce sont les actions que vous exécutez pendant que l’horloge d’incident tourne, et les actions que vous faites les mardis calmes
pour prévenir l’incident en premier lieu.

Tâche 1 : Voir ce que Compose pense être en cours

cr0x@server:~$ docker compose ps
NAME                IMAGE                         COMMAND                  SERVICE     STATUS                    PORTS
stack-postgres-1     postgres:16                   "docker-entrypoint.s…"   postgres    Up 2 minutes (healthy)    5432/tcp
stack-redis-1        redis:7                       "docker-entrypoint.s…"   redis       Up 2 minutes (healthy)    6379/tcp
stack-migrate-1      ghcr.io/example/app:1.9.3      "./app migrate up"       migrate     Exited (0) 90 seconds ago
stack-api-1          ghcr.io/example/app:1.9.3      "./app server"           api         Up 2 minutes (healthy)    127.0.0.1:8080->8080/tcp

Ce que cela signifie : « Up » ne suffit pas ; vous voulez (healthy) pour les services longue durée et Exited (0) pour les jobs one-shot comme les migrations.

Décision : Si l’API est Up mais pas (healthy), déboguez la readiness (logique du healthcheck, dépendances, temps de démarrage). Si migrate est non zéro, arrêtez-vous et corrigez les migrations en premier.

Tâche 2 : Identifier la première erreur dans les logs (pas la plus bruyante)

cr0x@server:~$ docker compose logs --no-color --timestamps --tail=200 api
2026-02-04T01:18:42Z api  | ERROR: could not connect to postgres: connection refused
2026-02-04T01:18:45Z api  | INFO: retrying in 3s
2026-02-04T01:18:48Z api  | ERROR: migration state not found

Ce que cela signifie : Vous voyez des symptômes. La première erreur est « connection refused », ce qui implique que Postgres n’écoutait pas encore ou que le réseau/DNS a échoué.
Le « migration state not found » plus tard peut être une conséquence.

Décision : Vérifiez la santé et les logs de Postgres ensuite ; ne vous focalisez pas uniquement sur l’app.

Tâche 3 : Inspecter l’état de santé du conteneur en détail

cr0x@server:~$ docker inspect --format '{{json .State.Health}}' stack-postgres-1
{"Status":"healthy","FailingStreak":0,"Log":[{"Start":"2026-02-04T01:18:21.112Z","End":"2026-02-04T01:18:21.189Z","ExitCode":0,"Output":"/var/run/postgresql:5432 - accepting connections\n"}]}

Ce que cela signifie : Le healthcheck réussit et reporte « accepting connections ». Bon signe.

Décision : Si l’app ne peut toujours pas se connecter, investiguez le réseau (DNS, attachement réseau) ou une chaîne de connexion erronée.

Tâche 4 : Valider la découverte de service depuis l’intérieur du réseau

cr0x@server:~$ docker exec -it stack-api-1 getent hosts postgres
172.22.0.2   postgres

Ce que cela signifie : DNS résout postgres en une IP de conteneur sur le réseau Compose.

Décision : Si cela échoue, vous avez probablement une mauvaise configuration réseau (service non sur le même réseau, faute de frappe dans le réseau personnalisé, ou utilisation incorrecte du mode host).

Tâche 5 : Tester la connectivité TCP vers la dépendance depuis le conteneur app

cr0x@server:~$ docker exec -it stack-api-1 bash -lc 'nc -vz postgres 5432'
Connection to postgres (172.22.0.2) 5432 port [tcp/postgresql] succeeded!

Ce que cela signifie : Le chemin réseau est ouvert. Si l’app échoue toujours, il s’agit probablement d’identifiants, du mode SSL, du nom de la BD, ou des paramètres de connexion.

Décision : Vérifiez le contenu du fichier secret et son parsing (avec précaution), et consultez les logs d’auth Postgres.

Tâche 6 : Confirmer quelle configuration le conteneur a réellement reçue

cr0x@server:~$ docker exec -it stack-api-1 bash -lc 'ls -l /run/secrets && sed -n "1p" /run/secrets/db_url'
total 4
-r--r----- 1 root root 74 Feb  4 01:17 db_url
postgres://app:REDACTED@postgres:5432/appdb?sslmode=disable

Ce que cela signifie : Le secret existe, les permissions semblent raisonnables, et l’URL cible postgres.

Décision : Si le fichier manque ou est vide, corrigez le montage du secret et le processus de déploiement. Si l’URL pointe vers localhost, c’est votre panne.

Tâche 7 : Vérifier les logs Postgres pour des problèmes d’auth ou de recovery

cr0x@server:~$ docker compose logs --tail=120 postgres
postgres  | LOG:  database system is ready to accept connections
postgres  | FATAL:  password authentication failed for user "app"

Ce que cela signifie : Postgres est up ; les identifiants sont mauvais.

Décision : Faites tourner/corrigez le secret de mot de passe, puis redémarrez les services affectés. Ne « redémarrez tout » sans corriger la cause racine.

Tâche 8 : Repérer rapidement les boucles de redémarrage

cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}'
NAMES              STATUS                          RUNNING FOR
stack-api-1         Restarting (1) 2 seconds ago    3 minutes
stack-postgres-1    Up 5 minutes (healthy)          5 minutes
stack-redis-1       Up 5 minutes (healthy)          5 minutes

Ce que cela signifie : L’API flappe ; vos logs peuvent être tronqués entre les redémarrages.

Décision : Désactivez temporairement le redémarrage pour le service en échec ou scalez-le à zéro, capturez les logs, corrigez, puis réactivez. Les boucles de redémarrage gaspillent du temps et cachent la première erreur.

Tâche 9 : Obtenir le code de sortie et la dernière erreur d’un conteneur planté

cr0x@server:~$ docker inspect --format '{{.State.ExitCode}} {{.State.Error}}' stack-api-1
1

Ce que cela signifie : Le code de sortie 1 est générique ; vous devez utiliser les logs et la sortie de l’app pour cibler l’échec.

Décision : Si le code de sortie est toujours le même, il s’agit probablement d’une mauvaise configuration déterministe (secrets, env, migration) plutôt que d’un problème infra transitoire.

Tâche 10 : Vérifier la pression disque de l’hôte avant toute astuce

cr0x@server:~$ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/nvme0n1p2  200G  192G  8.0G  97% /

Ce que cela signifie : 97% utilisé. Vous êtes en zone de danger. Les bases de données et Docker se comportent mal quand le disque est tendu.

Décision : Stoppez la croissance des logs (rotation, troncature avec précaution), nettoyez les images inutilisées, ou augmentez le disque. Ne redéployez pas en boucle et n’empirez pas la situation.

Tâche 11 : Vérifier la répartition d’utilisation disque de Docker

cr0x@server:~$ docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          28        6         35.2GB    23.4GB (66%)
Containers      14        4         1.1GB     920MB (83%)
Local Volumes   7         2         96.0GB    22.0GB (22%)
Build Cache     0         0         0B        0B

Ce que cela signifie : Les volumes sont volumineux (attendu pour les bases). Les images sont récupérables.

Décision : Purgez d’abord les images/conteneurs inutilisés ; ne touchez pas aux volumes sans plan de sauvegarde/restauration et compréhension claire de ce que vous supprimez.

Tâche 12 : Pruner en sécurité les images inutilisées (quand confirmé)

cr0x@server:~$ docker image prune -a
Deleted Images:
deleted: sha256:4c2c6b1f8b7c...
Total reclaimed space: 18.6GB

Ce que cela signifie : Vous avez récupéré de l’espace disque en supprimant des images inutilisées.

Décision : Si la pression disque persiste, adressez les logs et les volumes ensuite. Si vous êtes toujours proche du plein, il faut augmenter la capacité ou appliquer une politique de nettoyage.

Tâche 13 : Identifier qui mange la mémoire et déclenche l’OOM

cr0x@server:~$ docker stats --no-stream
CONTAINER ID   NAME              CPU %     MEM USAGE / LIMIT     MEM %     NET I/O           BLOCK I/O        PIDS
c0ffee12ab34   stack-api-1        180.23%   620MiB / 512MiB       121.1%    120MB / 95MB      2.1GB / 45MB    42
bada55aa9876   stack-postgres-1    35.01%    900MiB / 0B          0.0%      80MB / 110MB      8.2GB / 3.1GB   19

Ce que cela signifie : L’API a dépassé sa limite mémoire et peut être tuée ; Postgres n’a pas de limite (0B montre « illimité »).

Décision : Augmentez la mémoire de l’API si c’est légitime, corrigez les fuites mémoire, et définissez des limites raisonnables pour Postgres aussi — alignées sur la RAM de l’hôte et les besoins de cache.

Tâche 14 : Vérifier les OOM tués par le noyau sur l’hôte

cr0x@server:~$ dmesg -T | tail -n 12
[Mon Feb  4 01:22:13 2026] Out of memory: Killed process 21134 (app) total-vm:2104820kB, anon-rss:682312kB, file-rss:104kB, shmem-rss:0kB, UID:0 pgtables:1820kB oom_score_adj:0

Ce que cela signifie : Le noyau a tué votre processus. Ce n’est pas « le conteneur a crashé » ; c’est « l’hôte est à court de mémoire ».

Décision : Ajoutez de la mémoire, ajoutez des limites, réduisez la concurrence, ou corrigez l’app. Si vous ignorez les signaux OOM, votre prochain incident sera plus bruyant.

Tâche 15 : Vérifier que la rotation des logs est effectivement appliquée

cr0x@server:~$ docker inspect --format '{{.HostConfig.LogConfig.Type}} {{json .HostConfig.LogConfig.Config}}' stack-api-1
json-file {"max-file":"5","max-size":"10m"}

Ce que cela signifie : Le conteneur utilise des logs json-file bornés.

Décision : Si vous voyez une configuration vide, votre ancre de logging n’a pas été appliquée ou vous dépendez des valeurs par défaut du démon. Corrigez au niveau Compose.

Tâche 16 : Confirmer volumes et points de montage (le contrôle « où sont mes données ? »)

cr0x@server:~$ docker volume ls
DRIVER    VOLUME NAME
local     stack_pgdata
local     stack_redisdata
cr0x@server:~$ docker volume inspect stack_pgdata | sed -n '1,12p'
[
    {
        "CreatedAt": "2026-02-03T21:10:44Z",
        "Driver": "local",
        "Name": "stack_pgdata",
        "Mountpoint": "/var/lib/docker/volumes/stack_pgdata/_data",
        "Scope": "local"
    }
]

Ce que cela signifie : Les données vivent sous le point de montage des volumes Docker sur cet hôte.

Décision : Si vous attendiez des données ailleurs (comme un bind mount), réconciliez ça maintenant — avant qu’un remplacement d’hôte ne se transforme en suppression accidentelle de données.

Tâche 17 : Prendre une sauvegarde logique Postgres rapide et cohérente (pour BD petites/moyennes)

cr0x@server:~$ docker exec -t stack-postgres-1 pg_dump -U app -d appdb | gzip -c > /var/backups/appdb_$(date +%F).sql.gz

Ce que cela signifie : Vous avez créé un dump SQL gzippé sur l’hôte.

Décision : Si la BD est volumineuse, cela peut être trop lent pour la réponse à un incident. Planifiez des sauvegardes physiques ou la réplication ; ne découvrez pas ça pendant une panne.

Tâche 18 : Vérifier l’endpoint de santé depuis l’hôte

cr0x@server:~$ curl -fsS http://127.0.0.1:8080/healthz
ok

Ce que cela signifie : Le service est joignable depuis l’hôte et renvoie le corps attendu.

Décision : Si cela échoue mais que le conteneur est « healthy », votre healthcheck ment ou votre liaison de port est incorrecte.

Playbook de diagnostic rapide

Quand la production est en panne, vous n’avez pas besoin de sagesse. Vous avez besoin d’une boucle courte qui identifie le goulot rapidement et vous empêche
de « corriger » la mauvaise chose à grande vitesse.

Première étape : s’agit-il d’un problème de readiness de dépendance ou d’un bug applicatif ?

  1. Vérifiez l’état du graphe :
    cr0x@server:~$ docker compose ps
    NAME                IMAGE                         COMMAND               SERVICE   STATUS                     PORTS
    stack-api-1          ghcr.io/example/app:1.9.3      "./app server"        api       Up 1 minute (unhealthy)    127.0.0.1:8080->8080/tcp
    stack-postgres-1     postgres:16                   "docker-entrypoint"   postgres  Up 1 minute (healthy)      5432/tcp
    

    Décision : Si les dépendances sont saines mais que l’API est unhealthy, concentrez-vous sur la configuration de l’API et son propre chemin de readiness.

  2. Lisez les 200 dernières lignes du service en échec :
    cr0x@server:~$ docker compose logs --tail=200 api
    api  | ERROR: missing required setting: JWT_PUBLIC_KEY
    

    Décision : Configuration/secret manquant. Arrêtez de redéployer. Corrigez l’injection de configuration.

Deuxième étape : l’hôte est-il malade (disque, mémoire, CPU, IO) ?

  1. Disque:
    cr0x@server:~$ df -h /
    Filesystem      Size  Used Avail Use% Mounted on
    /dev/nvme0n1p2  200G  199G  1.0G  100% /
    

    Décision : Traitez « 100% » comme « rien ne fonctionne ». Libérez de l’espace avant toute autre chose.

  2. Pression mémoire:
    cr0x@server:~$ free -h
                   total        used        free      shared  buff/cache   available
    Mem:            16Gi        15Gi       210Mi       120Mi       790Mi       420Mi
    Swap:            0B          0B          0B
    

    Décision : Si la mémoire disponible est faible et que le swap est absent, attendez-vous à des OOM kills. Réduisez la charge ou ajoutez de la mémoire/limites.

  3. Qui consomme les ressources:
    cr0x@server:~$ docker stats --no-stream
    CONTAINER ID   NAME             CPU %     MEM USAGE / LIMIT     MEM %     NET I/O        BLOCK I/O      PIDS
    c0ffee12ab34   stack-api-1       220.14%   480MiB / 512MiB       93.8%     140MB / 98MB   1.8GB / 22MB  55
    

    Décision : Si un conteneur monopolise le CPU, suspectez des boucles serrées, des retries, ou un effet d’essaim depuis des dépendances échouées.

Troisième étape : est-ce le réseau (DNS, ports, exposition) ?

  1. DNS depuis l’intérieur du conteneur:
    cr0x@server:~$ docker exec -it stack-api-1 getent hosts postgres redis
    172.22.0.2   postgres
    172.22.0.3   redis
    

    Décision : Si la résolution échoue, vous avez des soucis d’attachement réseau ou de noms de service.

  2. Tests de connectivité:
    cr0x@server:~$ docker exec -it stack-api-1 bash -lc 'nc -vz postgres 5432; nc -vz redis 6379'
    Connection to postgres (172.22.0.2) 5432 port [tcp/postgresql] succeeded!
    Connection to redis (172.22.0.3) 6379 port [tcp/redis] succeeded!
    

    Décision : Le chemin réseau est bon ; le focus passe sur l’auth/config/migrations.

Le but du playbook est d’éviter l’arc classique d’incident : « redémarrer tout », puis « c’est toujours cassé », puis « pourquoi les logs ont disparu », puis « on a changé trois choses en même temps ».
Ne soyez pas cet arc.

Trois mini-récits d’entreprise depuis le terrain

Mini-récit n°1 : L’incident causé par une mauvaise hypothèse

Une entreprise SaaS de taille moyenne faisait tourner une stack Compose sur un hôte unique : Postgres, Redis, API et un worker. Ils étaient prudents—pour la plupart.
Ils utilisaient depends_on parce qu’on leur avait dit « ça aide l’ordre ». Ils supposaient que l’ordre signifiait disponibilité.
Cette hypothèse a vécu en production pendant des mois car la plupart des redémarrages étaient manuels et espacés.

Un jour, l’hôte a redémarré après un patch noyau routinier. Postgres est revenu plus lentement que d’habitude car il devait rejouer plus de WAL que d’ordinaire.
L’API a démarré immédiatement, a essayé de se connecter, a échoué, et a redémarré. Le worker a fait de même. Les deux étaient configurés avec
restart: always.

Leur moniteur externe voyait des 500 HTTP et des timeouts. En interne, les logs étaient un écran mitrailleur d’erreurs de connexion.
Le lead engineering a d’abord soupçonné une corruption Postgres parce que « ça prend trop de temps ». La base était fine ; elle était juste occupée.
Mais l’API et le worker l’ont martelée avec des retries, la rendant plus occupée.

Ils ont corrigé cela en un seul changement : ajouter un vrai healthcheck Postgres et verrouiller le démarrage des apps dessus. Deuxième changement : limiter la concurrence des retries au niveau de l’app.
Le redémarrage suivant s’est déroulé sans incident. La panne n’était pas causée par Compose. Elle était causée par le fait de traiter un événement de démarrage comme une garantie de disponibilité.

La leçon n’était pas subtile : le graphe de dépendances existe que vous le modélisiez ou non. Si vous ne le modélisez pas, la production le fera.

Mini-récit n°2 : L’optimisation qui a mal tourné

Une équipe financière subissait la pression de réduire l’utilisation disque. Leurs hôtes chauffaient, et les répertoires Docker grossissaient.
Quelqu’un a remarqué que les logs JSON étaient énormes. Ils ont « optimisé » en passant plusieurs services en journalisation minimale et en prônant une purge agressive.
Le changement semblait responsable : rétention de logs plus faible, plus de pruning, plus d’automatisation.

Des semaines plus tard, un bug subtil est apparu : un job en arrière-plan ratait parfois le renouvellement d’un token, provoquant des échecs intermittents en aval.
L’incident était réel mais assez intermittent pour dérouter. L’ingénieur d’astreinte a cherché dans les logs—pour ne trouver que les logs du conteneur concernés avaient été purgés par leur propre automatisation avant que quelqu’un ne remarque le pattern.

L’équipe a tenté de compenser en augmentant temporairement la verbosité. Cela a causé un autre problème : la pression disque a monté en flèche, car les réglages de rotation des logs n’étaient pas appliqués uniformément entre les services. Certains conteneurs tournaient en rotation. D’autres non. L’« optimisation » avait créé un mélange de politiques,
ce qui est la façon dont la production transforme le « raisonnable » en « chaos ».

La correction finale était ennuyeuse : faire respecter une politique uniforme de logs via les ancres Compose, garder assez d’historique pour couvrir la fenêtre de détection du monitoring,
et envoyer les logs applicatifs critiques vers un système central plutôt que de dépendre des logs locaux des conteneurs. Ils ont aussi retiré le pruning automatique pendant les heures ouvrables.

La leçon : le disque est une ressource, les logs sont une ressource, et « optimiser » sans observabilité n’est qu’une réduction de coûts à l’aveugle.

Mini-récit n°3 : La pratique ennuyeuse mais correcte qui a sauvé la mise

Une entreprise e-commerce faisait tourner Compose pour des services internes : mises à jour du catalogue, ingestion de prix, et une petite API. Rien de glamour.
Mais ils avaient une chose que beaucoup d’équipes sautent : un exercice de restauration écrit pour les volumes, testé trimestriellement.
L’exercice n’était pas « on a des sauvegardes ». C’était « on a restauré une sauvegarde sur un hôte propre et prouvé que le service fonctionnait ».

Lors d’un changement routinier, un ingénieur a nettoyé un répertoire sur l’hôte—convaincu que c’était « juste de vieilles choses Docker ».
Ce n’était pas ça. C’était un répertoire bind-monté utilisé par un service stateful. Le conteneur démarrait encore. Il avait même l’air OK pendant quelques minutes.
Puis il a commencé à renvoyer des données partielles et des timeouts.

Ils n’ont pas cherché à attribuer la faute. Ils ont exécuté l’exercice. Ils ont arrêté la stack, restauré depuis la sauvegarde la plus récente dans un nouveau volume nommé,
et remonté les services avec le même fichier Compose. Ils ont validé les healthchecks, puis exécuté le job de vérification de consistance de l’app.
L’impact total a été limité parce qu’ils savaient exactement ce que « restaurer » signifiait dans leur environnement.

Le postmortem n’était pas héroïque. Il était clinique. Ils ont migré ce service hors des bind mounts vers un volume nommé avec une gestion de cycle de vie plus claire,
et ils ont ajouté un point de checklist pré-changement : vérifier les points de montage pour les services stateful avant de nettoyer l’hôte.

La leçon : la pratique ennuyeuse—les exercices de restauration—transforme les incidents de données d’existentiels en inconvénients.

Erreurs courantes : symptôme → cause racine → correctif

1) L’API flappe (redémarre toutes les quelques secondes)

Symptôme : Restarting (1) dans docker ps, les logs montrent des erreurs de connexion répétées.

Cause racine : Dépendances pas prêtes ; échecs de migration ; secrets manquants ; la politique de redémarrage cache la première erreur.

Correctif : Ajouter des healthchecks ; verrouiller depends_on par santé ; séparer les migrations en service one-shot ; désactiver temporairement les redémarrages pour capturer la première erreur.

2) Tout est « Up » mais les utilisateurs obtiennent des 502/timeouts

Symptôme : Compose rapporte les services en cours ; le proxy externe renvoie 502 ou timeouts.

Cause racine : Le healthcheck est trop superficiel (processus existe) ou pointe vers la mauvaise interface ; l’app tourne mais ne sert pas.

Correctif : Faire que le healthcheck atteigne un vrai endpoint et valide une vraie réponse ; s’assurer que le service écoute sur la bonne adresse ; aligner l’upstream du proxy avec le port du conteneur.

3) La base est saine, l’app ne peut pas s’authentifier

Symptôme : Les logs Postgres montrent password authentication failed.

Cause racine : Fichier secret erroné, obsolète, ou formaté avec une nouvelle ligne finale que votre app gère mal ; mauvais utilisateur BD ; mauvais nom de BD.

Correctif : Utiliser les patterns _FILE ; standardiser le format des secrets ; valider les secrets dans le conteneur ; faire tourner les identifiants de façon délibérée.

4) Panne soudaine après activation du debug logging

Symptôme : Le disque se remplit ; Docker et la BD deviennent instables ; les écritures échouent.

Cause racine : Logs json-file non bornés ; rotation manquante sur un service ; verbosité debug trop élevée.

Correctif : Faire appliquer la rotation de logs via des ancres ; limiter la verbosité ; ajouter du monitoring disque ; ne pas compter sur un nettoyage manuel.

5) Après reboot, les services démarrent mais les données ont « disparu »

Symptôme : L’application se comporte comme une installation fraîche ; la BD n’a pas de tables ; le cache Redis se réinitialise.

Cause racine : Le chemin du bind mount a changé, les permissions empêchent la lecture, ou le service utilise un volume différent de celui attendu.

Correctif : Préférer les volumes nommés pour l’état ; vérifier les montages avec docker inspect ; documenter les noms de volume ; exécuter l’exercice de restauration.

6) Un service cause un ralentissement système global

Symptôme : Charge élevée, OOM kills, IO wait ; plusieurs conteneurs deviennent unhealthy.

Cause racine : Pas de limites de ressources ; requête runaway ; job batch qui entre en collision avec un trafic de pointe.

Correctif : Définir des limites mémoire/CPU ; planifier les jobs lourds ; limiter la concurrence ; monitorer les métriques de l’hôte et les stats des conteneurs.

7) « Ça marche localement, ça échoue en prod » après changement d’image

Symptôme : La nouvelle version d’image échoue immédiatement ; l’ancienne version fonctionne.

Cause racine : Dérive de configuration ; variable d’env manquante ; valeurs par défaut incompatibles ; migrations requises mais non exécutées.

Correctif : Épingler les tags d’image ; traiter la configuration comme versionnée ; exiger la complétion du job de migration avant le démarrage de l’app ; garder une procédure de rollback qui ne mutera pas l’état.

Blague n°2 : La seule chose pire qu’une panne est une panne avec des « redémarrages automatiques utiles »—comme un détecteur de fumée qui se réarme poliment.

Checklists / plan pas à pas

Checklist pattern Compose production (faites ceci avant d’appeler « production »)

  1. Des healthchecks existent pour chaque service important (BD, cache, API, proxy).
  2. Les healthchecks sont honnêtes : ils valident la readiness, pas seulement la liveness.
  3. Les dépendances sont verrouillées avec condition: service_healthy.
  4. Les migrations sont un job one-shot avec restart: "no" et verrouillage via service_completed_successfully.
  5. Les secrets sont des fichiers (ou injectés en sécurité), pas collés dans l’historique du shell.
  6. Volumes nommés pour l’état, à moins d’avoir une raison de stockage managé pour utiliser des bind mounts.
  7. Des sauvegardes existent et les restaurations sont testées. Planifiez un exercice de restauration.
  8. Les logs sont bornés (taille + nombre de fichiers) sur chaque service.
  9. Des limites de ressources sont définies pour qu’un service ne puisse pas faire tomber l’hôte.
  10. La publication de ports est minimale ; le trafic interne reste sur des réseaux internes.
  11. Les tags d’image sont épinglés ; les mises à jour sont délibérées, pas des surprises « latest ».
  12. Un plan de rollback existe et tient compte des changements de schéma de base de données.

Pas à pas : durcir une stack Compose existante en une semaine

  1. Jour 1 : Inventaire et graphe
    • Listez les services, dépendances, et lesquels sont stateful.
    • Décidez quels endpoints représentent la readiness.
  2. Jour 2 : Ajouter des healthchecks
    • BD : pg_isready (ou équivalent).
    • API : un endpoint /healthz qui vérifie les dépendances critiques.
    • Cache : redis-cli ping ou un test lecture/écriture réel si nécessaire.
  3. Jour 3 : Verrouiller les dépendances
    • Remplacez le naive depends_on d’ordre par des conditions de santé.
    • Introduisez un service migrate one-shot et verrouillez l’API dessus.
  4. Jour 4 : Stabiliser l’état
    • Déplacez les services stateful vers des volumes nommés si possible.
    • Documentez les noms de volumes et les points de montage.
  5. Jour 5 : Rendre la journalisation ennuyeuse
    • Définissez la rotation des logs via des ancres.
    • Confirmez via docker inspect que chaque conteneur l’a prise en compte.
  6. Jour 6 : Ajouter des limites de ressources et tester la charge
    • Commencez avec des limites mémoire conservatrices pour les apps bavardes ; assurez-vous que la BD a assez de marge.
    • Surveillez les OOM et la throttling sous trafic réaliste.
  7. Jour 7 : Répéter les pannes
    • Redémarrez l’hôte en fenêtre de maintenance et observez le retour de la stack.
    • Simulez un retard de dépendance et assurez-vous que le verrouillage empêche les flaps.
    • Exécutez un exercice de restauration pour le volume de la base de données.

Checklist de réponse à incident (quand vous êtes déjà en panne)

  1. Exécutez docker compose ps et identifiez le premier service unhealthy ou exited.
  2. Vérifiez df -h et free -h avant de modifier des configs.
  3. Tirez les logs du service en échec et de ses dépendances (--tail=200 avec timestamps).
  4. Validez DNS et connectivité depuis le conteneur en échec (getent, nc).
  5. Si boucle de redémarrage : désactivez temporairement les redémarrages, reproduisez une fois, capturez la première erreur, puis corrigez.
  6. Ne prunez pas les volumes pendant un incident sauf si vous restaurez depuis des sauvegardes connues bonnes.

FAQ

1) Est-ce que depends_on garantit que ma base de données est prête ?

Pas par défaut. Il influence seulement l’ordre de démarrage. Utilisez des healthchecks et verrouillez avec condition: service_healthy, ou implémentez une logique explicite de readiness.

2) Les healthchecks suffisent-ils à prévenir les problèmes de démarrage ?

Ils sont nécessaires, pas suffisants. Les healthchecks empêchent les flaps « démarrer trop tôt », mais vous avez toujours besoin de migrations idempotentes, de secrets corrects, de retries sains et de limites de ressources.

3) Pourquoi ne pas mettre les migrations dans la commande de démarrage de l’app ?

Parce que les redémarrages arrivent. Quand l’app redémarre, les migrations se relancent sauf si vous les avez rendues explicitement sûres et idempotentes. Un service de migration one-shot rend le cycle de vie visible et verrouillable.

4) Dois-je utiliser des volumes nommés ou des bind mounts pour les bases ?

Préférez les volumes nommés pour la clarté et la portabilité dans le cycle Docker. Utilisez des bind mounts seulement si vous avez une raison opérationnelle forte et que vous êtes discipliné sur les permissions, les sauvegardes et la gestion des chemins hôtes.

5) Comment garder les secrets hors de docker inspect ?

Évitez les variables d’environnement plain pour les secrets bruts. Utilisez des secrets basés fichier et faites que votre app lise depuis des variables *_FILE (ou équivalent). Évitez aussi de mettre des secrets dans les labels Compose ou les lignes de commande.

6) restart: always est-il parfois une bonne idée ?

Parfois—pour des services « crash-only » où les échecs sont vraiment transitoires et vous avez un monitoring solide. Dans la plupart des apps métier, cela masque des mauvaises configurations déterministes et crée des tempêtes de redémarrages bruyantes.

7) Comment faire des « rolling updates » avec Compose ?

Compose n’est pas un orchestrateur complet. Vous pouvez approcher des mises à jour sûres en exécutant plusieurs instances derrière un proxy, en mettant à jour une à la fois, et en utilisant des healthchecks pour verrouiller le trafic. Si vous avez besoin de vrais rolling updates sur plusieurs nœuds, vous voulez un ordonnanceur.

8) Pourquoi lier les ports API à 127.0.0.1 ?

Parce que la plupart des services internes n’ont pas besoin d’être accessibles publiquement. Liez à localhost et mettez un reverse proxy (ou des règles pare-feu) devant. Cela réduit l’exposition accidentelle et les collisions de ports.

9) Mes conteneurs sont healthy mais l’app est lente. Et maintenant ?

La santé est binaire ; la performance ne l’est pas. Vérifiez l’IO wait de l’hôte, l’espace disque, la pression mémoire, et les stats des conteneurs. Puis profilez l’app et les requêtes BD. La plupart des incidents « lenteur » sont de la contention de ressources, pas un problème Compose.

10) Puis-je exécuter Compose en production sur un hôte unique de façon responsable ?

Oui—si vous acceptez le domaine de défaillance et construisez en conséquence : sauvegardes, exercices de restauration, monitoring de l’hôte, planification de capacité, et procédure de reconstruction documentée. Compose ne vous protègera pas des lois physiques d’un hôte unique.

Conclusion : prochaines étapes à faire cette semaine

Le modèle Compose qui prévient la plupart des pannes n’est pas glamour. C’est le but. Vous gagnez en fiabilité production en supprimant l’ambiguïté :
la readiness est explicite, les dépendances sont verrouillées, l’état est géré, les logs sont bornés, et l’utilisation des ressources est contrainte.

Prochaines étapes qui déplacent réellement la ligne :

  1. Ajouter des healthchecks honnêtes à chaque service critique et confirmer qu’ils échouent lorsque le service n’est pas vraiment prêt.
  2. Convertir les migrations en service Compose one-shot et verrouiller le démarrage de l’app sur son succès.
  3. Faire appliquer la rotation des logs partout via une ancre Compose et la vérifier avec docker inspect.
  4. Définir des limites mémoire pour les plus gros consommateurs et surveiller les signaux OOM ; ajuster selon la charge réelle.
  5. Exécuter un exercice de restauration pour votre volume de base de données sur un hôte propre. Si vous ne pouvez pas le faire, arrêtez de l’appeler « sauvegardé ».

Faites ces cinq choses et vous éviterez la majorité des pannes qui ressemblent à des « problèmes Docker » mais qui sont en réalité juste
« nous n’avons pas spécifié le système que nous pensions avoir ».

← Précédent
« Accès refusé » sur vos propres fichiers après réinstallation : corriger la propriété
Suivant →
Saccades en jeu sur un PC puissant : bases de la latence DPC (et plan de correction)

Laisser un commentaire