Este tutorial acompaña a la Práctica 4 (Contenedores, dev-containers y aislamiento) y tiene dos partes:
- 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.
- 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.sockpara 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-pluginydocker-compose-plugin. Después, añade tu usuario al grupodockercomo se indica arriba. - Arch (y derivadas como EndeavourOS):
sudo pacman -S docker docker-compose docker-buildxysudo 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:5000en lugar de5000:5000y abrehttp://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:
- Descarga la imagen
hello-worldde Docker Hub, porque no la tienes en local (Unable to find image 'hello-world:latest' locally). - Crea un contenedor a partir de esa imagen.
- 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
-imantiene abierta la entrada estándar y-tasigna una terminal; juntos te dan una consola dentro del contenedor.--rmborra el contenedor al terminar, para no acumular contenedores parados.bashes 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/bashosh. Los dos primeros son el mismo intérprete;/bin/bashda la ruta completa.shes un intérprete más básico que está en casi todas las imágenes, incluso en las que no traenbash(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 condocker 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.
RUNfrente aCMD.RUNse ejecuta durantedocker buildy su resultado queda grabado en la imagen.CMDse ejecuta durantedocker run, cada vez que arranca un contenedor. Solo cuenta unCMDporDockerfile(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 --volumesborra 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:
- ¿Qué diferencia hay entre una imagen y un contenedor? ¿Cuántos contenedores puedes crear a partir de una imagen?
- En el apartado 3 instalaste
curlen un contenedor y luego había desaparecido. ¿Dónde habría que ponerlo para que estuviera siempre? - ¿Por qué el contenedor de
hello-worldse para solo y el de NGINX no? - ¿Por qué un contenedor arranca mucho más rápido que una máquina virtual?
- ¿Qué diferencia hay entre
EXPOSEen elDockerfiley-pendocker run? - Si borras un contenedor, ¿qué pasa con lo que escribió en su capa de escritura? ¿Y en un bind mount? ¿Y en un volumen?
- ¿Por qué el
busyboxno encontraba aweben la red por defecto y enred-camposí? ¿Hizo falta publicar algún puerto? - ¿Por qué Flask tenía que escuchar en
0.0.0.0? - ¿Por qué en el
Dockerfilese copiarequirements.txtantes 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 elDockerfile.devde 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 carpetaapp/acaba en/workspace/app/. Como enhola-flask, va después delpip 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 --debugpara 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-filetoma cada valor de.envliteralmente, 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
dbresponde comodbsin 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 volumenuvlhub-tutorial_db_datay la imagenuvlhub-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 carpetadocker/. El Compose de desarrollo de uvlhub hace lo mismo conname: 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 elDockerfile): qué hacer por defecto si nadie dice otra cosa.ENTRYPOINT(en elDockerfile, oentrypointen Compose): qué se ejecuta siempre. Si hay los dos, elCMDse le pasa como argumentos alENTRYPOINT. Es lo que hace nuestrowait-for-db.sh: espera y después ejecuta (exec "$@") lo que recibe.command(en Compose): sustituye alCMDdelDockerfile, pero no alENTRYPOINT. Del mismo modo,entrypointen Compose sustituye alENTRYPOINTde 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 consudo 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-composepara desarrollo (docker-compose.dev.yml) y tres para producción. - Seis
Dockerfileendocker/images. - Cuatro entrypoints en
docker/entrypoints. - Una carpeta
nginxcon configuraciones distintas para desarrollo y producción, y otraletsencryptpara los certificados.
Autoevaluación
Estudia cada archivo con calma e intenta responder:
- ¿Por qué en el
docker-compose.dev.ymlel serviciowebusaexposey noports? (Pista:nginx.) - ¿Qué es NGINX y para qué sirve aquí?
- ¿Cuántos bind mounts tiene el servicio
weben desarrollo? ¿Para qué sirve el de/var/run/docker.sock? - ¿Por qué el servicio
dbsí publica el puerto 3306? ¿Lo necesita la aplicación? - ¿Qué hace
docker/entrypoints/development_entrypoint.shque nosotros hacíamos a mano? ¿Qué diferencia hay conproduction_entrypoint.sh? Dockerfile.devse construye en dos etapas (buildery la final). ¿Qué se queda en la primera y por qué? Cuando levantes el entorno de desarrollo, compara condocker image lsel tamaño de tu imagenuvlhub:devdel ejercicio 1 con el deuvlhub-web, que además lleva dentro el cliente de Docker.- ¿Por qué
Dockerfile.devinstala las dependencias en/opt/venvy no en/workspace? (Pista: el bind mount.) - ¿Por qué el servicio
webde 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 downy 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 deweb_app_containerverás un bucle deUnknown server host 'db'y en el navegador un502 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 runy en produccióngunicorn: 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.