Docker health checks: cómo detectar contenedores rotos antes de que te afecten

Etiquetas:

Un contenedor en estado running no significa que el servicio dentro funcione correctamente. He tenido casos en los que Nginx arrancaba, Docker lo veía verde, pero la aplicación devolvía 502 en cada petición. Sin health checks, eso pasa desapercibido hasta que lo detecta un usuario. Con health checks, Docker lo detecta en segundos y puede reiniciar el contenedor automáticamente.

El problema: running no es healthy

Por defecto, Docker solo sabe si el proceso principal del contenedor sigue vivo. Si tu aplicación arranca, entra en un bucle de error interno y sigue corriendo, Docker no tiene forma de saberlo.

$ docker ps
CONTAINER ID   IMAGE         STATUS          PORTS
a3f9c2b1d4e5   mi-app:1.0    Up 3 minutes    0.0.0.0:8080->8080/tcp

Up 3 minutes solo dice que el proceso no ha muerto. No dice nada sobre si responde peticiones HTTP, si la base de datos está accesible desde dentro del contenedor, o si la aplicación está en medio de un deadlock.

HEALTHCHECK en el Dockerfile

La forma más portable de definir un health check es directamente en el Dockerfile. Así el check viaja con la imagen y no depende de cómo se despliegue:

FROM python:3.12-slim

WORKDIR /app
COPY . .
RUN pip install -r requirements.txt

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
  CMD curl -f http://localhost:8080/health || exit 1

CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

Los parámetros que importan:

Parámetro Valor por defecto Qué hace
--interval 30s Cada cuánto ejecuta el check
--timeout 30s Tiempo máximo para que el check responda
--start-period 0s Tiempo de gracia al arrancar (no cuenta fallos)
--retries 3 Fallos consecutivos antes de marcar como unhealthy

El --start-period es importante: si tu aplicación tarda en arrancar (carga de modelos, migraciones de base de datos, precalentamiento de caché), necesitas darle ese margen o Docker la marcará como unhealthy antes de que esté lista.

El endpoint /health

Para que el health check sea útil, tu aplicación debería exponer un endpoint específico que compruebe más que «estoy vivo». Un /health mínimo en FastAPI:

from fastapi import FastAPI
from sqlalchemy import text

app = FastAPI()

@app.get("/health")
async def health_check():
    # Comprueba que la base de datos responde
    try:
        db.execute(text("SELECT 1"))
    except Exception:
        return {"status": "unhealthy", "detail": "db unreachable"}, 503
    return {"status": "ok"}

Si el endpoint devuelve cualquier código que no sea 2xx, curl -f falla y el check falla.

HEALTHCHECK en docker-compose.yml

Si no controlas el Dockerfile (imagen de terceros) o quieres sobrescribir el health check definido en la imagen, puedes hacerlo directamente en docker-compose.yml:

services:
  servidor-web:
    image: nginx:alpine
    ports:
      - "80:80"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost/nginx_status"]
      interval: 30s
      timeout: 5s
      start_period: 10s
      retries: 3

  base-de-datos:
    image: postgres:16
    environment:
      POSTGRES_DB: mi_base_datos
      POSTGRES_USER: usuario
      POSTGRES_PASSWORD: TU_PASSWORD_AQUI
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U usuario -d mi_base_datos"]
      interval: 10s
      timeout: 5s
      start_period: 30s
      retries: 5

Nota la diferencia entre CMD y CMD-SHELL:

  • CMD: ejecuta el comando directamente, sin shell. Más seguro y predecible.
  • CMD-SHELL: ejecuta el comando a través de /bin/sh -c. Necesario cuando usas tuberías, variables o lógica shell.

depends_on con condición de salud

Aquí está el verdadero poder de los health checks. Si tu aplicación necesita la base de datos para arrancar, puedes indicarle a Docker Compose que espere a que la base de datos esté healthy antes de iniciar la aplicación:

services:
  base-de-datos:
    image: postgres:16
    environment:
      POSTGRES_DB: mi_app_db
      POSTGRES_USER: usuario
      POSTGRES_PASSWORD: TU_PASSWORD_AQUI
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U usuario -d mi_app_db"]
      interval: 10s
      timeout: 5s
      start_period: 30s
      retries: 5

  mi-aplicacion:
    image: mi-app:latest
    ports:
      - "8080:8080"
    depends_on:
      base-de-datos:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://usuario:TU_PASSWORD_AQUI@base-de-datos:5432/mi_app_db

Con condition: service_healthy, Docker Compose no arrancará mi-aplicacion hasta que base-de-datos devuelva un health check exitoso. Sin esto, si la base de datos tarda 20 segundos en estar lista, la aplicación puede fallar al arrancar porque intenta conectarse antes de tiempo.

Las tres condiciones disponibles son:

  • service_started → el contenedor simplemente está en marcha (comportamiento por defecto)
  • service_healthy → el health check pasa
  • service_completed_successfully → el contenedor ha terminado con código de salida 0 (para tareas de inicialización)

Ver el estado de salud

Una vez que los contenedores tienen health check configurado, docker ps muestra el estado:

$ docker ps
CONTAINER ID   IMAGE         STATUS                    PORTS
a3f9c2b1d4e5   mi-app:1.0    Up 5 minutes (healthy)    0.0.0.0:8080->8080/tcp
b1c4d5e6f7a8   postgres:16   Up 5 minutes (healthy)    5432/tcp
c9d0e1f2a3b4   nginx:alpine  Up 2 minutes (unhealthy)  0.0.0.0:80->80/tcp

Para ver el historial de los últimos checks de un contenedor:

docker inspect --format='{{json .State.Health}}' mi-aplicacion | python3 -m json.tool

Esto muestra los últimos 5 resultados con timestamp, código de salida y salida del comando. Muy útil para diagnosticar por qué un contenedor está marcado como unhealthy.

Reinicio automático con restart policies

Los health checks cobran su máximo valor combinados con la política de reinicio:

services:
  mi-aplicacion:
    image: mi-app:latest
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      start_period: 20s
      retries: 3

Con restart: unless-stopped, Docker reiniciará el contenedor si el proceso muere. Pero no lo reinicia automáticamente si el health check falla. Para eso necesitas una herramienta adicional como Autoheal:

services:
  autoheal:
    image: willfarrell/autoheal:latest
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      AUTOHEAL_CONTAINER_LABEL: autoheal
      AUTOHEAL_INTERVAL: 10
      AUTOHEAL_START_PERIOD: 0

  mi-aplicacion:
    image: mi-app:latest
    restart: unless-stopped
    labels:
      - "autoheal=true"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
      interval: 30s
      timeout: 10s
      start_period: 20s
      retries: 3

Autoheal monitoriza los contenedores marcados con la etiqueta autoheal=true y los reinicia cuando pasan a estado unhealthy.

Health checks para servicios sin HTTP

No todo es HTTP. Algunos ejemplos habituales:

Redis:

healthcheck:
  test: ["CMD", "redis-cli", "ping"]
  interval: 10s
  timeout: 3s
  retries: 3

MySQL/MariaDB:

healthcheck:
  test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "--password=TU_PASSWORD_AQUI"]
  interval: 10s
  timeout: 5s
  start_period: 30s
  retries: 5

Verificación de puerto con netcat (cuando no hay cliente disponible en la imagen):

healthcheck:
  test: ["CMD-SHELL", "nc -z localhost 8080 || exit 1"]
  interval: 15s
  timeout: 5s
  retries: 3

Script personalizado (para lógica compleja):

healthcheck:
  test: ["CMD", "/usr/local/bin/check-health.sh"]
  interval: 30s
  timeout: 15s
  retries: 3

El script puede hacer múltiples comprobaciones: conectividad de red, disponibilidad de ficheros, espacio en disco, o cualquier condición que defina «sano» para ese servicio específico.

Deshabilitar el health check de una imagen

Si una imagen base define un health check que no quieres, puedes desactivarlo:

healthcheck:
  disable: true

Útil cuando heredas una imagen con un health check que no aplica a tu caso de uso o que genera falsos positivos.

Qué he aprendido en producción

  • start_period demasiado corto: el error más común. Una base de datos con muchos datos puede tardar más de lo esperado en arrancar. Empieza con un valor generoso y ajusta observando los logs.
  • Health checks que consumen demasiado: un curl a un endpoint ligero cuesta muy poco. Un health check que lanza una query pesada cada 10 segundos puede añadir carga innecesaria. Mantén los checks simples y rápidos.
  • No confundir readiness con liveness: en Kubernetes existe la distinción entre readiness (¿listo para recibir tráfico?) y liveness (¿sigue vivo?). En Docker puro, el health check cubre ambos, así que diseña el endpoint /health pensando en ambas preguntas.
  • Logs del health check: cuando un contenedor está unhealthy y no sabes por qué, docker inspect con el filtro de Health te da los últimos resultados con la salida exacta del comando. Es la primera herramienta que busco.

Los health checks son una de esas cosas que tardas diez minutos en configurar y te ahorran horas de diagnóstico. Si tienes servicios en producción sin ellos, este fin de semana es buen momento para añadirlos.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *