¿Cuántas veces viste un HEALTHCHECK CMD curl -f http://localhost/health || exit 1 pegado en un Dockerfile sin que nadie se preguntara qué pasa si ese endpoint responde 200 con la base de datos caída atrás?
Esa es la escena que motiva este post. No una anécdota de incidente en producción — no tengo esa evidencia pública para mostrar acá — sino un patrón que se repite cada vez que alguien busca "docker healthcheck", "dockerfile healthcheck" o "docker container health check" en Google esperando una receta rápida. La reciben. Y con esa receta, en un despliegue real, el orquestador reinicia contenedores sanos o deja corriendo contenedores rotos, según de qué lado esté el error.
Mi tesis es simple y la sostengo: un healthcheck sin criterio, el clásico curl a /health copiado y pegado, es folklore de infraestructura, no observabilidad real. Sirve para completar un checklist de buenas prácticas. No sirve para saber si el contenedor puede atender tráfico.
El dolor real antes de escribir una línea de HEALTHCHECK
El problema no es la sintaxis — eso lo resuelve la documentación en dos minutos. El problema es decidir qué chequear, con qué frecuencia, y qué hacer cuando el chequeo falla. Ahí es donde la mayoría de las guías se quedan cortas: te dan el comando y te dejan solo con la decisión que realmente importa.
Y esa decisión tiene consecuencias concretas en un stack con Next.js o Node corriendo detrás de PostgreSQL: un healthcheck mal puesto no es neutral. Genera falsos positivos que reinician contenedores en medio de una carga de trabajo normal, o falsos negativos que dejan tráfico yendo a un proceso que ya no puede responder nada útil.
Qué dice la fuente oficial (y qué no dice)
La documentación de Docker sobre HEALTHCHECK es clara en lo sintáctico. Define la instrucción, sus flags (--interval, --timeout, --start-period, --retries) y los códigos de salida que Docker interpreta: 0 sano, 1 no sano, 2 reservado.
# Sintaxis oficial de Dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
Eso es lo que la fuente oficial te da: https://docs.docker.com/reference/dockerfile/#healthcheck
Lo que no te da — y ahí está el punto de este post — es criterio sobre:
- Qué debería devolver ese endpoint
/healthpara ser honesto (¿solo "el proceso está vivo" o "puedo hablar con la base"?) - Qué intervalo tiene sentido para tu carga real
- Qué pasa cuando el orquestador (Swarm, Kubernetes, Railway) decide qué hacer con un contenedor "unhealthy"
La documentación te da la herramienta. No te da el diseño de la señal.
Dockerfile vs Compose vs orquestador: no es la misma pregunta
Acá está la confusión que junta las tres búsquedas del título. Son tres capas distintas y cada una responde una pregunta distinta:
flowchart TD
A[Dockerfile HEALTHCHECK] -->|define la prueba| B[Docker Engine]
B -->|marca estado| C{Compose depends_on: condition}
C -->|healthy| D[Levanta el siguiente servicio]
C -->|unhealthy| E[Bloquea o reintenta]
B --> F{Orquestador: Swarm/K8s}
F -->|unhealthy repetido| G[Reemplaza el contenedor]
- Dockerfile define la prueba en sí: el comando, el intervalo, los reintentos. Es la capa más baja, vive con la imagen.
-
Compose consume ese estado para ordenar el arranque con
depends_on: condition: service_healthy, o para overridear los parámetros del HEALTHCHECK sin tocar la imagen:
# docker-compose.yml
services:
api:
build: .
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 15s
timeout: 3s
retries: 3
start_period: 20s
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
- El orquestador (Swarm, Kubernetes con sus propios liveness/readiness probes, o una plataforma como Railway) decide qué hacer con esa señal: reintentar, reemplazar el contenedor, sacarlo del balanceo. Ahí el HEALTHCHECK de Docker deja de ser la única fuente de verdad — Kubernetes, por ejemplo, tiene sus propios probes que no dependen del HEALTHCHECK del Dockerfile.
Confundir estas tres capas es la razón por la que alguien busca "docker healthcheck" pensando que hay una sola respuesta, cuando en realidad está preguntando tres cosas distintas según en qué capa esté parado.
Dónde se equivoca la gente: la receta común y su costo oculto
La receta común es esta: exponer un endpoint /health que devuelve 200 OK con un {"status": "ok"} hardcodeado, sin tocar nada más. Compila, funciona en el demo, pasa el checklist de "tengo healthcheck".
El costo oculto aparece cuando ese proceso Node sigue vivo — el runtime responde, el puerto escucha — pero la conexión a PostgreSQL se cayó, el pool de conexiones está agotado, o una dependencia externa crítica no responde. El healthcheck dice "sano". El contenedor no puede atender una sola request real.
Es el mismo error de diseño que discutí cuando hablé de qué exponer y qué ocultar en Actuator: la superficie que decidís mostrar como "estado" tiene que reflejar lo que de verdad importa, no lo que es fácil de chequear. Un /health que solo confirma que el proceso arrancó es equivalente a un endpoint de Actuator que devuelve UP sin chequear ninguna dependencia real.
El contraejemplo honesto, y este es el que más se pasa por alto: un healthcheck demasiado estricto puede ser igual de dañino que uno vacío. Pensalo así — si el endpoint chequea la base, el caché y tres servicios externos en cada ping cada pocos segundos, alcanza con que una sola de esas dependencias tenga una latencia transitoria para que el chequeo completo falle. El orquestador ve "unhealthy" y reinicia el contenedor, cortando conexiones activas por un problema que capaz se resolvía solo en el próximo intento. No tengo un caso productivo propio con logs para mostrar acá, pero es un patrón de fallo conocido en cualquier chequeo que agrega dependencias externas sin un timeout ajustado y sin distinguir "no puedo responder" de "una cosa que consulto está lenta".
Matriz de decisión: qué mirar antes de escribir el CMD
| Escenario | Qué chequear | Intervalo sugerido | Riesgo si te equivocás |
|---|---|---|---|
| API stateless simple | Proceso responde en el puerto | 30s, timeout 5s | Bajo — poco que romper |
| API con conexión a PostgreSQL | Puerto + query liviana tipo SELECT 1
|
15-30s, retries altos (3-5) | Alto si el chequeo es pesado: sobrecarga la base con pings |
| Worker sin puerto HTTP | Archivo de lock, cola procesada, heartbeat propio | Depende del ciclo del job | Falso "unhealthy" si el ciclo es más largo que el intervalo |
Servicio detrás de Compose con depends_on
|
Que el service_healthy no bloquee el arranque de todo el stack indefinidamente |
start_period generoso |
Stack entero no levanta por un start_period corto |
| Contenedor en orquestador (Swarm/K8s) | Separar liveness (¿está vivo?) de readiness (¿puede recibir tráfico?) | Liveness relajado, readiness estricto | Reinicios en cascada si liveness y readiness comparten el mismo chequeo |
Esta matriz no es una fórmula cerrada. Es un punto de partida para preguntarte "¿esto que estoy chequeando es lo que realmente falla cuando el servicio falla?" antes de copiar el primer ejemplo que aparece en un tutorial.
Ya me pasó algo parecido con librerías npm: la tentación de sumar una dependencia porque "todos la usan" en vez de evaluarla con criterio propio. Con un HEALTHCHECK el vicio es idéntico — que el comando aparezca copiado en cien Dockerfiles de GitHub no dice absolutamente nada sobre si tiene sentido para tu caso puntual.
Errores comunes / gotchas
-
Usar
curlsin tenerlo en la imagen final. Si el Dockerfile usa una imagen slim o alpine,curlpuede no estar instalado y el healthcheck falla siempre por un error de "command not found", no por un problema real del servicio. -
start_perioddemasiado corto para apps con arranque lento. Si la app tarda 15 segundos en levantar (migraciones, conexión a pool, warm-up) y elstart_periodes de 5 segundos, el contenedor se marca unhealthy antes de terminar de arrancar. -
Confundir liveness con readiness. Un chequeo que solo confirma "el proceso no crasheó" no dice si puede atender tráfico. Esa distinción, que Kubernetes hace explícita con dos probes separados, se pierde fácil cuando en Docker plano solo hay un
HEALTHCHECK. -
Healthchecks que escriben en la base para verificar. Un chequeo que hace un
INSERTde prueba cada 10 segundos genera ruido en los logs de PostgreSQL — algo que se nota rápido si alguna vez activaste el query logging de Prisma y viste ese tráfico de fondo compitiendo con las queries reales. -
No loguear el resultado del healthcheck.
docker inspect --format='{{json .State.Health}}' <container>te da el historial de los últimos chequeos. Si nunca lo miraste, es difícil saber si el healthcheck está funcionando o solo está ahí de adorno.
# Ver el historial de health checks de un contenedor corriendo
docker inspect --format='{{json .State.Health}}' mi_contenedor | jq
Límites de esta guía
Esto es criterio de diseño basado en la documentación oficial y en patrones de fallo conocidos, no un experimento con métricas propias. No tengo un benchmark reproducible que compare intervalos ni un caso productivo documentado públicamente para citar acá. Si estás decidiendo el healthcheck de un sistema con SLA real, el próximo paso no es leer un blog — es instrumentar tu propio sistema, correr un experimento con carga controlada y mirar los logs de docker inspect durante un período representativo. Esta guía te da el marco para diseñar esa prueba, no el resultado de haberla corrido.
Tampoco hay evidencia acá sobre comportamiento específico de Kubernetes probes o Railway — cada orquestador tiene su propia semántica y vale la pena leer su documentación puntual antes de asumir que se comporta igual que Docker Compose.
FAQ
¿Cuál es la diferencia entre HEALTHCHECK en Dockerfile y en Compose?
El Dockerfile define el healthcheck por defecto de la imagen. Compose puede heredarlo o sobreescribirlo con su propia sección healthcheck, sin necesidad de reconstruir la imagen. Es útil para ajustar intervalos según el entorno (dev vs staging) sin tocar el Dockerfile.
¿Qué pasa si no pongo ningún HEALTHCHECK?
Docker asume que el contenedor está sano mientras el proceso principal siga corriendo. No hay chequeo activo. No es necesariamente peor que un healthcheck mal diseñado — a veces "sin chequeo" es más honesto que un chequeo que miente.
¿Cuál es un buen intervalo para HEALTHCHECK?
No hay un número universal. Depende de qué tan caro es el chequeo y qué tan rápido necesitás detectar un problema. Un rango típico de referencia es 10-30 segundos con timeout corto (3-5s) y retries de 3 a 5 para evitar falsos positivos por un pico transitorio.
¿HEALTHCHECK reemplaza a los probes de Kubernetes?
No. Kubernetes tiene sus propios livenessProbe y readinessProbe, independientes del HEALTHCHECK de Docker. Si desplegás en K8s, el HEALTHCHECK del Dockerfile puede quedar sin uso — la configuración real vive en el manifiesto del pod.
¿Puedo usar un script en vez de curl?
Sí. El CMD de HEALTHCHECK acepta cualquier comando que devuelva código de salida 0 o distinto de 0. Un script propio te da más control para chequear, por ejemplo, si el pool de conexiones a PostgreSQL tiene conexiones disponibles, en vez de solo golpear un puerto HTTP.
¿Un healthcheck muy estricto puede ser contraproducente?
Sí, y es uno de los puntos centrales de esta guía. Si el chequeo depende de servicios externos con latencia variable, un pico transitorio puede tirar el contenedor a "unhealthy" y gatillar un reinicio que no resuelve nada — porque el problema real estaba afuera del contenedor.
Cierre: la postura
Un HEALTHCHECK no es un checkbox de buenas prácticas. Es una señal que otro sistema — Compose, Swarm, Kubernetes, la plataforma que uses — va a usar para tomar una decisión automática sobre tu contenedor. Diseñarlo sin pensar qué decisión vas a gatillar es folklore, no observabilidad.
Mi recomendación concreta: antes de escribir el CMD, escribí primero la pregunta que ese chequeo tiene que responder — "¿puede este proceso atender tráfico ahora mismo?" — y recién después el comando. Si la respuesta necesita un SELECT 1 a PostgreSQL, que lo tenga. Si necesita separar liveness de readiness porque el proceso puede estar vivo pero no listo, que lo separe. El comando es la parte fácil. La decisión de qué preguntar es la que hace que el healthcheck sirva para algo el día que algo se rompe de verdad.
La pregunta incómoda que me queda dando vueltas: ¿tu healthcheck actual lo diseñaste pensando en esto, o lo copiaste de un ejemplo y nunca lo volviste a mirar?
Fuente original: Docker Docs - HEALTHCHECK — https://docs.docker.com/reference/dockerfile/#healthcheck
Este artículo fue publicado originalmente en juanchi.dev
Top comments (0)