domingo, 20 de junio de 2021

Flask 22. Desplegando una aplicación de Flask mediante un contenedor de Docker.


Esquema de funcionamiento de Docker



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.


directorio de trabajo de la aplicación





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.

código del archivo Dockerfile



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 


imagen de Python en la página de Docker Hub



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.


distintas versiones de Python en Docker Hub

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

imagenes de Docker instaladas localmente, flask22 y Python

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:

página inicial del proyecto mostrada en un navegador



Para ver los contenedores que se están ejecutando podemos teclear:

src $ sudo docker ps

salida por pantalla de una imagen de docker



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:


página de inicio de PhpmyAdmin




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:

$ docker login
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.






No hay comentarios:

Publicar un comentario