Healthchecks de Docker Compose que funcionan
Entiende cómo se evalúan los healthchecks, por qué depends_on no espera readiness y cómo comprobar Postgres y tu aplicación con comandos fiables.
Qué hace realmente un healthcheck de Docker Compose
Un healthcheck de Docker Compose es un comando que Docker ejecuta dentro del contenedor según un intervalo. Docker no lee los logs, no supervisa el puerto ni inspecciona la lista de procesos. Ejecuta el comando, lee el código de salida y almacena un único estado en el contenedor: starting, healthy o unhealthy. El código de salida 0 indica que el contenedor está healthy. Cualquier otro código de salida indica que está unhealthy, y el código de salida 2 está reservado por Docker, por lo que nunca debe devolverlo intencionadamente.
Ese es todo el mecanismo. Casi todos los problemas de healthcheck son el mismo problema: el comando escrito responde a una pregunta distinta de la que se quería plantear. Esta guía presupone que ya sabe cómo escribir un archivo compose en un VPS y continúa desde el punto en que el stack se inicia en el orden incorrecto.
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: 30sEl valor test tiene dos formas útiles. Una lista que empieza por CMD ejecuta el comando directamente, sin shell, por lo que las tuberías, && y la expansión de variables no funcionan. Una lista que empieza por CMD-SHELL pasa el resto como una sola cadena a /bin/sh -c dentro del contenedor. Esta es la opción adecuada cuando la comprobación necesita sintaxis de shell. Una cadena simple se trata como CMD-SHELL. Una lista formada exactamente por ["NONE"] elimina el healthcheck que la imagen incorporó mediante su Dockerfile.
La comprobación se ejecuta dentro del contenedor, por lo que todos los binarios que menciona deben existir en esa imagen. Verifíquelo primero. Una imagen slim sin curl produce un contenedor que permanece permanentemente unhealthy por un motivo que nunca aparece en el log de la aplicación. Pruébelo manualmente:
docker compose exec api curl --versionSi falta el binario, la respuesta es OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown. Las imágenes basadas en Alpine suelen incluir BusyBox wget en su lugar, por lo que la comprobación queda como ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"].
Cómo se combinan interval, retries y start_period
Cinco opciones controlan los tiempos. Sus valores predeterminados proceden de Docker Engine, no de Compose.
interval: tiempo entre dos comprobaciones cuando el contenedor ya ha superado su período de inicio. Valor predeterminado: 30s.timeout: tiempo máximo que puede durar una ejecución de la comprobación antes de que Docker la finalice y cuente esa ejecución como un fallo. Valor predeterminado: 30s.retries: número de fallos consecutivos necesarios para que el estado cambie aunhealthy. Valor predeterminado: 3.start_period: período de gracia después de que se inicia el contenedor. Valor predeterminado: 0s.start_interval: frecuencia con la que se ejecuta la comprobación durante el período de inicio. Valor predeterminado: 5s; requiere Docker Engine 25.0 o posterior.
La regla importante es la siguiente: durante el período de inicio, una comprobación fallida no cuenta para retries y el contenedor permanece en starting. La primera vez que la comprobación tiene éxito, el contenedor pasa a healthy y el período de inicio termina inmediatamente, aunque todavía quede la mayor parte del tiempo disponible. Si el período de inicio termina mientras la comprobación sigue fallando, comienza la cuenta normal y el contenedor necesita retries fallos consecutivos antes de marcarse como unhealthy.
Por tanto, el peor caso desde el inicio del contenedor hasta unhealthy es start_period más retries multiplicado por interval, más timeout. Con los valores del archivo anterior, son 30 más 5 por 13, es decir, 95 segundos. Anote ese número antes de establecer un tiempo de espera para el despliegue, porque una implementación que abandone después de 60 segundos nunca verá que este contenedor alcance un estado final.
El error habitual es aumentar retries para compensar un inicio lento. Esto funciona una vez, pero después tiene consecuencias permanentes: un servicio que necesitó 8 reintentos para iniciarse ahora tolera 8 fallos consecutivos en producción antes de que se detecte el problema. Use start_period en su lugar, porque solo se aplica antes del primer éxito.
Por qué depends_on por sí solo no garantiza nada
La forma abreviada de depends_on es la causa de la mayoría de las confusiones.
api:
depends_on:
- dbEsto significa una sola cosa: iniciar el contenedor db antes que el contenedor api. Compose espera a que el contenedor se cree y se inicie. No espera a que PostgreSQL termine su inicialización inicial ni a que el puerto 5432 acepte conexiones. La aplicación se inicia aproximadamente un segundo después, intenta conectarse a un puerto en el que todavía no hay ningún proceso escuchando y se cierra. En el registro aparece Connection refused o FATAL: the database system is starting up cuando el servidor ya está activo, pero todavía se está recuperando.
La forma completa es la que normalmente se necesita:
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition tiene tres valores. service_started es igual que la forma abreviada. service_healthy mantiene detenido el servicio dependiente hasta que la dependencia informe de un estado saludable. Esto solo es útil cuando esa dependencia define un healthcheck, ya sea en el archivo de Compose o en su imagen. service_completed_successfully espera a que un contenedor de ejecución única, como una migración de base de datos, termine con el estado 0.
Junto a condition hay dos campos adicionales. restart: true indica a Compose que reinicie este servicio después de actualizar el servicio del que depende. required: false convierte la ausencia de una dependencia, que normalmente sería un error, en una advertencia.
Ahora, el límite que suele causar problemas. Estas condiciones se evalúan cuando se inicia la pila. Definen el orden de inicio, no una regla de supervisión. Si la base de datos se reinicia a las tres de la madrugada, nada vuelve a evaluar service_healthy y nada reinicia la aplicación para cumplirlo de nuevo. El código de la aplicación todavía debe volver a conectarse por su cuenta. docker compose up --no-deps api omite todo este mecanismo de forma intencionada, al igual que iniciar un contenedor directamente con docker start.
Escriba una comprobación que pruebe la disponibilidad, no solo la existencia de un proceso
Una comprobación como pgrep nginx demuestra que existe una entrada en la tabla de procesos. No demuestra que el servicio pueda responder a una solicitud. Una aplicación web puede mantener abierto su socket de escucha mucho después de que su conjunto de conexiones a la base de datos haya dejado de funcionar, y la comprobación del proceso seguirá indicando que todo está bien durante toda la interrupción.
Pida al contenedor que realice la tarea para la que existe:
- Para un servicio HTTP, solicite un endpoint real.
curl -fsSdevuelve un código distinto de cero con cualquier estado 400 o superior debido a-f, por lo que un 500 de una aplicación averiada hace que la comprobación falle. - Para PostgreSQL, use
pg_isready. Devuelve 0 cuando el servidor acepta conexiones, 1 cuando las rechaza, 2 cuando no responde y 3 cuando los parámetros proporcionados son incorrectos. - Para Redis, use
redis-cli ping. ImprimePONGy devuelve 0. - Para MariaDB, la imagen oficial incluye un script
healthcheck.sh, yhealthcheck.sh --connect --innodb_initializedes el formato que documentan sus mantenedores.
pg_isready tiene un detalle importante. En su primer inicio con un directorio de datos vacío, la imagen oficial de postgres ejecuta la inicialización contra un servidor temporal que solo escucha en el socket Unix. pg_isready sin un argumento de host usa ese socket, por lo que puede responder «aceptando conexiones» mientras el puerto TCP 5432 sigue cerrado para la aplicación. Dirija la comprobación explícitamente a TCP para resolver el problema, porque el servidor temporal no responde allí.
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: 30sLos signos de dólar duplicados no son un error tipográfico. Compose expande $VAR mientras lee el archivo, lo que incorporaría en la comprobación un valor del entorno del host. $$ lo escapa hasta convertirlo en un solo $, de modo que el shell dentro del contenedor lo expande usando el entorno propio del contenedor.
Una pila de postgres y la aplicación que se inicia en el orden correcto
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:Iníciala y observa cómo cambian los estados:
docker compose up -d
docker compose psLa columna STATUS contiene el estado de salud entre corchetes. Una pareja en buen estado muestra Up 41 seconds (healthy) en ambas filas. Mientras la base de datos todavía se inicializa, db muestra Up 4 seconds (health: starting) y api no aparece en la lista porque Compose todavía no la ha creado.
Para ver por qué una comprobación se aprobó o falló, lee el registro de estado de salud:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker conserva los últimos resultados, cada uno con una hora de inicio, una hora de finalización, un ExitCode y el Output del comando. La salida almacenada se trunca, por lo que una comprobación que imprime el contenido completo de una página produce una entrada de registro inútil. Mantén las comprobaciones silenciosas.
Qué hace Docker cuando un contenedor deja de estar saludable
Nada. Esta es la respuesta que más sorprende.
Docker Engine en un solo host no reinicia un contenedor que no está saludable. La política restart: unless-stopped reacciona cuando el proceso principal termina, y un contenedor que no está saludable no ha terminado. Puede permanecer en unhealthy durante una semana mientras Compose no hace nada. Swarm mode reemplaza las tareas que no están saludables, pero una pila de Compose normal en un solo servidor no lo hace.
Esto deja dos opciones claras. Hacer que el proceso termine cuando detecte que está averiado, para que la política de reinicio pueda actuar. O supervisar el estado desde fuera y generar una alerta. Configurar un monitor de Uptime Kuma para el mismo endpoint que consulta el healthcheck hace que una dependencia averiada aparezca en ambos lugares, y la alerta llegue desde el monitor en vez de desde un usuario. Si el tráfico llega a la aplicación mediante un proxy inverso Traefik, recuerde que la vista que tiene el proxy de un backend es independiente del estado de salud de Docker; por tanto, uno no sustituye al otro.
Depuración de una comprobación que nunca alcanza el estado saludable
Ejecute usted mismo el comando exacto, en el mismo contenedor, y compruebe el código de salida:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"Que exit=0 aquí mientras el contenedor sigue informando de un estado no saludable significa que su test de Compose difiere de lo que acaba de escribir. Normalmente, se debe a que se usó CMD donde era necesaria la sintaxis del shell.
La mayoría de los casos restantes se deben a 2 errores. El primero es usar el puerto incorrecto. La comprobación de estado se ejecuta dentro del contenedor, por lo que debe usar el puerto del contenedor, nunca el puerto del host publicado. Con ports: - "8080:3000", la aplicación escucha en 3000, y una comprobación contra http://localhost:8080 falla continuamente mientras el sitio funciona correctamente en un navegador. El segundo es usar el host incorrecto. Dentro de la comprobación, localhost es el mismo contenedor. Esto es correcto para comprobar ese contenedor, pero no para comprobar otro contenedor. En ese caso, necesita el nombre del servicio, por ejemplo db.
Hay un último caso que merece una mención: la comprobación de estado se ejecuta correctamente mientras los usuarios ven errores. Esto ocurre cuando el endpoint devuelve un código 200 estático sin comprobar ningún componente real. Un endpoint de disponibilidad que nunca consulta la base de datos no puede indicar que la base de datos está caída. Haga que ejecute una consulta real sencilla.
FAQ
¿Por qué mi aplicación sigue sin poder conectarse cuando depends_on indica que la base de datos está disponible?
Porque condition: service_healthy se evalúa una sola vez, cuando se inicia el stack. Después no supervisa nada. Si el contenedor de la base de datos se reinicia más tarde, Compose no reinicia la aplicación para volver a cumplir la condición. Por tanto, el código de la aplicación necesita su propia lógica de reconexión y reintento. La condición tampoco tiene efecto cuando inicia un solo contenedor con docker start o con docker compose up --no-deps.
¿Necesito un healthcheck si la imagen ya define uno?
Normalmente no. Sobrescribirlo suele ser un paso atrás, porque el mantenedor de la imagen sabe qué significa que ese software esté listo. Añada uno propio solo cuando la comprobación de la imagen no sea adecuada para su configuración, por ejemplo, si comprueba un puerto que ha cambiado. Para desactivar el healthcheck de una imagen, establezca test: ["NONE"] o disable: true en el servicio.
¿Debe el healthcheck usar curl o wget?
Use el que ya exista en la imagen y confírmelo con docker compose exec <service> curl --version antes de depender de él. Muchas imágenes basadas en Debian no incluyen ninguno de los dos. Las imágenes basadas en Alpine incluyen BusyBox wget. No añada un paquete a una imagen solo para ejecutar un healthcheck cuando el propio software incluye su cliente, como pg_isready o redis-cli.
¿Se reinicia automáticamente un contenedor cuyo estado es unhealthy?
No por parte de Docker Engine en un solo host. Las políticas de reinicio reaccionan a la salida del proceso, no al estado de salud. Por tanto, un contenedor unhealthy permanece activo y sigue fallando hasta que otra acción interviene. Haga que el proceso termine cuando detecte el fallo o ejecute un monitor externo que genere una alerta sobre el estado.
¿Cuánto debe durar start_period?
Debe ser suficiente para el primer inicio legítimo más lento que haya medido, con un margen adicional. Mídalo con docker compose up usando un volumen vacío, porque el primer inicio de una base de datos es mucho más lento que los siguientes. Un start period demasiado largo solo retrasa el primer veredicto de unhealthy. Un número de reintentos demasiado alto debilita la comprobación durante toda la vida del contenedor, lo que constituye el fallo más grave.