Healthcheck Docker Compose: controlli che funzionano
Scopri come Compose valuta gli healthcheck, perché depends_on non verifica la readiness e come controllare davvero Postgres e la tua app.
Cosa fa realmente un healthcheck di Docker Compose
Un healthcheck di Docker Compose è un comando che Docker esegue all'interno del container a intervalli regolari. Docker non legge i log, non monitora la porta e non analizza l'elenco dei processi. Esegue il comando, legge il codice di uscita e memorizza nel container un solo stato: starting, healthy o unhealthy. Il codice di uscita 0 indica che il container è integro. Qualsiasi altro codice indica che non è integro; il codice di uscita 2 è riservato da Docker, quindi non restituirlo mai intenzionalmente.
Questo è l'intero meccanismo. Quasi tutti i problemi degli healthcheck hanno la stessa causa: il comando scritto risponde a una domanda diversa da quella che si voleva porre. Questa guida presuppone che tu sappia già come scrivere un file compose su un VPS e riprende dal punto in cui lo stack viene avviato nell'ordine sbagliato.
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: 30sIl valore test ha due forme utili. Un elenco che inizia con CMD esegue direttamente il comando, senza una shell; quindi pipe, && ed espansione delle variabili non funzionano. Un elenco che inizia con CMD-SHELL passa il resto come un'unica stringa a /bin/sh -c all'interno del container. È la forma da usare quando il controllo richiede la sintassi della shell. Una stringa semplice viene trattata come CMD-SHELL. Un elenco composto esattamente da ["NONE"] rimuove l'healthcheck incorporato nell'immagine tramite il relativo Dockerfile.
Il controllo viene eseguito all'interno del container, quindi ogni binario indicato deve esistere nell'immagine. Verificalo prima, perché un'immagine minimale priva di curl produce un container permanentemente non integro per un motivo che non compare mai nel log dell'applicazione. Esegui il test manualmente:
docker compose exec api curl --versionUn binario mancante restituisce OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown. Le immagini basate su Alpine includono normalmente invece wget di BusyBox, quindi il controllo diventa ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"].
Combinazione di interval, retries e start_period
Cinque impostazioni controllano la temporizzazione. I relativi valori predefiniti provengono da Docker Engine, non da Compose.
interval: intervallo tra due controlli dopo che il container ha superato il periodo di avvio. Valore predefinito: 30s.timeout: durata massima di una singola esecuzione del controllo prima che Docker la termini e la consideri un errore. Valore predefinito: 30s.retries: numero di errori consecutivi necessari prima che lo stato passi aunhealthy. Valore predefinito: 3.start_period: periodo di tolleranza dopo l'avvio del container. Valore predefinito: 0s.start_interval: frequenza di esecuzione del controllo durante il periodo di avvio. Valore predefinito: 5s; è necessario Docker Engine 25.0 o versione successiva.
La regola importante è questa: durante il periodo di avvio, un controllo che restituisce un errore non viene conteggiato per retries e il container rimane in starting. Al primo controllo riuscito, il container passa a healthy e il periodo di avvio termina immediatamente, anche se gran parte del tempo previsto non è stata utilizzata. Se il periodo di avvio termina mentre il controllo continua a restituire errori, inizia il normale conteggio e il container deve registrare retries errori consecutivi prima di essere contrassegnato come unhealthy.
Pertanto, nel caso peggiore, il tempo dall'avvio del container a unhealthy è pari a start_period più retries moltiplicato per interval, più timeout. Con i valori del file precedente, il calcolo è 30 più 5 moltiplicato per 13, cioè 95 secondi. Annotare questo numero prima di impostare un timeout per il deploy: un rollout che termina dopo 60 secondi non vedrà mai questo container raggiungere uno stato finale.
L'errore più comune è aumentare retries per compensare un avvio lento. Questa soluzione funziona una volta, ma poi crea un problema permanente: un servizio che aveva bisogno di 8 tentativi per avviarsi tollererà ora 8 errori consecutivi in produzione prima che venga rilevato un problema. Usare invece start_period, perché si applica solo prima del primo controllo riuscito.
Perché depends_on da solo non garantisce nulla
La forma breve di depends_on è la principale fonte di confusione.
api:
depends_on:
- dbQuesto significa una sola cosa: avviare il container db prima del container api. Compose attende che il container venga creato e avviato. Non attende che PostgreSQL completi la prima inizializzazione e non attende che la porta 5432 accetti una connessione. L'applicazione si avvia circa un secondo dopo, tenta di connettersi a una porta sulla quale non è ancora in ascolto alcun processo e termina. Nel log viene visualizzato Connection refused oppure FATAL: the database system is starting up quando il server è attivo ma sta ancora completando il ripristino.
La forma estesa è quella che viene effettivamente utilizzata:
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition ha tre valori. service_started è uguale alla forma breve. service_healthy mantiene il servizio dipendente in attesa finché la dipendenza non segnala uno stato integro. Questa opzione è significativa solo quando la dipendenza definisce un healthcheck, nel file Compose o nella relativa immagine. service_completed_successfully attende che un container eseguito una sola volta, ad esempio per una migrazione del database, termini con codice di stato 0.
Accanto a condition sono disponibili altri due campi. restart: true indica a Compose di riavviare questo servizio dopo l'aggiornamento del servizio da cui dipende. required: false trasforma una dipendenza mancante da errore ad avviso.
Ora vediamo il limite che spesso causa problemi. Queste condizioni vengono valutate quando lo stack viene avviato. Definiscono l'ordine di avvio, non una regola di supervisione. Se il database si riavvia alle tre del mattino, nulla rivaluta service_healthy e nulla riavvia l'applicazione per soddisfare nuovamente la condizione. Il codice dell'applicazione deve comunque ristabilire autonomamente la connessione. docker compose up --no-deps api ignora deliberatamente l'intero meccanismo, così come l'avvio diretto di un container con docker start.
Scrivere un controllo che verifichi la disponibilità, non solo l'esistenza di un processo
Un controllo come pgrep nginx dimostra che esiste una voce nella tabella dei processi. Non dimostra che il servizio sia in grado di rispondere a una richiesta. Un'applicazione web può mantenere aperto il socket in ascolto anche dopo l'arresto del pool di connessioni al database, mentre il controllo del processo continua a risultare positivo per tutta l'interruzione.
Chiedi al container di eseguire l'operazione per cui esiste:
- Per un servizio HTTP, richiedi un endpoint reale.
curl -fsSrestituisce un codice diverso da zero per qualsiasi stato pari o superiore a 400 a causa di-f, quindi un errore 500 generato da un'applicazione non funzionante fa fallire il controllo. - Per PostgreSQL, usa
pg_isready, che restituisce 0 quando il server accetta connessioni, 1 quando le rifiuta, 2 quando non risponde affatto e 3 quando i parametri passati non sono corretti. - Per Redis, usa
redis-cli ping, che stampaPONGe restituisce 0. - Per MariaDB, l'immagine ufficiale include lo script
healthcheck.shehealthcheck.sh --connect --innodb_initializedè la sintassi documentata dai relativi manutentori.
pg_isready presenta un problema specifico da conoscere. Al primo avvio con una directory dati vuota, l'immagine ufficiale postgres esegue l'inizializzazione su un server temporaneo che ascolta solo sul socket Unix. pg_isready senza un argomento host usa quel socket, quindi può rispondere "accepting connections" mentre la porta TCP 5432 è ancora chiusa per l'applicazione. Indica esplicitamente TCP nel controllo per risolvere il problema, perché il server temporaneo non risponde tramite quella connessione.
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: 30sIl doppio simbolo del dollaro non è un errore di battitura. Compose espande direttamente $VAR durante la lettura del file e inserirebbe nel controllo un valore proveniente dall'ambiente dell'host. $$ lo fa arrivare come un singolo $, così la shell all'interno del container lo espande usando l'ambiente del container.
Uno stack PostgreSQL e applicazione che si avvia nell'ordine corretto
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:Avvialo e osserva il cambiamento degli stati:
docker compose up -d
docker compose psLa colonna STATUS contiene lo stato di salute tra parentesi quadre. Una coppia in buone condizioni mostra Up 41 seconds (healthy) su entrambe le righe. Mentre il database è ancora in fase di inizializzazione, db mostra Up 4 seconds (health: starting) e api non compare nell'elenco, perché Compose non lo ha ancora creato.
Per capire perché un controllo è riuscito o non è riuscito, leggi il log dello stato di salute:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker conserva gli ultimi risultati, ciascuno con un'ora di inizio, un'ora di fine, un ExitCode e l'Output del comando. L'output memorizzato viene troncato. Di conseguenza, un controllo che stampa il contenuto completo di una pagina produce una voce di log inutile. Mantieni concisi i controlli.
Cosa fa Docker quando un container diventa non integro
Nulla. Questa è la risposta che sorprende di più.
Docker Engine su un singolo host non riavvia un container non integro. La policy restart: unless-stopped reagisce quando il processo principale termina, mentre un container non integro non è terminato. Può rimanere nello stato unhealthy per una settimana mentre Compose non interviene. La modalità Swarm sostituisce i task non integri, ma un normale stack Compose su un solo server non lo fa.
Restano due opzioni corrette. Fare terminare il processo quando rileva di essere in errore, così la policy di riavvio può intervenire. Oppure monitorare lo stato dall'esterno e generare un avviso. Configurare un monitor Uptime Kuma sullo stesso endpoint chiamato dal controllo di integrità fa sì che una dipendenza non funzionante venga rilevata in entrambi i punti, e che l'avviso arrivi dal monitor invece che da un utente. Se il traffico raggiunge l'app tramite un reverse proxy Traefik, ricordare che la visualizzazione del backend da parte del proxy è separata dallo stato di integrità Docker: una non sostituisce l'altro.
Debug del controllo che non diventa mai integro
Eseguite personalmente il comando esatto nello stesso container e controllate il codice di uscita:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"La presenza di exit=0 mentre il container continua a segnalare uno stato non integro indica che il vostro file compose test è diverso da quello appena digitato. Di solito, questo accade perché è stato usato CMD al posto della sintassi della shell.
Per la maggior parte degli altri casi bastano due errori. Il primo riguarda la porta sbagliata. Il controllo dello stato viene eseguito all'interno del container, quindi deve usare la porta del container, mai la porta pubblicata sull'host. Con ports: - "8080:3000" l'applicazione è in ascolto sulla porta 3000. Un controllo diretto a http://localhost:8080 continua quindi a fallire, anche se il sito funziona correttamente nel browser. Il secondo riguarda l'host sbagliato. All'interno del controllo, localhost indica lo stesso container. È corretto per controllare il container stesso, ma non per controllare un container vicino. In quest'ultimo caso serve il nome del servizio, ad esempio db.
Esiste infine un caso specifico: il controllo dello stato ha esito positivo, mentre gli utenti visualizzano errori. Accade quando l'endpoint restituisce un codice 200 statico senza verificare alcuna dipendenza reale. Un endpoint di readiness che non esegue mai query sul database non può segnalare che il database non è più disponibile. Fate in modo che esegua una query reale e poco costosa.
FAQ
Perché la mia applicazione continua a non connettersi quando depends_on indica che il database è integro?
Perché condition: service_healthy viene valutato una sola volta, all'avvio dello stack. In seguito non esegue alcun monitoraggio. Se il container del database viene riavviato, Compose non riavvia l'applicazione per verificare nuovamente la condizione. Il codice dell'applicazione deve quindi implementare una propria logica di riconnessione e nuovi tentativi. La condizione non ha effetto nemmeno quando avvii un singolo container con docker start o con docker compose up --no-deps.
Devo definire un healthcheck se l'immagine ne include già uno?
Di solito no. Sovrascriverlo spesso è un passo indietro, perché il manutentore dell'immagine conosce il significato di disponibilità per quel software. Aggiungine uno personalizzato solo quando il controllo dell'immagine non è adatto alla tua configurazione, ad esempio quando verifica una porta che hai spostato. Per disattivare l'healthcheck dell'immagine, imposta test: ["NONE"] o disable: true sul servizio.
L'healthcheck deve usare curl o wget?
Usa quello che è già presente nell'immagine e verificane la presenza con docker compose exec <service> curl --version prima di farvi affidamento. Molte immagini basate su Debian non includono né l'uno né l'altro. Le immagini basate su Alpine includono wget di BusyBox. Non aggiungere un pacchetto a un'immagine solo per eseguire un healthcheck quando il software distribuisce un proprio client, come pg_isready o redis-cli.
Un container non integro viene riavviato automaticamente?
Non da Docker Engine su un singolo host. Le policy di riavvio reagiscono alla terminazione del processo, non allo stato di salute. Pertanto, un container non integro rimane attivo e guasto finché un altro componente non interviene. Puoi fare in modo che il processo termini quando rileva il problema oppure eseguire un monitor esterno che generi un avviso in base allo stato.
Quanto deve durare start_period?
Deve essere sufficiente per il primo avvio legittimo più lento che hai misurato, con un margine aggiuntivo. Misuralo con docker compose up usando un volume vuoto, perché il primo avvio di un database è molto più lento di tutti gli avvii successivi. Un periodo di avvio troppo lungo ritarda soltanto il primo responso unhealthy. Un numero di tentativi troppo elevato rende il controllo meno efficace per l'intera durata del container, causando il problema più grave.