9  Laboratorio: añadir un cliente Spark al clúster HDFS/YARN

big-data
spark
docker
yarn
laboratorio
jupyter
Autor/a

Wilder Ramírez Delgado

Fecha de publicación

30 de septiembre de 2026

Este laboratorio amplía el clúster que ya tienes (HDFS + YARN): añade spark-client, el Python compartido y Jupyter, y comprueba SparkPi. No explica HDFS ni YARN. No es la clase teórica del motor Spark (DAG, driver, executors): esa viene después de MapReduce, en Spark: el motor de procesamiento sobre YARN.

Antes: HDFS y YARN. Después: MapReduce, luego la teoría de Spark, luego PySpark.

En el equipo: Bash de Ubuntu/WSL, Docker Compose y navegador. En Docker: Python, Jupyter, cliente Hadoop y Spark. No instales Python ni Jupyter en Windows/Ubuntu para este laboratorio. No ejecutes estos bloques con Run All: copia las órdenes a la terminal indicada.

Los archivos de la ampliación se crean dentro de ~/hdfs-docker-lab, igual que en los laboratorios de HDFS y YARN.

La Figura 1 es el mapa de este laboratorio. La tabla de abajo dice, para cada caja, qué pregunta responde y qué ocurre si falta. La Figura 2 es el criterio de éxito: el recorrido de un trabajo cuando todo está montado. La Figura 3 es el pipeline de los diez pasos.

Qué se genera para ampliar el clúster: spark-client nuevo sobre YARN y HDFS, y los cuatro archivos que se crean

Figura 1. Lo que ya corre (HDFS y YARN) se conserva. Lo que hay que generar: el servicio spark-client, el volumen de Python 3.11 en los NodeManagers y cuatro archivos en ~/hdfs-docker-lab.

Para qué sirve cada parte

El clúster ya sabe guardar (HDFS) y repartir CPU y memoria (YARN). No sabe ejecutar Spark: la imagen apache/hadoop:3.4.1 no incluye spark-submit. Instalar Spark en cada nodo sería el reflejo equivocado. En un clúster real el cliente envía el trabajo y YARN lanza los procesos. Este laboratorio añade ese cliente.

Lee la Figura 1 de abajo hacia arriba. Las dos capas inferiores se comprueban; las superiores y la columna derecha se generan.

Pieza Estado Pregunta que responde Si falta
HDFS (NameNode + 3 DataNodes) Ya está ¿Dónde están los datos? Spark no tiene nada que leer ni dónde escribir
YARN (ResourceManager + 2 NodeManagers) Ya está ¿Quién obtiene CPU y memoria? El trabajo queda en ACCEPTED
Python 3.11 en los NodeManagers Nuevo ¿Con qué Python corren los executors? SparkPi (Scala) puede pasar y un script Python fallar
spark-client Nuevo ¿Desde dónde se envía el trabajo? No hay spark-submit, ni driver, ni Jupyter

Scala es un lenguaje que corre en la misma máquina virtual que Java (la JVM). Apache Spark está escrito en Scala; SparkPi (el ejemplo que estima π) también. En este laboratorio no programas en Scala: SparkPi solo sirve para probar el cliente sin depender de Python.

El driver (quien coordina) vive en spark-client. Los executors (quienes calculan) viven en los NodeManagers, como contenedores de YARN, no como contenedores Docker nuevos. Por eso el hostname debe ser spark-client: los executors tienen que devolverle la conexión.

Archivo Pregunta que responde Si falta
compose.spark.yaml ¿Cómo añado el cliente sin reescribir HDFS? Hay que mezclar capas en compose.yaml y se arriesga el NameNode
Dockerfile.spark-client ¿Cómo un contenedor habla Spark y Hadoop a la vez? Spark sin hdfs/yarn, o Hadoop sin spark-submit
spark-defaults.conf ¿Cómo encuentra al ResourceManager y cuánta RAM pide? spark-submit --master yarn no halla el RM, o el executor no cabe en 2 GB
preparar_jupyter.sh ¿Dónde se escribe código con el Python del clúster? El navegador usaría Python de Windows, distinto al de los workers

Si un job falla, la pregunta útil no es “¿falló el clúster?”. Es: ¿falló el disco (HDFS), el árbitro (YARN) o el cliente que acaba de enviar el trabajo?

Recorrido de un trabajo Spark: el usuario llama a spark-submit, el driver coordina, YARN reparte, los nodos leen HDFS y el portal confirma el resultado

Figura 2. El trabajo empieza cuando llamas a Spark. YARN reparte recursos, los executors leen HDFS y calculan, y el portal muestra una aplicación SPARK en FINISHED / SUCCEEDED.

Los pasos numerados que siguen construyen, en orden, lo que estas dos figuras ya mostraron. La Figura 3 es el pipeline: cada caja es un paso y la última (en teal) es el criterio de cierre.

Pipeline de los diez pasos: preparar, encender, comprobar y verificar Python

Figura 3. Recorrido del laboratorio. Preparar (1–3) deja archivos e imagen. Encender (4–5) arranca el cliente y el Python compartido. Comprobar (6–8) prueba HDFS, YARN y SparkPi. Verificar (9–10) abre Jupyter y confirma que los executors usan Python 3.11.10.

1. Localizar el clúster existente

Abre Docker Desktop y espera a que el motor esté listo. En Bash de Ubuntu/WSL:

docker version
docker compose ls
cd ~/hdfs-docker-lab
docker compose config --services
docker compose ps -a

Si el Compose real está en otra carpeta, entra a esa carpeta en lugar de crear otro proyecto. Conserva su nombre de proyecto y su archivo Compose. Los servicios esperados son namenode, datanode1, datanode2, datanode3, resourcemanager, nodemanager1 y nodemanager2; spark-client puede existir ya.

Si el proyecto se inició con -p, conserva ese nombre en todos los comandos mediante export COMPOSE_PROJECT_NAME=nombre_real. Si usaste un nombre diferente de compose.yaml, anótalo: más adelante harás que Docker Compose lea dos archivos a la vez. No crees un NameNode nuevo ni reformatees el existente.

Si los servicios están detenidos, puedes iniciarlos con docker compose start. Antes de seguir:

docker compose exec namenode hdfs dfsadmin -safemode get
docker compose exec namenode hdfs dfsadmin -report
docker compose exec namenode yarn node -list
docker compose exec namenode yarn application -list

Espera tres DataNodes vivos, dos NodeManagers RUNNING y modo seguro desactivado. Termina tus trabajos anteriores antes de recrear los NodeManagers para añadir el volumen de Python.

2. Crear la ampliación en la carpeta del clúster

Sigue dentro de ~/hdfs-docker-lab. No reemplaces compose.yaml ni los XML de HDFS/YARN. Vas a crear cuatro archivos nuevos y una carpeta de trabajo vacía para Jupyter.

TipAtajo: descargar los cuatro archivos

Los cuatro archivos de esta sección (compose.spark.yaml, Dockerfile.spark-client, config/spark-defaults.conf y preparar_jupyter.sh) están reunidos en spark-cliente-lab.zip. Descárgalo y cópialo a ~/hdfs-docker-lab (desde WSL, la carpeta Descargas de Windows está en /mnt/c/Users/<tu-usuario>/Downloads/), descomprímelo allí con unzip -o spark-cliente-lab.zip (si Ubuntu no tiene unzip: sudo apt install -y unzip), crea la carpeta de Jupyter con mkdir -p notebooks y sigue en la sección 3. Si preparar_jupyter.sh no queda ejecutable, corrígelo con chmod +x preparar_jupyter.sh. El contenido es el mismo de los bloques que siguen: úsalos si prefieres escribirlos a mano y entender cada línea.

mkdir -p notebooks config
respaldo="respaldo-spark-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$respaldo"
for archivo in compose.spark.yaml Dockerfile.spark-client preparar_jupyter.sh config/spark-defaults.conf; do
  if [ -f "$archivo" ]; then cp -- "$archivo" "$respaldo/"; fi
done

Qué hace cada línea:

  • mkdir -p notebooks config — crea las carpetas notebooks (trabajo de Jupyter) y config (XML y spark-defaults.conf) si no existen. -p evita error si ya estaban, que es el caso normal después de HDFS y YARN.
  • respaldo="respaldo-spark-$(date ...)" — guarda en una variable el nombre de una carpeta de copia de seguridad, con fecha y hora. Ejemplo: respaldo-spark-20261006-070611. No copia nada todavía: solo nombra.
  • mkdir -p "$respaldo" — crea esa carpeta vacía.
  • El for recorre los cuatro archivos que vas a escribir a continuación. Si ya existían (porque repetiste el laboratorio), cp los copia al respaldo antes de que el cat de abajo los sobrescriba. Si es la primera vez, el if [ -f ... ] no encuentra nada y no copia: la carpeta de respaldo queda vacía. Eso es correcto.

No borres archivos de laboratorios anteriores (texto.txt, archivo_300MiB.bin, los XML de Hadoop). El respaldo solo cubre las cuatro piezas de Spark, para no perder una edición tuya si vuelves a pegar los bloques.

Segundo archivo Compose (capa encima del clúster). Docker Compose, por defecto, lee un solo archivo: compose.yaml. Ese ya describe HDFS y YARN. No lo edites. En su lugar vas a crear otro YAML, compose.spark.yaml, que solo añade spark-client y el volumen de Python a los NodeManagers.

Un overlay es precisamente eso: un segundo archivo que Compose fusiona encima del primero. El resultado es el clúster de siempre más el cliente. compose.spark.yaml no define NameNode ni ResourceManager; si lo hiciera, arrancarías un segundo clúster.

cat > compose.spark.yaml <<'EOF'
# Aplicar sobre el Compose EXISTENTE, desde su misma carpeta/proyecto.
# No define NameNode, DataNodes ni ResourceManager nuevos.
services:
  spark-client:
    image: hdfs-lab/spark-client:3.5.3-hadoop3.4.1
    build:
      context: .
      dockerfile: Dockerfile.spark-client
    user: "0:0"
    hostname: spark-client
    entrypoint: ["/bin/bash", "-c"]
    command: ["sleep infinity"]
    environment:
      SPARK_HOME: /opt/spark
      HADOOP_HOME: /opt/hadoop
      HADOOP_CONF_DIR: /opt/cluster-conf
      SPARK_CONF_DIR: /opt/cluster-conf
      HADOOP_USER_NAME: root
      PYSPARK_PYTHON: /opt/python/bin/python3
      PYSPARK_DRIVER_PYTHON: /opt/python/bin/python3
      PATH: /opt/python/bin:/opt/hadoop/bin:/opt/spark/bin:/opt/java/openjdk/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
    ports:
      - "127.0.0.1:4040:4040"
      - "127.0.0.1:8888:8888"
    volumes:
      - ./config:/opt/cluster-conf:ro
      - ./notebooks:/opt/spark/work-dir
      - cpython311:/opt/python
  nodemanager1:
    volumes:
      - cpython311:/opt/python
  nodemanager2:
    volumes:
      - cpython311:/opt/python
volumes:
  cpython311:
EOF

Dockerfile del cliente. La imagen de Spark no trae hdfs, yarn ni el JAR de Streaming. Este archivo copia esa distribución desde la imagen de Hadoop; no levanta otro NameNode.

cat > Dockerfile.spark-client <<'EOF'
FROM apache/hadoop:3.4.1 AS hadoop_client
FROM apache/spark:3.5.3-python3
USER root
COPY --from=hadoop_client /opt/hadoop /opt/hadoop
ENV HADOOP_HOME=/opt/hadoop \
    HADOOP_CONF_DIR=/opt/cluster-conf \
    SPARK_CONF_DIR=/opt/cluster-conf \
    HADOOP_USER_NAME=root \
    PYSPARK_PYTHON=/opt/python/bin/python3 \
    PYSPARK_DRIVER_PYTHON=/opt/python/bin/python3
ENV PATH=/opt/python/bin:/opt/hadoop/bin:/opt/spark/bin:${PATH}
WORKDIR /opt/spark/work-dir
ENTRYPOINT ["/bin/bash", "-c"]
CMD ["sleep infinity"]
EOF

Configuración mínima de Spark (modo yarn, driver en este cliente, memoria pensada para NodeManagers de 2 GB):

cat > config/spark-defaults.conf <<'EOF'
# Cliente Spark sobre el clúster YARN del laboratorio.
# Memoria pensada para NodeManagers de 2048 MB (maximum-allocation-mb=2048):
# cada executor pide 512m de heap + 384m de overhead = 896 MB, por debajo del tope.
spark.master                       yarn
spark.submit.deployMode            client
spark.driver.memory                512m
spark.executor.memory              512m
spark.executor.cores               1
spark.executor.instances           2
spark.executor.memoryOverhead      384m
spark.yarn.am.memory               512m
spark.yarn.am.memoryOverhead       384m

# En modo client el driver vive en este contenedor. Los executors (en los
# NodeManagers) tienen que poder devolver la conexión a este hostname.
spark.driver.host                  spark-client
spark.driver.bindAddress           0.0.0.0

# PySpark 3.5 exige la misma versión menor en el driver y en los executors.
# La imagen Spark trae Python 3.8 y la de Hadoop solo Python 2.7, así que
# driver y NodeManagers comparten el mismo CPython 3.11 portable.
spark.pyspark.driver.python        /opt/python/bin/python3
spark.pyspark.python               /opt/python/bin/python3
spark.executorEnv.PYSPARK_PYTHON   /opt/python/bin/python3
spark.executorEnv.PYTHONDONTWRITEBYTECODE 1
EOF

Script de Jupyter (se usará más adelante, cuando el cliente ya esté arriba):

cat > preparar_jupyter.sh <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
if ! /opt/python/bin/python3 -c 'import sys; assert sys.version_info[:3] == (3,11,10)' 2>/dev/null; then
  echo 'Primero prepara el volumen CPython 3.11.10 siguiendo el laboratorio.' >&2
  exit 1
fi
/opt/python/bin/python3 -m pip install --no-cache-dir 'notebook>=7,<8' ipykernel
mkdir -p /opt/python/share/jupyter/kernels/pyspark
cat > /opt/python/share/jupyter/kernels/pyspark/kernel.json <<'EOF'
{
  "argv": ["/opt/python/bin/python3", "-m", "ipykernel_launcher", "-f", "{connection_file}"],
  "display_name": "Python del clúster",
  "language": "python",
  "env": {
    "PYTHONPATH": "/opt/spark/python:/opt/spark/python/lib/py4j-0.10.9.7-src.zip",
    "SPARK_HOME": "/opt/spark",
    "HADOOP_HOME": "/opt/hadoop",
    "HADOOP_CONF_DIR": "/opt/cluster-conf",
    "SPARK_CONF_DIR": "/opt/cluster-conf",
    "HADOOP_USER_NAME": "root",
    "PYSPARK_PYTHON": "/opt/python/bin/python3",
    "PYSPARK_DRIVER_PYTHON": "/opt/python/bin/python3"
  }
}
EOF
/opt/python/bin/jupyter kernelspec list
/opt/python/bin/python3 -m pip freeze > /opt/spark/work-dir/jupyter-paquetes.txt
SCRIPT
chmod +x preparar_jupyter.sh

Cómo se usa Compose de aquí en adelante. Hasta YARN bastaba un comando: docker compose ps. Compose busca por defecto un archivo (compose.yaml) y listo. Ahora el clúster está partido en dos archivos. Si sigues escribiendo docker compose ... a secas, Compose no abre compose.spark.yaml: no verás spark-client, no lo construirás y no podrás entrar a él.

Hay que decirle explícitamente que lea los dos, en este orden:

docker compose -f compose.yaml -f compose.spark.yaml ps

-f significa “usa este archivo”. El primero es HDFS y YARN; el segundo es Spark. Eso es lo que hay que repetir en cada up, ps, exec o build de lo que queda de este laboratorio.

dc no es un programa de Docker. Es un atajo en tu terminal para no teclear esa frase larga. Después de definirlo, dc ps hace exactamente lo mismo que el comando de arriba. Úsalo siempre que el laboratorio escriba dc ....

dc() { docker compose -f compose.yaml -f compose.spark.yaml "$@"; }
dc config --quiet
dc config --services

La primera línea crea el atajo (solo existe en esa ventana de terminal). "$@" son los argumentos que pongas después de dc. Las dos líneas siguientes lo comprueban:

  • dc config --quiet — no imprime nada si los dos YAML se pueden fusionar.
  • dc config --services — debe listar namenode, datanodes, resourcemanager, nodemanagers y spark-client. Si spark-client no sale, el atajo no está leyendo el segundo archivo.

Si tu archivo base se llama docker-compose.yml y no compose.yaml, cambia ese nombre dentro de dc(). Si cierras la terminal, el atajo se pierde: vuelve a pegar las tres líneas. Comprueba que el proyecto sigue siendo el mismo y que los volúmenes del NameNode y de los DataNodes no cambian.

Cuando termines de crear los cuatro archivos, tree (o ls -la) en ~/hdfs-docker-lab debe incluir al menos esto:

compose.yaml                 ← ya existía (HDFS + YARN); no se toca
compose.spark.yaml           ← nuevo: overlay
Dockerfile.spark-client      ← nuevo
preparar_jupyter.sh          ← nuevo
config/core-site.xml         ← ya existía
config/hdfs-site.xml         ← ya existía
config/yarn-site.xml         ← ya existía
config/mapred-site.xml       ← ya existía
config/spark-defaults.conf   ← nuevo
notebooks/                   ← nuevo, puede estar vacío
respaldo-spark-…/            ← copia de seguridad; vacía si es la primera vez

Pueden quedar restos de laboratorios previos (texto.txt, archivo_300MiB.bin, ventas.csv, mapper.py). No los borres para “limpiar”: no forman parte de este montaje y no estorban.

3. Descargar las imágenes y construir el cliente

Todavía no arrancas spark-client. Primero fabricas su imagen. Una imagen oficial no alcanza: Hadoop trae hdfs y yarn pero no Spark; Spark trae spark-submit pero no los comandos de Hadoop. El Dockerfile.spark-client copia /opt/hadoop de la primera dentro de la segunda. El resultado es una sola imagen tuya, con nombre de laboratorio.

Construcción de spark-client a partir de las imágenes oficiales de Hadoop y Spark

Figura 4. docker pull baja las dos imágenes oficiales. dc build spark-client las combina. No se levanta un segundo NameNode: solo se instala el software cliente.

docker pull apache/hadoop:3.4.1
docker pull apache/spark:3.5.3-python3
dc build spark-client
docker image ls hdfs-lab/spark-client

Qué hace cada línea:

  • docker pull apache/hadoop:3.4.1 — descarga (o confirma) la misma imagen del clúster HDFS/YARN. El Dockerfile la usa como origen de /opt/hadoop. Si ya la tienes de laboratorios anteriores, esta línea es rápida.
  • docker pull apache/spark:3.5.3-python3 — descarga Spark 3.5.3 con Python. Es la base sobre la que se construye el cliente. Aún no es el contenedor que vas a dejar corriendo.
  • dc build spark-client — lee Dockerfile.spark-client usando los dos YAML (dc), ejecuta el COPY --from=... y deja en tu máquina la imagen hdfs-lab/spark-client:3.5.3-hadoop3.4.1. Aquí sí hace falta internet si las capas no estaban.
  • docker image ls hdfs-lab/spark-client — comprueba que la imagen existe en local. Todavía no hay un contenedor spark-client arriba: eso es el paso 4 (dc up).

docker image ls lista plantillas (imágenes). dc ps lista procesos (contenedores). En este paso solo existe la plantilla; no debe aparecer en dc ps. El contenedor nace en el paso 4, con dc up.

4. Arrancar spark-client y montar Python en los nodos

En el paso 3 fabricaste una imagen (una plantilla en disco). Ahora hay que encender un contenedor a partir de ella. Solo entonces existe spark-client como proceso y aparece en dc ps. Desde ese contenedor enviarás los trabajos.

Los NodeManagers también tienen que ver el mismo Python que el cliente. Si no, SparkPi (Scala, sin Python) puede pasar y un script Python fallar (Figura 1). Compose no instala Python todavía: solo monta un volumen Docker llamado cpython311 en /opt/python de los tres. Un volumen es una carpeta que Docker guarda fuera de cualquier contenedor y puede enganchar en varios a la vez. Nace vacío; el paso 5 lo llena.

Por eso dc up nombra tres servicios: el cliente nuevo y los dos NodeManagers (para aplicarles el montaje). HDFS y el ResourceManager no se tocan.

dc up -d --no-deps spark-client nodemanager1 nodemanager2
dc ps -a
dc exec spark-client /opt/hadoop/bin/hdfs version
dc exec spark-client /opt/spark/bin/spark-submit --version
dc exec spark-client test -f /opt/hadoop/share/hadoop/tools/lib/hadoop-streaming-3.4.1.jar

Qué hace cada línea:

  • dc up -d --no-deps spark-client nodemanager1 nodemanager2 — crea o arranca esos tres contenedores. -d los deja en segundo plano (la terminal se libera). --no-deps no recrea NameNode, DataNodes ni ResourceManager: ya deben estar arriba. Los NodeManagers pueden recrearse porque su montaje cambió; termina jobs anteriores antes de esta línea. Si cpython311 ya existía en este proyecto, Docker lo reutiliza.
  • dc ps -a — ahora sí debe listar spark-client junto al resto del clúster.
  • dc exec spark-client ... hdfs version — entra al contenedor y comprueba que el cliente Hadoop copiado en el Dockerfile responde.
  • dc exec spark-client ... spark-submit --version — en el mismo contenedor debe existir Spark. Si hdfs funciona y esto falla (o al revés), la imagen no se construyó bien.
  • dc exec spark-client test -f ...hadoop-streaming-3.4.1.jar — confirma que también está el JAR de Streaming (parte de /opt/hadoop). No imprime nada si el archivo existe: el prompt vuelve y listo. Si fallara, verías un error o un código de salida distinto de 0.

Qué debes ver:

  • dc up: tres contenedores Started (spark-client, nodemanager1, nodemanager2).
  • dc ps -a: ocho servicios. NameNode, DataNodes y ResourceManager siguen «Up» desde antes. Los NodeManagers aparecen recién creados (Compose los recreó para montar el volumen). spark-client usa la imagen hdfs-lab/spark-client:3.5.3-hadoop3.4.1 y publica 4040 y 8888.
  • hdfs version: Hadoop 3.4.1. El aviso log4j.properties is not found. HADOOP_CONF_DIR may be incomplete es esperado: esa carpeta tiene los XML del clúster, no el archivo de log de Hadoop. No indica que falte Hadoop.
  • spark-submit --version: Spark 3.5.3. La línea Using Scala version 2.12.18 es la versión de Scala con la que se compiló Spark, no un programa que tengas que instalar.

5. Instalar el mismo Python 3.11 en cliente y nodos

El volumen ya está montado, pero la primera vez está vacío. PySpark exige la misma versión menor de Python en el driver (spark-client) y en los executors (procesos de YARN dentro de los NodeManagers). La imagen de Spark trae Python 3.8 y la de Hadoop, 2.7: ninguna sirve. Por eso se instala un CPython 3.11.10 portable una vez en el volumen; como los tres contenedores lo montan, una extracción alcanza. Si ya corriste este paso en el mismo proyecto Docker, el volumen se reutiliza y Python ya está.

Primero comprueba si ya está:

dc exec spark-client /opt/python/bin/python3 --version

Si responde Python 3.11.10: no descargues nada. Pasa directo al bloque de las tres comprobaciones (cliente + los dos NodeManagers). Ver spark-client no basta: hay que confirmar que los nodos ven el mismo volumen.

Si falta o es otra versión: descarga el runtime y descomprímelo dentro del contenedor, y después sí las tres comprobaciones. curl solo deja un archivo en el host; no ejecuta Python allí. Este runtime del curso es para x86-64; comprueba uname -m en el contenedor antes de usarlo en otro hardware.

dc exec spark-client uname -m
curl -fL --retry 3 -o /tmp/hdfs-lab-cpython.tgz \
  'https://github.com/astral-sh/python-build-standalone/releases/download/20241016/cpython-3.11.10+20241016-x86_64-unknown-linux-gnu-install_only.tar.gz'
dc cp /tmp/hdfs-lab-cpython.tgz spark-client:/tmp/hdfs-lab-cpython.tgz
dc exec spark-client tar -xzf /tmp/hdfs-lab-cpython.tgz -C /opt

Qué hace cada línea (solo si tuviste que descargar):

  • uname -m — debe decir x86_64. Si no, este paquete no corresponde a tu máquina.
  • curl ... -o /tmp/hdfs-lab-cpython.tgz — descarga el archivo en Ubuntu/WSL, no dentro de Docker.
  • dc cp ... spark-client:/tmp/... — copia ese archivo al contenedor.
  • tar -xzf ... -C /opt — lo descomprime en /opt. El contenido queda en /opt/python, que es el volumen compartido.

Haz esa instalación solo cuando sea necesaria, sin Jupyter ni jobs activos.

Comprobación en las tres ubicaciones (siempre, hayas descargado o no):

dc exec spark-client /opt/python/bin/python3 --version
dc exec nodemanager1 /opt/python/bin/python3 --version
dc exec nodemanager2 /opt/python/bin/python3 --version

Las tres deben responder Python 3.11.10. No se usa el Python preinstalado en las imágenes base.

6. Comprobar que el cliente llega a HDFS y a YARN

Tener hdfs y spark-submit instalados no basta. El cliente tiene que encontrar el NameNode y el ResourceManager usando los XML del clúster (montados en /opt/cluster-conf). Si esto falla, el trabajo Spark del paso 7 también fallará. «Up» no garantiza que HDFS ya acepte escrituras.

dc exec spark-client /opt/hadoop/bin/hdfs getconf -confKey fs.defaultFS
dc exec spark-client /opt/hadoop/bin/hdfs dfsadmin -safemode wait
dc exec spark-client /opt/hadoop/bin/hdfs dfsadmin -report
dc exec spark-client /opt/hadoop/bin/yarn node -list
dc exec spark-client /opt/hadoop/bin/hdfs dfs -ls /

Cada comando avisa WARNING: log4j.properties is not found. Es el mismo aviso del paso 4: ignóralo.

Qué hace cada línea y qué línea te dice que está bien:

Comando Para qué Está OK si ves
hdfs getconf ... fs.defaultFS ¿El cliente lee los XML y apunta al NameNode del clúster? hdfs://namenode:9000 (no un path local tipo file:///)
hdfs dfsadmin -safemode wait ¿HDFS ya acepta escrituras? Safe mode is OFF
hdfs dfsadmin -report ¿Los tres discos del laboratorio siguen vivos? Live datanodes (3): y los hostnames datanode1, datanode2, datanode3, estado Normal
yarn node -list ¿YARN ve los dos workers? Total Nodes:2, ambos RUNNING, y Connecting to ResourceManager at resourcemanager/...:8032
hdfs dfs -ls / ¿El cliente lista HDFS, no el disco de Windows? Found N items y carpetas como /user, /tmp, /yarn (pueden haber más de laboratorios anteriores)

En el report, basta con esos tres DataNodes vivos y Missing blocks: 0. No mires el «Configured Capacity» en TB: Docker está viendo el disco del host, no un clúster de 3 TB. Cache Used%: 100% con caché de 0 B es un artefacto de Hadoop; ignóralo. Los puertos raros en Node-Id (nodemanager1:35895) cambian al recrear los nodos; lo fijo es el nombre y RUNNING.

Si / muestra /pyspark, /yarn u otros restos, no los borres: no estorban. El paso 8 escribirá /yarn/entrada/texto.txt.

Cuando esas cinco lecturas coinciden, spark-client ya habla con el clúster de los laboratorios anteriores. Sigue al paso 7.

7. Enviar el primer trabajo Spark a YARN

Hasta aquí comprobaste versiones y que el cliente ve el clúster. Ahora envías un trabajo de verdad: SparkPi estima π. Está escrito en Scala (el lenguaje de Spark; tú no escribes Scala en este laboratorio), a propósito: prueba que spark-submit habla con el mismo YARN de siempre, sin mezclar todavía Python. La prueba de Python es el paso 10.

spark-defaults.conf ya fija YARN, el driver en spark-client y dos executors.

YARN no reserva “un poco de RAM” a ojo: cada executor pide un contenedor con dos partidas que se suman.

  • Heap — memoria de la máquina virtual Java (JVM) donde viven los objetos del programa Spark (en Scala o Java). Aquí son 512 MiB (spark.executor.memory).
  • Overhead — memoria extra, fuera de ese heap, que YARN reserva para el resto del proceso: código nativo, Python, buffers. Aquí son 384 MiB (spark.executor.memoryOverhead).

Cada executor pide entonces 512 + 384 = 896 MiB. El ApplicationMaster (el proceso de YARN que representa a esta aplicación Spark) pide otra tanda parecida. Cada NodeManager solo ofrece 2 GB, así que no caben varios trabajos Spark a la vez. Si un job queda en ACCEPTED, YARN lo aceptó pero aún no le dio contenedores: cierra otras aplicaciones Spark antes de tocar estas cifras.

dc exec spark-client /opt/spark/bin/spark-submit \
  --master yarn --deploy-mode client \
  --class org.apache.spark.examples.SparkPi \
  /opt/spark/examples/jars/spark-examples_2.12-3.5.3.jar 10
dc exec spark-client /opt/hadoop/bin/yarn application -list -appStates ALL

Qué hace cada línea:

  • spark-submit --master yarn --deploy-mode client — el driver queda en spark-client; YARN lanza los executors en los NodeManagers (Figura 2).
  • --class ... SparkPi y el JAR — el ejemplo que trae la imagen de Spark; 10 es el número de tareas.
  • yarn application -list -appStates ALL — lista las aplicaciones que YARN ha visto, incluidas las terminadas.

Espera una estimación cercana a 3,14 y, en http://localhost:8088, una aplicación Spark Pi, tipo SPARK, FINISHED / SUCCEEDED.

8. Subir un archivo de prueba a HDFS

El paso 6 listó HDFS. El paso 10 leerá un archivo de cuatro líneas. Hay que crearlo dentro de spark-client y subirlo al HDFS de siempre: así compruebas que el cliente también escribe. No lo crees en Windows ni en Ubuntu del host: ese archivo no llegaría a los DataNodes.

Si el laboratorio de YARN ya dejó /yarn/entrada/texto.txt, este paso lo reemplaza con el mismo contenido. El aviso de log4j puede volver a salir: ignóralo.

dc exec spark-client bash -c "printf 'hadoop hdfs yarn\nhadoop mapreduce yarn\nspark hadoop datos\nyarn datos datos\n' > /tmp/texto.txt"
dc exec spark-client /opt/hadoop/bin/hdfs dfs -mkdir -p /yarn/entrada /user/root
dc exec spark-client /opt/hadoop/bin/hdfs dfs -put -f /tmp/texto.txt /yarn/entrada/texto.txt
dc exec spark-client /opt/hadoop/bin/hdfs dfs -cat /yarn/entrada/texto.txt

Qué hace cada línea:

  • printf ... > /tmp/texto.txt — escribe las cuatro líneas dentro del contenedor, en /tmp (disco del cliente, no HDFS). Ese /tmp se pierde si recreas el contenedor; por eso el paso siguiente lo copia a HDFS.
  • hdfs dfs -mkdir -p /yarn/entrada /user/root — crea dos carpetas en HDFS. /yarn/entrada es donde queda el texto. /user/root es el directorio de staging: Spark, al enviar un trabajo, deja ahí jars y archivos temporales. -p no falla si ya existían.
  • hdfs dfs -put -f ... — copia el archivo local del contenedor hacia HDFS. -f sobrescribe si ya estaba.
  • hdfs dfs -cat ... — lee el archivo desde HDFS y lo imprime. Es la prueba de que la subida funcionó.

Qué debes ver (cuatro líneas, en este orden):

hadoop hdfs yarn
hadoop mapreduce yarn
spark hadoop datos
yarn datos datos

Si cat muestra eso, el paso 10 podrá hacer count() == 4. Sigue al paso 9.

9. Abrir Jupyter dentro del cliente, no en Windows

Jupyter es una página en el navegador donde escribes celdas de código. El kernel es el proceso que ejecuta esas celdas. El navegador no corre Python: solo muestra la interfaz.

Si abrieras Jupyter en Windows o en Ubuntu del host, el kernel usaría otro Python, distinto al 3.11.10 de los NodeManagers. Por eso Jupyter se instala y se arranca dentro de spark-client, con /opt/python/bin/python3.

La imagen de Spark no trae Jupyter. Hace falta una receta que, una sola vez y dentro del contenedor:

  1. compruebe que el Python del volumen es 3.11.10;
  2. instale Notebook e ipykernel en ese Python;
  3. registre un kernel cuyo ejecutable sea /opt/python/bin/python3 y que vea Spark sin pip install pyspark.

Esa receta es preparar_jupyter.sh. La generaste en el paso 2 (cat > preparar_jupyter.sh). Aquí no la vuelves a escribir: la copias al cliente y la ejecutas.

Qué contiene el script

Parte del archivo Para qué
set -euo pipefail Si un comando falla, el script se detiene.
assert sys.version_info[:3] == (3,11,10) No instala Jupyter sobre otro Python. Si el paso 5 no se hizo, sale exit 1.
pip install 'notebook>=7,<8' ipykernel Notebook es la página del navegador. ipykernel es el puente: convierte ese Python en un kernel de Jupyter. --no-cache-dir no deja ruedas de pip ocupando el volumen.
mkdir .../kernels/pyspark y kernel.json Ahí Jupyter busca kernels. La carpeta se llama pyspark; el nombre visible no es ese.
"display_name": "Python del clúster" Ese nombre lo pusimos nosotros. Es el rótulo que Jupyter muestra arriba a la derecha. No lo trae Spark ni Jupyter.
"argv": ["/opt/python/bin/python3", ...] Qué proceso arranca el kernel: el Python del volumen, no el 3.8 de la imagen Spark.
"env": { PYTHONPATH, SPARK_HOME, HADOOP_CONF_DIR, PYSPARK_PYTHON, ... } Variables que hereda cada celda. PYTHONPATH apunta a /opt/spark/python: import pyspark usa Spark de la imagen, no un paquete de pip. HADOOP_CONF_DIR son los XML del clúster.
jupyter kernelspec list Lista los kernels instalados. Debe aparecer pyspark.
pip freeze > .../jupyter-paquetes.txt Deja en ./notebooks (montada desde el host) la lista de paquetes, como evidencia.

Ahora copia el archivo del host al contenedor y ejecútalo una vez:

dc cp preparar_jupyter.sh spark-client:/tmp/preparar_jupyter.sh
dc exec spark-client bash /tmp/preparar_jupyter.sh

Qué hace cada línea:

  • dc cp ... — copia el script del host al contenedor.
  • bash /tmp/preparar_jupyter.sh — instala notebook e ipykernel en el Python del volumen, registra el kernel Python del clúster y escribe las versiones en notebooks/jupyter-paquetes.txt (esa carpeta está montada desde el host: el archivo te queda en ~/hdfs-docker-lab/notebooks/).

Está OK si al final listan un kernel pyspark / Python del clúster y no hay exit 1 pidiendo el CPython 3.11.10. Si el script ya se corrió antes, pip reutilizará paquetes: también vale.

Después arranca el servidor y deja esta terminal abierta. Ctrl+C lo apaga; si la cierras, el navegador dejará de conectar.

dc exec spark-client /opt/python/bin/jupyter notebook \
  --ip=0.0.0.0 --port=8888 --no-browser --allow-root \
  --ServerApp.root_dir=/opt/spark/work-dir

Qué significan las opciones:

  • --ip=0.0.0.0 — Jupyter escucha en todas las interfaces del contenedor, para que el puerto publicado 8888 llegue a tu Windows.
  • --port=8888 — el mismo puerto que compose.spark.yaml publica en 127.0.0.1:8888.
  • --no-browser — no intentes abrir un navegador dentro del contenedor (no hay).
  • --allow-root — el contenedor corre como root.
  • --ServerApp.root_dir=/opt/spark/work-dir — la carpeta que ves en Jupyter es ./notebooks del host. Hoy puede estar vacía: es el lugar de trabajo. Guardar con Ctrl+S conserva los cambios aunque se recree el contenedor.

Qué debes ver en esa terminal: una URL con un token (una clave larga en la query). Ábrela en el navegador de Windows:

http://127.0.0.1:8888/tree?token=...

Copia el token de tu ejecución. Si Jupyter imprime http://spark-client:8888/... u otro hostname, deja el token y cambia solo el host por 127.0.0.1. Sin token, la página pide contraseña y no entra.

Árbol de archivos de Jupyter Notebook dentro de spark-client

Figura 5. Lo que debes ver en el navegador: el árbol de Jupyter en http://127.0.0.1:8888. jupyter-paquetes.txt lo escribió el script. Un Untitled.ipynb aparece cuando creas el notebook del paso 10 (New). El token no se copia de esta figura: usa el de tu terminal.

Cuando veas ese árbol, sigue al paso 10 en el navegador, no en esta terminal.

10. Comprobar que Python es el mismo en el cliente y en los nodos

SparkPi (paso 7) estaba en Scala: demostró que Spark habla con YARN, sin Python. Esta celda cierra el laboratorio: el driver es 3.11.10, las tareas en YARN también, y se leen las cuatro líneas del paso 8.

No la pegues en Bash. En Jupyter: New → Notebook, kernel Python del clúster (no “Python 3” ni uno de Windows). Si eliges el kernel equivocado, los assert fallan aunque el clúster esté bien.

import sys
from pyspark.sql import SparkSession

assert sys.version_info[:3] == (3, 11, 10)
assert sys.executable.startswith('/opt/python/')
spark = SparkSession.builder.appName('verificacion-python-cluster').getOrCreate()
try:
    assert spark.sparkContext.master == 'yarn'
    def entorno_worker(_):
        import sys, socket
        return socket.gethostname(), tuple(sys.version_info[:3])
    entornos = spark.sparkContext.parallelize(range(4), 4).map(entorno_worker).collect()
    print(entornos)
    assert all(version == (3, 11, 10) for _, version in entornos)
    assert spark.read.text('hdfs://namenode:9000/yarn/entrada/texto.txt').count() == 4
finally:
    spark.stop()

Qué comprueba cada parte:

Línea Pregunta Está OK si
assert ... (3, 11, 10) ¿El driver (esta celda) usa Python 3.11.10? no lanza AssertionError
sys.executable.startswith('/opt/python/') ¿Es el Python del volumen, no el 3.8 de la imagen Spark? la ruta empieza por /opt/python/
SparkSession.builder ... getOrCreate() Abre una sesión Spark. SparkSession es el objeto con el que el código habla con el clúster. no hay error de conexión
sparkContext.master == 'yarn' ¿El trabajo va a YARN, no a local? el master es yarn
parallelize(...).map(...).collect() Lanza 4 tareas en executors de YARN. Cada una devuelve el hostname del nodo y la versión de Python. collect() las trae de vuelta al driver. print(entornos) muestra una lista de pares
all(version == (3, 11, 10) ...) ¿Los executors también son 3.11.10? todas las versiones de la lista coinciden
read.text(...).count() == 4 ¿Spark lee HDFS y ve las cuatro líneas del paso 8? el recuento es 4
finally: spark.stop() Cierra la aplicación para liberar memoria en YARN. el portal :8088 pasa a FINISHED

Qué debes ver en print(entornos): algo como [('nodemanager1', (3, 11, 10)), ('nodemanager2', (3, 11, 10)), ...]. Los nombres pueden repetirse: un job pequeño no obliga a usar los dos nodos. Lo que no puede aparecer es Python 3.8 ni el hostname spark-client como worker (el driver vive en el cliente; las tareas, en los NodeManagers).

Encima de esa lista, Jupyter suele pintar de rosa avisos WARN de Spark (native-hadoop library, spark.yarn.jars, nivel de log). No son un fallo: Spark está usando las clases Java y sube los jars a HDFS. El fallo sería un AssertionError o un traceback. Si la lista salió y no hay AssertionError, los assert posteriores también pasaron (count() == 4 incluido).

Celda de verificación en Jupyter: kernel Python del clúster, avisos WARN en rosa y lista de executors en nodemanager1 y nodemanager2 con Python 3.11.10

Figura 6. Resultado esperado del paso 10. Arriba a la derecha, el kernel Python del clúster. El recuadro rosa son avisos WARN, no un error. La lista muestra tareas en nodemanager1 y nodemanager2 con (3, 11, 10). El nombre del notebook es el tuyo.

Si la celda termina sin AssertionError, el clúster ya está ampliado: Spark habla con YARN, Python 3.11 está en el cliente y en los nodos, y se lee HDFS. Es el cierre de la Figura 3 (paso 10, caja teal).

11. Guardar y cerrar: qué apagar y qué encender

Hay tres piezas distintas. No son el mismo interruptor.

Pieza Qué es Cómo se apaga Cómo se enciende
Clúster HDFS + YARN: NameNode, 3 DataNodes, ResourceManager, 2 NodeManagers dc stop de esos siete dc up -d de esos siete
spark-client El contenedor del cliente (Compose). Por defecto solo hace sleep infinity. dc stop spark-client dc up -d --no-deps spark-client
Jupyter Un proceso dentro de spark-client, no un servicio Compose. El navegador solo muestra la página. Ctrl+C en su terminal el bloque jupyter notebook de abajo

Los notebooks en ./notebooks, los datos de HDFS y el volumen cpython311 no se borran con stop. Sí se pierden con down -v o si formateas el NameNode: no lo hagas si vas a seguir el curso.

Antes de apagar Jupyter: guarda el notebook (Ctrl+S). Si dejaste una SparkSession abierta, spark.stop() (el paso 10 ya lo hace en finally).

Define dc si abriste una terminal nueva, y un atajo para los siete servicios del clúster:

dc() { docker compose -f compose.yaml -f compose.spark.yaml "$@"; }
cluster="namenode datanode1 datanode2 datanode3 resourcemanager nodemanager1 nodemanager2"

Apagar

Quieres Qué sigue arriba Comando
Solo Jupyter Clúster + spark-client Ctrl+C en la terminal de Jupyter
Solo spark-client (Jupyter se cae con él) Clúster HDFS/YARN dc stop spark-client
Solo el clúster (spark-client queda huérfano: no hay HDFS ni YARN) Solo spark-client dc stop $cluster
Todo, sin borrar datos Nada (contenedores detenidos; HDFS y Python siguen en disco) dc stop
Borrar también los volúmenes HDFS vacío y Python 3.11 perdido No lo ejecutes en este curso. El comando sería dc down -v: down detiene y quita contenedores; -v borra los volúmenes (namenode_data, DataNodes, cpython311). Para apagar y seguir después, usa dc stop.

Encender

Quieres Qué queda arriba Comando
Solo el clúster HDFS + YARN, sin cliente ni Jupyter dc up -d $cluster
Solo spark-client (el clúster ya está) Clúster + cliente, sin Jupyter dc up -d --no-deps spark-client
Solo Jupyter (spark-client ya está) Clúster + cliente + Jupyter el bloque Jupyter
Clúster + spark-client, sin Jupyter Ocho contenedores, puerto 8888 sin servidor dc up -d
Todo: clúster + Spark + Jupyter Lo mismo que al final del paso 9 dc up -d y, en otra terminal, el bloque Jupyter

Jupyter no arranca solo: el contenedor duerme (sleep infinity) hasta que lanzas el servidor. Cada vez sale un token nuevo.

dc exec spark-client /opt/python/bin/jupyter notebook \
  --ip=0.0.0.0 --port=8888 --no-browser --allow-root \
  --ServerApp.root_dir=/opt/spark/work-dir

--no-deps en el cliente evita recrear NameNode o DataNodes. Tras un up del clúster, espera modo seguro OFF y dos NodeManagers RUNNING (paso 6) antes de enviar trabajos.

12. Diagnóstico y evidencia

Síntoma Comprobación
Docker no conecta Abre Docker Desktop y verifica Client y Server desde WSL.
Solo aparecen servicios nuevos o HDFS parece vacío Detente: revisa carpeta, nombre de proyecto y archivos Compose antes de continuar.
Falta hdfs, yarn o spark-submit en el cliente Construye Dockerfile.spark-client y recrea solo el cliente con dc up -d --no-deps spark-client.
Falta /opt/python/bin/python3 Prepara el volumen compartido y verifica sus montajes en los tres servicios.
Kernel equivocado Selecciona Python del clúster; su ejecutable está en /opt/python.
Connection refused Revisa XML, red compartida y disponibilidad de NameNode/ResourceManager.
El job queda en ACCEPTED Revisa recursos y cierra otras aplicaciones Spark antes de cambiar memoria.
No abre 8888 Jupyter debe seguir activo dentro del cliente; revisa puerto ocupado y publicación en localhost.

Este laboratorio está listo cuando tienes: versiones de imágenes y Python, tres DataNodes, dos NodeManagers, SparkPi FINISHED / SUCCEEDED en :8088 y la prueba Python del paso 10 leyendo HDFS.

Siguiente: MapReduce (MapReduce: el modelo de procesamiento de Hadoop y Taller de MapReduce Streaming). Después, la teoría de Spark y PySpark.

Referencias: Spark Overview, Spark sobre YARN, Compose: combinar archivos.