Saltar a contenido

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.conf está fijado spark.driver.host jupyter, así que cuando creas una SparkSession desde 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 puerto 4040 (publicado por el contenedor jupyter), mientras que la del clúster está en el 8080 (del spark-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 rutas s3a://. 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) con mc mb --ignore-existing.
  • iabd-airflow-init — ejecuta airflow db migrate y crea el usuario admin. 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 hive apuntando a mysql-metastore:3306 con autocreación de esquema desactivada (lo crea hive-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.DeltaSparkSessionExtension y el catálogo DeltaCatalog.
  • Driver: spark.driver.host jupyter y spark.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.

Referencias