Healthchecks Docker Compose fiables : Postgres et app
Comprenez l’évaluation des healthchecks Docker Compose, pourquoi depends_on ne suffit pas et comment tester la readiness de Postgres et de votre application.
Ce que fait réellement un healthcheck Docker Compose
Un healthcheck Docker Compose est une commande que Docker exécute dans le conteneur selon un intervalle défini. Docker ne lit pas vos logs, ne surveille pas votre port et n’inspecte pas la liste des processus. Il exécute la commande, lit le code de sortie et stocke un seul état sur le conteneur : starting, healthy ou unhealthy. Le code de sortie 0 signifie que le conteneur est sain. Tout autre code de sortie signifie qu’il est défaillant. Le code de sortie 2 est réservé par Docker. Ne le retournez donc jamais volontairement.
C’est tout le mécanisme. Presque tous les problèmes de healthcheck ont la même cause : la commande que vous avez écrite répond à une autre question que celle que vous vouliez poser. Ce guide suppose que vous savez déjà écrire un fichier Compose sur un VPS et reprend au moment où la stack démarre dans le mauvais ordre.
services:
api:
image: ghcr.io/example/api:1.4.0
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30sLa valeur test prend deux formes utiles. Une liste qui commence par CMD exécute directement la commande, sans shell. Les pipes, && et l’expansion des variables ne fonctionnent donc pas. Une liste qui commence par CMD-SHELL transmet le reste sous forme d’une seule chaîne à /bin/sh -c dans le conteneur. C’est ce qu’il faut utiliser lorsque le check nécessite la syntaxe du shell. Une chaîne simple est traitée comme CMD-SHELL. Une liste contenant exactement ["NONE"] supprime le healthcheck intégré à l’image via son Dockerfile.
Le check s’exécute dans le conteneur. Chaque binaire qu’il utilise doit donc exister dans cette image. Vérifiez-le d’abord. Une image minimale sans curl produit un conteneur qui reste définitivement défaillant, pour une raison qui n’apparaît jamais dans le log de l’application. Testez-le manuellement :
docker compose exec api curl --versionUn binaire manquant renvoie OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown. Les images basées sur Alpine fournissent généralement wget de BusyBox à la place. Le check devient donc ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"].
Comment se combinent interval, retries et start_period
Cinq paramètres contrôlent le calendrier. Leurs valeurs par défaut viennent de Docker Engine, pas de Compose.
interval: intervalle entre deux vérifications une fois que le conteneur a dépassé sa période de démarrage. Valeur par défaut : 30s.timeout: durée maximale d’une exécution de la vérification avant que Docker l’arrête et comptabilise cette exécution comme un échec. Valeur par défaut : 30s.retries: nombre d’échecs consécutifs nécessaires avant que l’état passe àunhealthy. Valeur par défaut : 3.start_period: période de grâce après le démarrage du conteneur. Valeur par défaut : 0s.start_interval: fréquence d’exécution de la vérification pendant la période de démarrage. Valeur par défaut : 5s. Ce paramètre nécessite Docker Engine 25.0 ou une version ultérieure.
La règle importante est la suivante : pendant la période de démarrage, une vérification en échec ne compte pas dans retries, et le conteneur reste dans l’état starting. Dès que la vérification réussit pour la première fois, le conteneur passe à l’état healthy et la période de démarrage se termine immédiatement, même si la majeure partie de sa durée n’a pas été utilisée. Si la période de démarrage se termine alors que la vérification échoue toujours, le décompte normal commence. Le conteneur doit alors subir retries échecs consécutifs avant d’être marqué unhealthy.
Dans le pire des cas, le délai entre le démarrage du conteneur et unhealthy est égal à start_period plus retries multiplié par interval, plus timeout. Avec les valeurs du fichier ci-dessus, cela donne 30 plus 5 fois 13, soit 95 secondes. Notez ce nombre avant de définir un délai d’expiration pour le déploiement. Un rollout qui abandonne après 60 secondes ne verra jamais ce conteneur atteindre un état final.
L’erreur courante consiste à augmenter retries pour couvrir un démarrage lent. Cela fonctionne une fois, puis devient permanent : un service qui avait besoin de 8 retries pour démarrer tolère désormais 8 échecs consécutifs en production avant qu’un problème soit détecté. Utilisez plutôt start_period, car ce paramètre s’applique uniquement avant la première réussite.
Pourquoi depends_on ne garantit rien à lui seul
La forme courte de depends_on est à l’origine de la plupart des confusions.
api:
depends_on:
- dbCela signifie une seule chose : démarrer le conteneur db avant le conteneur api. Compose attend que le conteneur soit créé et démarré. Il n’attend pas que PostgreSQL termine son initialisation initiale et n’attend pas non plus que le port 5432 accepte une connexion. Votre application démarre environ une seconde plus tard, se connecte à un port sur lequel rien n’écoute encore, puis s’arrête. Dans le journal, vous voyez Connection refused ou FATAL: the database system is starting up lorsque le serveur est démarré mais encore en phase de récupération.
La forme longue correspond à ce que les utilisateurs veulent réellement :
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition accepte trois valeurs. service_started est identique à la forme courte. service_healthy retarde le démarrage du service dépendant jusqu’à ce que la dépendance soit déclarée saine. Cela n’a de sens que si cette dépendance définit un healthcheck, dans le fichier Compose ou dans son image. service_completed_successfully attend qu’un conteneur exécutant une tâche unique, comme une migration de base de données, se termine avec le code d’état 0.
Deux champs supplémentaires se trouvent à côté de condition. restart: true indique à Compose de redémarrer ce service après la mise à jour du service dont il dépend. required: false transforme une dépendance manquante en avertissement au lieu d’une erreur.
Voici maintenant la limite qui surprend beaucoup d’utilisateurs. Ces conditions sont évaluées au démarrage de la stack. Elles définissent l’ordre de démarrage, pas une règle de supervision. Si la base de données redémarre à trois heures du matin, service_healthy n’est pas réévalué et votre application n’est pas redémarrée pour satisfaire à nouveau cette condition. Votre code applicatif doit toujours gérer lui-même la reconnexion. docker compose up --no-deps api désactive entièrement ce mécanisme, tout comme le démarrage direct d’un conteneur avec docker start.
Écrivez un check qui teste la disponibilité, pas seulement l’existence d’un processus
Un check comme pgrep nginx prouve qu’une entrée existe dans la table des processus. Il ne prouve pas que le service peut répondre à une requête. Une application web peut conserver son socket d’écoute ouvert longtemps après l’arrêt de son pool de connexions à la base de données, et le check du processus reste au vert pendant toute la panne.
Demandez au conteneur d’effectuer l’opération pour laquelle il existe :
- Pour un service HTTP, demandez un endpoint réel.
curl -fsSretourne un code différent de zéro pour tout code d’état supérieur ou égal à 400 grâce à-f. Une réponse 500 d’une application défaillante fait donc échouer le check. - Pour PostgreSQL, utilisez
pg_isready. Cette commande retourne 0 lorsque le serveur accepte les connexions, 1 lorsqu’il les refuse, 2 lorsqu’il ne répond pas du tout et 3 lorsque les paramètres transmis sont incorrects. - Pour Redis, utilisez
redis-cli ping. La commande affichePONGet retourne 0. - Pour MariaDB, l’image officielle fournit un script
healthcheck.sh.healthcheck.sh --connect --innodb_initializedest la forme documentée par ses mainteneurs.
pg_isready comporte un piège à connaître. Lors de son tout premier démarrage avec un répertoire de données vide, l’image officielle postgres effectue son initialisation sur un serveur temporaire qui écoute uniquement sur le socket Unix. Sans argument d’hôte, pg_isready utilise ce socket. Il peut donc répondre « connexions acceptées » alors que le port TCP 5432 est encore fermé pour votre application. Faites pointer explicitement le check vers TCP pour éviter ce problème, car le serveur temporaire n’y répond pas.
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30sLes signes dollar doublés ne sont pas une faute de frappe. Compose développe lui-même $VAR lors de la lecture du fichier, ce qui intégrerait dans le check une valeur provenant de l’environnement de votre hôte. $$ l’échappe en un seul $, afin que le shell du conteneur le développe à partir de l’environnement propre au conteneur.
Une pile PostgreSQL et applicative qui démarre dans le bon ordre
services:
db:
image: postgres:17.5
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
restart: unless-stopped
api:
image: ghcr.io/example/api:1.4.0
environment:
DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/appdb
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
volumes:
pgdata:Démarrez-la et observez l’évolution des états :
docker compose up -d
docker compose psLa colonne STATUS contient l’état de santé entre crochets. Une paire en bon état affiche Up 41 seconds (healthy) sur les deux lignes. Pendant l’initialisation de la base de données, db affiche Up 4 seconds (health: starting) et api est absent de la liste, car Compose ne l’a pas encore créée.
Pour comprendre pourquoi un contrôle a réussi ou échoué, consultez le journal de santé :
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker conserve les derniers résultats, chacun avec une heure de début, une heure de fin, un ExitCode et le Output de la commande. La sortie enregistrée est tronquée. Un contrôle qui affiche le contenu complet d’une page produit donc une entrée de journal inutile. Gardez les contrôles silencieux.
Ce que fait Docker lorsqu’un conteneur devient unhealthy
Rien. C’est la réponse qui surprend le plus.
Docker Engine sur un seul hôte ne redémarre pas un conteneur unhealthy. La policy restart: unless-stopped réagit lorsque le processus principal se termine, mais un conteneur unhealthy ne s’est pas terminé. Il peut rester à l’état unhealthy pendant une semaine, tandis que Compose ne fait rien. Le mode Swarm remplace les tâches unhealthy, mais une stack Compose classique sur un seul serveur ne le fait pas.
Il reste deux options réalistes. Faites terminer le processus lorsqu’il détecte qu’il est défaillant, afin que la restart policy puisse agir. Ou surveillez l’état depuis l’extérieur et déclenchez une alerte. Configurer un monitor Uptime Kuma sur le même endpoint que celui appelé par votre healthcheck permet de détecter une dépendance défaillante aux deux endroits. Vous êtes alors averti par le monitor plutôt que par un utilisateur. Si le trafic atteint l’application via un reverse proxy Traefik, rappelez-vous que la vue du proxy sur un backend est indépendante de l’état de santé Docker. L’un ne remplace donc pas l’autre.
Déboguer un check qui ne passe jamais à l’état healthy
Exécutez vous-même la commande exacte, dans le même conteneur, et vérifiez le code de sortie :
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"Le fait que exit=0 ici alors que le conteneur reste signalé comme unhealthy signifie que votre test compose diffère de ce que vous venez de saisir. La cause est généralement l’utilisation de CMD alors qu’il fallait une syntaxe shell.
Deux erreurs expliquent la plupart des autres cas. La première concerne le mauvais port. Le healthcheck s’exécute dans le conteneur. Il doit donc utiliser le port du conteneur, jamais le port publié sur l’hôte. Avec ports: - "8080:3000", l’application écoute sur le port 3000. Un check effectué sur http://localhost:8080 échoue indéfiniment alors que le site fonctionne correctement dans un navigateur. La seconde concerne le mauvais hôte. Dans le check, localhost désigne ce même conteneur. C’est correct pour vérifier le conteneur lui-même, mais pas pour vérifier un conteneur voisin. Dans ce cas, vous devez utiliser le nom du service, par exemple db.
Un dernier cas mérite d’être distingué : le healthcheck réussit alors que les utilisateurs voient des erreurs. Cela se produit lorsque l’endpoint renvoie un code 200 statique sans vérifier un composant réel. Un endpoint de readiness qui n’interroge jamais la base de données ne peut pas détecter qu’elle est indisponible. Faites-lui exécuter une requête réelle peu coûteuse.
FAQ
Pourquoi mon application échoue-t-elle toujours à se connecter alors que depends_on indique que la base de données est saine ?
Parce que condition: service_healthy est évalué une seule fois, au démarrage de la stack. Il ne supervise plus rien ensuite. Si le conteneur de base de données redémarre plus tard, Compose ne redémarre pas votre application pour réévaluer la condition. Votre code applicatif doit donc gérer lui-même les reconnexions et les nouvelles tentatives. La condition ne fait rien non plus lorsque vous démarrez un seul conteneur avec docker start ou avec docker compose up --no-deps.
Dois-je définir un healthcheck si l’image en fournit déjà un ?
En général, non. Le remplacer constitue souvent un retour en arrière, car le mainteneur de l’image sait ce que signifie l’état prêt pour ce logiciel. Ajoutez votre propre healthcheck uniquement si celui de l’image ne convient pas à votre configuration, par exemple s’il teste un port que vous avez modifié. Pour désactiver le healthcheck de l’image, définissez test: ["NONE"] ou disable: true sur le service.
Le healthcheck doit-il utiliser curl ou wget ?
Utilisez celui qui existe déjà dans l’image et vérifiez sa présence avec docker compose exec <service> curl --version avant de vous y fier. De nombreuses images basées sur Debian ne contiennent ni l’un ni l’autre. Les images basées sur Alpine disposent de wget de BusyBox. N’ajoutez pas un package à une image uniquement pour exécuter un healthcheck lorsque le logiciel fournit son propre client, comme pg_isready ou redis-cli.
Un conteneur unhealthy est-il redémarré automatiquement ?
Pas par Docker Engine sur un hôte unique. Les restart policies réagissent à l’arrêt du processus, pas à l’état de santé. Un conteneur unhealthy reste donc en fonctionnement tout en étant défaillant, jusqu’à ce qu’une autre action intervienne. Faites soit quitter le processus lorsqu’il détecte la défaillance, soit exécutez un monitor externe qui génère une alerte sur cet état.
Quelle doit être la durée de start_period ?
Elle doit couvrir le premier démarrage légitime le plus lent que vous avez mesuré, avec une marge. Mesurez-la avec docker compose up sur un volume vide, car le premier démarrage d’une base de données est bien plus lent que les démarrages suivants. Un start period trop long ne fait que retarder le premier verdict unhealthy. Un nombre de retries trop élevé affaiblit le contrôle pendant toute la durée de vie du conteneur, ce qui constitue la défaillance la plus grave.