Módulo 2: Local & Container Deployment

2. Docker Compose para AI Apps

Descripción

En esta cápsula vas a construir un Docker Compose file completo para una app AI multi-container. No un "hello world" con un servicio — un sistema real con FastAPI, Redis, y la estructura que una app AI de producción necesita. Al terminar, tendrás un Compose funcional que puedes adaptar a cualquier proyecto AI.

Contexto: Docker Compose define tu infraestructura como código: qué servicios corren, cómo se comunican, qué datos persisten. Es el "plano" de tu sistema. En esta guía, el Compose que construyes aquí es la base sobre la que se integran LocalStack (M4), migration patterns (M6), y el proyecto integrador (M8).


Compose File: Anatomía Completa

Estructura básica

# docker-compose.yml
# Cada servicio es un container con su configuración

services:
  api:
    # Tu app FastAPI — el punto de entrada
    build: ./api
    ports:
      - "8000:8000"
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - REDIS_URL=redis://cache:6379
    depends_on:
      cache:
        condition: service_healthy

  cache:
    # Redis — cache de responses
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 3

volumes:
  redis_data:

Cada sección explicada

services:           # Define los containers que componen tu sistema
  api:              # Nombre del servicio (también es su hostname en la red interna)
    build: ./api    # Buildea desde el Dockerfile en ./api/
    ports:          # Mapeo host:container
    environment:    # Variables de entorno
    depends_on:     # Qué servicios necesita antes de arrancar

  cache:
    image: redis:7-alpine  # Usa imagen pre-built (no buildea)
    volumes:        # Datos persistentes
    healthcheck:    # Verificación de salud

volumes:            # Definición de volúmenes persistentes
  redis_data:       # Los datos de Redis persisten entre restarts

La App AI: Código Completo

FastAPI con Redis cache

# api/main.py
import hashlib
import json
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from openai import OpenAI
import redis

app = FastAPI(title="AI API with Cache")

client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
cache = redis.Redis.from_url(
    os.environ.get("REDIS_URL", "redis://localhost:6379"),
    decode_responses=True
)

class AskRequest(BaseModel):
    prompt: str
    max_tokens: int = 500
    use_cache: bool = True

class AskResponse(BaseModel):
    answer: str
    cached: bool
    tokens_used: int | None = None

@app.get("/health")
def health():
    redis_ok = False
    try:
        redis_ok = cache.ping()
    except redis.ConnectionError:
        pass
    
    return {
        "status": "healthy" if redis_ok else "degraded",
        "services": {
            "api": "up",
            "redis": "up" if redis_ok else "down",
        }
    }

@app.post("/ask", response_model=AskResponse)
def ask(request: AskRequest):
    cache_key = hashlib.md5(
        f"{request.prompt}:{request.max_tokens}".encode()
    ).hexdigest()
    
    if request.use_cache:
        cached_response = cache.get(cache_key)
        if cached_response:
            data = json.loads(cached_response)
            return AskResponse(answer=data["answer"], cached=True)
    
    try:
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": request.prompt}],
            max_tokens=request.max_tokens,
        )
    except Exception as e:
        raise HTTPException(status_code=502, detail=f"LLM API error: {str(e)}")
    
    answer = response.choices[0].message.content
    tokens = response.usage.total_tokens
    
    cache.setex(
        cache_key,
        3600,  # TTL: 1 hora
        json.dumps({"answer": answer, "tokens": tokens})
    )
    
    return AskResponse(answer=answer, cached=False, tokens_used=tokens)

Dockerfile para la API

# api/Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Requirements

# api/requirements.txt
fastapi==0.115.0
uvicorn==0.30.0
openai>=1.0.0
redis==5.0.0
pydantic>=2.0.0

Compose Completo: 3 Servicios

# docker-compose.yml
services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - REDIS_URL=redis://cache:6379
      - ENVIRONMENT=${ENVIRONMENT:-development}
    depends_on:
      cache:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s
    restart: unless-stopped

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

  redis-commander:
    image: rediscommander/redis-commander:latest
    environment:
      - REDIS_HOSTS=local:cache:6379
    ports:
      - "8081:8081"
    depends_on:
      cache:
        condition: service_healthy
    profiles:
      - debug

volumes:
  redis_data:

Levantar y probar

# Levantar servicios principales
docker compose up -d

# Output esperado:
# ✔ Network module-02_default  Created
# ✔ Container module-02-cache-1  Healthy
# ✔ Container module-02-api-1    Started

# Verificar health
curl http://localhost:8000/health
# {"status":"healthy","services":{"api":"up","redis":"up"}}

# Test de ask
curl -X POST http://localhost:8000/ask \
  -H "Content-Type: application/json" \
  -d '{"prompt": "¿Qué es Docker Compose en una línea?"}'
# {"answer":"Docker Compose es...","cached":false,"tokens_used":45}

# Segundo request (cached)
curl -X POST http://localhost:8000/ask \
  -H "Content-Type: application/json" \
  -d '{"prompt": "¿Qué es Docker Compose en una línea?"}'
# {"answer":"Docker Compose es...","cached":true,"tokens_used":null}

# Levantar con debug tools
docker compose --profile debug up -d
# Ahora redis-commander disponible en http://localhost:8081

Patrones Comunes de Compose para AI

Patrón 1: API + Cache

El más básico y más común. FastAPI + Redis. Cubre el 70% de apps AI.

Patrón 2: API + Cache + Vector Store

Para apps RAG que necesitan búsqueda semántica:

services:
  api:
    build: ./api
    ports:
      - "8000:8000"
    depends_on:
      cache:
        condition: service_healthy
      vectordb:
        condition: service_healthy

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

  vectordb:
    image: qdrant/qdrant:latest
    ports:
      - "6333:6333"
    volumes:
      - qdrant_data:/qdrant/storage
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
      interval: 10s

volumes:
  qdrant_data:

Patrón 3: API + Cache + Worker (async)

Para procesamiento que no cabe en el request/response cycle (documentos largos, batch de embeddings):

services:
  api:
    build: ./api
    ports:
      - "8000:8000"
    environment:
      - REDIS_URL=redis://cache:6379

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

  worker:
    build: ./worker
    environment:
      - REDIS_URL=redis://cache:6379
      - OPENAI_API_KEY=${OPENAI_API_KEY}
    depends_on:
      cache:
        condition: service_healthy
    # El worker lee jobs de Redis y los procesa en background
    # La API publica jobs, el worker los consume

Comparación de patrones

PatrónServiciosCuándo usarloComplejidad
API + Cache2Chatbot, Q&A, asistentesBaja
API + Cache + VectorDB3RAG, búsqueda semánticaMedia
API + Cache + Worker3Procesamiento de documentos, batchMedia
API + Cache + VectorDB + Worker4+Sistema AI completoAlta

Recomendación: Empieza con el Patrón 1 (API + Cache). Agrega servicios cuando los necesites, no antes. El Compose file es fácil de extender — más fácil que refactorizar un monolito.

Patrón avanzado: Con Nginx como reverse proxy

Para producción o cuando escalas la API:

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      api:
        condition: service_healthy

  api:
    build: ./api
    expose:
      - "8000"  # Solo visible internamente
    # ...resto de config

Esto prepara tu Compose para la cápsula 05 (Networking) donde profundizarás en reverse proxy, SSL, y redes aisladas.


Comparación: Docker Run vs Docker Compose

Aspectodocker rundocker compose
Servicios1 containerN containers
NetworkingManual (--network)Automático
VolúmenesManual (-v)Declarativo
DependenciesNo gestionadodepends_on + healthcheck
ReproducibilidadComandos largosUn archivo YAML
ScalingManualdocker compose up --scale api=3

Troubleshooting

Problema 1: "El servicio api no conecta a Redis"

# Verificar que Redis está running
docker compose ps
# cache debe estar "Up (healthy)"

# Verificar networking
docker compose exec api ping cache
# Debe resolver a la IP interna de Redis

# Verificar variable de entorno
docker compose exec api env | grep REDIS
# REDIS_URL=redis://cache:6379

Problema 2: "El build tarda demasiado"

# Optimizar Dockerfile con layer caching
# ANTES (rebuild todo al cambiar código):
COPY . .
RUN pip install -r requirements.txt

# DESPUÉS (solo rebuild si requirements cambian):
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

Problema 3: "OpenAI API key no llega al container"

# Verificar que la variable está definida en tu shell
echo $OPENAI_API_KEY
# Si está vacía, Compose pasa una variable vacía al container

# Verifica que .env existe y tiene la variable
cat .env | grep OPENAI
# OPENAI_API_KEY=sk-proj-...

# Verifica dentro del container
docker compose exec api env | grep OPENAI
# Si no aparece, revisa que tu docker-compose.yml tenga:
# environment:
#   - OPENAI_API_KEY=${OPENAI_API_KEY}

Problema 4: "Los datos de Redis se pierden al reiniciar"

Asegúrate de tener volume definido y appendonly yes:

cache:
  image: redis:7-alpine
  command: redis-server --appendonly yes
  volumes:
    - redis_data:/data  # Persistencia

volumes:
  redis_data:  # Debe estar declarado

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

# Verificar restart count
docker compose ps
# Si ves "Restarting (1)" repetidamente:

# 1. Ver los logs del crash
docker compose logs api --tail=50
# Busca el error que causa el exit

# 2. Temporalmente desactiva restart para ver el error
# Cambia restart: unless-stopped → restart: "no"
# Levanta de nuevo y lee el log de error completo

# 3. Causas comunes en AI apps:
# - OPENAI_API_KEY vacía → la app valida y falla
# - Redis no ready → ConnectionRefusedError en startup
# - Puerto ya ocupado → bind: address already in use

Ejercicios Prácticos

Ejercicio 1: Agrega un servicio de health monitoring

Agrega un servicio que cada 60 segundos haga curl al endpoint /health de la API y loguee el resultado.

Ver solución
  health-monitor:
    image: alpine/curl
    command: >
      sh -c "while true; do
        echo \"$(date): $(curl -s http://api:8000/health)\";
        sleep 60;
      done"
    depends_on:
      api:
        condition: service_healthy

Ejercicio 2: Agrega Qdrant como vector store

Extiende el Compose para incluir Qdrant y haz que la API dependa de él.

Ver solución
  vectordb:
    image: qdrant/qdrant:latest
    ports:
      - "6333:6333"
    volumes:
      - qdrant_data:/qdrant/storage
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
      interval: 10s
      timeout: 5s
      retries: 3

# Agregar a api:
  api:
    depends_on:
      cache:
        condition: service_healthy
      vectordb:
        condition: service_healthy
    environment:
      - QDRANT_URL=http://vectordb:6333

# Agregar al final:
volumes:
  redis_data:
  qdrant_data:

Ejercicio 3: Scaling de la API

Levanta 3 instancias de la API y verifica que todas están healthy.

Ver solución
# Primero, elimina el port mapping fijo (no puedes tener 3 en puerto 8000)
# En docker-compose.yml, cambia ports a expose:
#   api:
#     expose:
#       - "8000"
#     # Elimina ports: - "8000:8000"

# Luego escala
docker compose up -d --scale api=3

# Verificar
docker compose ps
# Debe mostrar 3 instancias de api, todas healthy

# Para acceder, necesitas un load balancer (Nginx) adelante
# Eso es avanzado — por ahora, verifica que las 3 arrancan

Ejercicio 4: Docker Compose con profiles

Crea un profile "dev" que incluya redis-commander y hot reload, y un profile "prod" que no los incluya.

Ver solución
services:
  api:
    build: ./api
    # En dev: hot reload con volume mount
    volumes:
      - ./api:/app  # Solo en dev
    profiles:
      - dev
      - prod

  api-prod:
    build: ./api
    # Sin volume mount, sin reload
    profiles:
      - prod

  cache:
    image: redis:7-alpine
    # Siempre presente (sin profile = todos los profiles)

  redis-commander:
    image: rediscommander/redis-commander:latest
    profiles:
      - dev  # Solo en dev
docker compose --profile dev up -d     # Dev con debug tools
docker compose --profile prod up -d    # Prod sin extras

Resumen

  • Docker Compose define tu infraestructura como código: servicios, redes, volúmenes.
  • Para AI apps, el patrón base es FastAPI + Redis (cache de responses).
  • depends_on con condition: service_healthy asegura que los servicios arrancan en orden.
  • Volumes persisten datos entre restarts (Redis data, vector stores).
  • Profiles separan configuración de dev y prod.
  • El Compose que construyes aquí es la base para M4, M6, y M8.

Recursos Adicionales

  1. Docker Compose Specification — Referencia oficial del formato
  2. Compose Deploy Specification — Config de deployment
  3. Redis Docker Official Image — Documentación de la imagen Redis
  4. Qdrant Docker — Qdrant en Docker
  5. FastAPI with Docker — Deploy de FastAPI en Docker
  6. Docker Compose Networking — Networking entre servicios