Módulo 2: Local & Container Deployment
5. Networking y Comunicación entre Servicios
Descripción
En esta cápsula vas a entender cómo Docker Compose conecta servicios entre sí: redes internas, resolución de nombres, puertos, y patrones de comunicación entre containers. Al terminar, sabrás por qué redis://cache:6379 funciona y cómo configurar networking para escenarios más complejos.
Contexto: Cuando escribes REDIS_URL=redis://cache:6379, "cache" es el nombre del servicio en Compose. Docker crea una red interna donde cada servicio es accesible por su nombre. Entender esto es clave para debugging y para escenarios como agregar LocalStack (M4) o configurar reverse proxies.
La Red Default de Compose
Cómo funciona
Cuando haces docker compose up, Docker crea automáticamente una red bridge donde todos los servicios se pueden comunicar por nombre:
Red interna: module-02_default
├── api → IP: 172.18.0.3
├── cache → IP: 172.18.0.2
└── worker → IP: 172.18.0.4
api puede alcanzar cache como:
redis://cache:6379 ✅ (nombre de servicio)
redis://172.18.0.2:6379 ✅ (IP, pero no recomendado)
# Ver redes creadas
docker network ls
# Inspeccionar la red
docker network inspect module-02_default
# Desde dentro de un container, verificar conectividad
docker compose exec api ping cache
# PING cache (172.18.0.2): 56 data bytes
# 64 bytes from 172.18.0.2: seq=0 ttl=64 time=0.082 ms
Cómo Docker crea la red
El nombre de la red sigue el patrón {nombre-del-directorio}_default. Si tu proyecto está en ~/projects/ai-app/, la red se llamará ai-app_default.
# Antes de docker compose up
docker network ls
# NETWORK ID NAME DRIVER SCOPE
# abc123 bridge bridge local
# def456 host host local
# Después de docker compose up (en directorio ai-app/)
docker network ls
# NETWORK ID NAME DRIVER SCOPE
# abc123 bridge bridge local
# def456 host host local
# ghi789 ai-app_default bridge local ← Nueva
# Cuando haces docker compose down, la red se elimina
docker compose down
docker network ls
# La red ai-app_default ya no existe
Cada vez que haces docker compose up, los containers obtienen IPs nuevas. Por eso siempre usas nombres de servicio, nunca IPs hardcodeadas.
DNS Resolution en Docker Networks
Cómo funciona internamente
Docker corre un servidor DNS embebido en 127.0.0.11 dentro de cada container. Cuando tu app hace una request a cache, este DNS resuelve el nombre al IP actual del container:
# Dentro del container api, verificar DNS
docker compose exec api cat /etc/resolv.conf
# nameserver 127.0.0.11
# ndots:0
# Resolver un nombre manualmente
docker compose exec api nslookup cache
# Server: 127.0.0.11
# Name: cache
# Address 1: 172.18.0.2 cache.ai-app_default
# Ver todas las IPs en la red
docker network inspect ai-app_default --format='{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{"\n"}}{{end}}'
# ai-app-cache-1: 172.18.0.2/16
# ai-app-api-1: 172.18.0.3/16
DNS y replicas
Si escalas un servicio a múltiples réplicas, DNS devuelve todas las IPs (round-robin):
# Escalar workers
docker compose up -d --scale worker=3
# DNS ahora devuelve 3 IPs para "worker"
docker compose exec api nslookup worker
# Name: worker
# Address 1: 172.18.0.4
# Address 2: 172.18.0.5
# Address 3: 172.18.0.6
Tiempos de resolución y caching
# api/main.py — Cuidado con el caching de conexiones
import redis
# ✅ Correcto: redis-py resuelve DNS en cada reconexión
cache = redis.from_url("redis://cache:6379")
# ❌ Problema potencial: resolver DNS una sola vez al inicio
import socket
cache_ip = socket.gethostbyname("cache") # 172.18.0.2
# Si cache se reinicia, obtiene nueva IP → esta conexión se rompe
Ports vs Expose vs Sin Configuración
Comparación detallada
| Config | Acceso desde host | Acceso entre containers | Cuándo usar |
|---|---|---|---|
ports: ["8000:8000"] | ✅ localhost:8000 | ✅ api:8000 | API pública, herramientas de debug |
expose: ["6379"] | ❌ | ✅ cache:6379 | Servicios internos (Redis, Postgres) |
| Sin config | ❌ | ✅ (si expone puerto en imagen) | Servicios que ya definen EXPOSE en Dockerfile |
Ports: exponer al host
services:
api:
ports:
- "8000:8000" # Accesible desde TU MÁQUINA (host:container)
cache:
expose:
- "6379" # Solo accesible desde OTROS CONTAINERS
# NO tiene ports → no accesible desde tu máquina
redis-commander:
ports:
- "8081:8081" # Accesible desde tu máquina (debug tool)
profiles:
- debug
Tu máquina (host):
├── localhost:8000 → api ✅ (port mapping)
├── localhost:6379 → cache ❌ (no port mapping, solo expose)
└── localhost:8081 → redis-commander ✅ (si profile debug activo)
Red interna (entre containers):
├── api → cache:6379 ✅ (expose funciona internamente)
├── api → redis-commander:8081 ✅ (todos se ven internamente)
└── cache → api:8000 ✅ (bidireccional)
El detalle de expose
expose en Docker Compose es mayormente documentación. Dentro de la red de Compose, los containers siempre pueden comunicarse por los puertos que el proceso escucha, con o sin expose. La diferencia real es:
# Estas dos configuraciones tienen el MISMO efecto entre containers
services:
cache:
image: redis:7-alpine
# Redis escucha en 6379 (definido en la imagen)
# Otros containers pueden alcanzarlo en cache:6379
cache-explicit:
image: redis:7-alpine
expose:
- "6379"
# El resultado es idéntico: accesible en cache-explicit:6379
expose es útil como documentación: dice "este servicio escucha en este puerto" sin exponerlo al host.
Port mappings avanzados
services:
api:
ports:
# host:container
- "8000:8000" # Mapa directo
- "127.0.0.1:8000:8000" # Solo accesible desde localhost (más seguro)
- "8001:8000" # Puerto diferente en host vs container
api-dev:
ports:
- "8000-8003:8000-8003" # Rango de puertos (útil con workers)
# Binding a 127.0.0.1 es más seguro en servidores:
# Solo tu máquina puede acceder, no otras máquinas en la red
ports:
- "127.0.0.1:8000:8000" # Solo localhost
- "0.0.0.0:8000:8000" # Toda la red (default, menos seguro)
Redes Custom
Cuándo necesitas redes custom
# Escenario: separar frontend de backend
services:
nginx:
networks:
- frontend
- backend
api:
networks:
- backend
cache:
networks:
- backend
networks:
frontend:
backend:
# nginx puede hablar con api (ambos en backend)
# nginx puede recibir tráfico externo (frontend)
# cache NO es accesible desde frontend (solo backend)
Para esta guía: la red default es suficiente
Para apps AI típicas (API + cache + vector store), la red default de Compose cubre el caso. Redes custom son necesarias cuando:
- Tienes un reverse proxy (Nginx) que no debe ver servicios internos
- Tienes múltiples apps en el mismo host que no deben comunicarse
- Necesitas aislamiento por seguridad
Redes custom con configuración
networks:
backend:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
driver_opts:
com.docker.network.bridge.name: "ai-backend"
services:
api:
networks:
backend:
ipv4_address: 172.28.0.10
Rara vez necesitas IPs estáticas. Úsalas solo si un servicio externo requiere una IP fija.
Service Discovery
DNS interno de Compose
# En tu código Python, los servicios se referencian por nombre
import os
REDIS_URL = os.environ.get("REDIS_URL", "redis://cache:6379")
QDRANT_URL = os.environ.get("QDRANT_URL", "http://vectordb:6333")
LOCALSTACK_URL = os.environ.get("AWS_ENDPOINT_URL", "http://localstack:4566")
Cada nombre (cache, vectordb, localstack) se resuelve a la IP del container correspondiente via DNS interno de Docker.
Service discovery en la práctica
# api/config.py — Centraliza las URLs de servicios
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Todas las URLs usan nombres de servicio de Compose
redis_url: str = "redis://cache:6379"
qdrant_url: str = "http://vectordb:6333"
postgres_url: str = "postgresql://app:password@postgres:5432/aiapp"
embeddings_service_url: str = "http://embeddings-worker:8001"
# Para desarrollo local SIN Docker:
# redis_url: str = "redis://localhost:6379"
# qdrant_url: str = "http://localhost:6333"
# El .env para desarrollo local (sin Docker) usa localhost
REDIS_URL=redis://localhost:6379
QDRANT_URL=http://localhost:6333
# Docker Compose sobreescribe con nombres de servicio
# docker-compose.yml
services:
api:
environment:
- REDIS_URL=redis://cache:6379
- QDRANT_URL=http://vectordb:6333
Networking Patterns para Apps AI
Patrón 1: API Gateway → Servicios de Inferencia
En apps AI con múltiples modelos o servicios, un gateway centraliza el acceso:
# docker-compose.yml
services:
gateway:
build: ./gateway
ports:
- "8000:8000"
depends_on:
chat-service:
condition: service_healthy
embeddings-service:
condition: service_healthy
chat-service:
build: ./services/chat
expose:
- "8001"
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
embeddings-service:
build: ./services/embeddings
expose:
- "8002"
vectordb:
image: qdrant/qdrant:latest
expose:
- "6333"
cache:
image: redis:7-alpine
expose:
- "6379"
Tráfico externo:
Usuario → localhost:8000 → gateway
Tráfico interno:
gateway → chat-service:8001 (genera respuestas)
gateway → embeddings-service:8002 (genera embeddings)
chat-service → cache:6379 (cache de respuestas)
embeddings-service → vectordb:6333 (búsqueda semántica)
❌ Usuario NO puede acceder a chat-service directamente
❌ Usuario NO puede acceder a vectordb directamente
# gateway/main.py — Rutea requests a servicios internos
import httpx
from fastapi import FastAPI
app = FastAPI()
CHAT_URL = "http://chat-service:8001"
EMBEDDINGS_URL = "http://embeddings-service:8002"
@app.post("/chat")
async def chat(request: dict):
async with httpx.AsyncClient() as client:
response = await client.post(f"{CHAT_URL}/generate", json=request)
return response.json()
@app.post("/search")
async def search(request: dict):
async with httpx.AsyncClient() as client:
# Genera embedding y busca en vector store
response = await client.post(
f"{EMBEDDINGS_URL}/search", json=request
)
return response.json()
Patrón 2: Aislamiento por capas de red
services:
nginx:
networks: [public, api-net]
ports:
- "80:80"
api:
networks: [api-net, data-net]
embeddings-worker:
networks: [data-net]
vectordb:
networks: [data-net]
cache:
networks: [data-net]
networks:
public:
api-net:
data-net:
Quién puede hablar con quién:
nginx → api ✅ (ambos en api-net)
nginx → vectordb ❌ (nginx no está en data-net)
api → vectordb ✅ (ambos en data-net)
api → cache ✅ (ambos en data-net)
Patrón: Reverse Proxy con Nginx
Configuración básica
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
api:
condition: service_healthy
api:
build: ./api
expose:
- "8000" # Solo interno, Nginx hace el proxy
Nginx config completa para app AI
# nginx.conf
upstream api_backend {
server api:8000;
}
server {
listen 80;
server_name _;
# Logs
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Timeouts altos para requests de LLM (pueden tardar 30s+)
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
# Request size para uploads (imágenes, documentos)
client_max_body_size 50M;
location / {
proxy_pass http://api_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://api_backend/health;
# Sin buffering para health checks
proxy_buffering off;
}
# Streaming para responses de LLM (Server-Sent Events)
location /chat/stream {
proxy_pass http://api_backend/chat/stream;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
}
# Rate limiting básico
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://api_backend/api/;
}
}
# En el bloque http (si usas nginx.conf completo):
http {
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
# ...
}
El timeout de proxy_read_timeout: 120s es crítico para apps AI. Un request a un LLM puede tardar 30-60 segundos. El default de Nginx es 60s, que puede causar timeouts en requests complejos.
Troubleshooting
Problema 1: "Connection refused al conectar a otro servicio"
# Verificar que el servicio está running
docker compose ps
# Verificar que están en la misma red
docker network inspect module-02_default
# Verificar que el puerto es correcto
docker compose exec api curl http://cache:6379
# Redis no habla HTTP, pero verifica que el puerto responde
docker compose exec api redis-cli -h cache ping
Problema 2: "No puedo acceder al servicio desde mi máquina"
Necesitas ports (no solo expose):
# ❌ Solo accesible internamente
expose:
- "6379"
# ✅ Accesible desde tu máquina
ports:
- "6379:6379"
Problema 3: "Conflicto de puertos"
# Error: Bind for 0.0.0.0:8000 failed: port is already allocated
# Otro proceso usa el puerto 8000
# Opción 1: Cambiar el puerto del host
ports:
- "8001:8000" # Tu máquina en 8001, container en 8000
# Opción 2: Encontrar y detener el proceso que usa el puerto
lsof -i :8000
kill <PID>
Problema 4: "DNS no resuelve el nombre del servicio"
# "Could not resolve host: cache" dentro de un container
# 1. Verificar que el servicio está definido en el mismo docker-compose.yml
docker compose ps
# Si cache no aparece, no existe en este Compose file
# 2. Verificar que están en la misma red
docker compose exec api cat /etc/resolv.conf
# Debe mostrar nameserver 127.0.0.11
# 3. Verificar resolución DNS
docker compose exec api nslookup cache
# Si falla, el servicio probablemente no está corriendo
# 4. Causa común: typo en el nombre del servicio
# docker-compose.yml dice "redis-cache" pero tu código dice "cache"
Problema 5: "Nginx retorna 502 Bad Gateway"
# 502 = Nginx no puede conectar al backend
# 1. Verificar que api está corriendo y healthy
docker compose ps
docker compose logs api --tail 20
# 2. Verificar desde el container de nginx
docker compose exec nginx curl http://api:8000/health
# Si falla, la API no está respondiendo
# 3. Causa común: api arrancó pero está en start_period (aún no healthy)
# Solución: agregar depends_on con service_healthy
services:
nginx:
depends_on:
api:
condition: service_healthy
# 4. Causa común: el upstream name no coincide
# nginx.conf dice "proxy_pass http://backend:8000" pero el servicio se llama "api"
Ejercicios Prácticos
Ejercicio 1: Agrega Nginx como reverse proxy
Configura Nginx delante de tu API para que el tráfico entre por puerto 80 y se redirija a la API en puerto 8000.
Ver solución
# docker-compose.yml
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
api:
condition: service_healthy
api:
build: ./api
expose:
- "8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 15s
# nginx.conf
server {
listen 80;
location / {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
docker compose up -d
curl http://localhost:80/health
# Debe retornar el health check de la API
Ejercicio 2: Verifica conectividad entre servicios
Desde el container de api, verifica que puedes alcanzar cache y viceversa.
Ver solución
# Ping entre servicios
docker compose exec api ping -c 3 cache
docker compose exec cache ping -c 3 api
# Verificar DNS resolution
docker compose exec api nslookup cache
docker compose exec api nslookup vectordb
# Verificar que el health endpoint funciona
docker compose exec api curl -s http://localhost:8000/health
# Verificar la red completa
docker network inspect $(docker compose ps -q api | head -1 | xargs docker inspect --format='{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}')
Ejercicio 3: Redes separadas
Crea dos redes: public (nginx + api) y private (api + cache). Verifica que nginx NO puede alcanzar cache directamente.
Ver solución
services:
nginx:
networks: [public]
api:
networks: [public, private]
cache:
networks: [private]
networks:
public:
private:
docker compose exec nginx ping cache
# ping: bad address 'cache' — No puede resolver, correcto
docker compose exec api ping cache
# PING cache... — Sí puede, correcto
Ejercicio 4: Networking para pipeline AI con gateway
Configura un Docker Compose con un gateway que ruteé requests a dos servicios internos (chat y embeddings). Solo el gateway debe ser accesible desde el host. Usa redes para aislar los servicios de datos.
Ver solución
# docker-compose.yml
services:
gateway:
build: ./gateway
ports:
- "8000:8000"
networks: [public, services]
depends_on:
chat-service:
condition: service_healthy
chat-service:
build: ./services/chat
expose:
- "8001"
networks: [services, data]
environment:
- REDIS_URL=redis://cache:6379
embeddings-service:
build: ./services/embeddings
expose:
- "8002"
networks: [services, data]
environment:
- QDRANT_URL=http://vectordb:6333
cache:
image: redis:7-alpine
expose:
- "6379"
networks: [data]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
vectordb:
image: qdrant/qdrant:latest
expose:
- "6333"
networks: [data]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:6333/healthz"]
networks:
public:
services:
data:
# Verificar aislamiento
docker compose exec gateway ping cache
# ❌ No resuelve (gateway no está en data network)
docker compose exec chat-service ping cache
# ✅ Funciona (ambos en data network)
# Solo gateway es accesible desde host
curl http://localhost:8000/health # ✅
curl http://localhost:8001/health # ❌ Connection refused
Resumen
- Docker Compose crea una red default donde todos los servicios se comunican por nombre.
- Docker corre un DNS interno en
127.0.0.11que resuelve nombres de servicio a IPs de containers. - ports expone al host (tu máquina); expose solo es accesible entre containers; sin config, los puertos internos funcionan igual entre containers.
- Los servicios se referencian por nombre (no IP):
redis://cache:6379. Las IPs cambian en cada restart. - Redes custom son para aislar servicios (ej: separar gateway público de servicios de datos privados).
- Nginx como reverse proxy es el patrón para exponer una API. Configura timeouts altos para requests de LLM.
- Para apps AI multi-servicio, usa un gateway pattern con servicios internos aislados.
Recursos Adicionales
- Docker Compose Networking — Referencia oficial
- Docker Network Drivers — Bridge, host, overlay
- Nginx Reverse Proxy — Config de reverse proxy
- Caddy Server — Alternativa a Nginx con auto-SSL
- Docker DNS Resolution — Cómo funciona DNS interno
- Nginx Proxy for WebSocket/SSE — Configurar Nginx para streaming de LLM
- httpx — Async HTTP Client — Cliente HTTP para comunicación entre servicios Python
- Docker Compose Expose vs Ports — Diferencia oficial