1. Introducción
Si alguna vez te alegró la línea "Started CatalogApplication in 2.3 seconds" y pensaste «bueno, ya se puede ir a producción», te felicito: eres un auténtico optimista. El problema es que en el mundo de los contenedores “el contenedor está en ejecución” es más o menos como “el hervidor está encendido”. El agua puede estar fría, hirviendo, o incluso el hervidor puede estar vacío (sí, a veces ocurre).
Docker (y cualquier sistema a su alrededor) quiere saber no solo si “el proceso sigue respirando”, sino una respuesta mucho más práctica: ¿el servicio realmente está en condiciones de funcionar?. Para eso existe HEALTHCHECK. No es una «panacea» ni un «autotratamiento», sino un mecanismo muy concreto: Docker ejecuta periódicamente un comando dentro del contenedor y, según su resultado, marca el contenedor como healthy o unhealthy. Es decir, añadimos una señal adicional que se combina bien con los logs y Actuator.
2. Mecánica de Docker HEALTHCHECK: comando y código de salida
Para que HEALTHCHECK deje de parecer un hechizo mágico, conviene verlo como algo muy terrenal. Docker puede ejecutar un contenedor, y también puede ejecutar periódicamente un comando dentro de ese contenedor. Puede ser curl, wget, un pequeño script o cualquier cosa que esté en la imagen. Docker evalúa el resultado del comando igual que cualquier proceso Unix: mediante el código de salida.
Si el comando termina con código 0, todo está bien, el contenedor se considera healthy. Si el código no es 0, Docker incrementa el «contador de fallos», y cuando se acumulan suficientes fallos (dentro de --retries), el contenedor pasa a unhealthy. Importante: Docker no «repara» el servicio ni necesariamente lo reinicia. Simplemente pone una etiqueta honesta: «parece que algo va mal dentro».
Aquí tienes un esquema sencillo del ciclo de healthcheck (sin teoría superflua, pero con una clara relación causa-efecto):
flowchart TD
A[Contenedor iniciado] --> B[Docker espera interval]
B --> C[Ejecuta comando healthcheck dentro del contenedor]
C --> D{¿Código de salida = 0?}
D -- sí --> E[Estado: healthy]
D -- no --> F[Racha de fallos +1]
F --> G{¿Racha de fallos >= retries?}
G -- no --> B
G -- sí --> H[Estado: unhealthy]
E --> B
Aquí hay una importante «trampa psicológica para novatos». Muchos piensan que healthcheck es una «verificación especial de Docker» que toca el servicio desde fuera. En realidad es más simple: Docker ejecuta el comando dentro del contenedor, por lo que la comprobación depende de lo que realmente hay en tu imagen, los permisos, las utilidades disponibles y el puerto que escucha la aplicación.
3. /actuator/health como objetivo para healthcheck en Spring Boot
Ahora la pregunta lógica: «Vale, Docker puede ejecutar un comando. ¿Y qué comando queremos ejecutar para que tenga sentido?» Y aquí Spring Boot nos hace un regalo: Actuator. El /actuator/health de Actuator está diseñado precisamente para dar una respuesta breve a la pregunta «¿el servicio está bien?».
Un punto importante: /actuator/health es diferente de un endpoint de negocio cualquiera como GET /api/catalog/items. Un endpoint de negocio puede ser pesado, puede acceder a la base de datos, puede depender de los datos, puede estar bajo carga — y entonces healthcheck empezaría a «dispararse en el pie». En cambio, /actuator/health suele ser rápido, estable y no debe tener efectos secundarios.
Una respuesta típica del health endpoint es así:
# Comprobamos el health endpoint directamente (local, sin Docker)
curl -s http://localhost:8080/actuator/health
# {"status":"UP"}
En el healthcheck no necesitamos el mundo entero. Necesitamos la señal más básica, rápida y predecible: que el servicio se ha levantado y responde a HTTP. Por eso, para el healthcheck de Docker normalmente accedemos a la dirección local dentro del contenedor:
- no a localhost en tu máquina,
- no al nombre del contenedor,
- no al puerto publicado externamente,
- sino a 127.0.0.1:8080 dentro del contenedor.
Y sí, es útil: el healthcheck no depende del mapeo de puertos hacia fuera. Incluso si olvidaste -p 8080:8080, el healthcheck igual podrá comprobar el servicio, porque se ejecuta desde dentro del contenedor.
4. HEALTHCHECK en el Dockerfile
Ahora daremos el paso más práctico: añadir HEALTHCHECK al Dockerfile de nuestro servicio de aprendizaje Container-Ready Catalog Service. Es importante hacerlo de manera que la comprobación sea útil, pero no se convierta en un «chivato perpetuo» que cada 200 milisegundos moleste a la aplicación. Necesitamos una comprobación con una frecuencia humana, un tiempo de espera corto y una ventana de inicio razonable mientras Spring Boot arranca.
Empecemos por lo esencial: una línea de HEALTHCHECK. Aquí tienes la versión mínima (observa la forma exec en CMD — entre corchetes):
# Docker ejecutará periódicamente el comando dentro del contenedor
# Importante: nos interesa el código de salida del comando (0 = healthy, distinto de 0 = unhealthy)
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD ["curl", "-fsS", "http://127.0.0.1:8080/actuator/health"]
Esta receta tiene dos pilares silenciosos pero importantes. El primero es el puerto interno estable del servicio: dentro del contenedor mantenemos el 8080, y fuera solo cambiamos el mapeo del host, por ejemplo -p 18080:8080. Por lo tanto, el puerto externo puede cambiarse como se quiera, mientras que la URL del healthcheck en el Dockerfile permanece igual. Si deliberadamente cambias server.port dentro del contenedor, la URL de la comprobación también debe cambiarse sincrónicamente — Docker no puede adivinarlo por nosotros.
El segundo es --start-period. Es una ventana de inicio durante la cual la aplicación puede levantarse tranquilamente y los primeros fallos no se consideran indicios de avería. Los retries posteriores ya responden a fallos reales, no reemplazan el tiempo de arranque. Para nuestro servicio standalone, 20 segundos suele ser suficiente, pero si el arranque es más pesado, también hay que aumentar esta ventana.
Analicemos qué ocurre aquí, en un lenguaje que no requiere ser un «mago de Docker»:
| Parámetro | Qué significa en la práctica | Por qué es importante para nosotros |
|---|---|---|
| --interval=30s | cada 30 segundos Docker comprobará la salud | no generamos carga ni ruido innecesarios |
| --timeout=3s | si la comprobación se bloquea más de 3 segundos, se considera un error | una comprobación bloqueada es peor que un error honesto |
| --start-period=20s | da tiempo a la aplicación para levantarse tranquilamente tras iniciar el contenedor | las comprobaciones tempranas no se convierten en un falso unhealthy |
| --retries=3 | se necesitan 3 fallos consecutivos para que el contenedor pase a unhealthy | filtramos «estornudos» aleatorios |
| curl -fsS | -f hace que el comando falle en HTTP 4xx/5xx, -sS hace la salida limpia | Docker mira el código de salida, no la «belleza del texto» |
Ahora un detalle práctico importante que es fácil pasar por alto: la utilidad debe existir dentro de la imagen. Si en tu imagen runtime no hay curl, el healthcheck fallará no porque el servicio esté «enfermo», sino porque «el médico no llegó»: el comando simplemente no existe.
Por eso en el Dockerfile se suele optar por uno de dos caminos: elegir una imagen runtime que ya tenga curl/wget, o instalar curl en la etapa runtime. Para un proyecto de aprendizaje, vale la opción de instalar curl en una imagen basada en Debian/Ubuntu. Aquí tienes un fragmento del Dockerfile para la etapa runtime — es suficiente para entender la idea:
FROM eclipse-temurin:25-jre-jammy
WORKDIR /app
# Instalamos curl, de lo contrario HEALTHCHECK fallará por falta del comando
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY app.jar app.jar
# Comprobamos el endpoint HTTP local de la aplicación dentro del contenedor
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD ["curl", "-fsS", "http://127.0.0.1:8080/actuator/health"]
# Inicio de la aplicación (HEALTHCHECK describe el comportamiento justo junto al inicio runtime)
ENTRYPOINT ["java", "-jar", "app.jar"]
Si tu proyecto usa multi-stage (y ya debería ser así), en lugar de COPY app.jar app.jar se copiará desde la etapa builder, más o menos así:
# Tomamos el jar compilado de la etapa builder y lo colocamos en la imagen runtime
COPY --from=builder /workspace/build/libs/catalog-service.jar app.jar
El sentido es el mismo: healthcheck vive junto al lanzamiento final, porque describe el comportamiento en tiempo de ejecución del contenedor.
5. Verificación: docker ps y docker inspect
Después de modificar el Dockerfile, no queremos creerlo sin más, sino verlo con nuestros ojos: Docker realmente ejecuta la comprobación y cambia el estado del contenedor. La buena noticia: es fácil de ver con comandos estándar, sin «banderas secretas para iniciados». La mala noticia: la primera vez estarás actualizando docker ps como si fuera la página de resultados de un examen. Es normal.
Primero, construimos la imagen y ejecutamos el contenedor (ajusta los nombres a tu proyecto, pero la idea es esta):
# Construimos la imagen con HEALTHCHECK en el Dockerfile
docker build -t catalog-service:healthcheck .
# Ejecutamos el contenedor (el mapeo de puertos es necesario para pruebas externas, pero no para el propio HEALTHCHECK)
docker run --name catalog-service -p 8080:8080 -d catalog-service:healthcheck
Casi inmediatamente después del inicio, en docker ps verás que el contenedor está vivo, pero el estado de salud puede que aún no sea healthy — tiene un estado intermedio starting:
# Vemos el estado del contenedor, incluida la salud
docker ps --filter name=catalog-service
# ... STATUS: Up 10 seconds (health: starting)
Al cabo de un rato (normalmente tras la primera comprobación exitosa), el estado pasará a healthy:
# Tras 1-2 intervalos de comprobación, el estado debería ser healthy
docker ps --filter name=catalog-service
# ... STATUS: Up 40 seconds (healthy)
Si quieres una respuesta más «puntual» (sin ruido visual), puedes extraer el estado mediante docker inspect:
# Extraemos exactamente el estado de salud (starting/healthy/unhealthy) sin el resto del JSON
docker inspect --format='{{.State.Health.Status}}' catalog-service
# healthy
Y aquí hay un truco muy útil cuando algo va mal: healthcheck guarda un historial de intentos. En inspect hay un bloque .State.Health.Log donde están los últimos resultados: hora, código de salida, parte de la salida del comando. No hace falta convertir la búsqueda de la causa en un curso de plantillas Go dentro de docker inspect; basta con recordar este hecho: si el contenedor está unhealthy, mira la salida del comando healthcheck.
Un escenario sencillo de «autosabotaje» (solo para entender los síntomas) sería: cometiste un error tipográfico en la URL y pusiste /actuator/healt en lugar de /actuator/health. Entonces la aplicación funciona, el puerto está abierto, pero el healthcheck falla constantemente — y el contenedor se vuelve unhealthy. Es como un restaurante que funciona perfectamente pero tiene un cartel en la puerta que dice "Cerrado" por un error tipográfico. Desde fuera parece que todo está mal, mientras dentro los cocineros llevan tres horas sufriendo.
6. Healthcheck útil, no decorativo
Cuando descubres HEALTHCHECK por primera vez, dan ganas de añadir «algo, lo que sea» para que el contenedor parezca «de mayores». Y entonces Docker a veces se parece a un trabajo de fin de carrera: «lo importante es que esté, ya veremos qué es exactamente». Pero con healthcheck esto funciona mal, porque una comprobación decorativa es peor que no tener ninguna comprobación.
Si healthcheck siempre devuelve éxito (por ejemplo, echo ok), generas una falsa sensación de seguridad. El contenedor será healthy aunque la aplicación no esté escuchando en el puerto, se haya caído internamente o responda con errores. Al final ves una marca verde donde debería haber una luz roja, y eso es un camino directo a perder tiempo en diagnósticos.
Si healthcheck es demasiado pesado (por ejemplo, llama a una operación de negocio que escribe en la base de datos o inicia una exportación de archivos), empieza a afectar al sistema. Es el clásico: «la comprobación de salud empeora la salud». El healthcheck debe ser barato, rápido y no modificar el estado de la aplicación.
Y otro matiz importante sobre los puertos. En el mundo de los contenedores es mucho más cómodo tener un puerto interno estable para la aplicación (normalmente 8080 para Spring Boot) y cambiar solo el mapeo externo -p hostPort:8080. Así, el HEALTHCHECK en el Dockerfile sigue siendo correcto siempre. Si empiezas a cambiar el puerto interno mediante SERVER_PORT, tu healthcheck del Dockerfile no se enterará y seguirá comprobando el puerto «antiguo». Desde el punto de vista de Docker no es un error: hace exactamente lo que le has escrito. Desde el punto de vista humano, parece «¿por qué está unhealthy si yo mismo cambié el puerto?» — porque estás comprobando la salud en otro sitio.
Y si el servicio arranca notablemente más lento de lo normal, es mejor tratarlo con --start-period que con un aumento infinito de retries: de lo contrario, enmascaras la fase de inicialización con una serie de «averías».
7. Errores típicos
Cualquier tema en Docker se presta a que los errores sean muy «literales»: escribiste una cosa, Docker hizo una cosa. La mala noticia es que para un novato esto actúa como un humor cruel: un error tipográfico en un solo carácter se convierte en unhealthy y en la sensación de que «se ha roto todo». Vamos a repasar varias trampas típicas para que tengas menos de esos momentos.
Error n.º 1: el healthcheck comprueba «lo que no debe» porque el comando no falla en errores HTTP.
Si escribes curl http://127.0.0.1:8080/actuator/health sin -f, incluso si la respuesta es 500, curl puede terminar con código 0. Docker verá el código de salida 0 y dirá healthy, aunque el servicio realmente esté devolviendo un error. Por lo tanto, la opción -f (o la lógica equivalente) no es cosmética, sino el núcleo de la comprobación.
Error n.º 2: en la imagen runtime no está la utilidad con la que compruebas la salud.
Una historia muy común: añadiste HEALTHCHECK CMD ["curl", ...], construiste la imagen, y el contenedor se volvió unhealthy al instante. Mientras tanto, la aplicación funciona y docker logs muestra un inicio normal. La razón es simple: curl no existe, el comando falla, y Docker marca honestamente el contenedor como «malo». Se soluciona instalando curl/wget o eligiendo una imagen runtime adecuada.
Error n.º 3: confundir el puerto o la ruta porque piensas «desde fuera del contenedor».
El healthcheck funciona dentro del contenedor. No necesita localhost:hostPort, necesita 127.0.0.1:containerPort. Si accidentalmente compruebas un puerto incorrecto o escribes el puerto externo, el healthcheck fallará. Es la típica confusión entre «compruebo como usuario» vs «compruebo como contenedor».
Error n.º 4: --timeout demasiado corto o --interval demasiado frecuente.
Si pones --timeout=1s y compruebas cada 2 segundos, puedes obtener una salud inestable incluso en un servicio normal: a veces la JVM está ocupada con GC o el procesador está un poco cargado. El healthcheck se convierte en un generador de falsas alarmas. Por lo general, basta con un intervalo de 20–60 segundos y un tiempo de espera de 2–5 segundos para un servicio backend local.
Error n.º 5: esperar que unhealthy «repare» automáticamente el contenedor.
El estado de salud de Docker es una señal, no un mecanismo de reparación. Ayuda a ver el problema y da pistas a los sistemas que rodean a Docker, pero no garantiza que el contenedor se reinicie o se «cure» por sí mismo. Por lo tanto, si el contenedor se vuelve unhealthy, tu siguiente reacción debe ser mirar docker logs, mirar los resultados del healthcheck en docker inspect y buscar la causa, no esperar que Docker «lo adivine por sí solo».
GO TO FULL VERSION