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

ConfigAcceso desde hostAcceso entre containersCuándo usar
ports: ["8000:8000"]localhost:8000api:8000API pública, herramientas de debug
expose: ["6379"]cache:6379Servicios 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.11 que 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

  1. Docker Compose Networking — Referencia oficial
  2. Docker Network Drivers — Bridge, host, overlay
  3. Nginx Reverse Proxy — Config de reverse proxy
  4. Caddy Server — Alternativa a Nginx con auto-SSL
  5. Docker DNS Resolution — Cómo funciona DNS interno
  6. Nginx Proxy for WebSocket/SSE — Configurar Nginx para streaming de LLM
  7. httpx — Async HTTP Client — Cliente HTTP para comunicación entre servicios Python
  8. Docker Compose Expose vs Ports — Diferencia oficial