Desplegando una aplicación de Flask mediante un contenedor de Docker.
Docker nos permite utilizar imágenes para crear contenedores o procesos. Estos
se basan en una tecnología de virtualización bastante ligera en el uso de
recursos, que nos permite que una aplicación junto con sus dependencias y
configuración, se ejecuten en un completo aislamiento, pero sin la necesidad
de utilizar una solución de virtualización completa, del tipo máquina virtual,
que necesitan muchos más recursos, y a veces puede tener una degradación
significativa en el rendimiento de la máquina que hace de host.
En una máquina que hace de host de los containers, se pueden ejecutar muchos
de ellos, todos compartiendo el kernel del host y su hardware. Esto contrasta
con las máquinas virtuales que deben emular un sistema completo, con su cpu,
su disco, otra hardware, kernel etc.
A pesar de tener que compartir el Kernel es aislamiento de un contenedor es
bastante alto. Un contenedor tiene su propio sistema de archivos y puede
basarse en un sistema de archivos distinto al que se ejecuta en la máquina
host del contenedor. Por ejemplo pueden usarse contenedores en Ubuntu en un
host que ejecute Fedora, o viceversa. Aunque el origen es de linux, gracias a
la virtualización también se pueden ejecutar contenedores de linux en un host
de Windows o Mac
Instalando Docker.
Para poder trabajar con contenedores de Docker lo primero es tenerlo instalado
en nuestro ordenador. Hay instaladores para Windows, Mac o Linux
disponibles en
su página web. Es escoger el instalador que necesites y seguir los pasos que te dicen en
el manual para la instalación. Una cosa a puntualizar es que si instalas el
motor de docker sin más, para ejecutar los comandos en linux necesitaras
ejecutar los comandos con privilegio de superusuario (sudo). Si te incordia
tener que estar ejecutando sudo para cualquier comando y quieres ejecutarlo de
forma normal entonces tienes que seguir en las instrucciones el paso que pone
"
post-instalation step in linux"
Una vez que tenemos instalado Docker en nuestro ordenador podemos verificar
que todo ha ido correctamente tecleando la siguiente instrucción en una
ventana de terminal:
$ sudo docker version
Client: Docker Engine - Community
Version: 20.10.7
API version: 1.41
Go version: go1.13.15
Git commit: f0df350
Built: Wed Jun 2 11:56:38 2021
OS/Arch: linux/amd64
Context: default
Experimental: true
Server: Docker Engine - Community
Engine:
Version: 20.10.7
API version: 1.41 (minimum version 1.12)
Go version: go1.13.15
Git commit: b0f5bc3
Built: Wed Jun 2 11:54:50 2021
OS/Arch: linux/amd64
Experimental: false
containerd:
Version: 1.4.6
GitCommit: d71fcd7d8303cbf684402823e425e9dd2e99285d
runc:
Version: 1.0.0-rc95
GitCommit: b9ee9c6314599f1b4a7f497e1f1f856fe433d3b7
docker-init:
Version: 0.19.0
GitCommit: de40ad0
Construyendo una imagen de contenedor para nuestra aplicación.
Como en el capítulo anterior usaremos el código de la aplicación
del capítulo 22. El primer paso para crear un contendor para la aplicación es crear una
imagen para él. Una imagen de contenedor es como una plantilla que se usa
para crear un contenedor. Contiene una representación completa del sistema
de archivos del contenedor, junto con varias
configuraciones relacionadas con la red, las opciones de inicio
etc.
Un muy buen sitio para empezar a ver como funciona Docker la encontrarás en
este sitio de youtube, "FATZ, Docker, Curso Práctico para principiantes (desde
linux)"
https://www.youtube.com/watch?v=NVvZNmfqg6M
La forma más sencilla de crear una imagen de contenedor para cualquier
aplicación es iniciar un contenedor ya existente del sistema operativo
deseamos utilizar (ubuntu, Debian, Fedora, etc), conectarnos a ese contenedor
usando un shell de bash, copiar los archivos fuente de la aplicación e
instalar todas las dependencias. Después de instalar todo, podemos tomar una
instantánea del contenedor y esa instantánea se convertirá en la imagen que
queremos. Sin embargo esto no es muy práctico ya que no es conveniente tener
que instalar manualmente la aplicación cada vez que necesitemos crear una
nueva imagen.
En su lugar generaremos la imagen de la aplicación a través de un script. El
comando que crea imágenes de un contenedor a través de un script es "sudo
docker build". Este comando lee y ejecuta las instrucciones de compilación de
un archivo llamado Dockerfile, que tendremos que crear. Este archivo, Dockerfile, es básicamente una especie de script de instalación que ejecuta los pasos
de instalación para implementar la aplicación, además de algunas
configuraciones especificas del contenedor.
Empecemos.
Lo que tenemos que tener claro es que una cosa es la imagen y otra el
contenedor. Lo primero que vamos a crear es la imagen de la aplicación y
luego veremos como ejecutar el contenedor.
Creamos un directorio donde copiamos todos los archivos del proyecto. Yo le
llamaré src.
Entramos dentro del mismo y creamos un archivo llamado Dockerfile. (En el
código fuente de la aplicación este ya existe)
Cada línea de Dockerfile es una instrucción.
En la primera línea el comando FROM escribimos el nombre del módulo que queremos utilizar en este caso el
nombre del módulo de Python. La idea es que comencemos con una imagen que ya
exista, agreguemos o cambiemos algunas cosas y terminemos con la imagen
que permita ejecutar nuestra aplicación. Si visitamos la página de
Docker Hub y en
su buscador tecleamos Python, y
entramos en él
veremos que existen múltiples versiones en base principalmente al sistema
operativo base utilizado por la imagen. Nosotros usaremos la que está señalada
abajo.
La razón de utilizar la distribución Alpine Linux, en lugar de otras
distribuciones más populares como Debian o Ubuntu, es por su pequeño tamaño.
Al final en esta instrucción lo que le estamos diciendo a Docker es que
queremos utilizar el módulo de Python y exactamente queremos utilizar la
versión 3.9-alpine (para utilizar cualquier otra es copiar y pegar el nombre)
El comando WORKDIR le va a
decir en que directorio va a estar mi proyecto dentro del contenedor. Puede
ser uno que ya exista y sino existe como es el caso de este ejemplo lo creará.
Le diremos que cree la carpeta /code y es allí donde se van a ejecutar los
siguientes comandos.
Para que nuestra aplicación se ejecute es necesario que le pasemos una
variable de entorno que le diga al contenedor cual es el archivo que tiene que
arrancar. Para establecer variables de entorno dentro de Dockerfile
simplemente utilizamos el comando
ENV y establecemos el nombre y
el valor de la variable que queramos crear. De esta forma utilizamos la
variable de entorno:
ENV FLASK_APP inicio.py
para crear una variable de entorno que le diga al Docker que el programa que
ejecute al comenzar sea inicio.py, que es el archivo principal de la
aplicación.
A mayores definimos también la variable de entorno:
ENV FLASK_RUN_HOST 0.0.0.0
para decirle al servidor de Flask que nuestro proyecto sea visible y accesible
a través de nuestra red. Ya que sino solo estaría accesible a través del
localhost.
La siguiente instrucción
RUN apk add --no-cache gcc musl-dev linux-headers
nos servirá para instalar algunos módulos para que Python funcione más
rápidamente en la versión Alpine de Linux. (apk es el administrador de
paquetes de Alpine)
Nota:
RUN apk add build-base
Está puesta porque existe una incompatibilidad entre cython y python 3.7 que
provoca un error al tratar de instalar el modulo "Error greenlet docker
build". Esto se podría solucionar utilizando una imagen de python inferior
como FROM python:3.6-stretch
que funciona bien en distribuciones más pesadas, pero como yo quiero utilizar la versión alpine que es más ligera este
comando soluciona el error. Para más información ver
este link.
Necesitamos instalar todos los módulos o bibliotecas que se necesitan para
que funcione el proyecto y que se encuentran dentro del archivo
requirements.txt. Para ello le decimos en el Dockerfile, que queremos que
copie el archivo requierements.txt que está el el directorio scr, donde
también está el archivo Dockerfile, al contenedor, dentro del directorio de
trabajo y queremos que también lo llame requirements.txt. Para ello es que
añadimos:
COPY requirements.txt requirements.txt
Es como copiar de origen a destino, pero no añadimos rutas porque el origen
está dentro del directorio donde también está el Dockerfile y el destino
sino especificamos nada, por defecto entiende que es el directorio de
trabajo que hemos especificado antes (/code)
Una vez copiado queremos que ejecute el comando necesario para que el lea
el archivo requirements.txt (que ahora lo tiene en el directorio /code) e
instale los módulos necesarios para que funcione el proyecto, uno a
uno:
RUN pip install -r requirements.txt
Una vez que haya finalizado la instalación vamos a copiar lo más importante.
Todo el código con los archivos que conforman el proyecto y que están están
todos dentro de la carpeta src. Para ello le decimos a Docker que copie todo
lo que esta dentro del directorio src en donde estamos, a la carpeta de
trabajo.
COPY . .
Para finalizar tenemos que ejecutar el servidor gunicorn que servirá nuestra
aplicación de Flask. Lo hubiésemos podido ejecutar con el comando:
CMD [ "gunicorn", "-b", "0.0.0.0:5000", "inicio:app" ]
En donde "0.0.0.0:5000" es el puerto donde se servirá la aplicación dentro del
contenedor y "inicio:app" en donde inicio hace referencia a nuestra aplicación
principal de python que es el archivo inicio.py y app es el nombre del WSGI
que ejecutamos [app=Flask(__name__)]
Pero yo he escogido la opción de hacerlo a través de un ENTRYPOINT que es como
un pequeño archivo o script ejecutable que le dirá a Docker lo que quiero
ejecutar. Se puede poner el nombre que se quiera al archivo, yo le llamo
boot.sh. Al estar en linux para asegurarme de que se pueda ejecutar le doy
permisos de ejecución con:
RUN chmod +x boot.sh
El comando:
EXPOSE 5000
configura el puerto que este contenedor usará para su servidor. Este es
necesario para que Docker pueda configurar la red en el contenedor de la
manera más adecuada. Elegí el puerto 5000 que es el standar de Flask pero
podría haber sido cualquier otro.
Y para finalizar le digo cual va a ser el ENTRYPOINT:
ENTRYPOINT ["./boot.sh"]
que es el comando o archivo predeterminado que debe ejecutarse cuando se
inicia el contenedor. Este es el comando que iniciará el servidor web de
nuestra aplicación usando gunicorn para ello. En este ejemplo sencillo preferí
usar una secuencia de comandos separada para esto aunque como ya comente
tambien se podría haber hecho sin este script y usando un comando CMD.
boot.sh: Docker container start-up
script.
#!/bin/sh
# Se podrían haber usado otros comandos a ejecutar como
# flask db upgrade u otros.
exec gunicorn -b :5000 inicio:app
En resumen:
Lo que va hace lo anterior es instalar Python en un contendor, luego creará
una carpeta llamada /code, configurará las variables que necesita Flask para
funcionar, como cual es el archivo principal y cual es el host, y luego
ejecutará los módulos para que podamos ejecutar nuestro proyecto. Finalmente
copiamos el código y ejecutamos la aplicación.
Con todo esto ya estamos listos para crear la imagen de nuestro proyecto. (Es
como las isos de las instalaciones en windows). Luego a partir de esa imagen
podremos ejecutar múltiples instancias de ese contenedor con nuestra
aplicación en cualquier ordenador.
Para generar una imagen la instrucción a teclear es:
src $ sudo Docker build -t flask22 .
Tenemos que estar dentro del directorio que contiene el archivo Dockerfile. Si
utilizamos el parámetro -t (de tag, etiqueta en ingles) podemos especificar el
nombre de nuestra imagen. Si no la utilizamos nos asignará un nombre de imagen
por defecto.
Después de que realice todo los pasos acabará creando una imagen de nuestro
proyecto. Si tecleamos
src $ sudo Docker images
vemos que tenemos dos imágenes en nuestro equipo. Una que es la de nuestra
aplicación y otra python que la ha tenido que descargar en local porque era
necesaria para crear la nuestra.
Ejecutando un contenedor.
Con una imagen ya creada, ahora ya podemos ejecutar la versión de contenedor
para la aplicación. Para ver que todo funciona bien vamos a ejecutar el
contenedor a partir de la imagen creada. Tecleamos
src $ sudo docker run -d -p 8000:5000 --name prueba --rm flask22:latest
Que le está diciendo a Docker que ejecute un contenedor, al que hemos llamado
prueba usando el parámetro --name, que lo haga en segundo plano con el parámetro
-d ya que sino nos bloquearía el prompt del terminal. La opción -p asigna
puertos del contendor a puertos del host. El primer valor, a la izquierda de los
dos puntos, es el puerto donde se verá el programa en nuestro ordenador host y
el de la derecha es el puerto dentro del contenedor. En el ejemplo anterior se
asigna el puerto 5000 que es el que usamos en gunicorn y que se ejecuta dentro
del contenedor al puerto 8000 en el host, por lo que para ver la aplicación
tendremos que acceder al puerto 8000 aunque internamente el contenedor lo esté
ejecutando en el puerto 5000. La opción --rm borrará el contenedor una vez que
este terminado. Si bien esta opción no suele ser necesaria, los contenedores que
terminan o que se interrumpen generalmente ya no son necesarios, por lo que se
pueden eliminar automáticamente. El último argumento es el nombre de la imagen
en la que se basa el contenedor y (opcional) después de 2 puntos la etiqueta que
se usará para el contenedor .
Si ahora abres el navegador y vas al localhost:8000 verás como funciona el
proyecto:
Para ver los contenedores que se están ejecutando podemos teclear:
src $ sudo docker ps
Para parar el contenedor se usa:
src $ sudo docker stop prueba
o también se puede usar los 3 primeros dígitos del id del container. Por
ejemplo para volver a ejecutarlo tecleamos (en tu caso los 3 primeros dígitos
de identifiquen a tu container, sino hubiéramos usado la opción --rm):
src $ sudo docker start 416
que iniciará el contenedor con las mismas opciones con la que lo creamos.
Cada vez que realicemos cambios en la aplicación podemos actualizar la imagen
del contenedor ejecutando nuevamente el comando de compilación.
Si hubiésemos querido añadir variables de entorno pero no metiéndolas en el
archivo Dockerfile al compilar la imagen lo cual las haría accesibles para
todos los contenedores, sino solo para un contenedor específico, en tiempo de
ejecución, se puede hacer usando el parámetro u opción -e. Un ejemplo
imaginario podría ser por ejemplo:
sudo docker run -d -p 8000:5000 --name prueba --rm -e SECRET_KEY=mi clave secreta \
-e MAIL_SERVER=smtp.gogle.com flask22:latest
Usando servicios de contenedores de Terceros.
La versión de contenedor de nuestra aplicación funciona bien, pero hasta
ahora estamos usando para guardar los datos una base de datos sqlite que se
apoya en un archivo que se guarda en el disco. ¿Qué crees que le sucederá a
ese archivo cuando se detenga y elimine el contenedor? Pues que como el
archivo está dentro del contenedor, el archivo de sqlite desaparecerá.
Podríamos usar la opción -v para crear un enlace o volumen entre un
directorio del host y el directorio del contenedor que contiene el archivo
de la base de datos pero vamos a hacerlo de otro modo.
El sistema de archivos de un contenedor es efímero, lo que significa que
desaparece cuando lo hace el contenedor. Para arreglar esto vamos a utilizar
un contendor a mayores, una base de datos Mariadb
y luego la línea de comandos que inicia el contenedor de nuestra aplicación
será un poco más larga con opciones que nos permitan acceder a este nuevo
contenedor.
Añadiendo un contenedor de Mariadb
Como otros muchos productos y servicios, Mariadb o también Mysql tienen
imágenes de contenedores públicos disponibles en el registro de Docker. Al
igual que el contenedor de nuestra aplicación, Mariadb se basa en variables
de entorno que deben pasarse al comando que se encarga de su ejecución en
Docker (contraseña, nombre de la base de datos, etc). Utilizaremos la imagen
oficial que podemos encontrar en esta dirección:
https://hub.docker.com/_/mariadb
Si recordáis el proceso de configurar Mariadb para nuestro proyecto que se
encuentra en el
apéndice del capítulo 20
es bastante laborioso, que no complicado. Sin embargo utilizando los
contenedores de Docker es un juego de niños en comparación. Aquí el comando de
Docker que inicia un servidor de Mariadb con una persistencia de datos es:
$ docker run -d \
--name mariadbc \
-p 3306:3306 \
--rm \
-e MYSQL_RANDOM_ROOT_PASSWORD=yes \
-e MYSQL_DATABASE=mibase \
-e MYSQL_USER=usuario \
-e MYSQL_PASSWORD=password \
-v /home/usuario/src/base_de_mariadb:/var/lib/mysql \
mariadb
¡Y ya está ! En cualquier máquina que tenga Docker instalado, podemos
ejecutar el comando anterior y tendremos un servidor Mariadb completamente
instalado, con una contraseña de root generada aleatoriamente, una nueva
base de datos llamada "mibase" y un usuario nuevo "usuario" que está
configurado con permisos completos para acceder a la base de datos. Ten en
cuenta que debemos poner un valor adecuado para la contraseña en la variable
de entorno MYSQL_PASSWORD.
Lo interesante, además, es que hemos creado una vinculación (--volume o -v)
que no es ni más ni menos que se creará una copia de lo que en el
contenedor está en /var/lib/mysql (donde se guardan las bases de datos
en mysql o mariadb) en nuestro directorio de trabajo
/home/usuario/scr/base_de_mariadb y ambos directorios estarán sincronizados (tienes que poner la ruta de la carpeta
de tu proyecto. No importa que no exista la carpeta aún porque el comando
la crea. Puedes obtener la ruta con el comando
pwd en linux)
Y lo más interesante es que cualquier modificación que hagas en un archivo
dentro de cualquiera de las dos carpetas queda automáticamente reflejado en
la otra. De esta forma si cerramos el contenedor se guardarán los datos para
la próxima vez en el disco local del host.
Y ahora ya podemos ejecutar nuestra aplicación de nuevo, pero esta vez con
un enlace al contenedor de la base de datos para que ambos puedan
comunicarse a través de la red.
src $ docker run --name test1 \
-d -p 8000:5000 \
--rm \
--link mariadbc \
-e DATABASE_URL=mariadb+pymysql://usuario:password@mariadbc/mibase \
flask22
El argumento -d es para que el programa se ejecute en segundo plano.
El argumento -p 8000:5000 le dice a Docker que lo que internamente esta
funcionando en el puerto 5000 nosotros podamos usarlo a través del puerto
8000 del host.
El argumento --rm es para que el contedor se borre al cerrarlo.
El argumento --link le dice a Docker que haga que otro contenedor, en este caso el de
mariadbc, este disponible para este. Con el enlace entre los dos
contenedores establecido puedo configurar la variable de entorno
DATABASE_URL que es el conector entre mariadb y el paquete SQLAlchemy de
Python.
Con esto ya tenemos una base de datos funcionando y una aplicación de flask
que está conectada a esa base de datos. Sin embargo nuestra base de datos de
mariadb, "mibase", está vacía, no tiene ninguna tabla porque en los otros
capítulos estaba funcionando con una base sqlite. Sin embargo en el código
ya está preparada con la librería de python flask-migrate para realizar la
migración. Lo primero que tenemos que hacer es entrar en el shell del
contenedor test1 para realizar unas pocas instrucciones. (También hubieramos
podido usar las próximas dos instrucciones en local y la tercera meterla en
el ENTRYPOINT).
1º Ejecutamos el shell del contenedor test1:
src $ docker exec -it test1 sh
y una vez dentro inicializamos la migración:
/code # flask db init
Creating directory /code/migrations ... done
Creating directory /code/migrations/versions ... done
Generating /code/migrations/alembic.ini ... done
Generating /code/migrations/README ... done
Generating /code/migrations/env.py ... done
Generating /code/migrations/script.py.mako ... done
Please edit configuration/connection/logging settings in '/code/migrations/alembic.ini' before proceeding.
2º Realizamos la primera migración:
/code # flask db migrate -m "docker mariadb"
INFO [alembic.runtime.migration] Context impl MariaDBImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'usuario'
Generating /code/migrations/versions/a9939dd69617_docker_mariadb.py ... done
3 Finalizamos aplicando los cambios.
/code # flask db upgrade
INFO [alembic.runtime.migration] Context impl MariaDBImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> a9939dd69617, docker mariadb
Podemos salir del shell tecleando:
exit
Ya está todo. Como nuestro contenedor tiene persistencia todo esto quedará ya
guardado en el directorio local (acuérdate que funciona en ambos lados como un
espejo: ordenador local <-> Contenedor).
Si quieres, para seguir probando puedes usar un contenedor de phpmyadmin para
ver y gestionar la base de datos:
src $ docker run -d --rm \
--name phpmyadminc \
--link mariadbc \
-e PMA_HOST=mariadbc \
-p 8080:80 \
phpmyadmin/phpmyadmin
y entrando en el navegador en el local host a traves del puerto 8080 y usando
el usuario y contraseña con la que creasteis el contenedor de mariadb podemos
gestionar la base de datos:
Así de sencillo.
Ahora solo queda probar el probar nuestra aplicación varias veces
introduciendo varios usuarios, cerrando el contenedor y volviéndolo a ejecutar
de nuevo para ver que no se ha perdido la información.
El registro de contenedores de Docker.
Ahora tenemos la aplicación completa en funcionamiento en Docker, usando
dos contenedores, de los cuales uno proviene de una imagen de terceros que
es de acceso público (mariadb). Si quieres que las imágenes que tu crees
estén disponibles para otros, tenemos que enviarlas al registro de Docker
desde donde serán accesibles para todo el mundo.
Para acceder al registro de Docker tenemos que ir a la siguiente
dirección https://hub.docker.com y crearnos una cuenta. Asegúrate de elegir un nombre
de usuario que te guste ya que se utilizará en todas las imágenes que
publiques.
Una vez que estés registrado, para acceder a tu cuenta desde la línea de
comandos tenemos que iniciar sesión usando el siguiente comando:
Si has seguido las instrucciones de este capítulo, seguramente tendrás una
imagen llamada flask22 que está guardada físicamente en el ordenador. Para
poder subir esta imagen al archivo de docker es necesario cambiarle el nombre
para que incluya nuestra cuenta de usuario. Esto se hace con el comando de
Docker, tag:
$ docker tag flask22:latest <nombre_usuario_en_docker>/flask22:latest
* la etiqueta es opcional si no la pones por defecto te asigna la etiqueta "latest"
Otra forma de hacer esto es, cuando creamos originalmente la imagen (docker build) llamarla directamente usando esa sintaxis nombre_usuario/nombre_imagen:[etiqueta]
Una vez que la tenemos renombrada para que nuestro nombre de usuario de Docker aparezca antes del nombre de la imagen, la subimos a Docker Hug con:
$ docker push <nombre_usuario_en_docker>/flask22:latest
Ahora
la imagen está disponible públicamente
y podemos documentar como instalarla y ejecutarla desde el registro de
Docker de la misma manera que lo hace la imagen de Mariadb y otras
muchas.
Despliegue de aplicaciones que estén en contenedores.
Una de las mejores cosas de tener tu aplicación ejecutándose en
contenedores de Docker, es que una vez que los hayamos probado en un entorno
local, podemos llevarlo a cualquier plataforma que ofrezca soporte para
Docker (Digital Ocean, Linode,
Amazon lightsail, Google Clouds,
Microsoft Azure,
IBM cloud y otros). Incluso la oferta más barata de cualquiera de ellos es
suficiente para ejecutar varios contenedores de Docker.