Tutorial-docker
Contenido
Ejercicio 1: Empezamos desde cero
El objetivo es eliminar toda la configuración previa y crear desde cero la estructura de carpetas que contendrá nuestros futuros archivos Docker.
Eliminar la configuración existente
Antes de comenzar, asegúrate de borrar cualquier carpeta Docker existente en el proyecto. En este caso, le haremos una copia de seguridad para poder volver a ella.
mv docker docker.bk
También debes asegurarte que trabajas con las variables de entorno adecuadas:
cp .env.docker.example .env
⚠️ Este paso es importante: partimos completamente desde cero para entender cada parte del sistema.
Crear un Dockerfile mínimo
Ahora crea nuevamente la carpeta base donde almacenaremos los archivos relacionados con Docker:
mkdir -p docker/images
Vamos a explicar los distintos apartados:
FROM
FROM python:3.12-slim
Indica la imagen base sobre la que se construirá la tuya. En este caso, parte de una imagen ligera que ya tiene Python 3.12 instalado.
WORKDIR
WORKDIR /app
Esta línea define el directorio de trabajo dentro del contenedor. A partir de aquí, todos los comandos que aparezcan después (COPY, RUN, CMD, etc.) se ejecutarán dentro de /app.
Ojo: /app no es la carpeta app/ de tu proyecto local. Son cosas distintas.
En tu máquina puede existir algo como:
uvlhub
app
core
requirements.txt
Pero dentro del contenedor la estructura será distinta:
/ app/ app/ ← aquí dentro se habrá copiado tu carpeta local "app"
Cuando más adelante uses:
COPY . .
Docker copiará todos los archivos de tu proyecto dentro de la carpeta /app del contenedor. Por eso el código quedará en /app/app/.
COPY
COPY . .
Copia todos los archivos del proyecto (del host) dentro del contenedor, en /app. El primer punto es el origen; el segundo, el destino.
RUN
RUN pip install --no-cache-dir -r requirements.txt
Ejecuta comandos durante la construcción de la imagen. Aquí instalamos las dependencias necesarias desde requirements.txt. El resultado de este paso quedará guardado en la imagen.
EXPOSE
EXPOSE 5000
Documenta el puerto que la aplicación usa dentro del contenedor. Esto no lo abre al exterior; simplemente indica que la app escucha en el 5000.
CMD
CMD ["flask", "run", "--host=0.0.0.0", "--port=5000", "--reload", "--debug"]
Define el comando que se ejecutará cuando el contenedor se inicie. En este caso, ejecutará la aplicación Flask.
RUN vs CMD
Estas dos instrucciones parecen similares, pero hacen cosas muy distintas.
RUN
Se ejecuta durante la construcción de la imagen (en el momento del docker build). Cada RUN crea una nueva capa dentro de la imagen.
Ejemplo:
RUN pip install -r requirements.txt
Este comando instala las dependencias una sola vez, cuando se construye la imagen. El resultado (las librerías instaladas) queda grabado dentro de la imagen final.
Si después lanzas un contenedor nuevo a partir de esa imagen, las dependencias ya estarán instaladas y no se volverán a ejecutar.
CMD
Se ejecuta cuando se inicia el contenedor (en el docker run). Define el proceso principal que se ejecutará dentro del contenedor mientras esté activo.
Ejemplo:
CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"]
Este comando no se ejecuta durante la construcción, sino cada vez que arrancas el contenedor. Si detienes el contenedor, este proceso también se detiene.
Diferencia resumida
RUN → se ejecuta al construir la imagen (fase de docker build).
CMD → se ejecuta al iniciar el contenedor (fase de docker run).
Solo puede haber un CMD por Dockerfile. Si hay más de uno, solo se usará el último.
Dockerfile completo
FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 5000
CMD ["flask", "run", "--host=0.0.0.0", "--port=5000", "--reload", "--debug"]
Esto tiene que ir en un Dockerfile en docker/images
Construir la imagen
Desde la raíz del proyecto (no dentro de docker/images):
docker build -t uvlhub:dev -f docker/images/Dockerfile.dev .
Ejecutar el contenedor
docker run -p 5000:5000 uvlhub:dev
Abre el navegador en http://localhost:5000. ¿Qué es lo que observas?
Ejercicio 2: Conectando servicios
¡Vaya! ¡No funciona! Can't connect to MySQL server on 'db'... ¡Ah, claro, intenta conectarse a la base de datos, que no lo tenemos! Pero claro, en un contenedor no es recomendable tener la app y la base de datos conviviendo...
Crear contenedor MariaDB
Antes que nada, haz Ctrl + C para parar el contenedor web. En este ejercicio aprenderás a lanzar un contenedor de MariaDB 12.0.2 de forma manual, configurando todas las variables necesarias para tu aplicación Flask.
El objetivo es entender la complejidad de hacerlo “a mano”, antes de automatizarlo con docker-compose.
Descargar la imagen oficial
Primero descarga la versión exacta que usaremos:
docker pull mariadb:12.0.2
Crear el contenedor de MariaDB
Lanza un contenedor con todas las variables necesarias para que Flask pueda conectarse correctamente:
docker run -d --name mariadb_container \
-e FLASK_APP_NAME="UVLHUB.IO(dev)" \
-e FLASK_ENV=development \
-e DOMAIN=localhost \
-e MARIADB_HOSTNAME=db \
-e MARIADB_PORT=3306 \
-e MARIADB_DATABASE=uvlhubdb \
-e MARIADB_TEST_DATABASE=uvlhubdb_test \
-e MARIADB_USER=uvlhubdb_user \
-e MARIADB_PASSWORD=uvlhubdb_password \
-e MARIADB_ROOT_PASSWORD=uvlhubdb_root_password \
-e WORKING_DIR=/app/ \
-p 3306:3306 mariadb:12.0.2
Explicación:
-d: ejecuta el contenedor en segundo plano (modo detached).
--name: asigna un nombre identificable al contenedor.
-e: define variables de entorno dentro del contenedor.
-p 3306:3306: publica el puerto de MariaDB en tu máquina local.
mariadb:12.0.2: imagen exacta que vamos a usar.
Aquí ya, de entrada, vemos que esto es más feo que una nevera por detrás. Para empezar, estamos pasando a lo loco un montón de variables que difílmente será replicable en producción, por contener información sensible. Aparte, si metemos nuevas variables, ese comando queda inservible. Necesitaremos una solución más ágil para este problema
Comprobar que está corriendo
docker ps
Deberías ver algo así:
CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES
abcd1234efgh mariadb:12.0.2 "docker-entrypoint.s…" Up 5 seconds 0.0.0.0:3306->3306/tcp mariadb_container
Conectarse a la base de datos
Abre una consola dentro del contenedor:
docker exec -it mariadb_container bash
Y dentro, accede al cliente de MariaDB:
mariadb -u root -p
Contraseña: uvlhubdb_root_password
Verificar la base de datos
Una vez dentro del cliente MySQL, ejecuta:
SHOW DATABASES;
Deberías ver que existen la bases de datos uvlhubdb porque pedimos crearla a la hora de levantar el contenedor. ¿Qué tiene la base de datos dentro? Vamos a verlo...
USE uvlhubdb;
SHOW TABLES;
Un total de... 0 tablas... ¡Claro! Es que solo hemos creado la base en sí, pero no las tablas. ¿Con un script SQL tal vez? ¡Espera, espera, si tenemos las migraciones! Pero claro, para eso necesito el contenedor de la app levantado. Sal de la consola SQL y del contenedor de MariaDB. Bien, ahora en el host...
docker run -p 5000:5000 -d uvlhub:dev
Esta vez con el flag "-d" para que corra en segundo plano. Ahora, accedamos al contenedor y ejecutemos las migraciones. Pero... ¿cómo se llama mi contenedor?
docker ps
Fíjate que Docker asigna un nombre aleatorio a tu contenedor web porque no definiste ninguno. Una vez identificado el nombre:
docker exec -it <nombre> bash
flask db upgrade
What? ¿Qué pasa? ¿Otra vez error de conexión con la base? Si tenemos el contenedor funcionando, esto es un jaleo...
Redes internas
Ahora mismo tienes dos contenedores aislados, el de la app y el la base de datos. Por defecto, cada contenedor tiene su propia red interna y no puede ver a los demás por nombre. El hostname db que usa Flask no existe en su DNS interno.
Conectar servicios
Vamos a crear una red interna que compartan ambos contenedores
docker network create uvlhub_network
docker network ls
Vemos que ya aparece nuestra red uvlhub_network. Ahora, conectemos el contenedor de MariaDB a la red:
docker stop mariadb_container
docker rm mariadb_container
docker run -d \
--name mariadb_container \
--hostname db \
--network uvlhub_network \
-e FLASK_APP_NAME="UVLHUB.IO(dev)" \
-e FLASK_ENV=development \
-e DOMAIN=localhost \
-e MARIADB_HOSTNAME=db \
-e MARIADB_PORT=3306 \
-e MARIADB_DATABASE=uvlhubdb \
-e MARIADB_TEST_DATABASE=uvlhubdb_test \
-e MARIADB_USER=uvlhubdb_user \
-e MARIADB_PASSWORD=uvlhubdb_password \
-e MARIADB_ROOT_PASSWORD=uvlhubdb_root_password \
-e WORKING_DIR=/app/ \
-p 3306:3306 \
mariadb:12.0.2
Claro, si te fijas, ahora hay que añadir, además de toda esa configuración engorrosa, que se añada en la misma red y que además tenga de hostname "db" para que el contenedor web entienda qué es "db". Tenemos que hacer lo mismo para el contenedor web:
docker stop <nombre>
docker rm <nombre>
Y lo levantamos de nuevo, pero ya en la nueva red. De paso, le ponemos un nombre para identificarlo mejor.
docker run -p 5000:5000 --name web_app_container --network uvlhub_network -d uvlhub:dev
Y ahora ya podemos acceder al contenedor por su nombre y ejecutar las migraciones:
docker exec -it web_app_container bash
flask db upgrade
Abre el navegador en http://localhost:5000. ¿Mejor?
Ejercicio 3: Persistencia
Vamos a simular que hemos desplegado justo esto en producción y el servidor se reinicia por un corte de luz. Reiniciemos a mano cada contenedor:
docker restart web_app_container
docker restart mariadb_container
Abre de nuevo http://localhost:5000. What? ¿Cómo? ¿Qué ha pasado? ¿Otra vez problema de conexión? Ve dándole a F5... F5... ¡bingo! Ahora sí sale, pero menuda aleatoriedad, ¿no? Claro, es que ahora caes en que una cosa es que el contenedor haya arrancado y otra que esté listo.
Aparte, tenemos otro problema. ¿Qué pasa con la persistencia? Porque si borro el contenedor de MariaDB, todos los datos que estén en su interior también se pierden. Y si estamos en producción, lío asegurado.
Volúmenes
Cada vez que eliminas el contenedor de MariaDB, todas las bases de datos se pierden. Esto ocurre porque los datos se guardan dentro del sistema de archivos del contenedor, y al borrarlo desaparecen con él.
En este ejercicio creas un volumen de Docker para que los datos persistan entre ejecuciones.
Crear un volumen
Un volumen es una carpeta gestionada por Docker que vive fuera del ciclo de vida de los contenedores.
Ejecuta:
docker volume create mariadb_data
Comprueba que existe:
docker volume ls
La salida muestra algo como:
DRIVER VOLUME NAME local mariadb_data
Crear un contenedor de MariaDB con el volumen
Lanza el contenedor y conecta el volumen a la ruta donde MariaDB guarda los datos (/var/lib/mysql):
docker stop mariadb_container
docker rm mariadb_container
docker run -d \
--name mariadb_container \
--hostname db \
--network uvlhub_network \
-v mariadb_data:/var/lib/mysql \
-e FLASK_APP_NAME="UVLHUB.IO(dev)" \
-e FLASK_ENV=development \
-e DOMAIN=localhost \
-e MARIADB_HOSTNAME=db \
-e MARIADB_PORT=3306 \
-e MARIADB_DATABASE=uvlhubdb \
-e MARIADB_TEST_DATABASE=uvlhubdb_test \
-e MARIADB_USER=uvlhubdb_user \
-e MARIADB_PASSWORD=uvlhubdb_password \
-e MARIADB_ROOT_PASSWORD=uvlhubdb_root_password \
-e WORKING_DIR=/app/ \
-p 3306:3306 \
mariadb:12.0.2
Qué significa ":" en los volúmenes =
Cuando usas la opción -v o --volume en Docker, el signo dos puntos (:) separa dos rutas:
-v origen:destino
origen → es la ruta o el volumen en tu máquina (el host).
destino → es la ruta dentro del contenedor.
Ejercicio 4: Orquestación de servicios
Hasta ahora hemos creado y conectado los contenedores manualmente. Ahora vas a usar Docker Compose para definir todo en un solo archivo.
Orquestación básica
Crea un archivo llamado docker-compose.yml en la carpeta docker con este contenido:
services:
web:
container_name: web_app_container
env_file:
- ../.env
expose:
- "5000"
depends_on:
- db
build:
context: ../
dockerfile: docker/images/Dockerfile
networks:
- uvlhub_network
db:
container_name: mariadb_container
image: mariadb:12.0.2
env_file:
- ../.env
ports:
- "3306:3306"
volumes:
- db_data:/var/lib/mysql
networks:
- uvlhub_network
volumes:
db_data:
networks:
uvlhub_network:
Fíjate que esta orquestación de servicios me permite definir el nombre de cada servicio, su dependencia, sus puertos, sus volúmenes, redes... todo lo que hacíamos de forma manual y propenso a errores queda ahora más fácil de gestionar. Además, ahora ambos contenedores comparten el mismo archivo .env de variables de entorno.
Bien, ahora vamos a levantar estos contenedores y acceder al contenedor web:
docker compose -f docker/docker-compose.yml up -d --build
docker exec -it web_app_container bash
Inmediatamente después, ejecuta las migraciones:
flask db upgrade
¿Qué ha ocurrido? Depende del tiempo que hayas tardado, puede que haya saltado un error o puede que haya funcionado. Puede que hayas intentado varias veces el comando anterior hasta que ha funcionado. ¿Por qué crees que ocurre esta aparete aleatoriedad?
Dependencias entre contenedores y entrypoints
Fíjate que tenemos un "depends on" en el servicio web. Significa que el servicio "db" debe iniciarse antes que el servicio web. Pero, cuidado, ahí está la trampa, porque "servicio iniciado != servicio preparado". El servicio de MariaDB tiene que arrancar, configurarse, etc...
Cuando hicimos el comando COPY en nuestro Dockerfile, también copiamos la carpeta scripts. Uno de los scripts es wait-for-db.sh, que realiza continuamente llamadas a MariaDB hasta que este servicio está listo para recibir peticiones. Ábrelo y estúdialo.
Añade al servicio web de tu docker compose las siguientes líneas:
entrypoint: ["/app/scripts/wait-for-db.sh"]
command: ["flask", "run", "--host=0.0.0.0", "--port=5000", "--reload", "--debug"]
A su vez, del Dockerfile, hay que eliminar la línea de CMD que arrancaba nuestro servidor Flask.
Luego, reinicializa todos los servicios:
docker compose -f docker/docker-compose.yml down
docker compose -f docker/docker-compose.yml up -d --build
Abre de nuevo http://localhost:5000. What? ¿No abre? Vamos a investigarlo, veamos el logs:
docker logs web_app_container
Nos debe salir un mensaje eterno de "/app/scripts/wait-for-db.sh: line 18: mariadb: command not found". Esto ocurre porque en la imagen de nuestro servicio web NO TENEMOS el cliente de MariaDB. Debemos indicar entonces que lo instale. Añade justo debajo de FROM lo siguiente:
RUN apt-get update && apt-get install -y --no-install-recommends mariadb-client
Al ser un contenedor basado en Linux, y accediendo como root, es como si actualizáramos todos los paquetes y luego instalásemos el cliente de MariaDB. Intentémoslo otra vez:
docker compose -f docker/docker-compose.yml down
docker compose -f docker/docker-compose.yml up -d --build
Abre de nuevo http://localhost:5000. ¡Jopelines! ¿Tampoco? Si haces un "docker ps" verás el problema. El contenedor web no tiene expuesto ningún puerto.
services:
web:
(...)
expose:
- "5000"
(...)
"Expose" hace visible el puerto entre contenedores, pero NO entre tu contenedor y el host. Para eso usamos la directiva "ports", que se encarga del mapping. En nuestro caso, será:
services:
web:
(...)
ports:
- "5000:5000"
(...)
En ocasiones, si los dos puertos coinciden, se puede dejar uno solo con "5000" pero dejaremos los dos. El de la izquierda siempre es el puerto externo (host), el de la derecha es el puerto interno (contenedor). Así que cambia ese detalle y por última vez:
docker compose -f docker/docker-compose.yml down
docker compose -f docker/docker-compose.yml up -d --build
Abre de nuevo http://localhost:5000. ¡Ahora sí!
Trabajando con CMD, entrypoints y commands
Explicar utilidad de los entrypoints (puedo configurar el arranque sin tener que modificar la imagen)
Ejercicio 5: Volumen de trabajo
avisar del problema del código del contenedor y del uso de un volumen para eso
Ejercicio 6: Distintas configuraciones de despliegue
1: Explicar las distintas imágenes, entrypoints y docker-compose.ymls 2: Explicar el nginx 3: Explicar que cuanto más se parezca el despliegue en dev y en prod, menos propenso a errores seremos 4: Explicar diferencia entre flask server y gunicorn. Explicar que, dado que es una configuración distinta en dev y en prod, necesitamos siempre una máquina de preproducción