El stack Docker¶
A lo largo de las sesiones de Big Data, Spark, Kafka y Airflow trabajamos sobre una infraestructura común desplegada con Docker Compose. En vez de instalar cada herramienta por separado, levantamos un único stack del que activamos solo las piezas que necesita cada sesión.
Máquina virtual vs Docker
Esta página documenta la opción Docker. Si trabajas con la máquina virtual del curso, los servicios ya vienen instalados y configurados, y puedes saltarte esta página. Todo el material posterior funciona en ambos entornos.
Esta página es una referencia: consúltala una vez, vuelve a ella cuando algo no arranque. Solo necesitas tener instalados Docker y Docker Compose (incluido en Docker Desktop).
Estructura del proyecto¶
Todo el stack vive en una única carpeta spark-cluster que puedes descargar desde spark-cluster.zip:
spark-cluster/
├── docker-compose.yml
├── README.md
├── jars/ # JARs montados en Spark master/workers/Jupyter
│ └── mysql-connector-j-8.0.33.jar
├── spark-config/
│ ├── spark-defaults.conf # configuración base de Spark
│ └── create_db.sql # init de retail_db en mysql-datos
├── kafka/
│ ├── connect-distributed.properties # config de Kafka Connect (modo distribuido)
│ └── connect-plugins/ # conectores montados en kafka-connect (volumen)
├── notebooks/ # notebooks de Jupyter (volumen)
├── jupyter/ # Dockerfile de la imagen de Jupyter
└── airflow/
├── dags/ # DAGs del curso
├── jobs/ # scripts Spark lanzados por SparkSubmitOperator
├── data/ # ficheros de entrada/salida de los casos
├── logs/ # logs de Airflow (persistidos)
└── plugins/ # plugins Airflow (vacío por defecto)
Las carpetas que terminan en comentario (volumen) se montan dentro de los contenedores, de modo que lo que edites en tu equipo (un notebook, un DAG, un JAR) aparece dentro del contenedor sin reconstruir nada.
Arranque selectivo con perfiles¶
El docker-compose.yml agrupa los servicios en perfiles. Un perfil es un conjunto de servicios que arrancan juntos; si no indicas ningún perfil, no arranca nada. Así, una clase de Kafka no levanta Spark ni Airflow, y no malgastas memoria.
| Perfil | Servicios | Sesiones donde se usa |
|---|---|---|
kafka |
kafka (broker único KRaft) + kafka-ui |
Kafka 1, casos 3 y 4 de Kafka 2 |
kafka-connect |
kafka-connect (necesita el perfil kafka) |
Sesión de Kafka Connect |
kafka-cluster |
kafka-1, kafka-2, kafka-3 + kafka-ui |
Kafka 2 - Caso 2 |
spark |
MySQL metastore, Hive init, MinIO (+ init de buckets), Spark master + 2 workers, Jupyter | Sesiones de Spark con clúster completo |
spark-single |
Igual que spark pero con 1 worker |
Clúster Spark mínimo (menos memoria, misma arquitectura) |
datos |
MySQL con retail_db |
JDBC, Kafka Connect → MySQL, Airflow casos 2/3/4 |
airflow |
Postgres + init + webserver + scheduler (LocalExecutor) | Sesión de Airflow |
kafka y kafka-cluster son excluyentes
El servicio kafka (un solo broker, replication.factor=1) y el servicio kafka-1 (primer nodo de un clúster con replication.factor=2/3) son contenedores distintos a propósito. Los perfiles kafka y kafka-cluster ocupan los mismos puertos del host, así que usa uno u otro, nunca los dos a la vez.
La interfaz web (kafka-ui) aparece en ambos perfiles con los dos clústeres preconfigurados. Verás una pestaña verde (el clúster activo) y otra roja (el inactivo): es el comportamiento esperado.
kafka-connect siempre acompaña a kafka
Kafka Connect vive en su propio perfil para que las sesiones que solo usan el broker (Kafka 1, casos 3 y 4) no carguen un worker extra. Pero depende del broker, así que se arranca junto al perfil kafka: --profile kafka --profile kafka-connect. El depends_on por sí solo no activa el perfil del broker.
Los perfiles se combinan según lo que pida la sesión:
# Kafka 1, casos 3 y 4 de Kafka 2 (solo broker + UI)
docker compose --profile kafka up -d
# Kafka Connect (broker + Connect)
docker compose --profile kafka --profile kafka-connect up -d
# Kafka 2 - Caso 2 (clúster de 3 brokers)
docker compose --profile kafka-cluster up -d
# Spark con MinIO y Hive (clúster completo)
docker compose --profile spark up -d
# Spark mínimo (master + 1 worker) para clase ligera
docker compose --profile spark-single up -d
# Spark + JDBC sobre retail_db
docker compose --profile spark --profile datos up -d
# Spark Streaming sobre Kafka (broker único)
docker compose --profile spark --profile kafka up -d
# Sesión completa con Airflow orquestando Spark sobre retail_db
docker compose --profile airflow --profile spark --profile datos up -d
Arquitectura¶
Todos los servicios comparten una red bridge (spark-cluster_spark-net). Dentro de esa red cada contenedor se resuelve por su hostname (spark-master, minio, kafka...), de modo que las URLs internas (spark://spark-master:7077, http://minio:9000, kafka:9092) funcionan sin saber qué IP le ha tocado a cada uno.
graph TB
subgraph host[Tu equipo]
browser[Navegador / spark-submit]
end
subgraph net["Red spark-cluster_spark-net"]
subgraph p_spark["perfil: spark / spark-single"]
jupyter[jupyter<br/>driver]
master[spark-master]
w1[spark-worker-1]
w2["spark-worker-2<br/>(solo perfil spark)"]
minio[(minio<br/>S3)]
metastore[(mysql-metastore)]
jupyter --> master
master --> w1
master --> w2
end
subgraph p_datos["perfil: datos"]
mysqldatos[(mysql-datos<br/>retail_db)]
end
subgraph p_kafka["perfil: kafka"]
kafka[kafka<br/>broker KRaft]
kui[kafka-ui]
end
subgraph p_connect["perfil: kafka-connect"]
connect[kafka-connect]
end
subgraph p_airflow["perfil: airflow"]
scheduler[airflow-scheduler]
web[airflow-webserver]
pg[(postgres-airflow)]
end
end
jupyter -. "s3a://" .-> minio
master -. "s3a://" .-> minio
jupyter -. "metastore" .-> metastore
connect -- "JDBC source" --> mysqldatos
connect -- "S3 sink" --> minio
connect --> kafka
scheduler -. "SparkSubmit" .-> master
Dos decisiones de diseño que conviene tener claras desde el principio:
- El driver de Spark es Jupyter, no el master. En
spark-defaults.confestá fijadospark.driver.host jupyter, así que cuando creas unaSparkSessiondesde un notebook, ese notebook actúa como driver y reparte el trabajo a los workers a través del master. Por eso la Spark UI del trabajo en ejecución está en el puerto4040(publicado por el contenedorjupyter), mientras que la del clúster está en el8080(delspark-master). - El metastore y el warehouse están desacoplados. MySQL (
mysql-metastore) guarda solo los metadatos de Hive (qué tablas existen, su esquema y dónde están los datos). Los datos en sí viven en MinIO bajo rutass3a://. El metastore es un puntero; el almacén es otra cosa.
Usa siempre rutas s3a://
En modo distribuido, una ruta sin esquema (p. ej. datos/ventas.parquet) se resuelve como file:// dentro de cada contenedor, que es un sistema de ficheros local y distinto en cada worker. El trabajo falla o lee datos incompletos. Escribe siempre rutas completas: s3a://raw-data/..., s3a://processed/..., s3a://warehouse/....
Puertos publicados¶
Estos son los puertos que el stack expone en tu equipo (localhost):
| Servicio | Puerto host | Acceso |
|---|---|---|
| Spark Master UI | 8080 | http://localhost:8080 |
| Spark Master (submit) | 7077 | spark://localhost:7077 (host) / spark://spark-master:7077 (interno) |
| Spark Driver UI | 4040 | http://localhost:4040 |
| Jupyter | 8888 | http://localhost:8888 |
| MinIO API (S3) | 9000 | s3a://... desde Spark |
| MinIO Console | 9001 | http://localhost:9001 (minioadmin / minioadmin123) |
| Kafka (broker único) | 9094 | localhost:9094 |
| Kafka clúster | 9094, 9095, 9096 | localhost:9094,localhost:9095,localhost:9096 |
| Kafka UI | 8081 | http://localhost:8081 |
| Kafka Connect (REST API) | 8083 | http://localhost:8083 |
| MySQL metastore | 3306 | localhost:3306 (hive / hivepass) |
| MySQL retail_db | 3307 | localhost:3307 (iabd / iabd) |
| Airflow webserver | 8082 | http://localhost:8082 (admin / admin) |
Por qué Airflow va en el 8082
Spark Master ya ocupa el 8080, así que Airflow se publica en el 8082 para no chocar.
Contenedores one-shot¶
Algunos contenedores hacen su trabajo y se apagan: su estado normal es Exited (0), no es un error. Son pasos de inicialización idempotentes (los puedes volver a lanzar sin romper nada):
iabd-hive-init— inicializa el esquema del Hive Metastore. Si el esquema ya existe, lo detecta y no hace nada.iabd-minio-init— crea los buckets (warehouse,raw-data,processed) conmc mb --ignore-existing.iabd-airflow-init— ejecutaairflow db migratey crea el usuarioadmin. El webserver y el scheduler esperan a que termine antes de arrancar.
Configuración clave¶
No necesitas tocar estos ficheros para usar el stack, pero entender qué hacen ayuda a depurar cuando algo no conecta.
spark-defaults.conf¶
Se carga automáticamente al crear cualquier SparkSession, así que las sesiones del curso no tienen que repetir esta configuración. Lo más relevante:
- Clúster:
spark.master spark://spark-master:7077. - Hive Metastore: catálogo
hiveapuntando amysql-metastore:3306con autocreación de esquema desactivada (lo creahive-init, no Spark). - MinIO / S3A: endpoint
http://minio:9000, credenciales,path.style.access=true(obligatorio con MinIO) y SSL desactivado. - Delta Lake: registra la extensión
io.delta.sql.DeltaSparkSessionExtensiony el catálogoDeltaCatalog. - Driver:
spark.driver.host jupyteryspark.driver.bindAddress 0.0.0.0, imprescindibles para que el notebook de Jupyter pueda actuar de driver contra el clúster. - Recursos: límites de memoria y cores por executor pensados para un equipo de clase, no para producción.
connect-distributed.properties¶
Configuración de Kafka Connect en modo distribuido. Apunta a kafka:9092, usa JsonConverter con schemas.enable=false (mensajes más legibles, solo payload) y replication.factor=1 en sus topics internos, porque en el perfil kafka solo hay un broker. Los conectores se cargan desde plugin.path=/opt/kafka/plugins, que es el volumen kafka/connect-plugins/.
Conectores de Kafka Connect¶
Los conectores se definen como ficheros JSON que se registran vía la API REST (http://localhost:8083). En el curso usamos tres ejemplos:
| Fichero | Tipo | Qué hace |
|---|---|---|
mysql-categories-source.json |
source (Aiven JDBC) | Lee la tabla categories de retail_db y la publica en el topic iabd-retail_db-categories |
categories-s3-sink.json |
sink (Aiven S3) | Consume ese topic y lo vuelca en s3a://raw-data/kafka-connect/categories/ como JSONL |
mysql-connector.json |
source (JDBC) | Lee la tabla orders y la publica en el topic mysql-orders |
Conectores: usar siempre el de Aiven
mysql-categories-source.json usa la clase de Aiven (io.aiven.connect.jdbc.JdbcSourceConnector, Apache 2.0), mientras que mysql-connector.json aún usa la clase de Confluent (io.confluent.connect.jdbc.JdbcSourceConnector). Conviene unificar ambos a la versión de Aiven para mantener la misma licencia y el mismo plugin en connect-plugins/.
create_db.sql¶
Es un dump de la base de datos retail_db (tablas categories, products, orders, etc.) que se monta en mysql-datos en /docker-entrypoint-initdb.d/. MySQL ejecuta ese script solo la primera vez que se crea el volumen, así que retail_db queda poblada automáticamente sin pasos manuales. Es la base de datos de prácticas para JDBC, Kafka Connect y los casos integradores de Airflow.
Gestión del ciclo de vida¶
Día a día (sin perder estado)¶
# Arrancar
docker compose --profile spark --profile kafka up -d
# Parar al acabar la sesión (mantiene contenedores, red y datos)
docker compose --profile spark --profile kafka stop
# Reanudar (instantáneo, los contenedores ya existen)
docker compose --profile spark --profile kafka start
stop/start conserva la red, las IPs internas y los volúmenes. Es lo que quieres entre clases.
Reinicio limpio (tras cambios en el compose)¶
# Eliminar contenedores y red (mantiene los volúmenes con nombre)
docker compose --profile spark --profile kafka down
# Volver a arrancar
docker compose --profile spark --profile kafka up -d
down borra los contenedores y la red, pero los volúmenes con nombre (mysql_data, kafka_data...) persisten: tus datos sobreviven.
Borrado total¶
# Borra TODO, incluidos los datos persistidos
docker compose --profile spark --profile kafka down -v
| Comando | Procesos | Contenedores | Red | Volúmenes |
|---|---|---|---|---|
stop |
parados | mantenidos | mantenida | mantenidos |
start |
reanudados | (ya existen) | (ya existe) | (ya existen) |
down |
parados | eliminados | eliminada | mantenidos |
down -v |
parados | eliminados | eliminada | eliminados |
up -d |
arrancados | creados si faltan | creada si falta | creados si faltan |
Errores comunes¶
failed to set up container networking: network ... not found¶
La red Docker se borró pero los contenedores siguen referenciándola por su ID antiguo. Suele pasar tras docker network prune o docker system prune. Solución:
docker compose --profile <X> down
docker compose --profile <X> up -d
O, más quirúrgico, si solo afecta a un servicio:
docker rm -f iabd-kafka
docker compose --profile kafka up -d
No uses docker network prune ni docker system prune entre sesiones
Esos comandos asumen que lo parado es basura y borran la red del stack. Si necesitas liberar espacio, lo seguro es limpiar solo imágenes y caché de build:
docker image prune # imágenes huérfanas
docker builder prune # caché de builds
port is already allocated¶
Otro proceso del host ya usa ese puerto. Comprueba cuál:
sudo lsof -i :8080 # Linux/macOS
netstat -ano | findstr :8080 # Windows
Casos frecuentes: el 8080 lo usa a veces Jenkins/Tomcat; el 3306, un MySQL local; el 9000, Portainer.
El alias de mc (MinIO) desaparece al reiniciar¶
Si entras al contenedor de MinIO y configuras un alias con mc alias set, ese alias no persiste entre reinicios del contenedor. Tendrás que volver a ejecutarlo en cada sesión. (El contenedor minio-init ya crea los buckets por ti al arrancar el perfil, así que normalmente no necesitas hacerlo a mano.)
La SparkSession no se cierra entre ejecuciones¶
Si en un notebook creas varias SparkSession sin cerrar la anterior, se acumulan aplicaciones "zombi" en el clúster y agotan los recursos (lo verás en la Spark UI del 8080). Cierra la sesión con spark.stop() antes de recrearla, o reinicia el kernel.
FAQ¶
¿Tengo que arrancar todo el stack para una clase de Kafka?
No. Activa solo el perfil que necesites: docker compose --profile kafka up -d. Los servicios de otros perfiles (Spark, Airflow) no se levantan y no consumen memoria.
¿Pierdo mis datos si hago down?
No. down elimina contenedores y red, pero los volúmenes con nombre persisten. Solo down -v borra los datos.
¿Por qué hay contenedores en estado Exited (0)?
Son los contenedores one-shot de inicialización (hive-init, minio-init, airflow-init). Hacen su trabajo y se apagan: Exited (0) es su estado correcto, no un fallo.
Estoy en modo distribuido y Spark no encuentra mi fichero. ¿Por qué?
Seguramente usaste una ruta sin esquema, que se resuelve como file:// local a cada contenedor. Usa rutas s3a:// (s3a://raw-data/...) para que todos los workers accedan al mismo almacén en MinIO.
¿Cuál es la diferencia entre el perfil spark y spark-single?
La misma arquitectura (master, metastore, MinIO, Jupyter), pero spark-single levanta un worker en lugar de dos. Consume menos memoria y sirve para clases ligeras donde no interesa repartir el trabajo entre varios nodos.