Healthcheck in Docker Compose: far aspettare depends_on sul serio

Lo schema è sempre lo stesso. docker compose up, l’applicazione parte, prova a collegarsi al database, il database non c’è ancora, l’applicazione muore. Docker la fa ripartire, al secondo o al terzo giro il database ha finito di svegliarsi e tutto funziona. Sul portatile passa. In CI, dove la macchina è più lenta e i tentativi finiscono prima, no.
La tentazione è aggiungere depends_on: [db] e considerare il problema chiuso. Non lo è, e la documentazione di Docker lo dice in una riga: “On startup, Compose does not wait until a container is ‘ready’, only until it’s running”. Compose crea i servizi nell’ordine giusto e li rimuove nell’ordine inverso. Nient’altro. Che PostgreSQL abbia finito l’inizializzazione o stia ancora scrivendo su disco, per depends_on in forma breve è la stessa cosa.
Due pezzi, non uno
Perché l’attesa funzioni servono due cose insieme, e quasi sempre ne manca una.
La prima è una healthcheck sul servizio da aspettare: un comando che gira dentro il container e dice se il servizio risponde davvero. La seconda è depends_on in forma estesa, con condition: service_healthy, che lega il secondo servizio a quel verdetto.
Questo è l’esempio che sta nella documentazione di Compose:
services:
web:
build: .
depends_on:
db:
condition: service_healthy
restart: true
redis:
condition: service_started
redis:
image: redis
db:
image: postgres:18
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
Il doppio dollaro in $${POSTGRES_USER} non è un refuso: serve perché la variabile la risolva la shell dentro il container e non Compose quando legge il file.
Le condizioni ammesse sono tre. service_started è l’equivalente della forma breve. service_healthy aspetta che la healthcheck passi. service_completed_successfully aspetta che il servizio finisca e finisca bene — è quella giusta per un container che gira le migrazioni e poi esce, non per un demone.
Due opzioni in più che vale la pena conoscere: restart: true (da Compose 2.17.0) fa ripartire il servizio dipendente quando la dipendenza viene aggiornata, e required: false (da 2.20.0) abbassa la dipendenza a un avviso invece che a un errore bloccante.
Come si scrive la healthcheck
Il campo test accetta una lista o una stringa. Se è una lista, il primo elemento deve essere CMD, CMD-SHELL o NONE: CMD esegue direttamente, CMD-SHELL passa dalla shell del container, NONE disattiva il controllo. Se è una stringa, equivale a CMD-SHELL seguito da quella stringa. C’è anche disable: true, che serve a spegnere una healthcheck ereditata dall’immagine.
Il comando viene giudicato dal codice di uscita: 0 sano, 1 malato, 2 riservato. Non stampare niente e uscire con 0 è tutto quello che serve.
I valori di default, se non li si scrive, sono quelli dell’istruzione HEALTHCHECK del Dockerfile: interval 30s, timeout 30s, retries 3, start_period 0s, start_interval 5s. Trenta secondi di intervallo con tre tentativi vuol dire che nel caso peggiore il container viene dichiarato malato dopo un minuto e mezzo, e che un servizio lento a partire può restare in starting parecchio più di quanto uno si aspetti.
start_period è il parametro che si dimentica sempre ed è quello che risolve metà dei problemi: dentro quella finestra i fallimenti non contano verso il limite di retries. È lì per i servizi che ci mettono trenta secondi a diventare utili senza per questo essere rotti. start_interval (Compose 2.20.2, motore Docker 25.0) permette di sondare più fitto proprio in quella fase, così il servizio viene dichiarato sano appena è pronto invece che al prossimo interval.
Il caso PostgreSQL, dove anche l’esempio ufficiale inciampa
pg_isready senza argomenti di connessione passa dal socket Unix. E l’entrypoint dell’immagine ufficiale postgres, durante la prima inizializzazione, avvia un server temporaneo proprio su quel socket per eseguire gli script in /docker-entrypoint-initdb.d. La documentazione dell’immagine lo dice esplicitamente: “the temporary daemon started for these initialization scripts listens only on the Unix socket”. Nell’entrypoint il server temporaneo parte con -c listen_addresses=''.
Il risultato è che pg_isready risponde «pronto» mentre il database è ancora a metà del lavoro, e l’applicazione parte in anticipo. Sul primo up di un volume vuoto, cioè esattamente in CI.
La correzione è di un parametro: forzare il TCP.
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
Con listen_addresses vuoto il server temporaneo non ascolta su 127.0.0.1: il controllo fallisce finché non parte quello vero. È il comportamento che si voleva.
In CI
docker compose up --wait — “Wait for services to be running|healthy. Implies detached mode.” — restituisce il controllo solo quando tutti i servizi sono sani, e con --wait-timeout si mette un tetto. È una riga sola al posto dei soliti venti secondi di sleep sparsi nella pipeline.
Cosa evitare
curl nelle immagini slim. Metà delle healthcheck copiate da internet usano curl e metà delle immagini base non ce l’hanno: il comando esce con 127, il container non diventa mai sano e si perde un’ora. Meglio wget --spider -q, o ancora meglio un comando che il servizio porta già con sé (pg_isready, redis-cli ping, mysqladmin ping).
Controlli costosi. Una healthcheck che fa una query vera ogni cinque secondi è carico che paghi per sempre in cambio di un’informazione che ti serve per trenta secondi. Un endpoint che risponde 200 basta.
Scambiarla per una garanzia. service_healthy copre l’avvio. Il database può sparire anche dopo, e a quel punto l’unica difesa è che l’applicazione gestisca la riconnessione. La healthcheck fa partire le cose nell’ordine giusto: non rende l’applicazione resistente ai guasti.
