Módulo 2: Local & Container Deployment

4. Health Checks y Dependencies

Descripción

En esta cápsula vas a implementar health checks que verifican readiness real de cada servicio y dependency ordering que asegura que tus containers arrancan en el orden correcto. Al terminar, tu Docker Compose no aceptará tráfico hasta que TODOS los servicios estén realmente operativos — no solo "running."

Contexto: Un servicio "running" no es lo mismo que un servicio "ready." Redis puede estar arrancando pero no aceptar conexiones. Tu API puede estar importando librerías pero no respondiendo requests. Health checks verifican readiness real; dependency ordering asegura que la API no intenta conectar a Redis antes de que Redis esté listo.


Health Checks: Más que "el proceso existe"

El problema sin health checks

# Sin health checks, Compose solo verifica que el proceso arrancó
docker compose up -d
# ✔ cache-1  Started     # ¿Pero acepta conexiones?
# ✔ api-1    Started     # ¿Pero /health responde 200?

# La API intenta conectar a Redis que aún está inicializando
# → ConnectionRefusedError
# → La API crashea o retorna 500s durante los primeros segundos

Health check anatomy

healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
  interval: 30s      # Cada cuánto verificar
  timeout: 10s       # Máximo tiempo de espera por check
  retries: 3         # Intentos antes de declarar unhealthy
  start_period: 10s  # Gracia inicial (no cuenta como fallo)
Timeline de un health check:
0s ─────────── start_period (10s) ──────────── 10s
                                                │
10s ── check ── 40s ── check ── 70s ── check ── 100s
        │              │              │
      pass? ─── SÍ → healthy    NO → retry (hasta 3)
                                      │
                                   3 fallos → unhealthy

Health checks por servicio

services:
  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3
    # Redis responde PONG cuando está listo para aceptar comandos

  api:
    build: ./api
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s  # Más gracia: la API necesita importar librerías
    # El endpoint /health verifica que la API Y Redis están operativos

  vectordb:
    image: qdrant/qdrant:latest
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
      interval: 15s
      timeout: 5s
      retries: 3

Health endpoint en la API

# api/main.py — Health check que verifica dependencias
@app.get("/health")
def health():
    checks = {"api": "up"}
    overall = "healthy"
    
    # Check Redis
    try:
        cache.ping()
        checks["redis"] = "up"
    except Exception:
        checks["redis"] = "down"
        overall = "degraded"
    
    status_code = 200 if overall == "healthy" else 503
    
    from fastapi.responses import JSONResponse
    return JSONResponse(
        status_code=status_code,
        content={"status": overall, "services": checks}
    )

Readiness vs Liveness: Dos Tipos de Health Check

En sistemas de producción, no basta con un solo health check. Hay dos preguntas diferentes que necesitas responder:

  • Liveness: "¿El proceso está vivo?" → Si no, reinícialo.
  • Readiness: "¿El servicio puede aceptar tráfico?" → Si no, no le envíes requests.

La diferencia en la práctica

Escenario: Tu API está importando un modelo ML grande (30 segundos)

Liveness:  ✅ El proceso está vivo (respondería a un ping básico)
Readiness: ❌ No está lista para aceptar requests (el modelo no se ha cargado)

Acción correcta: NO reiniciar (está viva), NO enviar tráfico (no está ready)

Implementar ambos en FastAPI

# api/main.py
from fastapi import FastAPI
from contextlib import asynccontextmanager
import redis
import time

app_ready = False
startup_time = None


@asynccontextmanager
async def lifespan(app: FastAPI):
    global app_ready, startup_time
    startup_time = time.time()

    # Simula carga pesada: importar modelo, calentar cache, etc.
    await initialize_services()
    app_ready = True

    yield

    app_ready = False


app = FastAPI(lifespan=lifespan)


@app.get("/health/live")
def liveness():
    """¿El proceso está vivo? Check básico, sin dependencias externas."""
    return {
        "status": "alive",
        "uptime_seconds": round(time.time() - startup_time, 1),
    }


@app.get("/health/ready")
def readiness():
    """¿Puede aceptar tráfico? Verifica dependencias críticas."""
    from fastapi.responses import JSONResponse

    if not app_ready:
        return JSONResponse(
            status_code=503,
            content={"status": "not_ready", "reason": "still initializing"},
        )

    checks = {}
    all_healthy = True

    try:
        cache.ping()
        checks["redis"] = "up"
    except Exception:
        checks["redis"] = "down"
        all_healthy = False

    try:
        import urllib.request
        urllib.request.urlopen("http://vectordb:6333/healthz", timeout=2)
        checks["vectordb"] = "up"
    except Exception:
        checks["vectordb"] = "down"
        all_healthy = False

    status_code = 200 if all_healthy else 503
    status = "ready" if all_healthy else "degraded"

    return JSONResponse(
        status_code=status_code,
        content={"status": status, "checks": checks},
    )

Health checks en Docker Compose: mapear a readiness

services:
  api:
    build: ./api
    healthcheck:
      # Usa el readiness endpoint, no el liveness
      test: ["CMD", "curl", "-f", "http://localhost:8000/health/ready"]
      interval: 15s
      timeout: 10s
      retries: 3
      start_period: 30s

Docker Compose solo tiene un health check (no distingue liveness/readiness como Kubernetes). Usa el readiness endpoint — es el que importa para dependency ordering.


Health Check Strategies for AI Services

Los servicios de AI tienen necesidades de health check únicas. Un API normal solo necesita verificar "¿puedo responder HTTP?" Los servicios AI necesitan verificar modelos cargados, conectividad a LLMs, y estados de vector stores.

Verificar conectividad al LLM

# api/health.py
from openai import OpenAI
import time


def check_llm_connectivity(client: OpenAI, timeout: float = 5.0) -> dict:
    """
    Verifica que la API de OpenAI responde.
    No envía un prompt completo — usa un request mínimo.
    """
    start = time.time()
    try:
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": "1"}],
            max_tokens=1,
        )
        latency_ms = round((time.time() - start) * 1000)
        return {
            "status": "up",
            "latency_ms": latency_ms,
            "model": "gpt-4o-mini",
        }
    except Exception as e:
        return {"status": "down", "error": type(e).__name__}

Verificar vector store readiness

# api/health.py
import urllib.request
import json


def check_vector_store(qdrant_url: str = "http://vectordb:6333") -> dict:
    """Verifica que Qdrant responde y tiene collections."""
    try:
        response = urllib.request.urlopen(
            f"{qdrant_url}/collections", timeout=3
        )
        data = json.loads(response.read())
        collections = [c["name"] for c in data.get("result", {}).get("collections", [])]
        return {
            "status": "up",
            "collections": collections,
            "collection_count": len(collections),
        }
    except Exception as e:
        return {"status": "down", "error": str(e)}

Health endpoint completo para app AI

# api/main.py
from health import check_llm_connectivity, check_vector_store
from config import settings


@app.get("/health/detailed")
def health_detailed():
    """Health check completo con latencias y estado de cada dependencia."""
    checks = {}

    # Redis
    import time
    start = time.time()
    try:
        cache.ping()
        checks["redis"] = {
            "status": "up",
            "latency_ms": round((time.time() - start) * 1000),
        }
    except Exception as e:
        checks["redis"] = {"status": "down", "error": str(e)}

    # LLM (solo en readiness, no en cada health check básico)
    checks["llm"] = check_llm_connectivity(openai_client)

    # Vector Store
    checks["vectordb"] = check_vector_store()

    # Overall status
    statuses = [c["status"] for c in checks.values()]
    if all(s == "up" for s in statuses):
        overall = "healthy"
    elif checks["redis"]["status"] == "up":
        overall = "degraded"
    else:
        overall = "unhealthy"

    from fastapi.responses import JSONResponse
    return JSONResponse(
        status_code=200 if overall == "healthy" else 503,
        content={"status": overall, "checks": checks},
    )

Un detalle importante: el health check que Docker Compose usa (/health/ready) debe ser rápido (< 2 segundos). El endpoint /health/detailed con latencias de LLM es para monitoring, no para el health check de Compose.


Dependency Ordering

depends_on con condition

services:
  api:
    depends_on:
      cache:
        condition: service_healthy  # Espera a que cache esté HEALTHY
      vectordb:
        condition: service_healthy

  cache:
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]

  vectordb:
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
Orden de arranque:
1. cache + vectordb arrancan en paralelo
2. Compose espera a que ambos reporten "healthy"
3. api arranca SOLO cuando cache Y vectordb están healthy
4. Si cache falla el healthcheck 3 veces → api NO arranca

depends_on sin condition (insuficiente)

# ❌ INSUFICIENTE: solo espera a que el container arranque, no a que esté ready
services:
  api:
    depends_on:
      - cache  # Solo verifica que cache "started", no que está healthy

Escenario Complejo: Pipeline AI Multi-Servicio

En apps AI reales, los servicios forman cadenas de dependencias. Un pipeline RAG típico tiene 3 capas:

# docker-compose.yml — Pipeline RAG con dependency layers
services:
  # Capa 1: Infraestructura (sin dependencias, arrancan en paralelo)
  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 3

  vectordb:
    image: qdrant/qdrant:latest
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s

  # Capa 2: Worker que depende de infra
  embeddings-worker:
    build: ./workers/embeddings
    depends_on:
      vectordb:
        condition: service_healthy
      cache:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')"]
      interval: 15s
      timeout: 10s
      retries: 3
      start_period: 20s

  # Capa 3: API principal (depende de todo)
  api:
    build: ./api
    depends_on:
      cache:
        condition: service_healthy
      vectordb:
        condition: service_healthy
      embeddings-worker:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health/ready"]
      interval: 15s
      timeout: 10s
      retries: 3
      start_period: 30s
Orden de arranque resultante:

Capa 1 (paralelo):  cache ──────┐
                     vectordb ───┘
                                 │ ambos healthy
                                 ▼
Capa 2:              embeddings-worker
                                 │ healthy
                                 ▼
Capa 3:              api

Restart policies

services:
  api:
    restart: unless-stopped
    # Opciones:
    # no            — nunca reinicia
    # always        — reinicia siempre (incluso si exit 0)
    # on-failure    — reinicia solo si exit != 0
    # unless-stopped — reinicia excepto si tú lo detienes

Graceful Shutdown y Signal Handling

Cuando haces docker compose down, Docker envía SIGTERM a cada container. Si el proceso no termina en 10 segundos (default), envía SIGKILL. Para apps AI, esos 10 segundos pueden no ser suficientes — un request al LLM puede tardar 30 segundos.

El problema

docker compose down
  → SIGTERM a todos los containers
  → API está procesando un request al LLM (tarda 15s)
  → 10 segundos... Docker envía SIGKILL
  → Request cortado, usuario recibe error, posible corrupción de cache

Configurar stop timeout

services:
  api:
    build: ./api
    stop_grace_period: 30s  # Espera 30s antes de SIGKILL
    restart: unless-stopped

Manejar SIGTERM en Python

# api/main.py
import signal
import asyncio
from fastapi import FastAPI
from contextlib import asynccontextmanager

shutdown_event = asyncio.Event()


def handle_sigterm(signum, frame):
    shutdown_event.set()


signal.signal(signal.SIGTERM, handle_sigterm)


@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    cache.close()


app = FastAPI(lifespan=lifespan)


@app.middleware("http")
async def check_shutdown(request, call_next):
    if shutdown_event.is_set():
        from fastapi.responses import JSONResponse
        return JSONResponse(
            status_code=503,
            content={"error": "Server is shutting down"},
        )
    return await call_next(request)

El patrón: registra un handler para SIGTERM que activa un flag. Un middleware rechaza requests nuevos cuando el flag está activo. El lifespan cierra conexiones al final.

Shutdown ordering en Compose

docker compose down detiene en orden inverso a depends_on: primero la API (deja de recibir tráfico), luego cache (la API ya no la necesita). Si necesitas un orden específico: docker compose stop api && docker compose stop cache.


Patrón Completo: Ordering + Health + Restart

services:
  cache:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3
    restart: unless-stopped

  api:
    build: ./api
    ports:
      - "8000:8000"
    environment:
      - REDIS_URL=redis://cache:6379
    depends_on:
      cache:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s
    restart: unless-stopped

Troubleshooting

Problema 1: "El container queda en 'health: starting' para siempre"

# Verificar logs del healthcheck
docker inspect --format='{{json .State.Health}}' module-02-api-1 | python -m json.tool

# El start_period puede ser muy corto
# Si tu app tarda 20s en arrancar, start_period debe ser >20s

Problema 2: "curl no encontrado en el container"

# Agregar curl al Dockerfile
FROM python:3.11-slim
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

O usa una alternativa sin curl:

healthcheck:
  test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]

Problema 3: "api arranca antes de que Redis esté ready"

Verifica que depends_on usa condition: service_healthy (no solo depends_on: - cache), y que cache tiene un healthcheck definido.

Problema 4: "Health check pasa pero el servicio no funciona bien"

# Tu health check es demasiado simple. Si solo verifica que el proceso existe,
# no detecta problemas como: pool de conexiones agotado, memoria llena, deadlock.

# Solución: el health check debe probar la funcionalidad real
# ❌ Solo verifica que el puerto responde
test: ["CMD", "curl", "-f", "http://localhost:8000/"]

# ✅ Verifica que las dependencias también funcionan
test: ["CMD", "curl", "-f", "http://localhost:8000/health/ready"]

# Verificar los últimos resultados del health check:
docker inspect --format='{{range .State.Health.Log}}{{.Output}}{{end}}' module-02-api-1

Problema 5: "Container reinicia en loop (restart loop)"

# El container falla, se reinicia, falla de nuevo, infinitamente
docker compose logs api --tail 50
# Busca el error que causa el crash

# Causas comunes:
# 1. Variable de entorno faltante → la app crashea al iniciar
# 2. Dependencia no accesible → la app crashea al conectar
# 3. Puerto ya en uso dentro del container

# Diagnóstico:
docker compose ps
# NAME          STATUS                   PORTS
# api-1         restarting (3 seconds)   

# Para depurar sin restart automático:
docker compose run --rm api bash
# Dentro del container, corre el comando manualmente:
python -c "from config import settings; print(settings)"

Ejercicios Prácticos

Ejercicio 1: Health check multi-servicio

Implementa un endpoint /health/detailed que verifique Redis, el modelo LLM (un test call), y retorne tiempos de respuesta.

Ver solución
import time

@app.get("/health/detailed")
def health_detailed():
    checks = {}
    
    # Redis check
    start = time.time()
    try:
        cache.ping()
        checks["redis"] = {"status": "up", "latency_ms": round((time.time() - start) * 1000)}
    except Exception as e:
        checks["redis"] = {"status": "down", "error": str(e)}
    
    # LLM check
    start = time.time()
    try:
        r = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": "ping"}],
            max_tokens=5
        )
        checks["llm"] = {"status": "up", "latency_ms": round((time.time() - start) * 1000)}
    except Exception as e:
        checks["llm"] = {"status": "down", "error": str(e)}
    
    overall = "healthy" if all(c["status"] == "up" for c in checks.values()) else "degraded"
    return {"status": overall, "checks": checks}

Ejercicio 2: Graceful degradation

Modifica la API para que funcione (sin cache) si Redis se cae, en lugar de devolver 500.

Ver solución
@app.post("/ask")
def ask(request: AskRequest):
    # Intenta cache, pero no falla si Redis está down
    cached = None
    try:
        if request.use_cache:
            cached = cache.get(cache_key)
    except redis.ConnectionError:
        pass  # Cache unavailable, proceed without it
    
    if cached:
        return AskResponse(answer=json.loads(cached)["answer"], cached=True)
    
    # LLM call (siempre funciona si Redis está down)
    response = client.chat.completions.create(...)
    
    # Intenta cachear, pero no falla si Redis está down
    try:
        cache.setex(cache_key, settings.cache_ttl, json.dumps({...}))
    except redis.ConnectionError:
        pass  # Can't cache, but response still works
    
    return AskResponse(answer=answer, cached=False, tokens_used=tokens)

Ejercicio 3: Readiness y Liveness separados

Implementa dos endpoints separados: /health/live (solo verifica que el proceso corre) y /health/ready (verifica Redis + vector store). Configura Docker Compose para usar el readiness endpoint.

Ver solución
# api/main.py
import time

startup_time = time.time()
app_ready = False


@app.get("/health/live")
def liveness():
    return {
        "status": "alive",
        "uptime_seconds": round(time.time() - startup_time, 1),
    }


@app.get("/health/ready")
def readiness():
    from fastapi.responses import JSONResponse

    if not app_ready:
        return JSONResponse(
            status_code=503,
            content={"status": "initializing"},
        )

    checks = {}
    try:
        cache.ping()
        checks["redis"] = "up"
    except Exception:
        checks["redis"] = "down"

    try:
        import urllib.request
        urllib.request.urlopen("http://vectordb:6333/healthz", timeout=2)
        checks["vectordb"] = "up"
    except Exception:
        checks["vectordb"] = "down"

    all_up = all(v == "up" for v in checks.values())
    return JSONResponse(
        status_code=200 if all_up else 503,
        content={"status": "ready" if all_up else "degraded", "checks": checks},
    )
# docker-compose.yml
services:
  api:
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health/ready"]
      interval: 15s
      timeout: 10s
      retries: 3
      start_period: 30s

La diferencia práctica: si Redis se cae temporalmente, /health/live sigue respondiendo 200 (no reinicies el container), pero /health/ready responde 503 (no envíes tráfico nuevo).

Ejercicio 4: Graceful shutdown con stop_grace_period

Configura tu API para manejar SIGTERM correctamente: deja de aceptar requests nuevos, termina los requests en curso, cierra conexiones, y luego muere limpiamente. Configura stop_grace_period de 30 segundos.

Ver solución
# api/main.py
import signal
import asyncio
from fastapi import FastAPI
from contextlib import asynccontextmanager

shutdown_event = asyncio.Event()


def handle_sigterm(signum, frame):
    shutdown_event.set()


signal.signal(signal.SIGTERM, handle_sigterm)


@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    # Cleanup
    cache.close()
    await asyncio.sleep(0.5)


app = FastAPI(lifespan=lifespan)


@app.middleware("http")
async def reject_during_shutdown(request, call_next):
    if shutdown_event.is_set():
        from fastapi.responses import JSONResponse
        return JSONResponse(
            status_code=503,
            content={"error": "Shutting down, try another instance"},
        )
    return await call_next(request)
# docker-compose.yml
services:
  api:
    build: ./api
    stop_grace_period: 30s
    restart: unless-stopped
# Probar graceful shutdown
docker compose down
# Observa los logs: verás "SIGTERM received" y luego cleanup
docker compose logs api --tail 20

Resumen

  • Health checks verifican que un servicio está realmente operativo, no solo que el proceso existe.
  • Readiness vs liveness: readiness verifica dependencias (¿puedo aceptar tráfico?), liveness verifica el proceso (¿estoy vivo?). Docker Compose usa uno solo — elige readiness.
  • depends_on con condition: service_healthy asegura ordering correcto.
  • start_period da gracia inicial para que el servicio arranque antes de contar fallos.
  • Restart policies (unless-stopped) mantienen servicios corriendo ante crashes.
  • El endpoint /health debe verificar dependencias (Redis, vector store), no solo la API.
  • Graceful degradation: si una dependencia falla, la app degrada funcionalidad en vez de crashear.
  • Graceful shutdown: maneja SIGTERM, usa stop_grace_period, cierra conexiones limpiamente.

Recursos Adicionales

  1. Docker Compose Healthcheck — Referencia oficial
  2. Docker Container Health — Healthcheck en Dockerfile
  3. Health Check Patterns — Microsoft — Patrones de health monitoring
  4. Graceful Degradation Patterns — Patrones de degradación
  5. Docker Compose depends_on — Dependency ordering
  6. FastAPI Lifespan Events — Startup y shutdown en FastAPI
  7. Docker Stop Grace Period — Configurar timeout de shutdown
  8. Python Signal Handling — Manejar SIGTERM en Python