lección 8
Proyecto: entorno de desarrollo completo con docker-compose
Python + PostgreSQL + pgAdmin todo en docker-compose. Un mini pipeline ETL que corre 100% en Docker.
⏱ 55 min
Es hora de poner todo junto. En esta lección vas a construir un entorno de desarrollo profesional COMPLETO: un pipeline ETL en Python que extrae datos de un archivo CSV, los transforma, los carga en PostgreSQL, y puedes verificar el resultado en pgAdmin. Todo orquestado con docker-compose. Todo reproducible. Cualquier persona de tu equipo puede hacer git clone + docker compose up y tener el mismo entorno funcionando en minutos.
Este proyecto simula lo que harás en tu primer día como data engineer junior en una empresa real. Te dan un CSV con datos crudos, te piden que los limpies y los metas en la base de datos para que el equipo de BI pueda analizarlos. La diferencia es que ahora lo harás de forma PROFESIONAL: dockerizado, reproducible y versionado en Git.
### Arquitectura del proyecto
### Paso 1: Estructura del proyecto
1# Estructura de archivos del proyecto2mi-pipeline-docker/3├── docker-compose.yml # Orquesta todos los servicios4├── Dockerfile # Imagen personalizada para el ETL5├── requirements.txt # Dependencias Python del ETL6├── etl/7│ └── pipeline.py # El script ETL principal8├── data/9│ └── ventas_raw.csv # Datos crudos de entrada10└── init-db/11 └── schema.sql # Esquema de la tabla destino
Estructura profesional del proyecto — limpia y organizada
### Paso 2: Los datos crudos (CSV)
1# Archivo: data/ventas_raw.csv2id,producto,cantidad,precio_unitario,fecha,cliente,ciudad31,Laptop Pro,2,1299.99,2025-01-15,Ana García,Madrid42,Mouse Wireless,,29.99,2025-01-15,Carlos López,Barcelona53,Teclado Mecánico,1,89.50,15/01/2025,María Fernández,Valencia64,Monitor 4K,1,549.00,2025-01-16,,Sevilla75,Laptop Pro,3,1299.99,2025-01-16,Ana García,Madrid86,USB Hub,-1,19.99,2025-01-17,Pedro Martín,Bilbao97,Webcam HD,2,79.99,2025-01-17,Laura Sánchez,Madrid108,Monitor 4K,1,549.00,2025-01-18,Carlos López,Barcelona
Datos crudos con errores típicos: campos vacíos, fechas inconsistentes, cantidad negativa
### Paso 3: El esquema SQL
1-- Archivo: init-db/schema.sql2CREATE TABLE IF NOT EXISTS ventas_limpias (3 id SERIAL PRIMARY KEY,4 producto VARCHAR(100) NOT NULL,5 cantidad INTEGER NOT NULL CHECK (cantidad > 0),6 precio_unitario DECIMAL(10,2) NOT NULL,7 total DECIMAL(10,2) NOT NULL,8 fecha DATE NOT NULL,9 cliente VARCHAR(100),10 ciudad VARCHAR(50) NOT NULL,11 cargado_en TIMESTAMP DEFAULT NOW()12);
schema.sql — la tabla destino con validaciones
### Paso 4: El script ETL en Python
1# Archivo: etl/pipeline.py2"""Mini pipeline ETL: CSV → limpieza → PostgreSQL"""3import csv4import psycopg25from datetime import datetime6import time7import os89def esperar_postgres(max_intentos=10):10 """Espera a que PostgreSQL esté listo antes de conectar."""11 for intento in range(max_intentos):12 try:13 conn = psycopg2.connect(14 host=os.getenv("DB_HOST", "postgres"),15 port=5432,16 user=os.getenv("DB_USER", "etl_user"),17 password=os.getenv("DB_PASS", "etl_secreto"),18 database=os.getenv("DB_NAME", "ventas")19 )20 conn.close()21 print(f"✅ PostgreSQL listo (intento {intento + 1})")22 return True23 except psycopg2.OperationalError:24 print(f"⏳ Esperando PostgreSQL... (intento {intento + 1})")25 time.sleep(2)26 raise Exception("❌ PostgreSQL no respondió después de varios intentos")2728def leer_csv(filepath):29 """Lee el CSV crudo."""30 with open(filepath, 'r', encoding='utf-8') as f:31 reader = csv.DictReader(f)32 return list(reader)3334def limpiar_fila(fila):35 """Limpia y valida una fila. Retorna None si no es válida."""36 # Validar cantidad37 try:38 cantidad = int(fila['cantidad'])39 if cantidad <= 0:40 print(f" ⚠️ Fila {fila['id']}: cantidad inválida ({cantidad})")41 return None42 except (ValueError, TypeError):43 print(f" ⚠️ Fila {fila['id']}: cantidad vacía o no numérica")44 return None4546 # Validar cliente47 cliente = fila.get('cliente', '').strip() or 'Desconocido'4849 # Normalizar fecha (soportar YYYY-MM-DD y DD/MM/YYYY)50 fecha_raw = fila['fecha'].strip()51 try:52 if '/' in fecha_raw:53 fecha = datetime.strptime(fecha_raw, '%d/%m/%Y').date()54 else:55 fecha = datetime.strptime(fecha_raw, '%Y-%m-%d').date()56 except ValueError:57 print(f" ⚠️ Fila {fila['id']}: fecha inválida ({fecha_raw})")58 return None5960 # Validar precio61 try:62 precio = float(fila['precio_unitario'])63 if precio <= 0:64 print(f" ⚠️ Fila {fila['id']}: precio inválido ({precio})")65 return None66 except (ValueError, TypeError):67 print(f" ⚠️ Fila {fila['id']}: precio vacío o no numérico")68 return None6970 total = cantidad * precio7172 return {73 'producto': fila['producto'].strip(),74 'cantidad': cantidad,75 'precio_unitario': precio,76 'total': round(total, 2),77 'fecha': fecha,78 'cliente': cliente,79 'ciudad': fila['ciudad'].strip(),80 }8182def cargar_en_postgres(filas_limpias):83 """Inserta las filas limpias en PostgreSQL."""84 conn = psycopg2.connect(85 host=os.getenv("DB_HOST", "postgres"),86 port=5432,87 user=os.getenv("DB_USER", "etl_user"),88 password=os.getenv("DB_PASS", "etl_secreto"),89 database=os.getenv("DB_NAME", "ventas")90 )91 cursor = conn.cursor()9293 for fila in filas_limpias:94 cursor.execute("""95 INSERT INTO ventas_limpias96 (producto, cantidad, precio_unitario, total, fecha, cliente, ciudad)97 VALUES (%s, %s, %s, %s, %s, %s, %s)98 """, (99 fila['producto'], fila['cantidad'], fila['precio_unitario'],100 fila['total'], fila['fecha'], fila['cliente'], fila['ciudad']101 ))102103 conn.commit()104 cursor.close()105 conn.close()106 return len(filas_limpias)107108if __name__ == '__main__':109 print("=" * 50)110 print("🚀 Pipeline ETL iniciado")111 print("=" * 50)112113 esperar_postgres()114115 # EXTRACT116 print("\n📥 Extrayendo datos del CSV...")117 datos_crudos = leer_csv('/app/data/ventas_raw.csv')118 print(f" Filas leídas: {len(datos_crudos)}")119120 # TRANSFORM121 print("\n🔧 Limpiando y transformando...")122 datos_limpios = []123 for fila in datos_crudos:124 limpia = limpiar_fila(fila)125 if limpia:126 datos_limpios.append(limpia)127128 print(f" Filas válidas: {len(datos_limpios)}/{len(datos_crudos)}")129130 # LOAD131 print("\n📤 Cargando en PostgreSQL...")132 insertadas = cargar_en_postgres(datos_limpios)133 print(f" Filas insertadas: {insertadas}")134135 print("\n✅ Pipeline completado exitosamente!")136 print("=" * 50)
pipeline.py — ETL completo: Extract, Transform, Load
### Paso 5: Dockerfile para el ETL
1# Archivo: Dockerfile2FROM python:3.12-slim34WORKDIR /app56COPY requirements.txt .7RUN pip install --no-cache-dir -r requirements.txt89COPY etl/ ./etl/10COPY data/ ./data/1112CMD ["python", "etl/pipeline.py"]
Dockerfile del ETL — simple y efectivo
1# Archivo: requirements.txt2psycopg2-binary==2.9.9
requirements.txt — solo necesitamos el driver de PostgreSQL
### Paso 6: El docker-compose.yml que une todo
1# Archivo: docker-compose.yml2services:3 postgres:4 image: postgres:165 environment:6 POSTGRES_USER: etl_user7 POSTGRES_PASSWORD: etl_secreto8 POSTGRES_DB: ventas9 ports:10 - "5432:5432"11 volumes:12 - pgdata:/var/lib/postgresql/data13 - ./init-db:/docker-entrypoint-initdb.d # Ejecuta schema.sql al inicio1415 pgadmin:16 image: dpage/pgadmin4:latest17 environment:18 PGADMIN_DEFAULT_EMAIL: admin@datos.com19 PGADMIN_DEFAULT_PASSWORD: admin12320 ports:21 - "8080:80"22 depends_on:23 - postgres2425 etl:26 build: .27 environment:28 DB_HOST: postgres29 DB_USER: etl_user30 DB_PASS: etl_secreto31 DB_NAME: ventas32 depends_on:33 - postgres34 volumes:35 - ./data:/app/data # Montar los CSVs3637volumes:38 pgdata:
docker-compose.yml final — 3 servicios orquestados
### Paso 7: ¡Ejecutar!
1# Levantar todo (construye la imagen ETL + arranca PostgreSQL + pgAdmin)2docker compose up --build34# Verás los logs de los 3 servicios:5# postgres | PostgreSQL init process complete6# pgadmin | Listening at: http://0.0.0.0:807# etl | 🚀 Pipeline ETL iniciado8# etl | ✅ PostgreSQL listo9# etl | 📥 Extrayendo datos del CSV...10# etl | 🔧 Limpiando y transformando...11# etl | 📤 Cargando en PostgreSQL...12# etl | ✅ Pipeline completado exitosamente!1314# El ETL termina pero PostgreSQL y pgAdmin siguen corriendo15# Abre http://localhost:8080 para ver los datos en pgAdmin
Un comando y todo funciona — la magia de docker-compose
Consejo de senior: este proyecto es EXACTAMENTE el patrón que usarás en producción, solo que a mayor escala. En producción, el CSV viene de un S3, el ETL corre en Airflow, y PostgreSQL es un RDS en AWS. Pero la estructura es la misma. Si dominas este patrón en local con Docker, la transición a producción es natural.
### Verificar resultados
1# Conectarte directamente a PostgreSQL desde la terminal2docker compose exec postgres psql -U etl_user -d ventas34# Dentro de psql:5# ventas=# SELECT * FROM ventas_limpias;6# ventas=# SELECT COUNT(*) FROM ventas_limpias;7# ventas=# SELECT ciudad, SUM(total) FROM ventas_limpias GROUP BY ciudad;8# ventas=# \q (para salir)
Verificar que los datos llegaron correctamente a PostgreSQL
Si el ETL falla con "connection refused", es porque PostgreSQL aún no estaba listo cuando el ETL intentó conectar. La función esperar_postgres() en el script resuelve esto con reintentos. En producción usarías healthchecks de docker-compose para un control más fino.
### Resumen: lo que has aprendido en esta skill
En 8 lecciones has pasado de no saber qué es Docker a tener un pipeline ETL profesional corriendo en contenedores. Recapitulemos el viaje: entendiste el problema que Docker resuelve, lo instalaste, ejecutaste contenedores de otros, creaste tus propias imágenes con Dockerfiles, orquestaste múltiples servicios con docker-compose, persististe datos con volúmenes, y construiste un entorno profesional completo. Esto es la BASE de todo lo que viene en tu carrera de data engineering.
Consejo de senior: guarda este proyecto como template. Cada vez que empieces un proyecto nuevo de datos, copia esta estructura y adáptala. Docker-compose.yml + Dockerfile + script + datos de ejemplo. Es el scaffold perfecto para cualquier pipeline.
### ¿Has terminado el proyecto?
Ejecuta estos comandos en la carpeta del proyecto. Si todo sale como se indica, has terminado:
1# 1. Los tres servicios arriba, postgres marcado (healthy)2docker compose ps --format "{{.Service}} {{.Status}}"34# 2. Tu ETL cargó datos en la base5docker compose exec postgres psql -U etl_user -d ventas \6 -tAc "SELECT COUNT(*) FROM ventas_limpias;"78# 3. El reporte existe9cat output/reporte.json1011# 4. Tu imagen es pequeña (multi-stage)12docker images --format "{{.Repository}}:{{.Tag}} {{.Size}}" | grep etl1314# 5. El compose sigue siendo válido15docker compose config --services | sort
Si los cinco comandos dan resultado, tu entorno profesional está completo.
## ejercicios
Añade un healthcheck a PostgreSQL
En vez de usar la función esperar_postgres() en Python, configura un healthcheck en docker-compose para que Docker sepa cuándo PostgreSQL está realmente listo. El ETL debe depender de que la DB esté "healthy".
Extender el pipeline con un reporte
Modifica el pipeline para que, después de cargar los datos, genere un reporte JSON con estadísticas: total de ventas, número de filas, y la ciudad con más ventas. Guárdalo en /app/output/reporte.json.
Dockerfile multi-stage para producción
El Dockerfile de desarrollo funciona pero no es seguro ni ligero. Escribe la versión de producción: builder para instalar, runtime para ejecutar, y un usuario sin privilegios que pueda escribir en /app/output.
El .dockerignore perfecto
Tu proyecto tiene archivos que NO deberían estar en la imagen Docker: entornos virtuales, archivos de test, documentación, secrets. Escribe el .dockerignore completo.
Regístrate para guardar tu progreso.
## comentarios
Reporta erratas, ayuda a otros o comparte tu opinión. Sé constructivo.
Inicia sesión para comentar y responder.
cargando comentarios...