Healthchecks Docker Compose fiables : Postgres et app
Comprenez comment Compose évalue un healthcheck, pourquoi depends_on n’attend pas un service prêt et comment tester correctement Postgres et 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 journaux, ne surveille pas votre port et n’inspecte pas votre liste de processus. Il exécute la commande, lit son code de sortie et enregistre un seul état pour 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 renvoyez donc jamais volontairement.
C’est tout le mécanisme. Presque tous les problèmes de healthcheck ont la même cause : la commande é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 une syntaxe shell. Une chaîne simple est traitée comme CMD-SHELL. Une liste contenant exactement ["NONE"] supprime le healthcheck intégré à l’image par son Dockerfile.
Le check s’exécute dans le conteneur. Chaque binaire qu’il appelle doit donc exister dans cette image. Vérifiez-le d’abord. Une image slim sans curl produit un conteneur qui reste définitivement défaillant, pour une raison qui n’apparaît jamais dans le journal de l’application. Testez la commande manuellement :
docker compose exec api curl --versionLorsqu’un binaire est absent, la commande 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"].
Combinaison de interval, retries et start_period
Cinq paramètres contrôlent la temporisation. Leurs valeurs par défaut viennent de Docker Engine, pas de Compose.
interval: intervalle entre deux contrôles 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 du contrôle avant que Docker l’arrête et considère 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: délai de grâce après le démarrage du conteneur. Valeur par défaut : 0s.start_interval: fréquence d’exécution du contrôle 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, un contrôle en échec ne compte pas pour retries, et le conteneur reste dans l’état starting. Dès que le contrôle 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 elle n’est pas arrivée à son terme. Si la période de démarrage expire alors que le contrôle échoue encore, le décompte normal commence. Le conteneur doit alors cumuler 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, puis plus timeout. Avec les valeurs du fichier ci-dessus, cela donne 30 plus 5 fois 13, soit 95 secondes. Notez cette valeur avant de définir le délai d’expiration d’un 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 compenser un démarrage lent. Cela fonctionne une fois, puis devient pénalisant en permanence : 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 ne s’applique qu’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 sa première initialisation 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 généralement :
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 indique un état sain. Cela n’a de sens que si cette dépendance définit un healthcheck, soit dans le fichier Compose, soit dans son image. service_completed_successfully attend qu’un conteneur exécutant une seule tâche, 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 demande à Compose de redémarrer ce service après la mise à jour du service dont il dépend. required: false transforme une dépendance manquante, qui serait normalement une erreur, en avertissement.
Voici maintenant la limite qui pose problème. 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, rien ne réévalue service_healthy et rien ne redémarre votre application pour l’appliquer à nouveau. Le code de votre application doit toujours gérer lui-même la reconnexion. docker compose up --no-deps api désactive volontairement tout ce mécanisme. C’est également le cas lorsqu’un conteneur est démarré directement avec docker start.
Écrivez un contrôle qui vérifie que le service est prêt, pas seulement qu’un processus existe
Un contrôle 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 contrôle du processus reste au vert pendant toute la panne.
Demandez au conteneur d’effectuer la tâche pour laquelle il existe :
- Pour un service HTTP, demandez un endpoint réel.
curl -fsSrenvoie un code de sortie différent de zéro pour tout statut supérieur ou égal à 400 grâce à-f. Ainsi, un statut 500 renvoyé par une application défaillante fait échouer le contrôle. - Pour PostgreSQL, utilisez
pg_isready. La commande renvoie 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 fournis sont incorrects. - Pour Redis, utilisez
redis-cli ping. La commande affichePONGet renvoie 0. - Pour MariaDB, l’image officielle fournit un script
healthcheck.sh, ethealthcheck.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 avec un serveur temporaire qui écoute uniquement sur le socket Unix. pg_isready sans argument d’hôte utilise ce socket. Il peut donc répondre « connexions acceptées » alors que le port TCP 5432 est encore fermé pour votre application. Pointez explicitement le contrôle vers TCP pour résoudre le 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 erreur. Compose développe lui-même $VAR lors de la lecture du fichier, ce qui intégrerait dans le contrôle une valeur provenant de l’environnement de votre hôte. $$ l’échappe en un seul $, afin que le shell à l’intérieur du conteneur le développe à partir de l’environnement propre au conteneur.
Une stack PostgreSQL et application 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 saine 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 check a réussi ou échoué, consultez le health log :
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker conserve les derniers résultats. Chaque résultat contient 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 check qui affiche le contenu d’une page volumineuse produit donc une entrée de journal peu utile. Gardez les checks silencieux.
Ce que fait Docker lorsqu’un conteneur devient unhealthy
Rien. C’est la réponse qui surprend le plus.
Docker Engine sur un hôte unique ne redémarre pas un conteneur unhealthy. La policy restart: unless-stopped réagit à l’arrêt du processus principal, or un conteneur unhealthy ne s’est pas arrêté. 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 arrêter 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 voir une dépendance défaillante aux deux endroits. Vous êtes alors alerté par le monitor plutôt que par un utilisateur. Si le trafic atteint l’application via un reverse proxy Traefik, n’oubliez pas que la vue du proxy sur un backend est distincte de l’état de santé Docker. L’un ne remplace donc pas l’autre.
Déboguer un contrôle qui ne passe jamais à l’état healthy
Exécutez vous-même la commande exacte dans le même conteneur, puis consultez le code de sortie :
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"La présence de exit=0 alors que le conteneur reste signalé comme unhealthy signifie que votre configuration compose test diffère de la commande que vous venez de saisir, généralement parce que CMD a été utilisé alors que la syntaxe du shell était nécessaire.
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, et jamais le port publié sur l’hôte. Avec ports: - "8080:3000", l’application écoute sur le port 3000. Un contrôle vers http://localhost:8080 échoue indéfiniment alors que le site fonctionne correctement dans un navigateur. La seconde concerne le mauvais hôte. Dans le contrôle, localhost désigne ce même conteneur. C’est correct pour vérifier ce conteneur, 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 nommé : 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 quoi que ce soit de réel. Un endpoint de readiness qui n’interroge jamais la base de données ne peut pas vous indiquer que celle-ci est indisponible. Faites-lui exécuter une véritable requête, peu coûteuse.
FAQ
Pourquoi mon application ne parvient-elle toujours pas à 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 n’effectue ensuite aucune supervision. 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 application doit donc gérer elle-même les reconnexions et les nouvelles tentatives. La condition n’a pas non plus d’effet 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 une régression, car le mainteneur de l’image sait ce que signifie « 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 d’une image, définissez test: ["NONE"] ou disable: true sur le service.
Le healthcheck doit-il utiliser curl ou wget ?
Utilisez celui qui est déjà présent dans l’image et vérifiez-le avec docker compose exec <service> curl --version avant de compter dessus. De nombreuses images basées sur Debian ne contiennent ni l’un ni l’autre. Les images basées sur Alpine disposent de BusyBox wget. 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 restant défaillant, jusqu’à ce qu’un autre mécanisme intervienne. Faites soit quitter le processus lorsqu’il détecte la défaillance, soit exécutez un monitor externe qui déclenche 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-le avec docker compose up sur un volume vide, car le premier démarrage d’une base de données est beaucoup plus lent que les suivants. Un start_period trop long retarde uniquement 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.