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_onconcondition: service_healthyasegura 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
/healthdebe 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
- Docker Compose Healthcheck — Referencia oficial
- Docker Container Health — Healthcheck en Dockerfile
- Health Check Patterns — Microsoft — Patrones de health monitoring
- Graceful Degradation Patterns — Patrones de degradación
- Docker Compose depends_on — Dependency ordering
- FastAPI Lifespan Events — Startup y shutdown en FastAPI
- Docker Stop Grace Period — Configurar timeout de shutdown
- Python Signal Handling — Manejar SIGTERM en Python