Día 47 - Dockerizar una app Flask en Python
Problema / Desafío
El equipo de Nautilus tiene una app Python (Flask) que debe correr en contenedor sobre App Server 1 (stapp01). El código y dependencias ya están en /python_app/src/. Hay que:
- Crear un
Dockerfileen/python_app/que use cualquier imagen oficial de Python como base - Instalar las dependencias declaradas en
requirements.txt - Exponer el puerto
8087(donde escucha Flask) - Definir el
CMDpara ejecutarserver.py - Construir la imagen como
nautilus/python-app - Correr un contenedor
pythonapp_nautilusmapeando8094(host) →8087(contenedor)
Validación: curl localhost:8094 debe devolver Welcome to xFusionCorp Industries!.
Conceptos clave
Variantes de la imagen oficial de Python
La imagen python en Docker Hub no es una sola — son varias familias con tags como python:<v>-alpine, python:<v>-slim, python:<v>-bookworm, etc.
| Variante | Base | Tamaño aprox. | Cuándo usarla |
|---|---|---|---|
python:3.12 (default) |
Debian completo | ~1 GB | Compat máxima, builds que necesitan compiladores y libs del SO |
python:3.12-slim |
Debian mínimo (sin doc, sin extras) | ~150 MB | Producción cuando necesitas glibc pero no todo Debian |
python:3.12-alpine |
Alpine Linux + musl libc | ~50 MB | Apps puras Python o con wheels precompilados — muy ligera |
python:3.12-bookworm |
Debian 12 explícito | ~1 GB | Cuando necesitas pinear la versión de Debian para reproducibilidad |
Cuidado con alpine + paquetes con C extensions: alpine usa musl en vez de glibc. Paquetes de Python que dependen de wheels precompilados para manylinux (NumPy, pandas, psycopg2, cryptography…) no instalan directo y obligan a compilar en build, lo que aumenta el tiempo y a veces requiere instalar gcc, musl-dev, headers, etc. Para esta app (solo Flask), alpine es ideal.
WORKDIR: directorio de trabajo dentro de la imagen
WORKDIR /app hace dos cosas:
- Crea el directorio si no existe.
- Cambia el directorio activo para todas las instrucciones siguientes (
COPY,RUN,CMD).
Sin WORKDIR, cada COPY o RUN necesitaría rutas absolutas (COPY ./src/ /app/, RUN cd /app && pip install...). Con WORKDIR, las rutas relativas funcionan limpiamente.
EXPOSE es documentación, no publicación
Una confusión clásica: EXPOSE 8087 no abre el puerto ni lo publica al host. Es metadata informativa que dice "este contenedor escucha aquí". Para publicar de verdad, hay que pasar -p al docker run:
Sirve para herramientas que leen la metadata (como docker run -P, que mapea automáticamente todos los EXPOSE) y como contrato visible de qué puerto debe usar el operador.
CMD exec form vs shell form
# Exec form (recomendada) — JSON array
CMD ["python3", "/app/server.py"]
# Shell form — string que se ejecuta vía /bin/sh -c
CMD python3 /app/server.py
Diferencias:
- Exec form corre el comando directamente como PID 1. Las señales (
SIGTERM,SIGINT) llegan al proceso real. Esto es lo que quieres para quedocker stopapague Flask limpiamente en vez de matarshy dejar al hijo huérfano. - Shell form envuelve el comando en
/bin/sh -c. PID 1 essh, no Python. Las señales no se propagan bien por default. La ventaja es que puedes usar variables de entorno y operadores de shell (&&,|,>).
Flask dev server vs producción
Cuando Flask arranca con app.run(debug=True), levanta su dev server integrado, que:
- Recarga código al cambiar archivos
- Muestra debugger interactivo en errores
- No es para producción — single-thread, sin manejo robusto de concurrencia, debugger expone shell remota
El propio Flask lo grita en los logs:
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
Para producción real se usa gunicorn, uwsgi, o uvicorn (para apps async). Para este lab el dev server está OK porque la consigna es solo "ejecutar server.py".
Pasos
- Inspeccionar
requirements.txtyserver.pypara entender la app - Elegir la imagen base de Python
- Escribir el Dockerfile
- Construir la imagen con tag
nautilus/python-app - Correr el contenedor con port mapping
- Validar con
curl
Comandos / Código
1. Inspeccionar la app
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello():
return "Welcome to xFusionCorp Industries!"
if __name__ == "__main__":
app.config['TEMPLATES_AUTO_RELOAD'] = True
app.run(host='0.0.0.0', debug=True, port=8087)
host='0.0.0.0' es importante: Flask escuchando en 127.0.0.1 solo aceptaría conexiones del propio contenedor; con 0.0.0.0 acepta desde cualquier interfaz, lo que permite que el port mapping de Docker funcione.
2. Dockerfile
FROM python:3.12.13-alpine
WORKDIR /app
COPY ./src/ .
RUN pip3 install -r requirements.txt
EXPOSE 8087
CMD ["python3", "/app/server.py"]
3. Construir la imagen
62.5 MB es notablemente liviana — la base alpine + Python + Flask suman menos que la imagen base de Debian sola. Ese es el valor de elegir alpine cuando se puede.
4. Ejecutar el contenedor (foreground primero, para ver logs)
* Serving Flask app 'server'
* Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment...
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:8087
* Running on http://172.12.0.2:8087
* Debugger is active!
* Debugger PIN: 126-068-238
Foreground es útil para confirmar que arranca sin errores. Ctrl+C lo detiene y libera la terminal.
5. Re-ejecutar en detached
# Quitar el contenedor anterior primero (sino, conflicto de nombre)
docker rm pythonapp_nautilus
docker run --name pythonapp_nautilus -p 8094:8087 -d nautilus/python-app
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c0baab2eb461 nautilus/python-app "python3 /app/server…" 3 seconds ago Up 2 seconds 0.0.0.0:8094->8087/tcp, :::8094->8087/tcp pythonapp_nautilus
6. Validar la app
App respondiendo correctamente — el port mapping 8094 → 8087 funciona y Flask está sirviendo la ruta /.
Optimización: aprovechar el cache de capas para pip install
El Dockerfile actual hace:
COPY ./src/ . # copia TODO (código + requirements.txt)
RUN pip3 install -r requirements.txt # instala deps
El problema: cada vez que modificas el código (por ejemplo, cambias server.py), Docker invalida la capa del COPY — y por consecuencia también la capa del RUN pip3 install. Tienes que volver a descargar e instalar Flask aunque las dependencias no hayan cambiado.
La regla del cache de Docker: una capa se invalida si su input cambia, y todas las capas siguientes también. Para evitar reinstalar deps en cada build, hay que separar lo que cambia poco (deps) de lo que cambia mucho (código):
FROM python:3.12.13-alpine
WORKDIR /app
COPY ./src/requirements.txt .
RUN pip3 install -r requirements.txt
COPY ./src/server.py .
EXPOSE 8087
CMD ["python3", "/app/server.py"]
Trade-off: dos
COPYseparados son una línea más de Dockerfile, pero ahorran segundos (o minutos en proyectos con muchas deps) en cada build de iteración. Para imágenes conpandas,tensorflow, etc., la diferencia es enorme — esa es la razón por la que el patrón es estándar en el ecosistema Python.
Troubleshooting
| Problema | Solución |
|---|---|
pip: command not found durante el build |
La imagen base no incluye pip — usar python:<v>-alpine o python:<v>-slim (no python:<v>-alpine-bare o variantes minimal) |
error: command 'gcc' failed: No such file or directory al instalar wheels en alpine |
Paquete necesita compilar — agregar RUN apk add --no-cache gcc musl-dev <headers> antes del pip install, o cambiar a python:<v>-slim |
curl: (52) Empty reply from server |
Flask escuchando en 127.0.0.1 en vez de 0.0.0.0 — verificar el app.run(host=...) |
Bind for 0.0.0.0:8094 failed: port is already allocated |
Otro contenedor o proceso usa el 8094 — docker ps o ss -tlnp \| grep 8094 |
El contenedor inicia pero docker stop tarda 10s en detenerlo |
CMD está en shell form (CMD python3 ...) — Python no recibe SIGTERM y Docker mata después del timeout. Cambiar a exec form (CMD ["python3", "..."]) |
| Cambié el código pero el contenedor sigue mostrando lo viejo | Reconstruir la imagen (docker build -t ...) y recrear el contenedor — la imagen es inmutable, no toma cambios del host después del build |