pg_isready répond prêt trop tôt quand Postgres tourne dans l’image Docker officielle postgres : au premier démarrage, le point d’entrée (l’entrypoint, le script lancé au démarrage du conteneur) démarre un serveur temporaire qui n’écoute que sur la socket Unix, le temps de l’initialisation. Un pg_isready lancé par docker exec passe par cette socket et répond PRÊT, alors que rien n’écoute encore en TCP. Mes tests, eux, se connectaient en TCP et recevaient the database system is starting up. Le correctif : sonder le port publié, puis une vraie requête en TCP.
Le symptôme : une CI rouge sur main, verte en PR
Le contexte : une app de signalements sur Supabase, dont les règles RLS (Row Level Security, les politiques Postgres qui filtrent les lignes selon l’utilisateur) sont testées par une suite Vitest contre un Postgres jetable. Un script start-pg.sh lance un conteneur postgres:15, attend qu’il soit prêt, puis rend la main aux tests. La CI tourne sur des runners GitHub Actions self-hosted, sur la machine srv-a.
Le 4 août 2026, la CI passe au rouge sur main alors que la même révision était verte en PR : 7 suites mortes dans leur beforeAll, puis les 15 suivantes vertes, parce que la base avait fini de démarrer entre-temps. L’erreur :
the database system is starting up
Un échec intermittent, sur du code inchangé : exactement ce qui se lit comme un défaut de code alors que ce n’en est pas un. L’attente du script était pourtant là :
# Attendre que Postgres soit prêt (max 30 s)
for i in $(seq 1 60); do
if docker exec "${CONTAINER}" pg_isready -U postgres >/dev/null 2>&1; then
echo "[start-pg] Ready on :${PORT}"
exit 0
fi
sleep 0.5
done
Pourquoi pg_isready répond prêt trop tôt ?
L’image postgres démarre en deux temps. Quand le volume de données est vide, l’entrypoint lance initdb, puis un serveur temporaire limité à la socket Unix pour exécuter les scripts d’initialisation, l’arrête, et démarre enfin le vrai serveur, seul à publier le port TCP. Les logs du conteneur montrent deux « ready » :
listening on Unix socket … ← serveur temporaire
database system is ready to accept connections
the database system is shutting down
listening on IPv4 address "0.0.0.0", port 5432 ← vrai serveur
database system is ready to accept connections
pg_isready est l’utilitaire de Postgres qui demande au serveur s’il accepte les connexions. Sans option -h, il se connecte par la socket Unix : entre les deux « ready », il répond PRÊT au serveur temporaire. Le script rendait la main à ce moment-là.
Une sonde de disponibilité doit tester le chemin qu’emprunteront les clients : une socket Unix qui répond ne prouve rien pour un client TCP.
Mesuré sur un poste de dev : l’ancienne sonde déclarait la base prête à 0,42 s, le port était réellement utilisable à 0,64 s. 218 ms de fenêtre en local, bien plus sur le runner, où le démarrage a pris 26 s.
Le correctif : sonder le port publié, puis une requête TCP
La nouvelle attente vérifie deux choses que le serveur temporaire ne peut pas satisfaire :
# 1. Le port publié accepte-t-il une connexion ? Bash pur, sans psql sur l'hôte.
port_open() {
(exec 3<>"/dev/tcp/127.0.0.1/${PORT}") 2>/dev/null && exec 3>&- 3<&-
}
# 2. Le serveur répond-il à une vraie requête, en TCP ?
answers_query() {
docker exec -e PGPASSWORD=postgres "${CONTAINER}" \
psql -h 127.0.0.1 -p 5432 -U postgres -d postgres \
-v ON_ERROR_STOP=1 -tAc 'select 1' >/dev/null 2>&1
}
deadline=$((SECONDS + MAX_WAIT_SECONDS))
while ((SECONDS < deadline)); do
if port_open && answers_query; then
echo "[start-pg] Ready on :${PORT}"
exit 0
fi
sleep 0.5
done
echo "[start-pg] Postgres pas prêt après ${MAX_WAIT_SECONDS}s — abort"
docker logs --tail 30 "${CONTAINER}" 2>&1 || true
exit 1
/dev/tcp/hôte/port est une redirection propre à bash qui ouvre une connexion TCP : pas besoin d’installer un client Postgres sur le runner. Le select 1 passe par docker exec, mais avec -h 127.0.0.1 : il emprunte le TCP, donc il échoue tant que le vrai serveur n’écoute pas. En cas d’échec, les 30 dernières lignes de logs du conteneur partent dans la sortie du job.
Deuxième trou bouché au passage : le script sortait immédiatement si le conteneur tournait déjà (exit 0). Or un conteneur « en cours » peut être en plein démarrage. Il passe désormais lui aussi par la boucle d’attente.
Combien de temps attendre sur un runner chargé ?
L’ancien plafond était de 30 s. Je l’ai d’abord porté à 90 s, calibré sur les 26 s mesurées sur le runner. Le 24 septembre 2026, le même initdb a pris 171 s : la machine compte huit cœurs pour une charge moyenne de 26, avec trois piles Supabase et 76 conteneurs à côté du runner (sur la contention disque de ces runners, voir le _temp des runners en tmpfs). La suite a échoué sur toutes les branches.
| Plafond | Calibré sur | Résultat |
|---|---|---|
| 30 s | valeur d’origine du script | remplacé avec la nouvelle sonde |
| 90 s | initdb de 26 s (4 août) | échec à 171 s (24 septembre) |
| 300 s | facteur 10 sur la mesure d’origine | en place |
Le seuil mesurait la charge du serveur, pas l’état de la base. Attendre longtemps ne coûte rien : la boucle sort dès que la base répond. L’inverse rend la CI intermittente, et fait chercher un défaut dans la PR qui passait par là.
Lessons learned
- Avec l’image Docker
postgres,pg_isreadypar la socket peut répondre au serveur temporaire. L’entrypoint initialise la base avec un serveur qui n’écoute pas en TCP, puis le redémarre. - Une sonde de disponibilité teste le chemin des clients. Le port publié puis un
select 1en TCP, c’est ce que les tests utiliseront ensuite. the database system is starting upderrière une sonde verte signale une sonde trop optimiste. Le code testé n’y est pour rien.- Un plafond d’attente se calibre sur le pire cas du runner. Une boucle qui sort dès que la base répond ne ralentit pas le cas nominal.
- Un conteneur « déjà lancé » n’est pas un conteneur prêt. Le raccourci « il tourne, je sors » réouvre la même fenêtre.