Módulo 3: Volumes, Networking y Registries

8. Proyecto: Persistent Data App

Descripción

Este es el proyecto integrador del Módulo 3 y cierra la Phase 1 (Docker Fundamentals). Vas a construir una aplicación que demuestra los tres pilares que aprendiste: volumes para persistir datos, networking para conectar containers, y port mapping para acceder a servicios desde tu máquina. El resultado es un mini-sistema de dos containers que se comunican y persisten datos.

Qué vas a construir: Una aplicación Python que funciona como un "Log Analyzer" — recibe textos, los almacena en un volumen persistente, y usa Redis como cache para resultados. Dos containers, una network, un volume, y port mapping. Es el preludio exacto de lo que harás con Docker Compose en el Módulo 5.

Por qué importa: Este proyecto demuestra que ya puedes pensar en sistemas Docker multi-componente. Es la transición de "un container aislado" a "containers que trabajan juntos como un sistema." Exactamente lo que necesitas para Phase 2.


Objetivo del Proyecto

Construir un sistema de dos containers:

  • Redis (container 1): Cache de datos, persistido con named volume
  • Python app (container 2): Escribe/lee datos de Redis, accesible via port mapping
  • Network custom que conecta ambos containers
  • ✅ Datos que sobreviven a docker rm gracias al volume en Redis
  • ✅ Acceso desde el host via port mapping

Tiempo estimado: 25-35 minutos.


Recap: Lo Que Ya Sabes

CápsulaConcepto aplicado
02Persistencia: sin volumes los datos se pierden
03Named volumes: datos que sobreviven al container
04Bind mounts: código local en el container
05Networking: containers se comunican por nombre
06Port mapping: acceso desde el host
07Docker Hub: compartir imágenes

Paso 1: Crear la Estructura del Proyecto

mkdir -p docker-essentials/module-03/persistent-app
cd docker-essentials/module-03/persistent-app

La aplicación: app.py

"""
Log Analyzer — Almacena y analiza entradas de texto usando Redis como backend.
Demuestra volumes (persistencia Redis) y networking (Python ↔ Redis).

CONCEPTOS DOCKER APLICADOS:
- host="redis": En una custom network, los containers se resuelven por nombre.
  El container Redis tiene --name redis, por eso la app usa ese hostname.
- La app DEBE correr con --network app-net para estar en la misma red que Redis.
"""
import redis
import json
import sys
from datetime import datetime
from collections import Counter


def get_redis_client():
    """
    Conecta a Redis usando el nombre del container como hostname.

    NETWORKING: En Docker, cada container en una custom network tiene un DNS interno.
    Cuando escribes host="redis", el resolver de la red app-net traduce "redis"
    a la IP del container llamado redis. Por eso --name redis es crítico.
    """
    try:
        # decode_responses=True evita bytes y devuelve strings directamente
        client = redis.Redis(host="redis", port=6379, decode_responses=True)
        client.ping()  # Verifica conectividad antes de continuar
        return client
    except redis.ConnectionError:
        print("ERROR: No se puede conectar a Redis.")
        print("Asegúrate de que Redis está corriendo en la misma network.")
        print("  docker run -d --network app-net --name redis redis:7-alpine")
        sys.exit(1)


def add_entry(r, text):
    """
    Agrega una entrada de texto al log.

    REDIS DATA STRUCTURES usadas:
    - INCR: contador atómico para IDs únicos
    - HSET: hash (diccionario) por entrada
    - LPUSH: lista que mantiene orden de inserción (último primero)
    """
    entry = {
        "text": text,
        "timestamp": datetime.now().isoformat(),
        "word_count": len(text.split()),
        "char_count": len(text),
    }
    # INCR es atómico: evita race conditions si múltiples procesos escriben
    entry_id = r.incr("entry_counter")
    # Hash entry:1, entry:2... almacena cada entrada como key-value
    r.hset(f"entry:{entry_id}", mapping=entry)
    # LPUSH añade al inicio: las entradas más recientes primero
    r.lpush("entry_ids", entry_id)
    return entry_id, entry


def get_stats(r):
    """
    Calcula estadísticas de todas las entradas.

    LRANGE 0 -1: obtiene toda la lista (desde índice 0 hasta -1 = último).
    HGETALL: devuelve todos los campos de un hash como dict.
    """
    entry_ids = r.lrange("entry_ids", 0, -1)
    total_entries = len(entry_ids)

    if total_entries == 0:
        return {"total_entries": 0, "message": "No hay entradas aún"}

    all_words = []
    total_chars = 0
    total_words = 0

    for eid in entry_ids:
        entry = r.hgetall(f"entry:{eid}")
        if entry:
            total_words += int(entry.get("word_count", 0))
            total_chars += int(entry.get("char_count", 0))
            all_words.extend(entry.get("text", "").lower().split())

    top_words = Counter(all_words).most_common(5)

    return {
        "total_entries": total_entries,
        "total_words": total_words,
        "total_chars": total_chars,
        "avg_words_per_entry": round(total_words / max(total_entries, 1), 1),
        "top_words": [{"word": w, "count": c} for w, c in top_words],
    }


def show_entries(r, limit=5):
    """
    Muestra las últimas entradas.

    LRANGE 0 limit-1: obtiene los primeros N elementos de la lista.
    Como usamos LPUSH, los más recientes están al inicio.
    """
    entry_ids = r.lrange("entry_ids", 0, limit - 1)
    entries = []
    for eid in entry_ids:
        entry = r.hgetall(f"entry:{eid}")
        if entry:
            entries.append({"id": eid, **entry})
    return entries


def main():
    r = get_redis_client()

    print("=" * 60)
    print("LOG ANALYZER v1.0 — Persistent Data App")
    print("=" * 60)

    if len(sys.argv) > 1:
        command = sys.argv[1]

        if command == "add" and len(sys.argv) > 2:
            text = " ".join(sys.argv[2:])
            entry_id, entry = add_entry(r, text)
            print(f"\n✅ Entrada #{entry_id} agregada:")
            print(f"   Texto: {text}")
            print(f"   Palabras: {entry['word_count']}")
            print(f"   Timestamp: {entry['timestamp']}")

        elif command == "stats":
            stats = get_stats(r)
            print(f"\n📊 Estadísticas:")
            print(f"   Total entradas: {stats['total_entries']}")
            if stats['total_entries'] > 0:
                print(f"   Total palabras: {stats['total_words']}")
                print(f"   Promedio palabras/entrada: {stats['avg_words_per_entry']}")
                print(f"\n   Top palabras:")
                for item in stats.get('top_words', []):
                    print(f"     {item['word']:15s} ({item['count']})")

        elif command == "list":
            entries = show_entries(r)
            print(f"\nÚltimas {len(entries)} entradas:")
            for e in entries:
                print(f"   [{e['id']}] {e.get('text', '')[:50]}... ({e.get('word_count', 0)} palabras)")

        elif command == "reset":
            # FLUSHDB: borra toda la base de datos actual (solo la DB 0 en Redis)
            r.flushdb()
            print("\n🗑️  Base de datos limpiada.")

        else:
            print(f"\nComando no reconocido: {command}")
            print("Uso: add <texto> | stats | list | reset")
    else:
        print("\nUso:")
        print("  add <texto>   — Agrega una entrada")
        print("  stats         — Muestra estadísticas")
        print("  list          — Lista últimas entradas")
        print("  reset         — Limpia la base de datos")

    print("=" * 60)


if __name__ == "__main__":
    main()

Dependencias: requirements.txt

redis==5.0.1

Dockerfile

FROM python:3.11-slim

ENV PYTHONUNBUFFERED=1

WORKDIR /app

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

COPY . .

ENTRYPOINT ["python", "app.py"]
CMD []

.dockerignore

__pycache__/
*.pyc
.venv/
.git/
.env
*.log
.DS_Store

Paso 2: Construir la Imagen

docker build -t log-analyzer:1.0 .

Output esperado:

[+] Building 8.5s (9/9) FINISHED
 => [1/4] FROM python:3.11-slim  ...
 => [2/4] WORKDIR /app
 => [3/4] COPY requirements.txt .
 => [4/4] RUN pip install --no-cache-dir -r requirements.txt
 => [5/5] COPY . .
 => naming to docker.io/library/log-analyzer:1.0

Paso 3: Crear la Infraestructura Docker

Network

docker network create app-net

Volume para Redis

docker volume create redis-data

Iniciar Redis

docker run -d \
  --name redis \
  --network app-net \
  -v redis-data:/data \
  redis:7-alpine \
  redis-server --appendonly yes

Desglose:

  • -d — Detached (background)
  • --name redis — Nombre que la app Python usará para conectarse
  • --network app-net — Misma network que la app
  • -v redis-data:/data — Volume para persistir datos de Redis
  • redis-server --appendonly yes — Redis con persistencia a disco (escribe cada cambio a /data/appendonly.aof; sin esto, los datos se pierden al reiniciar)
# Verifica que Redis está corriendo
docker ps --filter name=redis

Output esperado:

CONTAINER ID   IMAGE            STATUS         PORTS      NAMES
abc123...      redis:7-alpine   Up 5 seconds   6379/tcp   redis

Paso 4: Usar la Aplicación

Agregar entradas

docker run --rm --network app-net log-analyzer:1.0 add "Docker permite containerizar aplicaciones AI con todas sus dependencias"

docker run --rm --network app-net log-analyzer:1.0 add "FastAPI es el framework preferido para APIs de AI en Python"

docker run --rm --network app-net log-analyzer:1.0 add "ChromaDB es una vector database ideal para sistemas RAG"

Output esperado (para cada comando):

============================================================
LOG ANALYZER v1.0 — Persistent Data App
============================================================

✅ Entrada #1 agregada:
   Texto: Docker permite containerizar aplicaciones AI con todas sus dependencias
   Palabras: 9
   Timestamp: 2026-03-08T16:30:00.123456
============================================================

Ver estadísticas

docker run --rm --network app-net log-analyzer:1.0 stats

Output esperado:

============================================================
LOG ANALYZER v1.0 — Persistent Data App
============================================================

📊 Estadísticas:
   Total entradas: 3
   Total palabras: 28
   Promedio palabras/entrada: 9.3

   Top palabras:
     es              (2)
     ai              (2)
     para            (2)
     docker          (1)
     ...
============================================================

Listar entradas

docker run --rm --network app-net log-analyzer:1.0 list

Cómo funciona la comunicación

Cuando ejecutas docker run --rm --network app-net log-analyzer:1.0 add "texto":

  1. Docker crea un container temporal con la imagen log-analyzer:1.0
  2. Lo conecta a la network app-net (misma que Redis)
  3. El DNS interno de Docker resuelve redis → IP del container Redis
  4. La app Python abre conexión TCP a redis:6379
  5. Redis escribe los datos en /data (que es el volume redis-data)
  6. Al terminar, el container se elimina (--rm), pero Redis y el volume siguen

Cada comando add, stats, list crea un container nuevo que vive unos segundos. Redis persiste entre todas esas ejecuciones gracias al volume y a --appendonly yes.


Paso 5: Verificar Persistencia

Este es el test más importante del proyecto: los datos sobreviven a la destrucción de containers.

# 1. Detén y elimina Redis
docker stop redis
docker rm redis

# 2. Verifica que el volume sigue existiendo
docker volume ls --filter name=redis-data

Output esperado:

DRIVER    VOLUME NAME
local     redis-data
# 3. Recrea Redis con el MISMO volume
docker run -d \
  --name redis \
  --network app-net \
  -v redis-data:/data \
  redis:7-alpine \
  redis-server --appendonly yes

# 4. Verifica que los datos persisten
docker run --rm --network app-net log-analyzer:1.0 stats

Output esperado: Las mismas 3 entradas siguen ahí. Los datos sobrevivieron porque están en el named volume redis-data, no dentro del container.

# 5. Agrega una nueva entrada post-recreación
docker run --rm --network app-net log-analyzer:1.0 add "Los datos persisten gracias a Docker volumes"

docker run --rm --network app-net log-analyzer:1.0 stats

Output esperado: Ahora muestra 4 entradas.


Paso 6: Limpieza

# Detener y eliminar containers
docker stop redis
docker rm redis

# Eliminar network
docker network rm app-net

# Eliminar volumes (si quieres limpiar los datos)
docker volume rm redis-data

# Eliminar imágenes del proyecto (opcional)
docker image rm log-analyzer:1.0

Troubleshooting del Proyecto

Cuando algo falla, estos son los cinco problemas más frecuentes y cómo resolverlos:

1. ConnectionError: Cannot connect to Redis

Síntoma: La app Python muestra "ERROR: No se puede conectar a Redis" al ejecutar cualquier comando.

Causas posibles:

  • Redis no está corriendo
  • Redis y la app no están en la misma network
  • Olvidaste --network app-net al ejecutar la app

Solución:

# Verifica que Redis está corriendo
docker ps --filter name=redis

# Si no está, inícialo con network y volume
docker run -d \
  --name redis \
  --network app-net \
  -v redis-data:/data \
  redis:7-alpine \
  redis-server --appendonly yes

# Verifica que la app usa la misma network
docker run --rm --network app-net log-analyzer:1.0 stats

2. Network not found

Síntoma: Error response from daemon: network app-net not found

Causa: La network debe crearse antes de usarla. Si la eliminaste con docker network rm app-net o nunca la creaste, los containers no pueden unirse.

Solución:

# Crea la network primero
docker network create app-net

# Luego inicia Redis (y la app) con --network app-net

3. Volume data not persisting

Síntoma: Después de docker stop redis && docker rm redis y recrear, los datos desaparecen.

Causas posibles:

  • El nombre del volume no coincide al recrear (ej: escribiste redis_data en vez de redis-data)
  • Redis no se inició con --appendonly yes (sin persistencia a disco)
  • Usaste un volume anónimo o un path incorrecto

Solución:

# Verifica que el volume existe y tiene el nombre correcto
docker volume ls --filter name=redis-data

# Al recrear Redis, usa EXACTAMENTE el mismo binding
docker run -d \
  --name redis \
  --network app-net \
  -v redis-data:/data \
  redis:7-alpine \
  redis-server --appendonly yes

El flag --appendonly yes es obligatorio: hace que Redis escriba cambios a /data/appendonly.aof dentro del volume. Sin él, Redis solo mantiene datos en memoria y se pierden al reiniciar.


4. Container can't resolve hostname 'redis'

Síntoma: redis.exceptions.ConnectionError: Error -2 connecting to redis:6379. Name or service not known

Causa: Estás usando la network por defecto (bridge) en vez de una custom network. En la network default, los containers no se resuelven por nombre.

Solución:

# La app DEBE usar --network app-net
docker run --rm --network app-net log-analyzer:1.0 stats

# Si omitiste --network app-net, la app intenta resolver "redis" y falla

Solo las custom networks tienen DNS automático por nombre. En bridge, tendrías que usar la IP del container (que cambia cada vez).


5. Port already in use

Síntoma: Error: bind: address already in use o similar al iniciar Redis con -p 6379:6379.

Causa: Otro proceso (local Redis, otro container) ya usa el puerto 6379 en tu host.

Solución: Para este proyecto no necesitas port mapping: la app se conecta desde dentro de la network. Si aun así quieres exponer Redis al host:

# Usa otro puerto en el host
docker run -d ... -p 6380:6379 redis:7-alpine ...
# Luego conéctate con redis-cli -p 6380 desde tu máquina

O identifica qué usa el puerto:

# macOS/Linux
lsof -i :6379

Resumen rápido: Si algo falla

SíntomaAcción rápida
ConnectionError a Redisdocker ps → Redis corriendo? --network app-net en la app?
Network not founddocker network create app-net antes de run
Datos no persistenRevisa -v redis-data:/data y --appendonly yes
Name or service not knownApp sin --network app-net — añádelo
Port in useNo mapees puerto, o usa -p 6380:6379

Errores Comunes

Evita estos errores que suelen cometer quienes arrancan con Docker multi-container:

ErrorQué pasaCómo evitarlo
Olvidar --network app-netLa app no puede resolver el hostname redis. ConnectionError.Siempre incluye --network app-net al ejecutar la app: docker run --rm --network app-net log-analyzer:1.0 ...
No usar --appendonly yesRedis no persiste a disco. Al recrear el container, todos los datos se pierden.Inicia Redis con redis-server --appendonly yes para que escriba en el volume.
docker volume pruneElimina todos los volumes no usados. Pierdes redis-data y todo el contenido.No ejecutes prune sin revisar qué borrarás. Usa docker volume rm redis-data solo si quieres limpiar este proyecto.
Containers sin nombreDificulta gestionarlos: no sabes cuál es cuál, y la app no puede usar DNS por nombre.Usa --name redis para Redis. La app usa ese nombre para conectarse.
Crear network/volume en orden incorrectoSi intentas docker run --network app-net sin haber creado app-net, falla.Orden: (1) docker network create app-net, (2) docker volume create redis-data, (3) iniciar Redis, (4) ejecutar app.

Orden de arranque recomendado

Para evitar errores de "network not found" o "volume not found", sigue siempre este orden:

  1. Networkdocker network create app-net
  2. Volumedocker volume create redis-data
  3. Redisdocker run -d --name redis --network app-net -v redis-data:/data redis:7-alpine redis-server --appendonly yes
  4. Appdocker run --rm --network app-net log-analyzer:1.0 <comando>

Si reinicias desde cero (después de docker rm y docker network rm), repite los pasos 1-3. El volume persiste, así que no hace falta recrearlo a menos que quieras datos frescos.


Criterios de Éxito

Tu proyecto está completo si:

  • Network creada: app-net conecta la app Python con Redis
  • Volume creado: redis-data persiste datos de Redis
  • Redis corriendo: Container detached con volume y network
  • App funciona: Puedes agregar, listar, y ver stats
  • Persistencia verificada: Datos sobreviven a docker stop + docker rm + recrear Redis
  • Networking funciona: La app se conecta a Redis por nombre (redis:6379)
  • Limpieza completa: Puedes derribar y limpiar todo

Checklist de validación

# Red existe
docker network ls --filter name=app-net

# Volume existe
docker volume ls --filter name=redis-data

# Redis corriendo
docker ps --filter name=redis --format "{{.Names}}: {{.Status}}"

# App funciona
docker run --rm --network app-net log-analyzer:1.0 stats

Verificación Completa

Antes de dar por terminado el proyecto, ejecuta esta batería de tests para confirmar que todo funciona correctamente. Hazlo en orden:

Test 1: Network existe

docker network ls --filter name=app-net

Esperado: Una línea con app-net en la columna NAME.


Test 2: Volume existe

docker volume ls --filter name=redis-data

Esperado: Una línea con redis-data en la columna VOLUME NAME.


Test 3: Redis está healthy

docker exec redis redis-cli ping

Esperado: PONG

Si falla: Redis no está corriendo o el container no se llama redis. Verifica con docker ps --filter name=redis.


Test 4: App puede conectar a Redis

docker run --rm --network app-net log-analyzer:1.0 stats

Esperado: Output del Log Analyzer sin errores de conexión. Si hay entradas, muestra estadísticas; si no, "No hay entradas aún".


Test 5: Datos persisten al recrear Redis

Este es el test crítico de persistencia:

# 5a. Agrega datos
docker run --rm --network app-net log-analyzer:1.0 add "Test de persistencia"
docker run --rm --network app-net log-analyzer:1.0 stats
# Anota cuántas entradas hay (ej: 5)

# 5b. Destruye Redis
docker stop redis
docker rm redis

# 5c. Recrea Redis con el mismo volume
docker run -d \
  --name redis \
  --network app-net \
  -v redis-data:/data \
  redis:7-alpine \
  redis-server --appendonly yes

# 5d. Verifica que los datos siguen ahí
docker run --rm --network app-net log-analyzer:1.0 stats

Esperado: El mismo número de entradas (o más si agregaste después). Los datos sobrevivieron al ciclo completo.


Test 6: Clean shutdown funciona

docker stop redis
docker rm redis

Esperado: Redis se detiene y se elimina sin errores. El volume redis-data sigue existiendo (docker volume ls).


Qué Aprendiste en Phase 1

Con los módulos 1-3 completados, dominas los fundamentos de Docker:

PilarMóduloQué dominas
Conceptos1Qué es Docker, por qué, arquitectura, comandos básicos
Images/Containers2Dockerfiles, build, layers, caching, ciclo de vida
Persistencia3Named volumes, bind mounts
Comunicación3Networking, port mapping
Distribución3Docker Hub, push/pull

Qué Viene Después: Phase 2

La Phase 2 aplica todo esto a aplicaciones AI reales. La transición es directa: "Ya dominas Docker fundamentals. Ahora aplícalos a tus AI apps."

Módulo 4: FastAPI + OpenAI con Secrets

Tomarás una API real (FastAPI + OpenAI) y la containerizarás. La diferencia clave: secrets management. En este proyecto no había API keys; en Phase 2 aprenderás a inyectar OPENAI_API_KEY de forma segura con --env-file o Docker secrets, sin hardcodear en el Dockerfile. El patrón app + network + volume se repite, pero con una capa más de producción.

Módulo 5: Docker Compose

Todo lo que hiciste manualmente (network, volumes, containers, orden de arranque) se define en un archivo YAML. En vez de 6 comandos docker run y docker network create, tendrás algo como:

services:
  redis:
    image: redis:7-alpine
    volumes: [redis-data:/data]
    command: redis-server --appendonly yes
  app:
    build: .
    depends_on: [redis]
    networks: [app-net]
volumes:
  redis-data: {}
networks:
  app-net: {}

Un solo comando: docker compose up -d. La misma arquitectura que construiste a mano, pero declarativa y reproducible.

Módulo 6: Multi-stage Builds

Las imágenes de Python suelen superar 1GB. Con multi-stage builds reducirás eso a ~200MB: un stage para compilar/dependencias y otro minimal para el runtime. Menos superficie de ataque, pushes más rápidos, despliegues más ágiles.

Conexión con lo que hiciste aquí

Este proyecto (Phase 1)Phase 2
docker run manual para cada containerdocker compose up para todo el stack
Network + volume creados a manoDefinidos en docker-compose.yml
App que habla con Redis por nombreIgual, pero el compose crea la network automáticamente
Sin secretsSecrets para API keys y credenciales
Imagen Python estándarImagen optimizada con multi-stage

No deseches nada de lo que aprendiste: lo reutilizarás. Solo cambia la herramienta (CLI → Compose) y el dominio (log analyzer → AI apps).


Resumen

  • Construiste un sistema de 2 containers (Redis + Python app) conectados por networking
  • Named volumes persisten datos de Redis que sobreviven a docker rm
  • Custom network permite que la app se conecte a Redis por nombre (redis:6379)
  • Los datos sobreviven al ciclo de vida completo: stop → rm → recrear → datos intactos
  • Este patrón (app + DB + network + volume) es exactamente lo que Docker Compose (Módulo 5) automatiza
  • Completaste Phase 1: tienes todos los fundamentos para containerizar AI apps

Recursos Adicionales

  1. Docker Volumes (docs) — Referencia de volumes
  2. Docker Networking (docs) — Referencia de networking
  3. Redis Docker Image — Imagen oficial de Redis
  4. redis-py Documentation — Cliente Redis para Python
  5. Docker Compose Overview — Preview del Módulo 5
  6. Container Networking (Docker blog) — Explicación profunda de networking