3  Docker para Big Data: por qué “en mi máquina funciona” ya no es una excusa

Contenedores, imágenes y tu primer clúster de Spark, explicados desde cero

big-data
docker
contenedores
spark
Autor/a

Wilder Ramírez Delgado

Fecha de publicación

20 de septiembre de 2026

En este capítulo vas a entender, desde los fundamentos, qué es un contenedor y por qué Docker se convirtió en la pieza que sostiene casi toda la infraestructura moderna de datos. No vamos a empezar tecleando comandos: primero vas a construir la intuición completa —qué problema resuelve, cómo lo resuelve por dentro y qué relación hay entre una imagen y un contenedor— y solo después vas a levantar tu propio mini-clúster de Spark con dos líneas de configuración.


Tiempo estimado de lectura: 35 minutos

Nivel: Intermedio

Requisitos: - Manejo básico de la terminal Linux (capítulos 1 y 2) - Docker Desktop o Docker Engine instalado en tu equipo

Tecnologías utilizadas: - Docker y Docker Compose - Nginx, MySQL y phpMyAdmin - Apache Spark (a través de las imágenes bitnamilegacy/spark) - Python (pandas, NumPy)


Seguro te ha pasado: pasas una tarde entera instalando Java, configurando variables de entorno, descargando la versión exacta de Hadoop, peleando con la versión de Python que necesita Spark y que no es la misma que ya tienes instalada para otro proyecto. Al final lo logras, tu notebook corre… y cuando se lo compartes a un compañero, o cuando lo despliegas en un servidor, deja de funcionar. La respuesta clásica es “en mi máquina funciona”. El problema es que a un clúster de producción no le importa tu máquina: le importa que el entorno sea exactamente el mismo en todas partes. Ese es, en esencia, el problema que resuelven los contenedores, y es el problema que vas a entender a fondo en este capítulo antes de tocar un solo comando de Docker.


Parte 1 — Fundamentos: qué es realmente un contenedor

El problema que resuelven los contenedores

Para entender por qué existen los contenedores conviene mirar la evolución de cómo hemos ejecutado software en los últimos treinta años.

Hardware físico (bare metal). Al principio, cada aplicación importante corría directamente sobre un servidor físico dedicado. Si querías aislar dos aplicaciones para que no interfirieran entre sí —por ejemplo, para que una no consumiera toda la memoria y tumbara a la otra—, la única solución era comprar otro servidor. Esto era extremadamente caro y, la mayoría del tiempo, ese servidor estaba utilizado a una fracción mínima de su capacidad real.

Máquinas virtuales. La virtualización de hardware resolvió el problema del desperdicio: un hipervisor (un software como VMware, VirtualBox o Hyper-V) permite dividir un único servidor físico en varias máquinas virtuales, cada una con su propio sistema operativo completo, su propio kernel y sus propios recursos asignados. Esto multiplicó el aprovechamiento del hardware, pero introdujo un costo nuevo: cada máquina virtual carga un sistema operativo entero, lo que la hace pesada (varios gigabytes), lenta de iniciar (minutos) y costosa en recursos, porque cada una necesita su propia copia del kernel corriendo en memoria.

Contenedores. Los contenedores dan un paso más: en lugar de virtualizar el hardware completo, virtualizan el sistema operativo. Todos los contenedores que corren en una misma máquina comparten el mismo kernel del sistema operativo anfitrión (host), pero cada uno cree tener su propio sistema de archivos, sus propios procesos y su propia red, completamente aislados de los demás. El resultado: contenedores que pesan megabytes en lugar de gigabytes, que arrancan en segundos (o milisegundos) en lugar de minutos, y que permiten correr decenas de ellos en una máquina donde antes cabían dos o tres máquinas virtuales.

¿Para qué sirve entender esta evolución? Porque te permite responder con criterio una pregunta que te van a hacer constantemente en el mundo profesional: “¿esto lo montamos en una VM o en un contenedor?”. La respuesta casi siempre depende de cuánto aislamiento necesitas realmente frente a cuánta eficiencia quieres ganar.

Virtualización de hardware vs. virtualización a nivel de sistema operativo

La diferencia de fondo entre una máquina virtual y un contenedor no es de grado, es de capa.

Una máquina virtual virtualiza el hardware. El hipervisor le presenta a cada máquina virtual un CPU, una memoria y un disco “virtuales”, y sobre ellos se instala un sistema operativo completo e independiente, con su propio kernel. Si tu servidor físico corre Linux, puedes tener una máquina virtual con Windows y otra con una distribución distinta de Linux, cada una totalmente ajena a las demás.

Un contenedor virtualiza el sistema operativo. No hay un kernel por contenedor: todos los contenedores de una misma máquina host comparten el mismo kernel del sistema operativo anfitrión. Lo que cambia entre un contenedor y otro no es el kernel, sino la vista que cada uno tiene de los procesos, el sistema de archivos y la red. Por eso un contenedor Linux solo puede correr sobre un kernel Linux (en Windows y macOS, Docker soluciona esto ejecutando una pequeña máquina virtual Linux por debajo, de forma transparente para ti).

Máquina virtual Contenedor
Qué virtualiza El hardware completo El sistema operativo
Kernel Uno por cada máquina virtual Compartido con el host
Tamaño típico Gigabytes Megabytes
Tiempo de arranque Minutos Segundos (a veces milisegundos)
Aislamiento Muy fuerte (kernel independiente) Fuerte, pero comparte kernel
Densidad por servidor Decenas Cientos
Caso de uso típico Correr sistemas operativos distintos, aislamiento máximo Empaquetar y desplegar aplicaciones de forma reproducible

¿Cuándo usar cada una? Si necesitas correr un sistema operativo distinto al del host, o necesitas el aislamiento más fuerte posible (por ejemplo, para clientes que no confían entre sí en un entorno multi-tenant), una máquina virtual sigue siendo la herramienta correcta. Si lo que necesitas es empaquetar una aplicación —o, como veremos, un nodo de un clúster de Big Data— para que corra igual en tu laptop, en el servidor de un compañero y en producción, el contenedor es casi siempre la mejor opción.

Qué es un contenedor (definición rigurosa)

Con esa base, ya podemos ser precisos: un contenedor es un proceso normal del sistema operativo host, aislado de tal manera que, desde adentro, parece tener su propia máquina completa para él solo: su propio sistema de archivos, su propia lista de procesos, su propia configuración de red y sus propios límites de CPU y memoria. Desde afuera, sigue siendo simplemente un proceso más, visible y administrable por el sistema operativo anfitrión como cualquier otro.

La analogía útil aquí es la de una caja sellada: dentro de la caja va todo lo que la aplicación necesita para funcionar —el código, las librerías, las dependencias del sistema, la configuración exacta de versiones—. La caja se sella una sola vez y se puede mover a cualquier máquina que tenga Docker instalado: la aplicación de adentro se ejecuta exactamente igual, porque no depende de nada que esté “afuera” de la caja. Eso es, literalmente, lo que resuelve el problema de “en mi máquina funciona”: si el entorno completo viaja dentro de la caja, deja de importar qué tiene instalado la máquina que la recibe.

¿Para qué sirve esto en la práctica? Te permite decir con confianza “este contenedor corre igual en mi laptop que en el servidor de producción”, porque literalmente es el mismo entorno, no una aproximación.

Cómo lo logra Linux, en lenguaje simple

Docker no inventó la magia del aislamiento: se apoya en dos mecanismos que ya existen en el kernel de Linux desde hace años. No necesitas dominarlos a nivel de implementación, pero sí entender qué hace cada uno, porque son la razón de que todo lo anterior sea posible.

Namespaces (aislamiento). Un namespace es un mecanismo del kernel que le da a un grupo de procesos su propia “vista” aislada de un recurso del sistema, sin que ese recurso deje de ser compartido físicamente. Por ejemplo:

  • Un namespace de sistema de archivos hace que un contenedor vea su propia raíz (/), sin poder ver los archivos del host ni de otros contenedores.
  • Un namespace de procesos hace que, dentro del contenedor, el primer proceso que arranca se vea a sí mismo como el proceso número 1 (PID 1), como si fuera el único programa corriendo en esa máquina, aunque en el host tenga en realidad otro número de proceso y conviva con miles de procesos más.
  • Un namespace de red le da al contenedor su propia interfaz de red, su propia dirección IP y su propia tabla de rutas, separadas de las del host.

En conjunto, los namespaces son los que hacen que un contenedor “crea” que está solo en la máquina, cuando en realidad está compartiendo el mismo kernel con docenas de otros procesos.

Cgroups (límites de recursos). Control groups (cgroups) es el mecanismo del kernel que limita y contabiliza cuántos recursos —CPU, memoria RAM, ancho de banda de disco o de red— puede consumir un grupo de procesos. Gracias a cgroups puedes decirle a un contenedor “no uses más de 2 CPU ni más de 4 GB de RAM”, y el kernel se encarga de hacerlo cumplir. Esto es lo que evita que un contenedor mal comportado (por ejemplo, un job de Spark con una fuga de memoria) tumbe a los demás contenedores que corren en la misma máquina.

¿Para qué sirve saber esto? Namespaces te dan aislamiento (cada contenedor ve su propio mundo); cgroups te dan control de recursos (cada contenedor tiene un límite claro). Docker simplemente automatiza la configuración de ambos mecanismos cada vez que ejecutas docker run, para que tú no tengas que hacerlo a mano.

Qué es una imagen

Una imagen es una plantilla inmutable y de solo lectura que contiene todo lo necesario para ejecutar una aplicación: el sistema de archivos base, las librerías, las dependencias y el propio código de la aplicación. La imagen no se ejecuta por sí sola: es el molde a partir del cual se crean contenedores.

Internamente, una imagen no es un único bloque monolítico: está construida por capas (layers), donde cada capa representa un cambio incremental sobre la anterior (por ejemplo: “capa 1: sistema base Ubuntu”, “capa 2: instalar Python”, “capa 3: instalar pandas”, “capa 4: copiar mi código”). Estas capas se apilan una encima de otra para formar el sistema de archivos final de la imagen.

Esto tiene dos consecuencias muy prácticas:

  • Cacheo: si reconstruyes una imagen y solo cambiaste la última capa (por ejemplo, tu código), Docker reutiliza las capas anteriores que no cambiaron, en lugar de reconstruirlas desde cero. Esto hace que reconstruir una imagen sea, casi siempre, muchísimo más rápido que construirla la primera vez.
  • Compartición: si dos imágenes distintas usan la misma capa base (por ejemplo, ambas parten de python:3.11-slim), esa capa se descarga y se almacena una sola vez en tu disco, y ambas imágenes la reutilizan.

La analogía correcta aquí es la del molde: la imagen es como el molde de un pastel. El molde define la forma exacta que va a tener cada pastel que se hornee con él, pero el molde en sí mismo no es un pastel que puedas comer; para eso necesitas hornear uno.

¿Para qué sirve esto? Una imagen es lo que haces circular: la subes a un registro, la comparte tu equipo, la despliega el servidor de producción. Es el artefacto reproducible por excelencia: la misma imagen, en cualquier máquina con Docker, produce siempre el mismo resultado.

Qué es un contenedor en relación con la imagen

Aquí está la confusión más común de quien empieza con Docker, así que vale la pena dejarla completamente clara.

Un contenedor es una instancia en ejecución de una imagen. Técnicamente, un contenedor es la imagen (de solo lectura) más una capa de escritura adicional que Docker añade encima, exclusiva de ese contenedor. Todo lo que el contenedor escribe mientras corre —archivos temporales, logs, cambios de estado— va a esa capa de escritura, nunca a la imagen original. La imagen permanece intacta pase lo que pase dentro del contenedor.

Siguiendo la analogía del molde: si la imagen es el molde, el contenedor es el pastel horneado a partir de ese molde. Puedes hornear tantos pasteles como quieras con el mismo molde —es decir, puedes crear tantos contenedores como quieras a partir de la misma imagen—, y cada pastel es independiente de los demás: si le pones más azúcar a uno, los otros no se ven afectados, y el molde en sí mismo sigue intacto para la siguiente hornada.

Un contenedor tiene además un ciclo de vida con estados bien definidos:

  1. Creado: el contenedor existe (Docker reservó su capa de escritura y su configuración), pero todavía no ha empezado a ejecutar nada.
  2. En ejecución: el proceso principal del contenedor está corriendo activamente.
  3. Detenido: el proceso principal terminó (o lo detuviste tú), pero el contenedor y su capa de escritura siguen existiendo en disco; puedes reiniciarlo y retomar donde quedó.
  4. Eliminado: el contenedor y su capa de escritura se destruyen definitivamente. La imagen original, sin embargo, permanece intacta y disponible para crear nuevos contenedores.

Diferencia clave para recordar: una imagen es estática y compartida (el molde); un contenedor es dinámico y desechable (el pastel horneado). Puedes borrar un contenedor sin ningún temor, porque la imagen que le dio origen sigue ahí para crear otro idéntico en segundos.

El Dockerfile

Un Dockerfile es un archivo de texto plano que describe, paso a paso y de forma declarativa, cómo construir una imagen. Cada instrucción del Dockerfile (FROM, RUN, COPY, CMD, entre otras) genera, precisamente, una nueva capa sobre las anteriores.

El Dockerfile es lo que hace que una imagen sea completamente reproducible: en lugar de instalar cosas a mano y esperar recordar exactamente qué pasos seguiste, escribes esos pasos una sola vez en un archivo, y cualquier persona —o cualquier servidor de integración continua— puede ejecutar esas mismas instrucciones y obtener exactamente la misma imagen.

Esto completa la cadena central que vas a usar durante todo este capítulo:

Cadena Dockerfile, imagen y contenedor

El Dockerfile es la receta; la imagen es el molde que resulta de seguir la receta; el contenedor es el pastel que se hornea con ese molde, tantas veces como lo necesites.

Registros: Docker Hub

Una imagen que solo existe en tu laptop no le sirve a tu equipo ni a tu servidor de producción. Para eso existen los registros (registries): repositorios centralizados donde se almacenan y distribuyen imágenes, de forma parecida a como GitHub almacena y distribuye repositorios de código.

Docker Hub es el registro público más usado del ecosistema. Cuando ejecutas docker pull nombre-imagen, le estás pidiendo a Docker que descargue esa imagen desde un registro (Docker Hub, por defecto) hacia tu máquina local. Cuando ejecutas docker push, subes una imagen tuya al registro para que otros puedan descargarla.

Docker Hub aloja, entre otras cosas, imágenes oficiales: versiones mantenidas y verificadas de software ampliamente usado —ubuntu, python, postgres, y, como vas a usar más adelante, imágenes del ecosistema Spark publicadas por organizaciones como Bitnami—. Usar una imagen oficial te ahorra tener que construir desde cero algo que ya está construido, probado y mantenido por otros.

¿Cuándo usar un registro propio en lugar de Docker Hub? Cuando trabajas con imágenes privadas de tu organización, o cuando necesitas control total sobre disponibilidad y seguridad; ahí entran registros privados (Amazon ECR, Google Artifact Registry, Azure Container Registry, o un registro propio). Para este capítulo, Docker Hub es más que suficiente.

La arquitectura de Docker

Con todas las piezas anteriores ya definidas, podemos ver cómo encajan entre sí. Docker sigue una arquitectura cliente-servidor con estas piezas:

  • Cliente (Docker CLI): el comando docker que escribes en tu terminal. No hace el trabajo pesado él mismo: simplemente traduce tu comando en una petición y se la envía al daemon.
  • Daemon (Docker Engine / dockerd): el proceso que corre en segundo plano en tu máquina (o en un servidor remoto) y que hace todo el trabajo real: construir imágenes, crear y ejecutar contenedores, gestionar redes y volúmenes. Es el corazón de Docker.
  • Imágenes: las plantillas de solo lectura que ya conoces, almacenadas localmente por el daemon.
  • Contenedores: las instancias en ejecución, gestionadas también por el daemon.
  • Registro: el repositorio externo (Docker Hub u otro) de donde el daemon descarga imágenes y hacia donde puede subirlas.

De forma esquemática, así encajan estas piezas:

Arquitectura de Docker: cliente, motor y registro

El flujo típico es: tú escribes docker run python:3.11-slim en el cliente → el cliente se lo pide al daemon → el daemon revisa si ya tiene esa imagen localmente; si no la tiene, la descarga del registro → el daemon crea un contenedor a partir de esa imagen y lo ejecuta.

¿Para qué sirve entender esta arquitectura? Te explica, por ejemplo, por qué el primer docker run de una imagen que nunca has usado tarda más (tiene que descargarla del registro) y los siguientes son casi instantáneos (la imagen ya está en tu disco local).

Persistencia y comunicación

Antes de llegar al laboratorio, falta una pieza conceptual clave: por defecto, un contenedor es efímero (todo lo que escribe en su capa de escritura desaparece cuando lo eliminas) y está aislado (no puede hablar con el mundo exterior ni con otros contenedores a menos que tú se lo permitas explícitamente). Tres mecanismos resuelven esto:

  • Volúmenes: un mecanismo para que los datos sobrevivan más allá del ciclo de vida de un contenedor. En lugar de escribir datos únicamente en la capa de escritura efímera del contenedor, un volumen conecta una carpeta del contenedor con un almacenamiento que persiste de forma independiente —ya sea una carpeta de tu máquina host (lo que se llama bind mount) o un volumen gestionado por el propio Docker—. Si el contenedor se elimina, los datos del volumen permanecen.
  • Puertos: por defecto, los servicios que corren dentro de un contenedor (por ejemplo, un servidor web escuchando en el puerto 80) son invisibles desde fuera de él. Mapear un puerto (-p puerto_host:puerto_contenedor) le dice a Docker “todo lo que llegue a este puerto de mi máquina, redirígelo a este otro puerto dentro del contenedor”. Así expones un servicio hacia el exterior.
  • Redes: cuando varios contenedores necesitan hablar entre sí —por ejemplo, un maestro de Spark y sus workers—, Docker permite conectarlos a una red virtual compartida. Dentro de esa red, cada contenedor puede localizar a los demás simplemente por su nombre de servicio, sin necesidad de conocer direcciones IP, porque Docker resuelve internamente ese nombre como si fuera un servidor DNS.

¿Para qué sirve esto en Big Data? Estos tres mecanismos son, literalmente, los que permiten construir un clúster: la red es lo que deja que el maestro y los workers se encuentren por nombre; los puertos son lo que te deja abrir el panel de control del clúster desde tu navegador; los volúmenes son lo que evita que pierdas tus datos o resultados cuando un contenedor se reinicia.

Por qué todo esto es crítico en Big Data

Un clúster de Big Data —Spark, Hadoop, Kafka— no es una sola aplicación: es un conjunto de nodos (maestro, workers, coordinadores) que tienen que correr exactamente la misma versión de software, con exactamente la misma configuración, y que tienen que poder encontrarse entre sí en la red. Instalar eso a mano, nodo por nodo, es lento, propenso a errores y casi imposible de reproducir de forma idéntica dos veces.

Con Docker, cada tipo de nodo se convierte en una imagen. Levantar un clúster completo deja de ser “seguir quince páginas de instrucciones de instalación” y se convierte en “ejecutar un archivo de configuración declarativo”, como vas a hacer en unos minutos. Esto trae tres beneficios muy concretos:

  • Clústeres reproducibles: el mismo clúster que levantas en tu laptop para experimentar es, en su configuración, el mismo que se despliega en un servidor o en la nube.
  • Paridad con producción: el código que pruebas contra tu clúster local de Docker se comporta igual que contra el clúster real, porque corre sobre las mismas imágenes.
  • Puente hacia Cloud y MLOps: los mismos contenedores que levantas localmente son la unidad básica que usan los orquestadores en la nube (Kubernetes, servicios gestionados de AWS, GCP o Azure) y los pipelines de MLOps para entrenar y desplegar modelos de forma reproducible. Entender Docker hoy es, literalmente, entender el bloque de construcción sobre el que se apoya casi toda la infraestructura de datos moderna.

Con toda esta base conceptual ya construida, es momento de pasar del papel al teclado.


Parte 2 — Laboratorio: de la teoría al clúster funcionando

Referencia rápida: los comandos que más vas a usar

Antes de tocar la primera imagen, vale la pena tener a mano una referencia rápida. No la memorices: vuelve a ella cada vez que la necesites durante el laboratorio.

Imágenes

Comando Qué hace
docker images Lista las imágenes descargadas en tu máquina
docker pull <imagen> Descarga una imagen desde un registro (Docker Hub por defecto)
docker build -t <nombre>:<tag> . Construye una imagen a partir de un Dockerfile en la carpeta actual
docker rmi <imagen> Elimina una imagen local
docker image prune Elimina imágenes sueltas que ya no están en uso

Contenedores: crear y verificar

Comando Qué hace
docker run <imagen> Crea y arranca un contenedor a partir de una imagen
docker run -it <imagen> bash Lo arranca en modo interactivo, con terminal propia
docker run -d <imagen> Lo arranca en segundo plano (detached), como un servicio
docker run --name <nombre> <imagen> Le asigna un nombre propio en lugar de uno aleatorio
docker run -p <host>:<contenedor> <imagen> Mapea un puerto del host hacia el contenedor
docker run -v <host>:<contenedor> <imagen> Monta un volumen o bind mount
docker ps Lista los contenedores en ejecución
docker ps -a Lista todos, incluidos los detenidos
docker logs <contenedor> Muestra la salida (logs) de un contenedor
docker inspect <contenedor> Muestra el detalle completo de su configuración
docker exec -it <contenedor> bash Abre una terminal dentro de un contenedor que ya está corriendo

Contenedores: detener y borrar

Comando Qué hace
docker stop <contenedor> Detiene un contenedor en ejecución (parada ordenada)
docker start <contenedor> Vuelve a arrancar un contenedor detenido
docker restart <contenedor> Lo reinicia
docker rm <contenedor> Elimina un contenedor detenido
docker rm -f <contenedor> Fuerza su eliminación aunque siga en ejecución

Sistema

Comando Qué hace
docker system df Muestra cuánto espacio en disco ocupa Docker
docker system prune Limpia contenedores, imágenes y redes que no estén en uso

Verificar la instalación

Antes de nada, confirma que Docker está instalado y corriendo correctamente:

docker --version

Y prueba el flujo completo con la imagen más simple que existe:

docker run hello-world

Este comando descarga (si no la tienes ya) una imagen mínima llamada hello-world, crea un contenedor a partir de ella, lo ejecuta, y el propio programa dentro del contenedor imprime un mensaje confirmando que todo el flujo —cliente, daemon, descarga de imagen, creación y ejecución de contenedor— funcionó correctamente. Salida esperada: si ves una línea que empieza con Hello from Docker! seguida de un párrafo explicando qué acaba de pasar, tu instalación funciona correctamente y puedes seguir con el resto del laboratorio.

Tu primer contenedor interactivo

Vamos a entrar dentro de un contenedor y explorarlo como si fuera una máquina propia:

docker run -it ubuntu bash

Aquí, -i (interactivo) mantiene abierta la entrada estándar, -t asigna una terminal, ubuntu es la imagen (si no la tienes localmente, Docker la descarga primero de Docker Hub) y bash es el comando que se ejecuta como proceso principal del contenedor. Dentro, puedes moverte como en cualquier terminal Linux:

ls /
whoami
cat /etc/os-release
exit

Al ejecutar exit, el proceso principal (bash) termina, y con él termina el contenedor: pasa al estado detenido, pero no desaparece. Compruébalo:

docker ps
docker ps -a
docker images

docker ps solo muestra contenedores en ejecución (por eso ahora mismo aparecerá vacío); docker ps -a muestra también los detenidos, incluyendo el que acabas de usar; docker images muestra las imágenes que ya tienes descargadas localmente, incluyendo ubuntu y hello-world. Salida esperada de docker ps -a (los valores de CONTAINER ID y NAMES van a ser distintos en tu caso):

CONTAINER ID   IMAGE     COMMAND   CREATED         STATUS                     PORTS   NAMES
3f2a1c9d8e21   ubuntu    "bash"    2 minutes ago   Exited (0) 30 seconds ago          affectionate_wilson

El dato clave está en STATUS: Exited (0) significa que el proceso principal del contenedor terminó limpiamente (código de salida 0), no que algo falló.

Persistir datos con un volumen

Ahora vas a comprobar, en la práctica, algo que explicamos en la Parte 1: los datos de un contenedor desaparecen con él, a menos que uses un volumen. Crea primero una carpeta local y un archivo:

mkdir data
echo "Este archivo debe sobrevivir al contenedor" > data/nota.txt

Ahora levanta un contenedor montando esa carpeta local dentro del contenedor, usando un bind mount (-v ruta_host:ruta_contenedor):

docker run -it -v "${PWD}/data:/data" ubuntu bash

Nota sobre tu terminal: la forma de referirte a “la carpeta actual” cambia según dónde estés escribiendo el comando. ${PWD} funciona en bash/zsh (Linux, macOS, Git Bash) y en PowerShell, pero no en el símbolo del sistema clásico de Windows (cmd.exe): ahí Docker interpreta el texto literal ${PWD} como nombre de volumen y falla con un error del tipo “includes invalid characters for a local volume name”. Usa la variante que corresponda a tu terminal:

# bash / zsh (Linux, macOS, Git Bash) y PowerShell
docker run -it -v "${PWD}/data:/data" ubuntu bash

# cmd.exe (símbolo del sistema clásico de Windows)
docker run -it -v "%cd%/data:/data" ubuntu bash

Si no estás seguro en qué terminal estás, fíjate en el símbolo del sistema (prompt): si ves algo como C:\Users\tu-usuario>, es cmd.exe y necesitas %cd%; si ves PS C:\Users\tu-usuario>, es PowerShell y ${PWD} funciona sin problema. El resto del capítulo usa ${PWD}; sustitúyelo por %cd% si tu terminal es cmd.exe.

Dentro del contenedor:

cat /data/nota.txt
echo "Escrito desde dentro del contenedor" >> /data/nota.txt
exit

Elimina el contenedor por completo (ya no lo necesitas, y esto es justamente el punto: eliminarlo es seguro). Primero identifica su ID:

docker ps -a --filter ancestor=ubuntu

Copia el valor de la columna CONTAINER ID (los primeros caracteres bastan) y elimínalo con ese ID:

docker rm <container-id>

(Si tu terminal es bash o zsh, en lugar de estos dos pasos puedes hacerlo en uno solo con docker rm $(docker ps -aq --filter ancestor=ubuntu). Esa sintaxis de sustitución de comandos no funciona igual en cmd.exe ni en todas las versiones de PowerShell, así que aquí usamos la forma de dos pasos, que funciona en cualquier terminal.)

Y verifica, desde tu máquina host, que el archivo sigue ahí con ambas líneas:

cat data/nota.txt

El contenedor ya no existe, pero el archivo sobrevivió, porque vivía en un volumen (una carpeta de tu host), no en la capa de escritura efímera del contenedor.

Reto: repite este mismo ejercicio, pero en lugar de un bind mount usa un volumen gestionado por Docker: docker run -it -v mis-datos:/data ubuntu bash. Escribe algo en /data/nota.txt, sal del contenedor y ejecuta docker volume inspect mis-datos. ¿Dónde te dice que están guardados los datos esta vez?

Levanta tu primer servidor web

Hasta ahora todos tus contenedores hicieron un trabajo puntual y terminaron: imprimir un mensaje, correr un script, terminar y desaparecer. Un servidor web es distinto: es un servicio de larga duración, algo que se queda corriendo, escuchando peticiones, hasta que tú decides detenerlo. Este ejemplo pone en práctica, todos juntos, los tres conceptos de la sección “Persistencia y comunicación” de la Parte 1: modo detached, mapeo de puertos y un volumen para servir contenido propio.

Vas a usar nginx, un servidor web ligero y muy usado, disponible como imagen oficial en Docker Hub. Primero crea una carpeta local con una página propia:

mkdir web
echo "<h1>Mi primer servidor con Docker</h1>" > web/index.html

Ahora levanta el contenedor:

docker run -d --name mi-servidor -p 8080:80 -v "${PWD}/web:/usr/share/nginx/html:ro" nginx

Repasemos cada flag: -d arranca el contenedor en segundo plano, como corresponde a un servicio que debe quedarse corriendo; --name mi-servidor le da un nombre fácil de referenciar en los siguientes comandos, en lugar de un identificador aleatorio; -p 8080:80 mapea el puerto 8080 de tu máquina hacia el puerto 80 del contenedor, que es donde nginx escucha por defecto; y -v "${PWD}/web:/usr/share/nginx/html:ro" monta tu carpeta local web (de solo lectura, :ro) en la carpeta desde donde nginx sirve archivos estáticos, reemplazando la página de bienvenida por defecto con la tuya.

Abre http://localhost:8080 en tu navegador: deberías ver tu propio encabezado. Confirma que el contenedor sigue vivo y revisa sus logs de acceso:

docker ps
docker logs mi-servidor

Ahora, sin tocar el contenedor para nada, edita el archivo local:

echo "<h1>Contenido actualizado sin reconstruir nada</h1>" > web/index.html

Refresca el navegador: el contenido cambió al instante. Esto es el bind mount en acción, exactamente como en el ejercicio de persistencia: el contenedor no tiene una copia propia del archivo, está leyendo en vivo desde tu carpeta local.

Cuando termines, detén y elimina el contenedor:

docker stop mi-servidor
docker rm mi-servidor

¿Para qué sirve este ejemplo? Porque acabas de exponer un servicio real al mundo exterior usando únicamente una imagen oficial ya construida.

Dos contenedores que se hablan: MySQL con phpMyAdmin

El servidor web anterior vivía solo. En la práctica, casi nunca es así: lo normal es tener una base de datos por un lado y una herramienta de administración por otro, y que ambos contenedores se encuentren entre sí. Este es el ejemplo perfecto para poner en práctica, sin Docker Compose todavía, el concepto de red que viste en la Parte 1: dos contenedores conectados a la misma red virtual, hablándose por nombre.

Primero, crea una red propia para que ambos contenedores vivan en ella:

docker network create red-mysql

Ahora levanta MySQL conectado a esa red. Esta vez, en lugar de un bind mount, vas a usar un volumen gestionado por Docker (uno de los dos tipos de volumen que mencionamos en la Parte 1): en lugar de apuntar a una carpeta tuya, le das un nombre y Docker se encarga de dónde y cómo guardarlo. Este comando es largo, así que va en una sola línea a propósito: el carácter \ para partir un comando en varias líneas es de bash/zsh y no funciona igual en PowerShell ni en cmd.exe, así que escribirlo todo seguido evita ese problema sin importar tu terminal:

docker run -d --name mi-mysql --network red-mysql -e MYSQL_ROOT_PASSWORD=supersecreto -e MYSQL_DATABASE=bigdata -v mysql-data:/var/lib/mysql mysql:8.0

La flag -e (environment) pasa variables de entorno al contenedor; en este caso, MySQL las usa para configurarse solo en el primer arranque: define la contraseña del usuario root y crea automáticamente una base de datos llamada bigdata. El volumen mysql-data asegura que, aunque elimines y recrees el contenedor, los datos de la base sobrevivan.

Dale unos segundos a MySQL para inicializar y luego levanta phpMyAdmin, la interfaz web de administración, en la misma red:

docker run -d --name mi-phpmyadmin --network red-mysql -e PMA_HOST=mi-mysql -p 8081:80 phpmyadmin

Aquí está el punto clave: PMA_HOST=mi-mysql no es una dirección IP, es el nombre del contenedor de MySQL. Como ambos están conectados a la red red-mysql, Docker resuelve ese nombre automáticamente, exactamente como explicamos en la Parte 1 con el ejemplo del maestro y los workers de Spark.

Abre http://localhost:8081 en tu navegador, entra con usuario root y la contraseña supersecreto, y verás la base bigdata ya creada, administrada desde una interfaz web que corre en un contenedor completamente distinto al de la base de datos.

Cuando termines, limpia todo lo que creaste:

docker stop mi-phpmyadmin mi-mysql
docker rm mi-phpmyadmin mi-mysql
docker network rm red-mysql
docker volume rm mysql-data

¿Para qué sirve esto en Big Data? Muchísimas piezas de un ecosistema de datos —el metastore de Hive, la base de metadatos de Airflow, el backend de configuración de Airbyte— son, por debajo, una base de datos relacional como esta corriendo en su propio contenedor, conversando con los demás servicios exclusivamente por nombre dentro de una red compartida. Lo que acabas de montar a mano, contenedor por contenedor, es exactamente lo que Docker Compose va a automatizar en la siguiente sección.

Todo junto en un solo archivo: Docker Compose

Repasa lo que hiciste en las dos secciones anteriores: para levantar apenas tres contenedores —el servidor web, MySQL y phpMyAdmin— tuviste que crear una red a mano, escribir tres docker run larguísimos con sus variables de entorno, sus puertos y sus volúmenes, y al final acordarte de detener y eliminar cada uno por separado. Funciona, pero no escala: imagina hacer eso mismo con diez contenedores, o tener que repetirlo idéntico en la máquina de un compañero.

Para esto existe Docker Compose: una herramienta que te permite describir, en un único archivo docker-compose.yml, un conjunto de contenedores (a los que Compose llama servicios), sus redes, sus volúmenes y sus dependencias entre sí. Ese archivo es, para varios contenedores, lo que el Dockerfile es para una sola imagen: una receta declarativa y reproducible. En lugar de una secuencia de comandos que solo tú recuerdas, queda un archivo que cualquiera puede leer y ejecutar igual.

Vas a describir en un solo archivo, de una vez, exactamente el mismo entorno de las dos secciones anteriores —servidor web, MySQL y phpMyAdmin— y comprobar cuánto más simple es levantarlo así:

services:
  web:
    image: nginx
    container_name: mi-servidor
    ports:
      - "8080:80"
    volumes:
      - ./web:/usr/share/nginx/html:ro
    networks:
      - app-net

  mysql:
    image: mysql:8.0
    container_name: mi-mysql
    environment:
      - MYSQL_ROOT_PASSWORD=supersecreto
      - MYSQL_DATABASE=bigdata
    volumes:
      - mysql-data:/var/lib/mysql
    networks:
      - app-net

  phpmyadmin:
    image: phpmyadmin
    container_name: mi-phpmyadmin
    environment:
      - PMA_HOST=mysql
    ports:
      - "8081:80"
    depends_on:
      - mysql
    networks:
      - app-net

networks:
  app-net:
    driver: bridge

volumes:
  mysql-data:

Fíjate en lo que ya no tienes que escribir: no hay ningún docker network create, porque la sección networks al final del archivo la crea por ti; no hay ningún -v mysql-data:/var/lib/mysql suelto en la terminal, porque el volumen nombrado se declara una sola vez bajo volumes y Compose lo gestiona; y PMA_HOST=mysql sigue funcionando exactamente igual que antes —por nombre de servicio, no por IP—, solo que ahora ese nombre es la clave del servicio en el propio archivo (mysql:), no un --name que tuviste que teclear a mano.

Levanta los tres contenedores con un solo comando:

docker compose up -d

Verifica el estado de los tres servicios (más específico que docker ps, porque agrupa por proyecto de Compose):

docker compose ps

Abre http://localhost:8080 para tu servidor web y http://localhost:8081 para phpMyAdmin (usuario root, contraseña supersecreto); deberías ver la base bigdata ya creada, igual que en el ejercicio manual, pero esta vez sin haber escrito una sola línea de docker run.

Cuando termines, apaga y elimina los tres contenedores y la red con un único comando (agrega -v si además quieres borrar el volumen de MySQL):

docker compose down -v

¿Para qué sirve esto? Porque a partir de aquí, cada vez que necesites coordinar varios contenedores que dependen entre sí, la respuesta es siempre la misma: un archivo docker-compose.yml, no una lista de comandos que hay que recordar en el orden correcto. Hasta ahora, en todos los ejemplos usaste imágenes que otros construyeron (nginx, MySQL, phpMyAdmin). Antes de llegar al ejemplo que le da su nombre a este capítulo, falta una pieza: construir una imagen con tu propio código adentro.

Reto: cambia en el archivo el puerto publicado de phpMyAdmin de "8081:80" a "8082:80", guarda el archivo y corre docker compose up -d de nuevo. Confirma que ahora responde en http://localhost:8082 y ya no en :8081. Es la forma más rápida de que se te quede grabado qué significa cada mitad de un mapeo host:contenedor.

Construir tu propia imagen

Ahora vas a escribir tu primer Dockerfile: una imagen mínima para correr un script de análisis de datos con pandas y NumPy.

Crea una carpeta para el proyecto y, dentro, un archivo requirements.txt:

pandas
numpy

Un script analisis.py:

import pandas as pd
import numpy as np

datos = pd.DataFrame({
    "usuario": [f"user_{i}" for i in range(10)],
    "eventos": np.random.randint(1, 100, size=10),
})

print("Resumen de eventos por usuario:")
print(datos.describe())
print("\nTotal de eventos registrados:", datos["eventos"].sum())

Y el Dockerfile:

FROM python:3.11-slim

WORKDIR /app

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

COPY analisis.py .

CMD ["python", "analisis.py"]

Repasemos qué hace cada instrucción, porque cada una es, literalmente, una capa de la imagen final: FROM parte de una imagen base oficial y liviana con Python ya instalado; WORKDIR define la carpeta de trabajo dentro del contenedor; COPY requirements.txt . copia solo el archivo de dependencias primero (a propósito, para aprovechar el cacheo de capas: si luego cambias analisis.py pero no requirements.txt, Docker no necesita reinstalar las librerías); RUN instala las dependencias; la segunda COPY trae el código; y CMD define qué se ejecuta cuando el contenedor arranca.

Construye la imagen:

docker build -t analisis-bigdata:1.0 .

Y ejecútala:

docker run --rm analisis-bigdata:1.0

--rm le dice a Docker que elimine el contenedor automáticamente al terminar, ya que en este caso no necesitas conservarlo: lo único que te interesa es la salida en pantalla.

Plato fuerte: un mini-clúster de Spark con Docker Compose

Ya usaste Docker Compose para levantar tres contenedores distintos que se necesitaban entre sí, y ya construiste tu propia imagen desde un Dockerfile. Ahora vas a juntar ambas ideas en el ejemplo más ambicioso del capítulo: no contenedores diferentes que colaboran, sino copias idénticas de un mismo contenedor trabajando en paralelo, coordinadas por un maestro. Esa es, exactamente, la idea de un clúster:

Mini-clúster de Spark: maestro y tres workers

Tres workers, misma imagen, mismo maestro: eso es exactamente lo que vas a construir con el archivo docker-compose.yml.

Crea un archivo docker-compose.yml:

services:
  spark-master:
    image: bitnamilegacy/spark:3.5
    container_name: spark-master
    environment:
      - SPARK_MODE=master
      - SPARK_RPC_AUTHENTICATION_ENABLED=no
      - SPARK_RPC_ENCRYPTION_ENABLED=no
      - SPARK_SSL_ENABLED=no
    ports:
      - "8080:8080"
      - "7077:7077"
    networks:
      - spark-net

  spark-worker:
    image: bitnamilegacy/spark:3.5
    environment:
      - SPARK_MODE=worker
      - SPARK_MASTER_URL=spark://spark-master:7077
      - SPARK_WORKER_MEMORY=1G
      - SPARK_WORKER_CORES=1
      - SPARK_RPC_AUTHENTICATION_ENABLED=no
      - SPARK_RPC_ENCRYPTION_ENABLED=no
      - SPARK_SSL_ENABLED=no
    depends_on:
      - spark-master
    networks:
      - spark-net

networks:
  spark-net:
    driver: bridge

Fíjate en dos detalles que conectan directamente con la Parte 1: spark-worker encuentra a spark-master usando spark://spark-master:7077, es decir, por nombre de servicio, gracias a que ambos comparten la red spark-net que definimos al final. Y observa que spark-worker no tiene container_name fijo: lo dejamos así a propósito, porque en el siguiente paso vamos a pedirle a Docker que cree varias copias de ese mismo servicio, y dos contenedores no pueden compartir el mismo nombre.

Nota sobre la imagen: usamos bitnamilegacy/spark y no bitnami/spark. Bitnami reorganizó su catálogo en Docker Hub y movió las versiones de sus imágenes gratuitas y de código abierto —incluidas las de versiones ya publicadas, como esta— al espacio de nombres bitnamilegacy, dejando bitnami para su catálogo más reciente. Si en el futuro este comando también falla, revisa en Docker Hub qué etiquetas están disponibles actualmente para spark antes de asumir que el archivo está mal escrito: es un buen recordatorio de que las imágenes de terceros cambian con el tiempo, y por eso conviene fijar siempre una versión concreta (aquí, 3.5) en lugar de usar latest.

Levanta el clúster en segundo plano (-d, detached):

docker compose up -d

Abre en tu navegador http://localhost:8080. Ahí verás la interfaz web del maestro de Spark, con un worker ya registrado (revisa que columnas como Cores y Memory aparezcan disponibles).

Ahora viene la parte que conecta todo con la idea de procesamiento distribuido de la que hablamos en la Parte 1: escalar el clúster sin tocar una sola línea de configuración, simplemente pidiéndole a Docker más copias del mismo servicio:

docker compose up -d --scale spark-worker=3

Refresca http://localhost:8080: ahora deberías ver tres workers registrados ante el mismo maestro. Esto es “divide y vencerás” hecho infraestructura: cada worker es un molde idéntico (la misma imagen bitnamilegacy/spark), y el maestro reparte el trabajo entre todos ellos para procesarlo en paralelo, exactamente el principio detrás de cualquier motor de procesamiento distribuido.

Reto: escala a 5 workers (docker compose up -d --scale spark-worker=5) y mira cuántos aparecen realmente en la interfaz. Si tu máquina no tiene CPU o memoria suficiente para los cinco, alguno puede quedarse sin arrancar: es una forma muy directa de sentir en carne propia los límites de recursos (cgroups) de los que hablamos en la Parte 1.

Por último, lanza un trabajo de prueba contra el clúster usando spark-submit dentro del propio contenedor del maestro, apuntando a un ejemplo incluido en la instalación de Spark que estima el valor de pi mediante muestreo distribuido:

docker exec -it spark-master spark-submit --master spark://spark-master:7077 --conf spark.jars.ivy=/tmp/.ivy2 /opt/bitnami/spark/examples/src/main/python/pi.py 10

Nota: el flag --conf spark.jars.ivy=/tmp/.ivy2 no es parte del ejemplo original de Spark; lo agregamos porque, en algunos entornos —sobre todo con Docker Desktop en Windows—, el contenedor no logra resolver el directorio “home” del usuario que ejecuta Spark. Sin este flag, Spark intenta construir automáticamente una ruta de caché a partir de ese home (algo como .ivy2/local dentro de él) y, si no puede resolverlo, falla con un error de Ivy (basedir must be absolute) antes de llegar a ejecutar tu trabajo. Fijar spark.jars.ivy a una ruta absoluta conocida evita por completo ese problema.

En la salida verás cómo el trabajo se reparte entre los workers registrados y, al final, una aproximación del valor de pi. Salida esperada (entre muchas líneas de log, busca esta cerca del final):

Pi is roughly 3.147760

Tu número no va a ser idéntico —el cálculo usa muestreo aleatorio—, pero debería rondar 3.14. Ese cómputo se dividió entre varios procesos independientes que corrieron en paralelo, cada uno en su propio contenedor: la misma idea que sostiene el procesamiento de terabytes de datos en un clúster real, aquí en miniatura, corriendo en tu propia máquina.

Limpieza

Cuando termines de experimentar, apaga y elimina todos los contenedores y la red que Compose creó:

docker compose down

Si además quieres liberar espacio en disco eliminando imágenes, contenedores detenidos y redes que ya no estés usando en ningún proyecto:

docker system prune

Úsalo con cuidado: elimina todo lo que no esté en uso activo, no solo lo relacionado con este laboratorio.

Errores comunes y cómo resolverlos

Ningún laboratorio de Docker sale perfecto a la primera. Estos son los errores más frecuentes que te vas a encontrar siguiendo esta guía —todos ellos, de hecho, aparecieron probando este mismo capítulo— con su causa y su solución:

Error Causa Solución
"${PWD}/data" includes invalid characters for a local volume name Estás en cmd.exe, donde ${PWD} no se expande Usa %cd% en lugar de ${PWD}, o cambia a PowerShell
unknown shorthand flag: 'a' in -aq al usar $(docker ps -aq ...) $() es sustitución de comandos de bash; cmd.exe no la interpreta Usa el enfoque de dos pasos: docker ps -a para ver el ID, luego docker rm <id>
Un comando con \ al final de línea se ejecuta como varios comandos sueltos y con errores El \ de continuación es de bash/zsh; PowerShell usa ` en su lugar Escribe el comando completo en una sola línea
Cannot connect to the Docker daemon Docker Desktop no está corriendo Ábrelo y espera a que el ícono indique que está listo, luego reintenta
Bind for 0.0.0.0:8080 failed: port is already allocated Ya tienes otro contenedor (o programa) usando ese puerto Cambia el mapeo (por ejemplo -p 8081:80) o detén el contenedor que lo está usando (docker ps para encontrarlo)
manifest for bitnami/spark:3.5 not found El proveedor de la imagen movió o retiró esa etiqueta Revisa en Docker Hub qué etiquetas existen hoy; en este capítulo la solución fue usar bitnamilegacy/spark:3.5
IllegalArgumentException: basedir must be absolute: ?/.ivy2/local en spark-submit Spark no logra resolver el directorio “home” dentro del contenedor Agrega --conf spark.jars.ivy=/tmp/.ivy2 al comando

¿Para qué sirve esta tabla? Docker en sí mismo es una herramienta muy estable; la mayoría de los errores que vas a encontrar como principiante no son de Docker, sino de la terminal que usas para hablarle (bash vs. PowerShell vs. cmd.exe) o de que una imagen de terceros cambió con el tiempo. Aprender a distinguir un error de sintaxis de tu terminal de un error real de Docker te ahorra horas de frustración.


Cuándo usar Docker y hacia dónde sigue

Docker no es la respuesta a todos los problemas de infraestructura, pero sí es, hoy, el punto de partida casi universal. Úsalo cuando necesites que un entorno sea exactamente reproducible entre tu máquina, la de tu equipo y producción; cuando quieras aislar dependencias conflictivas entre proyectos sin tener que gestionar entornos virtuales distintos para cada capa del stack; y cuando estés construyendo o probando localmente cualquier pieza de infraestructura distribuida —como acabas de hacer con Spark— antes de llevarla a un entorno real.

Lo que acabas de construir con docker-compose.yml es, en esencia, la misma lógica que usan los orquestadores de contenedores a mayor escala, como Kubernetes, para gestionar cientos o miles de contenedores en producción, decidiendo automáticamente dónde correr cada uno, reiniciándolos si fallan y escalándolos según la carga. Y esa misma unidad —el contenedor— es también el bloque de construcción sobre el que se apoyan hoy los flujos de MLOps: empaquetar un modelo entrenado junto con sus dependencias exactas en una imagen es lo que garantiza que ese modelo se comporte igual en el notebook donde lo entrenaste que en el servicio que lo pone en producción. Entender Docker a fondo, como acabas de hacerlo, es el primer paso natural hacia ambos mundos.

Resumen

En este capítulo aprendiste:

  • Por qué existen los contenedores y qué problema resuelven frente a las máquinas virtuales.
  • Qué es un contenedor, qué es una imagen y cómo se relacionan a través de capas y una capa de escritura exclusiva de cada contenedor.
  • Cómo Linux logra el aislamiento (namespaces) y el control de recursos (cgroups) por debajo de Docker.
  • Qué es un Dockerfile, qué es un registro como Docker Hub, y cómo encajan cliente, daemon, imágenes, contenedores y registro en la arquitectura de Docker.
  • Cómo persisten datos (volúmenes) y se comunican entre sí los contenedores (redes, puertos).
  • A construir, paso a paso, un entorno completo: un volumen, un servidor web, una base de datos con su interfaz de administración —primero a mano y luego con Docker Compose—, una imagen propia con tu código, y finalmente un clúster de Spark que escalaste con un solo comando.

Ahora comprendes no solo los comandos de Docker, sino el porqué detrás de cada uno: por qué un contenedor es efímero, por qué una imagen es inmutable, y por qué “divide y vencerás” es literalmente lo que ocurre cuando escalas un clúster con docker compose up --scale.

En el siguiente capítulo, Ecosistema Hadoop, levantamos la mirada del contenedor al mapa completo: qué piezas forman una plataforma Big Data y qué pregunta responde cada una. A partir del laboratorio de HDFS, Docker deja de ser el tema y pasa a ser la herramienta con la que montamos el clúster.