Docker healthchecks: qué miden de verdad y qué no deberías prometer
La solución correcta para saber si tu contenedor está sano es dejar de preguntarle al contenedor si está sano. Sé que suena raro. Dejame explicar por qué un HEALTHCHECK que responde 200 OK puede estar mintiéndote en la cara.
El problema no es la instrucción en sí. Es la promesa implícita que le asignamos: si el healthcheck pasa, la app funciona. Eso es lo que no cierra. Un proceso puede responder en /healthz y al mismo tiempo tener la base desconectada, la cola saturada o un worker interno colgado. El HEALTHCHECK de Docker no sabe nada de eso a menos que vos se lo enseñes explícitamente.
Mi tesis: el HEALTHCHECK es una señal operativa útil pero estrecha. Decirle a alguien "si el healthcheck pasa, el servicio está bien" es prometer algo que la herramienta no puede cumplir.
Qué dice la documentación oficial — y qué no dice
La referencia oficial de HEALTHCHECK en Dockerfile describe la instrucción con precisión. Lo que hace: ejecuta un comando periódicamente dentro del contenedor y actualiza el estado del contenedor entre starting, healthy y unhealthy según el exit code. Exit 0 = healthy. Exit 1 = unhealthy. Exit 2 = reservado (no usar).
# Patrón básico según la documentación oficial
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD curl -f http://localhost:3000/healthz || exit 1
Lo que la documentación no dice: qué tiene que responder ese endpoint para que el chequeo sea significativo. Eso es decisión tuya, y ahí está el problema que más veo en codebases de producción ajenas.
Los parámetros disponibles son --interval, --timeout, --start-period, --retries y --start-interval (agregado en Dockerfile v1.4). Cada uno tiene un default razonable pero no universal. Lo que ningún parámetro puede hacer es entender el dominio del negocio que corre adentro del contenedor.
Una cosa más que la documentación menciona sin énfasis: Docker no reinicia el contenedor cuando pasa a unhealthy. Eso depende de la política de restart o del orquestador. En Railway, por ejemplo, el comportamiento ante un contenedor unhealthy depende de la configuración del servicio, no de Docker solo. Si esperás que Docker resuelva el problema al detectar el fallo, vas a esperar sentado.
La receta estándar y su costo oculto
La receta que aparece en el 80% de los Dockerfiles que leo sigue este patrón:
# Receta común — funciona para liveness, no para readiness completo
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:8080/health || exit 1
El endpoint /health devuelve { "status": "ok" } y HTTP 200. El contenedor figura como healthy. Todo prolijo.
Ahora imaginate este escenario reproducible: el servidor HTTP levantó, responde en el puerto, pero el pool de conexiones a Postgres está agotado porque hubo un spike de tráfico y nadie liberó conexiones correctamente. Las requests del mundo real fallan con 503. El healthcheck sigue pasando porque pregunta al proceso, no a la base.
Esto no es hipotético ni un incidente inventado — es el comportamiento exacto que obtenés si el endpoint /health no verifica el pool. Y la mayoría de los endpoints /health que existen en repos públicos no lo hacen. Verifican que el proceso arrancó, no que el servicio sirve tráfico.
La diferencia tiene nombre: liveness vs readiness. Kubernetes los separó en dos probes distintas por una razón. Docker tiene una sola instrucción HEALTHCHECK, lo que obliga a elegir qué querés medir.
# Endpoint que verifica liveness (el proceso vive)
# GET /healthz → 200 siempre que el server responda
# Endpoint que verifica readiness real (el servicio puede atender)
# GET /ready → 200 solo si DB conectada, caché disponible, workers activos
Si usás un solo endpoint para ambas cosas, lo que perdés es precisión diagnóstica. El contenedor figura healthy cuando en realidad está vivo pero no listo.
Dónde la gente se equivoca: tres patrones con consecuencias
1. Healthcheck que no cubre dependencias externas
# Esto solo confirma que Node.js levantó y escucha
HEALTHCHECK CMD node -e "require('http').get('http://localhost:3000/health')"
Si Postgres está caído, este check igual pasa. La forma de cambiar eso es hacer que /health consulte activamente las dependencias críticas:
// src/health/route.ts — Next.js App Router
import { db } from "@/lib/db"; // tu cliente de base de datos
export async function GET() {
try {
// Consulta mínima para verificar conectividad real
await db.$queryRaw`SELECT 1`;
return Response.json({ status: "ok", db: "connected" });
} catch {
// Exit implícito con 503 — el healthcheck lo va a leer como unhealthy
return Response.json(
{ status: "degraded", db: "unreachable" },
{ status: 503 }
);
}
}
Ahora el check mide algo real. Pero atención al trade-off: cada invocación del healthcheck hace una query a la base. Con --interval=10s en un servicio con muchas instancias, eso se acumula. Elegí el intervalo con criterio, no con el default.
2. --start-period demasiado corto para apps pesadas
# Spring Boot puede tardar 20-40s en arrancar dependiendo del contexto
# Con start-period=5s, el contenedor pasa a unhealthy antes de estar listo
HEALTHCHECK --start-period=5s --interval=10s CMD curl -f http://localhost:8080/actuator/health || exit 1
Si usás Railway o cualquier plataforma que reacciona al estado unhealthy, un --start-period corto puede matar el contenedor antes de que arranque. No es un bug de Docker — es calibración incorrecta. La documentación oficial especifica que durante el start-period los failures no cuentan como unhealthy, pero si la app no levantó antes de que termine ese período, el primer chequeo real puede fallar.
3. Ausencia total de HEALTHCHECK
Sin instrucción HEALTHCHECK, el contenedor siempre figura en estado none. Para Docker Compose eso significa que los depends_on: condition: service_healthy no funcionan. Para Railway y plataformas similares, significa que no tenés señal de estado operativo.
# docker-compose.yml — patrón con dependencia de salud
services:
app:
build: .
depends_on:
postgres:
condition: service_healthy # Requiere que postgres tenga HEALTHCHECK
postgres:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
Sin HEALTHCHECK en postgres, el depends_on con condition: service_healthy falla en runtime. Es el tipo de error que aparece a las 11pm cuando hacés un deploy nuevo y no recordás por qué ese servicio tardaba en arrancar — hasta que revisás los logs y ves que la app conectó antes de que Postgres estuviera lista.
Matriz de decisión: qué chequear y cuándo importa
| Escenario | Qué medir | Endpoint sugerido | Costo a considerar |
|---|---|---|---|
| Solo liveness | Proceso vive |
/healthz — responde 200 siempre |
Mínimo |
| Readiness con DB | DB accesible |
/ready — SELECT 1 o equivalente |
Una query por chequeo |
| Dependencias externas | APIs críticas |
/ready — timeout bajo, no bloquear |
Latencia de red |
| Worker / job | Heartbeat propio | Archivo de timestamp o endpoint dedicado | Lógica propia a mantener |
| Solo compose local | Orden de arranque |
pg_isready, redis-cli ping
|
Nada |
La pregunta que vale antes de definir el comando: ¿qué tiene que ser verdad para que este contenedor pueda atender tráfico real? Si la respuesta incluye "la base tiene que estar conectada" o "el worker tiene que estar vivo", eso tiene que aparecer en el endpoint que chequea.
Límites reales: qué no podés concluir con un healthcheck
Esto es lo que un HEALTHCHECK no puede darte sin instrumentación adicional:
-
Latencia de respuesta: el check solo mide si respondió, no en cuánto tiempo. Un endpoint que tarda 9 segundos y tiene
--timeout=10spasa comohealthy. Si te importa la latencia, necesitás métricas externas — Prometheus, OpenTelemetry, los logs que analizamos en el post de OpenTelemetry en Spring Boot. -
Correctitud de la respuesta: el healthcheck no parsea el body. Podés devolver datos corruptos y seguir siendo
healthysi el HTTP status es 200. - Estado de la lógica de negocio: si una queue está creciendo sin control, si un proceso de reconciliación está fallando silenciosamente, si los cálculos son incorrectos — nada de eso lo ve el healthcheck.
- Capacidad bajo carga: que el endpoint responda solo cuando Docker lo invoca no implica que va a responder cuando lleguen 500 requests concurrentes.
Esto no invalida el HEALTHCHECK. Lo que hace es delimitar su responsabilidad. Es una señal de que el proceso vive y puede responder una request mínima. Eso es valioso para orchestration y para restart policies. No es suficiente para afirmar que el servicio está funcionando correctamente.
Para el resto necesitás alertas basadas en métricas, traces distribuidos o al menos logs estructurados que puedas consultar. El healthcheck es la capa más básica de observabilidad, no la única.
FAQ — Docker healthcheck buenas prácticas
¿Cada cuánto debería ejecutarse el healthcheck?
Depende de qué tan rápido querés detectar un fallo. El default de --interval=30s es razonable para la mayoría de los servicios. Si el chequeo hace queries a la base, bajarlo a 10s en servicios con muchas instancias puede generar carga innecesaria. Para deploy pipelines donde necesitás readiness rápido, --interval=5s con --start-period bien calibrado suele funcionar. No hay una respuesta universal — medí el impacto del endpoint antes de ajustar el intervalo.
¿El healthcheck reinicia el contenedor si falla?
No directamente. Docker marca el contenedor como unhealthy, pero la acción posterior depende de la restart policy del contenedor (--restart always, on-failure, etc.) o del orquestador. En Docker Compose y Swarm, podés configurar la reacción. En plataformas como Railway, el comportamiento depende de la configuración del servicio. No asumir que el contenedor se va a reiniciar solo porque pasó a unhealthy.
¿Tiene sentido usar HEALTHCHECK en desarrollo local?
Sí, especialmente en compose para controlar el orden de arranque con depends_on: condition: service_healthy. Ahorra ese ciclo de "la app arrancó antes que la base y tiró error" que todos conocemos. En desarrollo no necesitás intervalos ajustados — el default funciona.
¿Qué diferencia hay entre HEALTHCHECK en Dockerfile y healthcheck en docker-compose.yml?
Los dos configuran lo mismo pero en distintos niveles. El HEALTHCHECK en Dockerfile está embebido en la imagen — aplica siempre que corrás esa imagen. La clave healthcheck: en docker-compose.yml sobreescribe o define el chequeo para ese servicio específico en ese compose. Para imágenes que controlás, definirlo en el Dockerfile tiene más sentido. Para imágenes de terceros (postgres, redis, etc.), configurarlo en compose es la única opción.
¿Puedo deshabilitar el HEALTHCHECK que viene en una imagen base?
Sí. La documentación oficial indica que HEALTHCHECK NONE deshabilita cualquier healthcheck heredado de la imagen padre. Útil cuando usás una imagen base que trae un chequeo que no aplica a lo que estás corriendo.
¿El healthcheck afecta el performance del contenedor?
El comando se ejecuta dentro del contenedor y consume recursos del proceso que llama. Un curl liviano tiene impacto mínimo. Un endpoint que hace queries complejas o llama servicios externos con cada chequeo puede acumularse. Si en algún momento ves CPU o conexiones de base inusuales en un contenedor que no está bajo carga de tráfico real, el healthcheck es uno de los primeros lugares donde mirar.
Conclusión: señal útil, promesa chica
Lo que me parece honesto decir después de trabajar con Docker en deploys cotidianos — en Railway, en compose local, en backends que mezclan Next.js con servicios separados — es esto: el HEALTHCHECK vale la pena configurarlo bien. No porque sea la bala de plata de la observabilidad, sino porque es la capa más barata de detección temprana que podés agregar sin infraestructura extra.
Pero hay que ser claro con lo que promete. Un healthcheck que apunta a un endpoint que solo responde 200 OK sin verificar dependencias es una señal de liveness, no de readiness. Llamarlo "verificación de salud completa" es sobreprometer.
Mi recomendación práctica: si definís un solo endpoint para el HEALTHCHECK, hacelo verificar las dependencias críticas del servicio — como mínimo la base de datos. Calibrá --start-period según el tiempo real de arranque de la app. Y documentá en el mismo Dockerfile qué está midiendo ese chequeo, para que el próximo que lea el archivo entienda el contrato.
Lo que no hagas: confundas "el contenedor está healthy" con "el servicio está funcionando correctamente". Son frases que se parecen y miden cosas distintas. El primer claim lo podés respaldar con el healthcheck. El segundo requiere métricas, traces y alertas — cosas que arrancan donde termina el scope de HEALTHCHECK.
Si querés ir más profundo en cómo conectar señales de observabilidad entre capas, el post sobre caching en Next.js App Router y el de rate limiting antes de elegir librería tocan decisiones operativas similares — dónde poner la lógica, qué promete cada capa y cuándo la abstracción te esconde el problema real.
Fuente original
- Docker HEALTHCHECK reference: https://docs.docker.com/reference/dockerfile/#healthcheck
Este artículo fue publicado originalmente en juanchi.dev
Top comments (0)