EGC 2026/2027

Tutorial: dockerizando uvlhub

Este tutorial acompaña a la Práctica 4 (Contenedores, dev-containers y aislamiento) y tiene dos partes:

  1. Campo de entrenamiento de Docker: Docker desde cero, con contenedores sencillos. Qué son una imagen y un contenedor (y por qué no es una máquina virtual), puertos, volúmenes, redes y tu primera imagen con una aplicación Flask.
  2. Dockerizando uvlhub: apartamos la configuración Docker de uvlhub y la reconstruimos a mano, pieza a pieza, hasta entender por qué el proyecto tiene los archivos que tiene.

La segunda parte es engorrosa a propósito. Cuando todo se levanta con una sola orden parece magia; aquí vamos a montar cada pieza a mano y a ver qué falla cuando falta.

Antes de empezar

Docker en los equipos del aula

Los equipos del aula ya tienen instalados Docker Engine y el plugin de Docker Compose. Comprueba que tu usuario puede usarlos sin sudo:

docker --version
docker compose version
docker run --rm hello-world

Si la última orden falla con permission denied while trying to connect to the Docker daemon socket, tu usuario no está en el grupo docker:

sudo usermod -aG docker $USER
newgrp docker

newgrp docker aplica el cambio en esa terminal; para las demás, cierra la sesión y vuelve a entrar. Si en el equipo del aula no tienes permisos de administrador, avisa al profesor.

Ojo. No uses sudo chmod 666 /var/run/docker.sock para salir del paso. Quien puede escribir en ese socket controla Docker, y quien controla Docker es, en la práctica, root en la máquina.

Docker en tu equipo

Si trabajas en tu propio equipo, instala Docker siguiendo la documentación oficial:

  • Ubuntu (y derivadas como Linux Mint): Install Docker Engine on Ubuntu, con los paquetes docker-ce, docker-ce-cli, containerd.io, docker-buildx-plugin y docker-compose-plugin. Después, añade tu usuario al grupo docker como se indica arriba.
  • Arch (y derivadas como EndeavourOS): sudo pacman -S docker docker-compose docker-buildx y sudo systemctl enable --now docker.
  • macOS y Windows: Docker Desktop. En macOS, el receptor de AirPlay ocupa el puerto 5000; si al publicar ese puerto Docker dice que está ocupado, usa 5001:5000 en lugar de 5000:5000 y abre http://localhost:5001.

Parte 1: campo de entrenamiento de Docker

Esta parte no da nada por sabido. Ejecuta cada orden, lee lo que imprime y no pases a la siguiente sección hasta entender qué ha pasado. Al final tienes una chuleta con todas las órdenes y unas preguntas para comprobar que lo has entendido.

0. Las cuatro piezas

Antes de teclear nada, cuatro conceptos que se usan todo el rato:

Pieza Qué es Una analogía
Imagen Una plantilla de solo lectura con todo lo necesario para ejecutar algo: un sistema mínimo, las dependencias y la aplicación. Una clase
Contenedor Una imagen en ejecución. Tiene su propia capa de escritura, su red y sus procesos. De una imagen se pueden crear tantos contenedores como quieras. Un objeto de esa clase
Dockerfile Un archivo de texto con la receta para construir una imagen. El código fuente de la clase
Registro Un almacén de imágenes del que se descargan y al que se suben. El público por defecto es Docker Hub. Un repositorio de paquetes, como PyPI

Y el ciclo que las une:

Dockerfile ──docker build──▶ imagen ──docker run──▶ contenedor
                               ▲
Docker Hub ───docker pull──────┘

1. Tu primer contenedor

docker run hello-world

Esta orden hace tres cosas:

  1. Descarga la imagen hello-world de Docker Hub, porque no la tienes en local (Unable to find image 'hello-world:latest' locally).
  2. Crea un contenedor a partir de esa imagen.
  3. Lo ejecuta: el contenedor imprime un mensaje que empieza por Hello from Docker! y termina.

¿Y dónde está ahora ese contenedor?

docker ps        # contenedores en ejecución: ninguno
docker ps -a     # todos: ahí está, con estado Exited (0)

Primera idea clave: un contenedor vive mientras vive su proceso principal. El de hello-world imprime el mensaje y termina, así que el contenedor se para. No se borra: queda parado hasta que lo borres tú (docker rm <id>).

2. Imágenes y etiquetas

Las imágenes que tienes en local se listan con:

docker image ls

Descarga una imagen de Ubuntu:

docker pull ubuntu:24.04

Lo que va detrás de los dos puntos es la etiqueta (tag), normalmente una versión. Si no pones ninguna, Docker usa latest, que es una etiqueta móvil: hoy apunta a una versión y dentro de unos meses a otra. En una asignatura de gestión de la configuración eso debería sonarte mal: lo que funciona hoy podría no funcionar mañana sin que hayas cambiado nada. Por eso en este tutorial fijamos siempre la versión (ubuntu:24.04, nginx:1.29.1, python:3.13-slim, mariadb:12.0.2), igual que hace uvlhub.

3. Dentro de un contenedor

docker run -it --rm ubuntu:24.04 bash
  • -i mantiene abierta la entrada estándar y -t asigna una terminal; juntos te dan una consola dentro del contenedor.
  • --rm borra el contenedor al terminar, para no acumular contenedores parados.
  • bash es la orden que se ejecuta dentro: el proceso principal.

El prompt ha cambiado: estás dentro. Prueba:

cat /etc/os-release
ps aux
curl --version

ps aux muestra solo dos procesos: tu bash, con PID 1, y el propio ps. El contenedor no arranca un sistema operativo completo, solo tu orden. Y curl no existe (command not found): la imagen trae lo mínimo. Instálalo:

apt-get update && apt-get install -y curl
curl --version
exit

Al salir de bash termina el proceso principal, así que el contenedor se para (y --rm lo borra). Vuelve a lanzar otro igual y pregunta por curl:

docker run -it --rm ubuntu:24.04 bash
curl --version
exit

Ha desaparecido. Segunda idea clave: lo que cambias dentro de un contenedor vive en su capa de escritura, no en la imagen. Cada docker run crea un contenedor nuevo a partir de la imagen intacta. Si quieres que algo forme parte del entorno, tiene que estar en la receta de la imagen: el Dockerfile (lo verás en el apartado 11).

Nota. A veces verás bash, /bin/bash o sh. Los dos primeros son el mismo intérprete; /bin/bash da la ruta completa. sh es un intérprete más básico que está en casi todas las imágenes, incluso en las que no traen bash (como las basadas en Alpine).

4. Un contenedor no es una máquina virtual

Compara el núcleo (kernel) de tu máquina con el que ve un contenedor:

uname -r
docker run --rm ubuntu:24.04 uname -r

Es el mismo. Una máquina virtual arranca su propio sistema operativo con su propio núcleo; un contenedor comparte el núcleo de la máquina anfitriona y solo aísla lo que el proceso puede ver: sus archivos, sus procesos, su red, su nombre de máquina. Por eso arranca en milisegundos y ocupa megas en lugar de gigas.

Lanza ahora un contenedor en segundo plano (-d, detached) que no hace nada durante mucho tiempo:

docker run -d --name caja ubuntu:24.04 sleep infinity

Y busca su proceso desde tu máquina, fuera del contenedor:

ps -ef | grep "sleep infinity"
docker top caja

Ahí está, como cualquier otro proceso de tu sistema. Un contenedor es, en el fondo, un proceso normal al que el núcleo de Linux le pone unas anteojeras. Por eso Docker necesita Linux (en macOS y Windows, Docker Desktop esconde una pequeña máquina virtual con Linux) y por eso se dice que containers are Linux.

Cada contenedor tiene su propio nombre de máquina. Ejecuta esto dos veces:

docker run --rm ubuntu:24.04 hostname

Salen dos nombres distintos: son dos contenedores distintos de la misma imagen.

5. El ciclo de vida de un contenedor

caja sigue en marcha:

docker ps          # contenedores en ejecución
docker ps -a       # todos, también los parados

Abre una consola dentro de un contenedor que ya está funcionando:

docker exec -it caja bash

Sal con exit: el contenedor sigue vivo, porque su proceso principal es sleep, no el bash que abriste con exec. Páralo, arráncalo de nuevo y bórralo:

docker stop caja
docker ps -a
docker start caja
docker rm -f caja

docker rm -f para y borra en un solo paso. A un contenedor puedes referirte por su nombre (--name) o por su ID; si no le das nombre, Docker se inventa uno.

6. Configurar un contenedor: variables de entorno

Las imágenes se configuran desde fuera con variables de entorno, que se pasan con -e:

docker run --rm -e NOMBRE=EGC ubuntu:24.04 bash -c 'echo "Hola, $NOMBRE"'
docker run --rm ubuntu:24.04 bash -c 'echo "Hola, $NOMBRE"'

La misma imagen se comporta distinto según la configuración que recibe. Es así como se le dice a una imagen de base de datos qué base crear y con qué contraseña, o a una aplicación dónde está su base de datos: lo harás en la parte 2. Cuando son muchas, se escriben en un archivo y se pasan todas a la vez con --env-file.

7. Puertos

Vamos a levantar un servidor web con la imagen oficial de NGINX, la misma versión que usa uvlhub:

docker run -d --rm --name web -p 8080:80 nginx:1.29.1

NGINX escucha en el puerto 80 del contenedor, que está en su propia red. -p 8080:80 publica ese puerto: lo que llegue al 8080 de tu máquina se reenvía al 80 del contenedor. A la izquierda va siempre el puerto del anfitrión (host); a la derecha, el del contenedor. Compruébalo:

curl http://localhost:8080
docker logs web

Abre también http://localhost:8080 en el navegador y vuelve a mirar docker logs web: cada petición deja una línea. docker logs muestra lo que el proceso principal escribe en su salida, y es lo primero que hay que mirar cuando algo falla.

docker stop web

Al llevar --rm, el contenedor se borra al pararse.

8. Bind mounts: tus archivos dentro del contenedor

NGINX sirve lo que haya en /usr/share/nginx/html. En lugar de copiar ahí nuestros archivos, vamos a montar una carpeta de nuestra máquina:

mkdir -p ~/campo-docker/site-content
cd ~/campo-docker
cat > site-content/index.html <<'EOF'
<!doctype html>
<html lang="es">
<head><meta charset="utf-8"><title>Docker NGINX</title></head>
<body><h2>Hola desde un contenedor NGINX</h2></body>
</html>
EOF
docker run -d --rm --name web -p 8080:80 \
  -v "$PWD/site-content":/usr/share/nginx/html:ro \
  nginx:1.29.1
curl http://localhost:8080

La opción -v origen:destino separa dos rutas con los dos puntos: a la izquierda, la carpeta de tu máquina; a la derecha, dónde aparece dentro del contenedor. El :ro final la monta en solo lectura.

Ahora edita site-content/index.html (cambia el texto del h2) y vuelve a hacer curl. El cambio aparece sin reiniciar nada: el contenedor está leyendo tu carpeta. Esto es un bind mount, y lo usaremos en la parte 2 para desarrollar sin reconstruir la imagen a cada cambio.

docker stop web

9. Volúmenes: datos que sobreviven al contenedor

Un volumen es almacenamiento que gestiona Docker y que vive fuera del ciclo de vida de los contenedores:

docker volume create nginx-data
docker volume ls
docker run -d --name web -p 8080:80 -v nginx-data:/usr/share/nginx/html nginx:1.29.1
docker exec web sh -c 'echo "<h2>Hola desde un volumen</h2>" > /usr/share/nginx/html/index.html'
curl http://localhost:8080

Borra el contenedor y crea otro nuevo con el mismo volumen:

docker rm -f web
docker run -d --name web -p 8080:80 -v nginx-data:/usr/share/nginx/html nginx:1.29.1
curl http://localhost:8080

El contenido sigue ahí, aunque el contenedor que lo escribió ya no existe. Es lo que necesita una base de datos, como verás en la parte 2.

Nota. La diferencia con el apartado anterior: en un bind mount tú decides la carpeta del anfitrión ($PWD/site-content); en un volumen solo das un nombre (nginx-data) y Docker decide dónde guardarlo. Puedes consultarlo con docker volume inspect nginx-data. Regla práctica: bind mounts para tu código mientras desarrollas, volúmenes para los datos.

docker rm -f web

10. Redes: contenedores que se hablan

Una aplicación de verdad son varios contenedores que se comunican: la aplicación con su base de datos, NGINX con la aplicación... Levanta un NGINX sin publicar ningún puerto e intenta llegar a él desde otro contenedor (usamos busybox, una imagen diminuta con herramientas básicas como wget):

docker run -d --rm --name web nginx:1.29.1
docker run --rm busybox:1.36 wget -qO- -T 3 http://web

Falla: el nombre web no lleva a ninguna parte. Los contenedores a los que no les indicas una red van a la red bridge por defecto, y en ella no hay resolución de nombres. Crea una red propia y repite dentro de ella:

docker stop web
docker network create red-campo
docker run -d --rm --name web --network red-campo nginx:1.29.1
docker run --rm --network red-campo busybox:1.36 wget -qO- http://web

Ahora sí: en una red creada por ti, Docker hace de DNS y cada contenedor es accesible por su nombre. Y fíjate en que no ha hecho falta -p: publicar un puerto solo es necesario para llegar desde fuera de Docker (tu navegador). Entre contenedores de la misma red se llega directamente.

docker stop web
docker network rm red-campo

11. Tu primera imagen: una aplicación Flask

Hasta ahora solo hemos usado imágenes de otros. Vamos a construir la nuestra, con una aplicación Flask mínima: el mismo tipo de aplicación que uvlhub, pero en un solo archivo.

mkdir -p ~/campo-docker/hola-flask
cd ~/campo-docker/hola-flask

Crea app.py:

import os
import socket

from flask import Flask

app = Flask(__name__)


@app.route("/")
def hola():
    nombre = os.getenv("NOMBRE", "mundo")
    return f"Hola, {nombre}. Te responde el contenedor {socket.gethostname()}\n"

Crea requirements.txt con una sola línea:

flask==3.1.0

Y el Dockerfile:

FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5000
CMD ["flask", "run"]

Línea a línea:

  • FROM python:3.13-slim: la imagen de partida. Una Debian mínima con Python 3.13 ya instalado. Toda imagen parte de otra.
  • WORKDIR /app: el directorio de trabajo dentro de la imagen. Las instrucciones siguientes se ejecutan ahí (y lo crea si no existe).
  • COPY requirements.txt .: copia un archivo de tu carpeta (el contexto de construcción) a la imagen, en el directorio de trabajo.
  • RUN pip install ...: ejecuta una orden al construir la imagen. Las dependencias quedan instaladas dentro de ella.
  • COPY app.py .: copia el código.
  • EXPOSE 5000: documenta en qué puerto escucha la aplicación. No publica nada; eso sigue haciéndolo -p.
  • CMD ["flask", "run"]: la orden que se ejecuta al arrancar cada contenedor, su proceso principal.

RUN frente a CMD. RUN se ejecuta durante docker build y su resultado queda grabado en la imagen. CMD se ejecuta durante docker run, cada vez que arranca un contenedor. Solo cuenta un CMD por Dockerfile (el último).

Construye la imagen y arranca un contenedor:

docker build -t hola-flask:1.0 .
docker run -d --rm --name hola -p 5000:5000 hola-flask:1.0
curl http://localhost:5000

-t le pone nombre y etiqueta a la imagen, y el . final es el contexto: la carpeta cuyo contenido puede ver COPY.

¿Funciona? No:

curl: (56) Recv failure: Connection reset by peer

Mira el log del contenedor:

docker logs hola

Running on http://127.0.0.1:5000. Flask, por defecto, solo acepta conexiones de 127.0.0.1, es decir, del propio contenedor. Las peticiones que llegan por el puerto publicado vienen de fuera, por la red del contenedor, y Flask no las atiende. Hay que decirle que escuche en todas las interfaces (0.0.0.0). Para el contenedor, cambia la última línea del Dockerfile y reconstruye:

docker stop hola
CMD ["flask", "run", "--host=0.0.0.0"]
docker build -t hola-flask:1.1 .
docker run -d --rm --name hola -p 5000:5000 -e NOMBRE=EGC hola-flask:1.1
curl http://localhost:5000
docker stop hola

Ahora sí: Hola, EGC. Te responde el contenedor .... Esta trampa es clásica, y la vas a ver otra vez en uvlhub.

Capas y caché

Fíjate en la salida de ese último docker build: todos los pasos aparecen como CACHED. Cada instrucción del Dockerfile produce una capa de la imagen, y Docker reutiliza las capas que no han cambiado. Míralas:

docker history hola-flask:1.1

Cambia ahora el saludo en app.py (por ejemplo, Hola por Buenas) y reconstruye:

docker build -t hola-flask:1.2 .

El pip install sale de la caché y solo se rehace COPY app.py . y lo que va detrás. Docker reutiliza todas las capas anteriores al primer cambio. Por eso copiamos primero requirements.txt e instalamos, y el código después: las dependencias cambian poco y tu código cambia mucho. Si hubiéramos hecho COPY . . antes del pip install, cualquier cambio en el código reinstalaría todas las dependencias. En uvlhub, donde instalarlas tarda minutos, esto importa mucho.

Por último, arranca dos contenedores de la misma imagen, en dos puertos distintos de tu máquina:

docker run -d --rm --name hola1 -p 5000:5000 hola-flask:1.2
docker run -d --rm --name hola2 -p 5001:5000 hola-flask:1.2
curl http://localhost:5000
curl http://localhost:5001
docker stop hola1 hola2

Cada uno responde con su propio nombre de máquina: una imagen, muchos contenedores (la clase y sus objetos del apartado 0).

12. (Opcional) Publica tu imagen

Si tienes cuenta en Docker Hub, puedes subir tu imagen para que cualquiera la ejecute con un docker run. El nombre de la imagen tiene que empezar por tu usuario:

docker login
docker tag hola-flask:1.2 <tu-usuario>/hola-flask:1.2
docker push <tu-usuario>/hola-flask:1.2

Es lo mismo que hace, de forma automática, el workflow CD_dockerhub.yml de uvlhub que viste en la Práctica 1. Y recuerda lo del apartado 2: lo que publiques, con su versión.

13. Limpieza

Docker no borra nada por su cuenta, y las imágenes ocupan espacio:

docker system df              # cuánto ocupa todo
docker container prune        # borra los contenedores parados
docker image prune            # borra las imágenes huérfanas (sin etiqueta)
docker volume rm nginx-data
docker image rm hola-flask:1.0 hola-flask:1.1 hola-flask:1.2

Ojo. docker system prune -a --volumes borra todo lo que no esté en uso en ese momento: imágenes, redes, caché y volúmenes, incluida la base de datos de uvlhub si su contenedor está parado. Úsala solo si sabes lo que haces.

Chuleta

Orden Qué hace
docker pull imagen:tag Descarga una imagen
docker image ls Lista las imágenes locales
docker build -t nombre:tag . Construye una imagen a partir del Dockerfile de la carpeta
docker history imagen Muestra las capas de una imagen
docker run [opciones] imagen [orden] Crea y arranca un contenedor
-it, -d, --rm, --name Opciones de run: interactivo, en segundo plano, borrar al parar, ponerle nombre
-e VAR=valor, --env-file f Opciones de run: variables de entorno
-p anfitrión:contenedor Opción de run: publica un puerto
-v origen:destino Opción de run: monta una carpeta (bind mount) o un volumen
--network red Opción de run: conecta el contenedor a una red
docker ps / docker ps -a Contenedores en marcha / todos
docker logs [-f] contenedor Lo que ha escrito el proceso principal (-f lo sigue en directo)
docker exec -it contenedor bash Abre una consola en un contenedor en marcha
docker stop / start / rm -f Parar / arrancar / borrar un contenedor
docker volume create / ls / rm Gestionar volúmenes
docker network create / ls / rm Gestionar redes
docker system df Cuánto espacio ocupa Docker

Comprueba que lo has entendido

Antes de pasar a la parte 2, intenta responder:

  1. ¿Qué diferencia hay entre una imagen y un contenedor? ¿Cuántos contenedores puedes crear a partir de una imagen?
  2. En el apartado 3 instalaste curl en un contenedor y luego había desaparecido. ¿Dónde habría que ponerlo para que estuviera siempre?
  3. ¿Por qué el contenedor de hello-world se para solo y el de NGINX no?
  4. ¿Por qué un contenedor arranca mucho más rápido que una máquina virtual?
  5. ¿Qué diferencia hay entre EXPOSE en el Dockerfile y -p en docker run?
  6. Si borras un contenedor, ¿qué pasa con lo que escribió en su capa de escritura? ¿Y en un bind mount? ¿Y en un volumen?
  7. ¿Por qué el busybox no encontraba a web en la red por defecto y en red-campo sí? ¿Hizo falta publicar algún puerto?
  8. ¿Por qué Flask tenía que escuchar en 0.0.0.0?
  9. ¿Por qué en el Dockerfile se copia requirements.txt antes que el código?

Parte 2: dockerizando uvlhub

Prepara tu fork

Trabaja en tu fork de uvlhub. Antes de seguir, tráete los últimos cambios del repositorio de la asignatura, porque este curso se han corregido varios archivos de Docker y del dev-container:

git remote add upstream https://github.com/EGCETSII/uvlhub.git   # solo la primera vez
git fetch upstream
git checkout main
git merge upstream/main

(También puedes usar el botón Sync fork de GitHub y después hacer git pull.)

Ejercicio 1: empezamos desde cero

El objetivo es quitar la configuración Docker del proyecto y crear desde cero la estructura que la contendrá. No la borramos: la apartamos para compararla al final.

mv docker docker.bk

Prepara también las variables de entorno para Docker. Guarda antes la .env que usabas en la instalación manual:

cp .env .env.manual.bk
cp .env.docker.example .env

Fíjate en la diferencia: en la instalación manual la base de datos estaba en localhost; ahora MARIADB_HOSTNAME=db, el nombre que tendrá el servicio de la base de datos.

Un Dockerfile mínimo

mkdir -p docker/images

Crea docker/images/Dockerfile con este contenido:

FROM python:3.13-slim
WORKDIR /workspace
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["flask", "run", "--host=0.0.0.0", "--port=5000", "--reload", "--debug"]

Es el mismo esquema que el de hola-flask del apartado 11 de la parte 1, con tres diferencias:

  • WORKDIR /workspace: el mismo directorio de trabajo que usa el Dockerfile.dev de uvlhub. No es casualidad: lo verás en el dev-container.
  • COPY . .: ahora el código no es un archivo sino todo el proyecto (el primer punto, el contexto) copiado en /workspace (el segundo punto). Tu carpeta app/ acaba en /workspace/app/. Como en hola-flask, va después del pip install, para que un cambio en el código no reinstale las dependencias.
  • CMD: ya con --host=0.0.0.0 (la trampa del apartado 11), y con --reload --debug para que Flask recargue el código al cambiarlo y muestre los errores con detalle.

python:3.13-slim es la versión que exige uvlhub en su pyproject.toml.

Construir la imagen

Desde la raíz del proyecto (no desde docker/images):

docker build -t uvlhub:dev -f docker/images/Dockerfile .

-f indica dónde está el Dockerfile, porque esta vez no está en la carpeta del contexto (el . final, la raíz del proyecto).

Fíjate en la línea transferring context: varios cientos de megas. ¿Por qué tanto? Porque el contexto incluye tu entorno virtual .venv de la instalación manual, la carpeta .git y todo lo que haya en el proyecto. Para excluir archivos del contexto existe .dockerignore. Créalo en la raíz del proyecto:

cat > .dockerignore <<'EOF'
.venv
venv
.git
**/__pycache__
.pytest_cache
.coverage
htmlcov
app.log
.env
EOF

También excluimos .env: las contraseñas no deben acabar dentro de una imagen, que se puede publicar en un registro como Docker Hub. Las variables se le pasarán al contenedor al ejecutarlo.

Pero la construcción falla de todas formas al instalar python-sat:

error: [Errno 2] No such file or directory: 'g++'
...
ERROR: Failed building wheel for python-sat

python-sat no publica paquetes precompilados (wheels) para Python 3.13 en Linux, así que pip intenta compilarlo, y la imagen slim no trae compilador. En tu máquina funcionó porque en la Práctica 0 tenías instalado build-essential: era una dependencia del sistema que no aparece en requirements.txt. Es el environment drift del reto de entornos aislados. Añade la dependencia a la receta, justo debajo de FROM:

RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
    && rm -rf /var/lib/apt/lists/*

Y vuelve a construir. El contexto baja a casi nada y esta vez la imagen se construye, aunque compilar python-sat tarda unos minutos (verás Building wheel for python-sat (pyproject.toml): still running...). Aprovecha para leer lo que viene.

Ejecutar el contenedor

docker run --rm --name web_app_container -p 5000:5000 --env-file .env uvlhub:dev

--env-file .env le pasa al contenedor las variables de .env. Abre http://localhost:5000. ¿Qué observas?

Ejercicio 2: conectando servicios

¡Vaya, no funciona! En la terminal aparece:

Can't connect to MySQL server on 'db' ([Errno -2] Name or service not known)

Claro: la aplicación intenta conectarse a una base de datos que no existe. Y no la vamos a meter en el mismo contenedor: un contenedor, un servicio.

Para el contenedor web con Ctrl + C.

Un contenedor de MariaDB

Vamos a lanzar MariaDB 12.0.2 a mano, con las variables que la imagen necesita para crear la base de datos y el usuario de uvlhub:

docker run -d --name mariadb_container \
  -e MARIADB_DATABASE=uvlhubdb \
  -e MARIADB_USER=uvlhubdb_user \
  -e MARIADB_PASSWORD=uvlhubdb_password \
  -e MARIADB_ROOT_PASSWORD=uvlhubdb_root_password \
  mariadb:12.0.2
  • -d: en segundo plano.
  • --name: un nombre para referirnos a él.
  • -e: variables de entorno dentro del contenedor.

Esto ya es más feo que una nevera por detrás: estamos tecleando unos valores que ya están en .env, incluidas contraseñas que se quedan en el historial de la shell. Si mañana cambia una, hay que acordarse de cambiarla en los dos sitios. Lo arreglaremos.

MariaDB tarda unos segundos en estar lista. Sigue su log hasta que veas ready for connections con port: 3306 (Ctrl + C para dejar de seguirlo):

docker logs -f mariadb_container

Entra en la base de datos:

docker exec -it mariadb_container mariadb -u root -puvlhubdb_root_password

Dentro del cliente:

SHOW DATABASES;
USE uvlhubdb;
SHOW TABLES;

uvlhubdb existe, porque se lo pedimos al crear el contenedor, pero no tiene tablas. Las crean las migraciones, y para lanzarlas necesitamos el contenedor de la aplicación. Sal con exit y arranca la aplicación, esta vez en segundo plano:

docker run -d --name web_app_container -p 5000:5000 --env-file .env uvlhub:dev
docker exec -it web_app_container flask db upgrade

¿Otra vez Can't connect to MySQL server on 'db'? ¿Con los dos contenedores funcionando?

Redes

Es lo que viste en el apartado 10 de la parte 1: en la red bridge por defecto no se resuelven nombres, así que nadie sabe quién es db. Vamos a crear una red para los dos:

docker network create uvlhub_network
docker network ls

Recreamos ambos contenedores dentro de ella. Al de MariaDB le damos, además, el alias db en esa red, que es el nombre que busca la aplicación:

docker rm -f mariadb_container web_app_container

docker run -d --name mariadb_container \
  --network uvlhub_network --network-alias db \
  -e MARIADB_DATABASE=uvlhubdb \
  -e MARIADB_USER=uvlhubdb_user \
  -e MARIADB_PASSWORD=uvlhubdb_password \
  -e MARIADB_ROOT_PASSWORD=uvlhubdb_root_password \
  mariadb:12.0.2

docker run -d --name web_app_container --network uvlhub_network \
  -p 5000:5000 --env-file .env uvlhub:dev

Fíjate en que no hemos publicado el puerto 3306: la aplicación llega a la base de datos por la red interna, sin pasar por tu máquina. Solo publicamos lo que tiene que ver alguien de fuera (el 5000).

Espera a que MariaDB esté lista (docker logs -f mariadb_container) y ahora sí:

docker exec -it web_app_container flask db upgrade

Abre http://localhost:5000. ¿Mejor?

Nota. Mira el título de la pestaña: "UVLHUB.IO(dev)" - Repository of..., con comillas. docker run --env-file toma cada valor de .env literalmente, comillas incluidas. Docker Compose, en cambio, las interpreta. Otro motivo para usarlo.

Ejercicio 3: persistencia

Simulemos que esto es producción y que el servidor se reinicia por un corte de luz:

docker restart mariadb_container web_app_container

Abre http://localhost:5000 en cuanto termine la orden y recarga varias veces. Lo normal es que las primeras peticiones fallen y que, tras unos segundos de F5... F5..., ¡funcione! Arrancado no significa preparado: el contenedor de MariaDB estaba en marcha, pero la base de datos todavía no aceptaba conexiones. Volveremos sobre esto.

Ahora un problema peor. Cuenta las tablas que crearon las migraciones, borra el contenedor de MariaDB, créalo otra vez y vuelve a contarlas:

docker exec mariadb_container mariadb -u uvlhubdb_user -puvlhubdb_password uvlhubdb -e 'SHOW TABLES'

docker rm -f mariadb_container
docker run -d --name mariadb_container \
  --network uvlhub_network --network-alias db \
  --env-file .env \
  mariadb:12.0.2

docker logs -f mariadb_container     # espera a "ready for connections ... port: 3306"
docker exec mariadb_container mariadb -u uvlhubdb_user -puvlhubdb_password uvlhubdb -e 'SHOW TABLES'

Las tablas han desaparecido. En producción, eso es perder la base de datos entera.

¿Adónde han ido? La imagen de MariaDB declara /var/lib/mysql como volumen, así que, si no le das uno, Docker crea un volumen anónimo con un nombre aleatorio. Míralos con docker volume ls. Los datos siguen en el del contenedor que borraste, pero ningún contenedor nuevo va a usar ese volumen: el nuevo estrena otro, vacío. (Esos volúmenes huérfanos se borran con docker volume prune.)

De paso, fíjate en --env-file .env: en lugar de repetir las variables con -e, se las pasamos desde el mismo archivo que usa la aplicación. Un único sitio para cada valor.

Un volumen para la base de datos

La solución es un volumen con nombre en /var/lib/mysql:

docker volume create mariadb_data
docker rm -f mariadb_container
docker run -d --name mariadb_container \
  --network uvlhub_network --network-alias db \
  --env-file .env \
  -v mariadb_data:/var/lib/mysql \
  mariadb:12.0.2

Espera a que esté lista y lanza las migraciones:

docker exec -it web_app_container flask db upgrade

Comprueba ahora que las tablas sobreviven a un docker rm -f mariadb_container seguido del mismo docker run.

Ejercicio 4: orquestación con Docker Compose

Hemos creado una red, un volumen y dos contenedores a mano, con órdenes largas que hay que teclear en el orden correcto. Docker Compose permite describir todo eso en un archivo. Primero, borra lo que hemos creado a mano para que no interfiera:

docker rm -f web_app_container mariadb_container
docker network rm uvlhub_network
docker volume rm mariadb_data
docker volume prune

Crea docker/docker-compose.yml:

name: uvlhub-tutorial

services:
  web:
    container_name: web_app_container
    build:
      context: ../
      dockerfile: docker/images/Dockerfile
    env_file:
      - ../.env
    expose:
      - "5000"
    depends_on:
      - db
    networks:
      - uvlhub_network

  db:
    container_name: mariadb_container
    image: mariadb:12.0.2
    env_file:
      - ../.env
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - uvlhub_network

volumes:
  db_data:

networks:
  uvlhub_network:

Todo lo que hacíamos a mano está aquí: el nombre de cada servicio, cómo se construye su imagen, sus variables, su volumen y su red. Además:

  • Dentro de la red de Compose, el nombre del servicio es su nombre de red: el servicio db responde como db sin necesidad de alias.
  • Las rutas son relativas a la carpeta del archivo (docker/), de ahí los ../.
  • name: fija el nombre del proyecto, que Compose usa como prefijo de todo lo que crea: la red será uvlhub-tutorial_uvlhub_network, el volumen uvlhub-tutorial_db_data y la imagen uvlhub-tutorial-web. Sin esa línea, el proyecto se llamaría como la carpeta (docker) y chocaría con cualquier otro proyecto que guarde su Compose en una carpeta docker/. El Compose de desarrollo de uvlhub hace lo mismo con name: uvlhub; aquí usamos otro nombre para no mezclar lo que creemos en el tutorial con el entorno real.

Levanta los servicios y, sin esperar, lanza las migraciones:

docker compose -f docker/docker-compose.yml up -d --build
docker exec -it web_app_container flask db upgrade

¿Qué ha pasado? Depende de lo rápido que hayas sido: lo normal es que falle y que, al repetirlo pasados unos segundos, funcione. ¿Por qué crees que ocurre esta aparente aleatoriedad?

Dependencias entre contenedores y entrypoints

El servicio web tiene depends_on: db, que significa que db se arranca antes que web. Pero cuidado, ahí está la trampa: servicio arrancado ≠ servicio preparado. MariaDB tarda unos segundos en inicializarse y aceptar conexiones.

uvlhub resuelve esto con un script, scripts/wait-for-db.sh, que intenta conectarse a MariaDB una y otra vez hasta que responde y entonces ejecuta la orden que recibe como argumentos (exec "$@"). Ábrelo y estúdialo.

Añade al servicio web del docker-compose.yml estas dos líneas:

    entrypoint: ["bash", "/workspace/scripts/wait-for-db.sh"]
    command: ["flask", "run", "--host=0.0.0.0", "--port=5000", "--reload", "--debug"]

Y reinicia los servicios:

docker compose -f docker/docker-compose.yml down
docker compose -f docker/docker-compose.yml up -d --build
docker logs -f web_app_container

(Ctrl + C deja de seguir el log sin parar el contenedor.) Verás un bucle infinito de:

/workspace/scripts/wait-for-db.sh: line 18: mariadb: command not found
MariaDB is unavailable - sleeping

La imagen de la aplicación no tiene el cliente de MariaDB que usa el script. Añádelo al Dockerfile en un RUN nuevo, después del pip install y antes del COPY . .:

RUN apt-get update && apt-get install -y --no-install-recommends mariadb-client \
    && rm -rf /var/lib/apt/lists/*

¿Por qué ahí y no en el primer RUN, junto a build-essential? Piensa en las capas: si cambias el primer RUN, Docker tiene que rehacer todas las capas que van detrás, incluida la del pip install, y volver a compilar python-sat. Añadiéndolo después, esa capa sale de la caché. Reconstruye y compruébalo: la salida debe mostrar el pip install como CACHED.

docker compose -f docker/docker-compose.yml down
docker compose -f docker/docker-compose.yml up -d --build
docker logs web_app_container

Esta vez el script espera a MariaDB (MariaDB is up - executing command) y arranca Flask.

Abre http://localhost:5000. ¿Tampoco? Haz docker ps y mira la columna PORTS: el contenedor web dice 5000/tcp, sin flecha, porque no publica ningún puerto. expose hace visible el puerto a los demás contenedores de la red, no a tu máquina. Para eso está ports, que hace la misma traducción que -p:

    ports:
      - "5000:5000"

Cambia expose por ports y, por última vez:

docker compose -f docker/docker-compose.yml up -d
docker exec -it web_app_container flask db upgrade

Abre http://localhost:5000. ¡Ahora sí! (Y sin comillas en el título.)

Una pregunta: ¿se te ocurre cómo evitar lanzar flask db upgrade a mano cada vez que levantas el entorno? Guárdate la respuesta para el ejercicio 6.

CMD, ENTRYPOINT y command

Al arrancar un contenedor siempre se ejecuta un único proceso principal. ENTRYPOINT, CMD y el command de Compose deciden cuál es y con qué argumentos:

  • CMD (en el Dockerfile): qué hacer por defecto si nadie dice otra cosa.
  • ENTRYPOINT (en el Dockerfile, o entrypoint en Compose): qué se ejecuta siempre. Si hay los dos, el CMD se le pasa como argumentos al ENTRYPOINT. Es lo que hace nuestro wait-for-db.sh: espera y después ejecuta (exec "$@") lo que recibe.
  • command (en Compose): sustituye al CMD del Dockerfile, pero no al ENTRYPOINT. Del mismo modo, entrypoint en Compose sustituye al ENTRYPOINT de la imagen.

La gran ventaja de definir entrypoint y command en el Compose es que puedes cambiar cómo arranca un servicio sin reconstruir la imagen. Es lo que hace uvlhub: arranca de una manera en desarrollo y de otra en producción.

Ejercicio 5: volumen de trabajo

Ya le hemos cogido el tranquillo a Docker Compose. Ahora vamos a trabajar como desarrolladores. Abre app/templates/base_template.html y cambia la línea 22:

<title>{{ FLASK_APP_NAME }} - Repository of feature models in UVL </title>

por:

<title>Hello world!</title>

Recarga http://localhost:5000 y mira el título de la pestaña. ¿Ha cambiado? ¿No? Vamos a reiniciar el contenedor:

docker restart web_app_container

¿Tampoco? Claro: al construir la imagen hicimos COPY . ., y la copia que hay dentro del contenedor es la de ese momento. Hay que reconstruir:

docker compose -f docker/docker-compose.yml up -d --build web

¡Ahora sí! Y ha sido rápido, porque solo ha cambiado la última capa (COPY . .). Pero... ¿reconstruir la imagen a cada cambio de una línea? Eso no es trabajar de forma ágil.

Un bind mount para el código

La solución es la del apartado 8 de la parte 1: montar la carpeta del proyecto dentro del contenedor, de forma que el contenedor vea siempre tu código. En el servicio web:

    volumes:
      - ../:/workspace

../ es la raíz del proyecto (recuerda que las rutas son relativas a docker/) y /workspace es el WORKDIR del Dockerfile. La coherencia entre el Dockerfile y el docker-compose.yml importa.

docker compose -f docker/docker-compose.yml up -d

Cambia ahora el título por cualquier otro texto y recarga. El cambio aparece al instante, y Flask (con --reload) recarga la aplicación cuando tocas un .py.

Cuidado con COPY y los bind mounts

Un bind mount se monta encima de la ruta del contenedor: todo lo que la imagen tuviera en /workspace queda oculto mientras el contenedor está en marcha. Con el bind mount, nuestro COPY . . final ya no aporta nada en desarrollo. Lo que sí hace falta es lo que se usa durante la construcción: requirements.txt para el pip install. Por eso uvlhub, en su Dockerfile.dev, copia solo unos pocos archivos.

Muy importante: los volúmenes y los bind mounts existen cuando el contenedor se ejecuta, no cuando se construye la imagen. Es un error común pensar que, porque el Compose define un bind mount, sus archivos están disponibles en el docker build. Es una fuente inagotable de problemas.

Nota. En Linux, el contenedor se ejecuta como root, así que los archivos que cree dentro de /workspace (una migración nueva, ficheros subidos, una feature generada con Rosemary) aparecerán en tu proyecto como propiedad de root. Si luego no puedes modificarlos o borrarlos, recupéralos con sudo chown -R $USER:$USER .

Ejercicio 6: distintas configuraciones de despliegue

Volvamos al punto de partida. Para y borra lo que has creado (-v borra también el volumen de la base de datos y --rmi local, la imagen que construyó este Compose) y recupera la configuración original:

docker compose -f docker/docker-compose.yml down -v --rmi local
rm -r docker
mv docker.bk docker
rm .dockerignore
git restore app/templates/base_template.html
git status

git status solo debería mostrar .env.manual.bk, la copia de tu .env de la instalación manual. Esta práctica ha sido engorrosa a propósito, para quitarle la magia a una orden que lo levanta todo a la primera. Eso está muy bien para desarrollar, pero no para aprender.

Ahora analiza lo que hay en docker/:

  • Un docker-compose para desarrollo (docker-compose.dev.yml) y tres para producción.
  • Seis Dockerfile en docker/images.
  • Cuatro entrypoints en docker/entrypoints.
  • Una carpeta nginx con configuraciones distintas para desarrollo y producción, y otra letsencrypt para los certificados.

Autoevaluación

Estudia cada archivo con calma e intenta responder:

  1. ¿Por qué en el docker-compose.dev.yml el servicio web usa expose y no ports? (Pista: nginx.)
  2. ¿Qué es NGINX y para qué sirve aquí?
  3. ¿Cuántos bind mounts tiene el servicio web en desarrollo? ¿Para qué sirve el de /var/run/docker.sock?
  4. ¿Por qué el servicio db sí publica el puerto 3306? ¿Lo necesita la aplicación?
  5. ¿Qué hace docker/entrypoints/development_entrypoint.sh que nosotros hacíamos a mano? ¿Qué diferencia hay con production_entrypoint.sh?
  6. Dockerfile.dev se construye en dos etapas (builder y la final). ¿Qué se queda en la primera y por qué? Cuando levantes el entorno de desarrollo, compara con docker image ls el tamaño de tu imagen uvlhub:dev del ejercicio 1 con el de uvlhub-web, que además lleva dentro el cliente de Docker.
  7. ¿Por qué Dockerfile.dev instala las dependencias en /opt/venv y no en /workspace? (Pista: el bind mount.)
  8. ¿Por qué el servicio web de producción monta varios bind mounts por separado en lugar de un volumen con todo el código?

Levanta el entorno de desarrollo de uvlhub

El docker-compose.dev.yml publica la base de datos en el puerto 3306 de tu máquina, y ahí ya está escuchando la MariaDB que instalaste en la instalación manual. Si no la paras, docker compose up fallará con:

failed to bind host port 0.0.0.0:3306/tcp: address already in use

Mientras trabajes con Docker, para la MariaDB local (para volver a la instalación manual: sudo systemctl start mariadb):

sudo systemctl stop mariadb

Y levanta el entorno siguiendo la instalación con Docker de la documentación de uvlhub:

docker compose -f docker/docker-compose.dev.yml up -d --build
docker ps

La primera vez tarda varios minutos: descarga las imágenes de Selenium, construye la de la aplicación (otra vez con python-sat) y el entrypoint aplica las migraciones y carga los datos de prueba. Síguelo con docker logs -f web_app_container y, cuando veas Running on http://127.0.0.1:5000, abre http://localhost (puerto 80, a través de NGINX).

Ojo. Si lo lanzaste antes de parar la MariaDB local y falló por el puerto 3306, haz primero docker compose -f docker/docker-compose.dev.yml down y luego vuelve a levantarlo. Si no, Compose reutiliza el contenedor de la base de datos que se quedó a medio crear, que arranca sin red: en el log de web_app_container verás un bucle de Unknown server host 'db' y en el navegador un 502 Bad Gateway.

Después, prueba los tests de interfaz con el Selenium Grid del entorno Docker, siguiendo la documentación de pruebas de interfaz. Ten en cuenta que en Docker se ejecutan y se visualizan de forma distinta que en local; estúdialo bien, porque te hará falta en el proyecto.

Ejercicio 7: el dev-container

Con el entorno de desarrollo en marcha, el código se ejecuta dentro del contenedor, pero tu VS Code sigue en tu máquina: no ve las librerías instaladas en la imagen y cada miembro del equipo lo tiene configurado a su manera. Para eso está el dev-container.

Abre .devcontainer/devcontainer.json. Fíjate en que no define una imagen nueva: reutiliza el docker-compose.dev.yml (dockerComposeFile), elige el servicio en el que se trabaja (web) y la carpeta (/workspace, la del bind mount), y fija las extensiones y la configuración de VS Code, incluido el intérprete de Python (/opt/venv/bin/python, donde Dockerfile.dev instala las dependencias).

Con la extensión Dev Containers instalada (en el aula ya lo está), abre el proyecto en VS Code y pulsa Ctrl + Shift + P (⌘ + Shift + P en macOS), escribe Dev Containers: Reopen in Container y elige la raíz del proyecto.

Cuando termine, abre una terminal en VS Code: estás dentro del contenedor web. Compruébalo:

pwd
which python
flask --version
rosemary --help

Al cerrar VS Code, el entorno se para ("shutdownAction": "stopCompose").

Cuando acabes la práctica puedes borrar la imagen del ejercicio 1, que ya no necesitas: docker image rm uvlhub:dev.

Comentarios finales

Cuanto más se parezca desarrollo a producción, menos sorpresas

En desarrollo es tentador usar configuraciones cómodas: el servidor de Flask con --reload, bind mounts, una base de datos vacía, logs en consola... Pero cuanto más se aleja ese entorno del de producción, más probable es que algo falle al desplegar. El código no falla por cambiar de servidor, sino porque los entornos se comportan de forma distinta:

  • En desarrollo usas SQLite y en producción MariaDB: aparecen incompatibilidades de SQL.
  • En desarrollo Flask sirve los archivos estáticos y en producción lo hace NGINX: cambian las rutas.
  • En desarrollo ejecutas flask run y en producción gunicorn: los hilos, los workers y la recarga funcionan distinto.

El ideal es que desarrollo, preproducción (staging) y producción sean idénticos salvo en las credenciales. Docker y Docker Compose te acercan a eso: las mismas imágenes base, y lo que cambia es el docker-compose (volúmenes, puertos, entrypoint...).

El servidor de Flask frente a Gunicorn

Flask trae un servidor de desarrollo muy útil para depurar: recarga el código automáticamente (--reload), muestra los errores con detalle en el navegador y es un único proceso. Pero no está pensado para producción: no gestiona bien muchas conexiones concurrentes ni controla procesos.

Ahí entra Gunicorn (Green Unicorn), un servidor WSGI de producción (WSGI es el estándar de comunicación entre aplicaciones web Python y servidores web): lanza varios workers en paralelo, atiende peticiones concurrentes y se integra fácilmente detrás de NGINX, que sirve el contenido estático y el HTTPS. Busca en docker/entrypoints/production_entrypoint.sh cómo arranca uvlhub en producción.