Healthcheck no Docker Compose: como testar de verdade
Entenda como o Compose avalia healthchecks, por que depends_on não garante prontidão e como criar testes confiáveis para Postgres e sua aplicação.
O que um healthcheck do Docker Compose realmente faz
Um healthcheck do Docker Compose é um comando que o Docker executa dentro do container em intervalos regulares. O Docker não lê seus logs, monitora sua porta nem inspeciona a lista de processos. Ele executa o comando, lê o código de saída e armazena um único estado no container: starting, healthy ou unhealthy. O código de saída 0 significa saudável. Qualquer outro código de saída significa não saudável, e o código de saída 2 é reservado pelo Docker. Portanto, nunca o retorne intencionalmente.
Esse é todo o mecanismo. Quase todo problema de healthcheck é o mesmo problema: o comando que você escreveu responde a uma pergunta diferente daquela que você queria fazer. Este guia pressupõe que você já sabe como escrever um arquivo compose em um VPS e continua a partir do ponto em que a stack é iniciada na ordem incorreta.
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: 30sO valor test tem duas formas úteis. Uma lista que começa com CMD executa o comando diretamente, sem um shell. Portanto, pipes, && e expansão de variáveis não funcionam. Uma lista que começa com CMD-SHELL passa o restante como uma única string para /bin/sh -c dentro do container. Essa é a opção correta sempre que a verificação precisar de sintaxe de shell. Uma string simples é tratada como CMD-SHELL. Uma lista com exatamente ["NONE"] remove um healthcheck incluído na imagem pelo Dockerfile.
A verificação é executada dentro do container. Portanto, todos os binários mencionados nela precisam existir nessa imagem. Verifique isso primeiro. Uma imagem enxuta sem curl cria um container que permanece permanentemente não saudável por um motivo que nunca aparece no log da aplicação. Teste manualmente:
docker compose exec api curl --versionUm binário ausente retorna OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown. Imagens baseadas em Alpine geralmente incluem o wget do BusyBox. Nesse caso, a verificação fica assim:
["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"].
Como interval, retries e start_period se combinam
Cinco configurações controlam o tempo. Os valores padrão vêm do Docker Engine, não do Compose.
interval: intervalo entre duas verificações depois que o container ultrapassa o período de inicialização. O padrão é 30s.timeout: tempo máximo que uma execução da verificação pode durar antes que o Docker a encerre e conte essa execução como uma falha. O padrão é 30s.retries: número de falhas consecutivas necessárias antes que o estado mude paraunhealthy. O padrão é 3.start_period: período de tolerância depois que o container é iniciado. O padrão é 0s.start_interval: frequência de execução da verificação durante o período de inicialização. O padrão é 5s, e esse recurso requer o Docker Engine 25.0 ou mais recente.
A regra importante é: durante o período de inicialização, uma verificação com falha não conta para retries, e o container permanece em starting. Na primeira vez que a verificação for bem-sucedida, o container passa para healthy e o período de inicialização termina imediatamente, mesmo que ainda restasse a maior parte do tempo. Se o período de inicialização terminar enquanto a verificação continuar falhando, começa a contagem normal, e o container precisa de retries falhas consecutivas antes de ser marcado como unhealthy.
Portanto, no pior caso, o tempo desde o início do container até unhealthy é start_period mais retries multiplicado por interval, mais timeout. Com os valores do arquivo acima, isso corresponde a 30 mais 5 vezes 13, ou seja, 95 segundos. Anote esse número antes de definir um tempo limite para o deploy, porque um rollout que desiste após 60 segundos nunca verá esse container chegar a um estado final.
O erro comum nesse caso é aumentar retries para compensar uma inicialização lenta. Isso funciona uma vez e depois causa problemas permanentemente: um serviço que precisou de 8 tentativas para iniciar passa a tolerar 8 falhas consecutivas em produção antes que qualquer ação seja tomada. Use start_period, porque ele se aplica somente antes do primeiro sucesso.
Por que depends_on sozinho não garante nada
A forma abreviada de depends_on é a principal causa da confusão.
api:
depends_on:
- dbIsso significa uma coisa: iniciar o contêiner db antes do contêiner api. O Compose aguarda a criação e o início do contêiner. Ele não aguarda a conclusão da inicialização inicial do PostgreSQL nem aguarda a porta 5432 aceitar uma conexão. Seu aplicativo é iniciado cerca de um segundo depois, tenta se conectar a uma porta que ainda não está sendo monitorada por nenhum processo e é encerrado. No log, você vê Connection refused ou FATAL: the database system is starting up quando o servidor está ativo, mas ainda está se recuperando.
A forma completa é o que as pessoas realmente precisam:
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition tem três valores. service_started é igual à forma abreviada. service_healthy mantém o serviço dependente parado até que a dependência informe um estado saudável, o que só é significativo quando essa dependência define um healthcheck, no arquivo Compose ou na imagem. service_completed_successfully aguarda um contêiner executado uma única vez, como uma migração de banco de dados, terminar com o status 0.
Há dois campos adicionais ao lado de condition. restart: true instrui o Compose a reiniciar este serviço depois que ele atualizar o serviço da dependência. required: false transforma uma dependência ausente de erro em aviso.
Agora, o limite que causa problemas. Essas condições são avaliadas quando a stack é iniciada. Elas definem a ordem de inicialização, não uma regra de supervisão. Se o banco de dados for reiniciado às três da manhã, nada reavaliará service_healthy e nada reiniciará seu aplicativo para satisfazer essa condição novamente. O código do aplicativo ainda precisa restabelecer a conexão por conta própria. docker compose up --no-deps api ignora todo o mecanismo por definição, assim como iniciar um contêiner diretamente com docker start.
Escreva uma verificação que teste a prontidão, não apenas a existência de um processo
Uma verificação como pgrep nginx prova que existe uma entrada na tabela de processos. Ela não prova que o serviço consegue responder a uma solicitação. Uma aplicação web pode manter o socket de escuta aberto muito depois de o pool de conexões do banco de dados ter parado de funcionar, e a verificação do processo continuará indicando sucesso durante toda a indisponibilidade.
Peça ao contêiner para executar a tarefa para a qual ele existe:
- Para um serviço HTTP, solicite um endpoint real.
curl -fsSretorna um código diferente de zero para qualquer status 400 ou superior por causa de-f, portanto um 500 causado por uma aplicação com falha faz a verificação falhar. - Para PostgreSQL, use
pg_isready, que retorna 0 quando o servidor aceita conexões, 1 quando as rejeita, 2 quando não responde de forma alguma e 3 quando os parâmetros informados estão incorretos. - Para Redis, use
redis-cli ping, que imprimePONGe retorna 0. - Para MariaDB, a imagem oficial inclui um script
healthcheck.sh, ehealthcheck.sh --connect --innodb_initializedé o formato documentado pelos mantenedores.
pg_isready tem uma particularidade importante. Na primeira inicialização com um diretório de dados vazio, a imagem oficial postgres executa a inicialização em um servidor temporário que aceita conexões somente pelo socket Unix. pg_isready sem um argumento de host usa esse socket. Assim, ele pode informar que está "aceitando conexões" enquanto a porta TCP 5432 ainda está fechada para a sua aplicação. Aponte a verificação explicitamente para TCP e o problema desaparece, porque o servidor temporário não responde nesse protocolo.
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: 30sOs cifrões duplicados não são um erro de digitação. O Compose expande $VAR durante a leitura do arquivo, o que incorporaria na verificação um valor do ambiente do host. $$ faz o escape para um único $, permitindo que o shell dentro do contêiner o expanda usando o próprio ambiente do contêiner.
Uma stack de PostgreSQL e aplicação que inicia na ordem correta
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:Inicie a stack e monitore a mudança dos estados:
docker compose up -d
docker compose psA coluna STATUS mostra o estado de integridade entre colchetes. Um par saudável exibe Up 41 seconds (healthy) nas duas linhas. Enquanto o banco de dados ainda está sendo inicializado, db exibe Up 4 seconds (health: starting) e api não aparece na lista, porque o Compose ainda não o criou.
Para entender por que uma verificação foi aprovada ou falhou, leia o log de integridade:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"O Docker mantém os últimos resultados, cada um com um horário de início, um horário de término, um ExitCode e o Output do comando. A saída armazenada é truncada. Portanto, uma verificação que imprime o corpo completo de uma página gera uma entrada de log inútil. Mantenha as verificações silenciosas.
O que o Docker faz quando um contêiner fica não saudável
Nada. Esta é a resposta que mais surpreende as pessoas.
O Docker Engine em um único host não reinicia um contêiner não saudável. A política restart: unless-stopped reage quando o processo principal é encerrado, e um contêiner não saudável não foi encerrado. Ele pode permanecer em unhealthy por uma semana enquanto o Compose não faz nada. O modo Swarm substitui tarefas não saudáveis, mas uma stack comum do Compose em um servidor não faz isso.
Isso deixa duas opções realistas. Faça o processo ser encerrado quando detectar que está com problemas, para que a política de reinício tenha algo a fazer. Ou monitore o estado externamente e gere um alerta. Apontar um monitor do Uptime Kuma para o mesmo endpoint chamado pelo healthcheck faz uma dependência com falha aparecer nos dois lugares, e você fica sabendo pelo monitor, não por um usuário. Se o tráfego chegar ao aplicativo por meio de um proxy reverso Traefik, lembre-se de que a visão do proxy sobre um backend é separada do estado de integridade do Docker. Portanto, um não cobre o outro.
Depuração de uma verificação que nunca fica saudável
Execute você mesmo o comando exato, no mesmo container, e verifique o código de saída:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"exit=0 aqui, enquanto o container ainda informa que está não saudável, significa que o seu test do compose é diferente do que você acabou de digitar, geralmente porque CMD foi usado onde era necessária a sintaxe do shell.
Duas falhas respondem pela maior parte dos casos restantes. A primeira é a porta incorreta. O healthcheck é executado dentro do container, portanto deve usar a porta do container, nunca a porta do host publicada. Com ports: - "8080:3000", a aplicação escuta na porta 3000, e uma verificação contra http://localhost:8080 falha indefinidamente, enquanto o site funciona normalmente no navegador. A segunda é o host incorreto. Dentro da verificação, localhost é o próprio container, o que é correto para verificar esse container e incorreto para verificar um container vizinho. Nesse caso, você precisa do nome do serviço, por exemplo db.
Há um último caso que merece ser identificado: o healthcheck passa enquanto os usuários veem erros. Isso acontece quando o endpoint retorna um 200 estático sem verificar nada real. Um endpoint de prontidão que nunca consulta o banco de dados não pode informar que o banco de dados está indisponível. Faça-o executar uma consulta real simples.
FAQ
Por que meu aplicativo ainda falha ao se conectar quando depends_on informa que o banco de dados está saudável?
Porque condition: service_healthy é avaliado uma vez, quando a pilha é iniciada. Depois disso, ele não supervisiona nada. Se o contêiner do banco de dados for reiniciado mais tarde, o Compose não reiniciará seu aplicativo para atender à condição novamente. Portanto, o código do aplicativo precisa ter sua própria lógica de reconexão e novas tentativas. A condição também não faz nada quando você inicia um único contêiner com docker start ou com docker compose up --no-deps.
Preciso de um healthcheck se a imagem já definir um?
Geralmente não. Substituí-lo costuma ser um retrocesso, porque o mantenedor da imagem sabe o que significa estar pronto para aquele software. Adicione seu próprio healthcheck somente quando a verificação da imagem estiver incorreta para sua configuração, por exemplo, quando ela consultar uma porta que você alterou. Para desativar o healthcheck da imagem, defina test: ["NONE"] ou disable: true no serviço.
O healthcheck deve usar curl ou wget?
Use o que já existir na imagem e confirme isso com docker compose exec <service> curl --version antes de depender dele. Muitas imagens baseadas em Debian não têm nenhum dos dois. Imagens baseadas em Alpine têm o wget do BusyBox. Não adicione um pacote à imagem apenas para executar um healthcheck quando o próprio software fornecer um cliente, como pg_isready ou redis-cli.
Um contêiner não saudável é reiniciado automaticamente?
Não pelo Docker Engine em um único host. As políticas de reinício reagem à saída do processo, não ao estado de saúde. Portanto, um contêiner não saudável permanece em execução e continua com falha até que outra ação seja executada. Faça o processo sair quando detectar a falha ou execute um monitor externo que gere um alerta sobre o estado.
Qual deve ser a duração de start_period?
Ela deve ser suficiente para a primeira inicialização legítima mais lenta que você mediu, com uma margem adicional. Meça o tempo com docker compose up usando um volume vazio, pois a primeira inicialização de um banco de dados é muito mais lenta que todas as inicializações seguintes. Um start period longo demais apenas atrasa o primeiro veredito de unhealthy. Um número excessivo de novas tentativas enfraquece a verificação durante toda a vida útil do contêiner, o que é a pior falha.