---
title: Introducción a Flask
tags: flask, aso-git
---
# Servicios web con Flask, docker y docker-compose
En esta práctica vamos a introducir un framework muy popular para la programación de servicios web con Python llamada `Flask`. Aprenderemos como poner en marcha una aplicación mínima que ejecutaremos desde dentro de un contenedor docker. Aprovecharemos para introducir el concepto de aplicaciones _stateless_ y aplicaciones _stateful_ junto con el bases de datos en memoria. Aprenderemos además a utilizar variables de entorno y a orquestar contenedores cuando su complejidad o número así lo requiera.
## ¿Qué es Flask?
- `Flask` es un framework para construir aplicaciones web:
- Se autodeclara como un microframework:
- Pocas dependencias externas
- `Werkzeug` a WSGI utility library
- `jinja2` which is its template engine
- Ligero
## Entorno para la ejecución de Flask
Para montar nuestra aplicación web vamos a comenzar creando un directorio de trabajo, en nuestro caso `intro-flask`. Dentro él iremos colocando todas las carpetas y los archivos necesarios, comenzando por la carpeta `webapp` que será donde desarrollaremos nuestra aplicación web.
Para evitar tener que instalar en nuestro entorno de desarrollo local todas las bibliotecas, frameworks y otros elementos, vamos a utilizar un contenedor que encapsule todo esto. Como desarrollaremos en python, crearemos una imagen a partir de la imagen de python oficial publicada en Dockerhub `python:3.8` utilizando el siguiente archivo `Dockerfile`
```dockerfile=
FROM python:3.10
MAINTAINER Óscar García <oscar.gpoblacion@uah.es>
# Declaramos la carpeta de trabajo
WORKDIR /app
# Copiamos el archivo requirements.txt dentro de la imagen del contenedor.
# El contenido de este archivo se explica más adelante
COPY requirements.txt /app
# Instalamos las dependencias dentro de la imagen
RUN pip install -r requirements.txt
# Copiamos la aplicación web dentro de la imagen
COPY ./app /app
# Puerto en el que escuchará el contenedor
EXPOSE 5000
CMD python main.py
```
El archivo `requirements.txt` es donde especificaremos las dependencias (paquetes, bibliotecas, etc.) que necesite nuestra aplicación. Inicialmente solo necesitaremos `Flask` así es que el contenido de este archivo será:
```python
Flask
```
En la línea 18 del archivo `Dockerfile` se indica que la imagen tendrán una copia de nuestra futura aplicación web, que a su vez estará contenida en el directorio `app`. A continuación vamos a crear un esqueleto de nuestra aplicación web. Para ello comenzaremos con una estructura inicial de carpetas que crearemos haciendo, desde `webapp`, `mkdir -p app/{static,templates}` (más adelante se explicará para que sirven esas carpetas). Dentro de `app` crearemos el archivo principal de la aplicación web, que llamaremos `main.py` y que tendrá el siguiente contenido:
```python
#!/usr/bin/env python
# Programa: main.py
# Propósito: Creación aplicación web Flask
# Autor: Óscar García
# Fecha: 09/12/2019
import flask
# Crear el objeto que representa la aplicacion web
APP = flask.Flask(__name__)
@APP.route('/')
def index():
""" Muestra la página inicial asociada al recurso `/`
y que estará contenida en el archivo index.html
"""
return flask.render_template('index.html')
if __name__ == '__main__':
APP.debug = True
APP.run(host='0.0.0.0', port=5000)
```
Este programa en python crea una aplicación web Flask, la coloca en modo `debug` y la ejecuta haciendo `APP.run()`. En este modo `debug` se lanza un servidor web diseñado para dar soporte a las operaciones de desarrollo. Para poner la aplicación en modo producción es necesario lanzar otro tipo de servidor, pero esto se abordará más adelante. Al ponerse en marcha, la aplicación abre el puerto TCP 5000 y queda a la escucha de peticiones HTTP. Por eso pusimos la directiva `EXPOSE 5000` en el archivo `Dockerfile`. Si la petición web se hace sobre el recurso `/` entonces Flask invocará la función `index` que está anotada con `@APP.route('/')` con este fin. A su vez, esta función pide a flask que interprete y devuelva (`render_template`) la plantilla `index.html`. Por defecto, Flask buscará una plantilla con este nombre en la carpeta `templates`, así es que crearemos `index.html` con el siguiente contenido:
```htmlmixed
<!DOCTYPE html>
<html lang='es'>
<head>
<meta charset="utf-8" />
<title>Mi primera aplicación web</title>
<link type="text/css" rel="stylesheet"
href="{{ url_for('static',
filename='hello.css')}}" />
</head>
<body>
Hola Mundo!
</body>
</html>
```
También añadiremos una hoja de estilos mínima para nuestro HTML que colocaremos en la carpeta `static` y tendrá el siguiente contenido:
```
#center-div {
text-align:center; /* center horizontally */
vertical-align:middle; /* center vertically */
}
#center-div p {
display:inline; /* you might want to use this instead of "inline-block" */
text-align:center; /* center horizontally */
vertical-align:middle; /* center vertically */
font-size: 4vmin;
}
```
Con todo esto, nuestro proyecto debería tener la siguiente estructura:
```
.
└── webapp
├── Dockerfile
├── app
│ ├── main.py
│ ├── static
│ │ └── hello.css
│ └── templates
│ └── index.html
└── requirements.txt
```
Ya estamos en disposición de construir nuestra imagen. En la carpeta `webapp`, donde se encuentra nuestro `Dockerfile` haremos `docker build . -t webapp`. Si todo sale bien, veremos cómo se construye nuestra imagen:
```bash
Sending build context to Docker daemon 6.656kB
Step 1/7 : FROM python:3.8
---> 0a3a95c81a2b
Step 2/7 : MAINTAINER Óscar García <oscar.gpoblacion@uah.es>
---> Using cache
---> 88eb90c9bbfd
Step 3/7 : RUN mkdir -p /app
---> Using cache
---> d36aebc6a5f9
Step 4/7 : WORKDIR /app
---> Using cache
---> 3447c082fcc2
Step 5/7 : COPY requirements.txt /app
---> 6a052968c8ba
Step 6/7 : RUN pip install --no-cache-dir -r requirements.txt
---> Running in ec7014a9ebd7
Collecting Flask
...
Installing collected packages: MarkupSafe, Jinja2, itsdangerous, Werkzeug, click, Flask
Successfully installed Flask-1.1.1 Jinja2-2.10.3 MarkupSafe-1.1.1 Werkzeug-0.16.0 click-7.0 itsdangerous-1.1.0
Removing intermediate container ec7014a9ebd7
---> 935b7923122d
Step 7/7 : COPY ./app /app
---> 8f98de20e126
Successfully built 8f98de20e126
Successfully tagged webapp:latest
```
Y ahora ya podemos arrancar un contenedor que ejecute nuestra imagen `webapp` haciendo `docker run -p5000:5000 webapp`. Al hacer esto crearemos un contendor que ejecutará nuestra imagen, concretamente las instrucciones que especificamos con la directiva `CMD` en el archivo `Dockerfile`, esto es, `python main.py`.
Al ponerse en marcha veremos una serie de mensajes informativos, y concretamente uno nos invitará a cargar la URL `http://0.0.0.0:5000/`. Al poner esa URL en el navegador, este intentará abrir una conexión al puerto 5000^[El puerto 5000 debería estar abierto en la máquina local para que funcionara, cosa que por defecto no lo está en las máquinas virtuales de Google Cloud. Para evitar abrirlo, se puede usar el flag `-p 80:5000` con lo que usaríamos el puerto 80 de la máquina local que, si lo hemos especificado al crear la máquina virtual, estará abierto] de nuestra máquina, pero ese puerto, gracias al flag `-p 5000:5000` está contectado al puerto 5000 del contenedor. Con lo que la conexión tendrá éxito y se mostrará nuestro hola mundo en el navegador.
:::info
Si intentamos parar el contenedor haciendo CTRL-C, veremos que dicha secuencia es ignorada, ya que el contenedor no está conectado al terminal. Para pararlo será necesario abrir otro terminal, consultar el identificador del contenedor haciendo `docker ps`, y detenerlo haciendo `docker stop <hash>`. La próxima vez puede arrancar el contenedor con el flag `-d` para hacerlo en modo _detached_ que es similar a ejecutarlo en segundo plano. Si lo hacemos así no bloquearemos el terminal, pero tampoco veremos los `logs`, aunque si queremos consultarlos basta con hacer `docker logs`
:::
## Configuración para desarrollo
Ahora que tenemos arrancado nuestro contenedor, que incluye todo lo necesario para ejecutar nuestra aplicación web, surge la necesidad de continuar con las labores de desarrollo, que casi con total seguridad incluyen modificar el código de `main.py`, añadir código nuevo con nuevas funcionalidades, crear nuevas plantillas, etc. Es decir, modificar el contenido de la carpeta `/app` de la imagen del contenedor. Por supuesto que podemos hacer las modificaciones necesarias, reconstruir la imagen y lanzar un nuevo contenedor, pero esto no es práctico para desarrollar ya que es un ciclo relativamente lento. Para agilizar este proceso, en lugar de reconstruir la imagen lo que haremos será proyectar la carpeta `app` de la máquina de desarrollo sobre la carpeta `/app` del contenedor, ocultando la de la imagen. Haremos esto utilizando un _bind mount_ haciendo uso del flag `-v` al que hay que pasar la ruta absoluta de la carpeta en el anfitrión y la correspondiente ruta dentro del contenedor, separadas ambas con el carácter de `:`. Desde la carpeta `webapp` podemos hacer:
```
docker run -p 5000:5000 -v $(pwd)/app:/app -d webapp
```
Ahora, si hace modificaciones a cualquier archivo desde el anfitrión, y recarga la página, verá que dichas modificaciones surten efecto sin tener que reconstruir la imagen y volver a lanzar el contenedor.
## Ejecución de la imagen en `Cloud Run`
En este punto se puede utilizar la imagen construida y usar el servicio Clour Run para ejecugtar dicha imagen.
Para esto tenemos que seguir los siguientes pasos:
1) Habilitar un repositorio en `cloud registry` para almacenar nuestras imágenes
2) Etiquetar la imagen con el nombre del repositorio
```bash!
$ docker tag webapp europe-central2-docker.pkg.dev/aso-git-2022-2023/aso-git-repo/webapp:latest
$ gcloud auth configure-docker
$ docker push europe-central2-docker.pkg.dev/aso-git-2022-2023/aso-git-repo/webapp
```
3) Crear un servicio en Docker Run para que ejecute esa imagen en un contenedor
Una alternativa al paso 2 es usar `cloud build` para construir la imagen.
```bash!
$ gcloud builds submit . --tag=europe-central2-docker.pkg.dev/aso-git-2022-2023/aso-git-repo/webapp:latest
```
## Las plantillas de `jinja2`
Las plantillas son documentos estáticos, típicamente utilizados para formar HTML, pero que disponen de unos símbolos especiales (_placeholders_) situados en aquellos puntos donde sea interesante sustituirlos por datos concretos. Por ejemplo, si en nuestra página queremos dar la bienvenida a un usuario podríamos hacer:
```htmlmixed=
<!DOCTYPE html>
<html lang='es'>
<head>
...
</head
<body>
<h1>
Hola Mundo!
</h1>
<p>
Hola {{user.username}}, bienvenido/a.
</p>
</body>
</html>
```
Como se puede ver en la línea 11 del fragmento anterior, el placeholder `{{user.username}}` será sustituido por el valor de esa variable durante la materialización de la plantilla (_render_), que se realiza cuando se ejecuta `render_template` en nuestra aplicación web. Modificaremos esta instrucción para inyectar las variables correspondientes.
```python=
@APP.route('/')
def index():
""" Muestra la página inicial asociada al recurso `/`
y que estará contenida en el archivo index.html
"""
userinfo = {
'username': 'Oscar'
}
return flask.render_template('index.html', user=userinfo)
```
Las plantillas admiten construcciones muy complejas y son una piedra angular en el desarrollo de aplicaciones web, pero su estudio se sale del objetivo de esta práctica. No obstante hay abundante documentación en Internet, como por ejemplo la de la [página oficial de jinja2](https://jinja.palletsprojects.com/en/2.10.x/intro/#)
Vamos a introducir dos informaciones adicionales que nos permitirán abordar algunos conceptos interesantes. Vamos a introducir un contador del número de visitas y vamos a informar al usuario del nombre del host que ha servido su petición. Para ello haremos lo siguiente en nuestra aplicación web:
```python=
#!/usr/bin/env python
# Programa: main.py
# Propósito: Creación aplicación web Flask dinámica
# Autor: Óscar García
# Fecha: 09/12/2019
import flask
import socket
# Crear el objeto que representa la aplicacion web
APP = flask.Flask(__name__)
@APP.route('/')
def index():
""" Muestra la página inicial asociada al recurso `/`
y que estará contenida en el archivo index.html
"""
userinfo = {
'username': 'Oscar'
}
num_visitas = num_visitas + 1
hostname = socket.gethostname()
return flask.render_template('index.html', num_visitas=num_visitas,
user=userinfo, server_name=hostname)
if __name__ == '__main__':
APP.debug=True
APP.run(host='0.0.0.0', port=5000)
```
Importamos el paquete `socket` en la línea 9 para disponer de la función `gethostname()`, creamos un contador para el número de visitas en la línea 13 e inyectamos las variables en la plantilla en la línea 25.
Sin embargo, si lo ejecutamos así, python nos dará un error, ya que estamos referenciando la variable `num_visitas` sin haberla inicializado primero. Pero si la inicializamos, cada vez que se invoque a la función `index` se inicializaría primero y siempre tendría ese valor inicial. Podríamos declarar el contador como global e inicializarla ahí, pero si reiniciamos el servidor se perdería el valor de la variable. Además hay un problema más grave. Debemos pensar que nuestra aplicación debe escalar horizontalmente, es decir, que en el futuro habrá múltiples copias de este software en varias máquinas, lo que haría que hubiera el mismo número de copias del contador de visitas, cosa que por supuesto no es deseable.
La raíz del problema es que nuestro servicio `webapp` es `stateless`, o dicho de otra forma, no es capaz de mantener su estado interno, es decir, de recordar el valor de sus variables entre dos invocaciones sucesivas. Los servicios `stateless` tienen muchas particularidades que los hacen deseables. No mantener estado hace que sean independientes, más fáciles de testear, más fáciles de escalar, etc. pero claro, no siempre es posible abstrarse del estado. Si se necesita mantener estado es necesario recurrir a algún tipo de servicio de persistencia: bases de datos, en disco o en memoria, relacionales o no, sistemas de archivos, blobs, etc. todas ellas con sus ventajas e inconvenientes.
A continuación vamos a introducir el concepto de bases de datos en memoria (in-memory databases), de forma que podamos guardar el valor del contador y lo podamos consultar y modificar en cada invocación del servicio. Para esto vamos a utilizar una implementación open-source llamada [redis](https://redis.io/).
## REDIS
Redis es una base de datos en memoria, orientada a almacenar datos estructurados, que se emplea como memoria cache, como base de datos y como método de transporte para paso de mensajes. También permite realizar operaciones sobre los datos de forma atómica, así es que es posible construir semáforos distribuidos o arquitecturas pub/sub.
Para experimentar con redis vamos a utilizar la imagen oficial, distribuida a través de Dockerhub. Pero antes hay que tener en cuenta que lo que vamos a ejecutar es un servidor, y que por lo tanto debe ser accesible en red. Podríamos publicar el puerto en el anfitrión como hemos hecho anteriormente con la aplicación web, pero ahora vamos a dar un paso más con objeto de que el contenedor sea accesible para otros contenedores.
En primer lugar crearemos una red privada que denominaremos `mynet` haciendo:
```bash
$ docker network create mynet
````
Ahora crearemos un contenedor que ejecute la imagen de redis dentro de esa red y además le asignaremos el nombre `redisserver` dentro de esa red para que el resto de participantes se pueda referir a él.
```bash
$ docker run --net mynet --name redisserver -d redis
```
:::warning
Hay que tener precaución cuando pongamos nombres con `--name` a los contenedores, ya que dichos nombres deben ser unívocos. Si queremos parar el contenedor podemos hacer `docker stop redisserver`, pero no podremos hacer de nuevo `docker run ... --name redisserver` ya que eso crearía un nuevo contenedor y el nombre colisionaría con el anterior. Podemos volver a arrancar el contenedor haciendo `docker start redisserver`, pero si queremos arrancarlo con otra configuración diferente, o con otros parámetros, no nos queda más remedio de que borrarlo previamente con `docker rm redisserver`
:::
Con esto tendremos un contendor docker, ejecutando una imagen redis, en la red `mynet` y con el nombre DNS `redisserver`. Ahora necesitamos un cliente compatible redis para acceder a nuestro servidor. Existen varias implementaciones. Más adelante usaremos una implementación de python con nuestra aplicación web, pero ahora vamos a usar el que publica oficialmente redis y que además está incluido en la imagen que hemos descargado. Se llama `redis-cli` y se invoca indicando con el flag `-h` el nombre del servidor (o su dirección IP) con el que queremos conectar. Como viene incluido dentro de la imagen podemos invocarlo de la siguiente forma:
```bash
$ docker run --net mynet -it redis redis-cli -h redisserver
redisserver:6379>
```
La orden `redis-cli` muestra un prompt que nos invita a introducir órdenes para el servidor redis. Por ejemplo podemos probar `info` que nos proporciona datos sobre el estado del propio servidor redis.
Redis almacena pares `<clave, valor>`. Para grabar uno de estos pares podemos utilizar la orden `set` de la siguiente forma:
```bash
redisserver:6379> set contador_visitas 0
OK
```
El contador lo podemos incrementar con `incr`
```bash
redisserver:6379> incr contador_visitas
(integer) 1
redisserver:6379> incr contador_visitas
(integer) 2
```
Y también consultar su valor con `get` y borrarlo con `del`
```bash
redisserver:6379> get contador_visitas
"2"
redisserver:6379> del contador_visitas
(integer) 1
redisserver:6379> get contador_visitas
(nil)
```
Existen muchas otras formas de utilizar redis, pero para cerrar esta introducción haremos un último comentario. Es posible asignar a las variables un tiempo de vida, pasado el cual dejan de existir:
```bash
redisserver:6379> set tiempo_en_alcala Soleado EX 10
OK
redisserver:6379> get tiempo_en_alcala
"Soleado"
````
:clock10: Esperamos 10 segundos ...
```bash
redisserver:6379> get tiempo_en_alcala
(nil)
```
Esta forma de funcionamiento es la base de los sistemas de cache. Si necesito saber el `tiempo_en_alcala` consulto primero en redis. Si no hay respueta `(nil)`, entonces hago una (cara) llamada a un servicio de meteorología que me dice `Soleado`. Guardo este valor en redis para futuras referencias y devuelvo el valor `Soleado` al interesado. Pero ahora cabe preguntarse hasta cuando responderemos `Soleado`, y ahí tenemos dos alternativas:
1. Hasta que el servicio de meteorología nos informe de que las condiciones han cambiado. Muy complicado de implemetar
2. Hasta pasado un tiempo prudencial, por ejemplo 10 minutos.
### Implementación del contador de visitas
Vamos a implementar el contador de visitas utilizando redis. Para facilitar la labor emplearemos una biblioteca que nos abstraiga de los detalles de conectar con redis y de su protocolo. En este caso utilizaremos la biblioteca [redis-py](https://github.com/andymccurdy/redis-py) publicada en el respositorio de paquetes pypi como `redis`. Para instalar esta dependencia la añadiremos al archivo `requirements.txt` que hicimos previamente.
```=
Flask
redis
```
Reconstruimos ahora la imagen para que se incorpore esta nueva dependencia:
```
docker build . -t webapp
```
Y ahora ya podemos hacer uso de la biblioteca en nuestra aplicación:
```python=
#!/usr/bin/env python
# Programa: main.py
# Propósito: Creación aplicación web Flask
# Autor: Óscar García
# Fecha: 09/12/2019
import flask
import socket
import redis
# Crear el objeto que representa la aplicacion web
APP = flask.Flask(__name__)
redis_cli = redis.Redis(host='redisserver', port=6379, db=0)
@APP.route('/')
def index():
""" Muestra la página inicial asociada al recurso `/`
y que estará contenida en el archivo index.html
"""
userinfo = {
'username': 'Oscar'
}
redis_cli.incr('num_visitas')
num_visitas = redis_cli.get('num_visitas')
hostname = socket.gethostname()
return flask.render_template('index.html',
visit_counter=num_visitas.decode("utf-8"),
user=userinfo,
server_name=hostname)
if __name__ == '__main__':
APP.debug = True
APP.run(host='0.0.0.0', port=5000)
```
Importamos la biblioteca en la línea 10, y cada vez que se cargue `index` se incrementa y obtiene el valor del contador en las líneas 24 y 25.
Modifica el fichero `index.html` incluyendo entre *placeholders* la nueva información pasada a `render_template`.
```htmlmixed=
...
</head>
<body>
<div id="center-div">
<p>Hola Mundo!</p>
<p>Bienvenido {{user.username}}</p>
<p>Soy el servidor {{server_name}}</p>
<p style="font-size:8vmin">Visita número {{visit_counter}}</p>
</div>
</body>
</html>
```
Ejecuta `webapp` teniendo en cuenta que se debe conectar a la misma red privada que redis:
```docker run --net mynet -p5000:5000 -d webapp```
Prueba a recargar varias veces la página y verás como el contador de visitas se incrementa consecuentemente. También puedes conectarte con el cliente `redis-cli` y probar a borrar el contador de visitas de `redis`.
## Variables de entorno
En el archivo `main.py` utilizamos la siguiente construcción para crear el cliente con el que acceder a REDIS:
```python
redis_cli = redis.Redis(host='redisserver', port=6379, db=0)
```
En multitud de ocasiones necesitamos utilizar literales como `redisserver` o `6379` para indicar valores que dependen de la configuración del entorno de ejecución. Es decir, que en nuestro caso de desarrollo local el servidor de redis tiene el nombre DNS `redisserver` y escucha en el puerto `6379`. Pero si nuestro servicio algún día corre en un entorno de ejecución diferente, uno de producción por ejemplo, es muy probable que el servidor redis tenga un nombre diferente, incluso uno que nosotros nunca llegaremos a saber.
Para solucionar este problema vamos a recurrir a un mecanismo muy utilizado en la actualidad: obtener los valores desde variables de entorno del propio sistema operativo. Antes de ejecutar nuestro contenedor, el entorno se encargará de inyectar en él las variables de entornos necesarias con los valores adecuados. La forma varía ligeramente de un entorno a otro, pero en el caso de docker se utilizando el modificador `-e`. Para comprobar qué ocurre al utilizar dicho modificador, podemos abrir una sesión interactiva haciendo:
```bash
docker run -e REDIS_LOCATION=redisserver -e REDIS_PORT=6379 -it webapp bash
```
Una vez dentro de la sesión, podemos usar `env` para visualizar todas las variables de entorno. Como son muchas las filtraremos con `grep`:
```shell
root@6163761d4ee8:/app# env | grep "REDIS_"
REDIS_PORT=6379
REDIS_LOCATION=redisserver
```
Ahora solo tenemos que modificar `main.py` para que lea el valor de esas variables de entorno. Para ello usaremos el paquete `os` de python, que nos proporciona acceso a los servicios del sistema operativo.
```python=
# ...
import os
# ...
redis_host = os.environ["REDIS_LOCATION"]
redis_port = os.environ["REDIS_PORT"]
redis_cli = redis.Redis(host=redis_host, port=redis_port, db=0)
```
Ahora podemos invocar de nuevo nuestra aplicación haciendo:
```shell=
docker run
-e REDIS_LOCATION=redisserver
-e REDIS_PORT=6379
-p80:5000
-it
--net mynet webapp
```
### Gestión de secretos
Un caso que requeriere especial atención es cuando necesitamos indicar contraseñas. No es nuestro caso, pero el acceso a redis podría requerir contraseña:
```python=
redis_cli = redis.Redis(host='redisserver', password="lapiz", port=6379, db=0)
```
Si lo hacemos así, estaríamos revelando la contraseña a todo aquel que leyera el código, ya que formaría parte del código que subimos a control de versiones. Esta es una práctica a todas luces inaceptable. Además, seguro que sea cual sea la contraseña de nuestros entorno de desarrollo, no será la misma que la del entorno de producción, y es muy probable (y deseable) que dicha contraseña de producción sea un secreto no revelado a nadie.
Las variables de entorno ayudan a mitigar este problema, pero los servicios cloud ofrecen servicios más específicos para este caso concreto, que suelen denominarse `secretos`. Se pueden ver como variables de entorno cuyo valor no se muestra al operador en prácticamente ningún caso. Para más información puedes consultar la documentación del proveedor, por ejemplo la de [Google Secret Manager](https://cloud.google.com/secret-manager)
## Orquestación de contenedores
A medida que vamos añadiendo nuevos servicios a nuestra arquitectura la labor de parar y arrancar los contenedores se hace más tediosa. Además, las líneas de órdenes cada vez tienen más configuraciones y hacen uso de _flags_ cada vez más complejos. Para facilitar la labor de componer arquitecturas con varios contenedores hay varias herramientas disponibles, pero dos son las más conocidas: `docker-compose` y `kubernetes`. Sin duda `kubernetes` es la elegida para gestionar muchos contenedores en un entorno de producción, pero su complejidad queda fuera del alcance de esta práctica (por ahora). Por su parte `docker-compose` es mucho más sencillo de utilizar y por lo tanto es muy apropiado para entornos de desarrollo y para algunos entornos de producción.
Para poder utilizar `docker-compose` es necesario instalarlo. Las instrucciones están en https://docs.docker.com/compose/install/ y se resume en ejecutar las siguentes dos líneas:
`sudo curl -L "https://github.com/docker/compose/releases/download/1.25.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
`
`
sudo chmod +x /usr/local/bin/docker-compose
`
### El archivo `docker-compose.yml`
Este archivo contiene la información necesaria para componer nuestra arquitectura de contenedores. A continuación se propone uno de ellos para aunar tanto el servidor redis como la aplicación web que hemos desarrollado hasta ahora. Colocamos este archivo `docker-compose.yml` en el directorio `intro-flask`:
```dockercompose=
version: '2.1'
services:
redisserver:
image: redis
expose:
- "6379"
networks:
- mynet
webapp:
build:
context: webapp
image: webapp:latest
expose:
- "5000"
volumes:
- ./webapp/app:/app
depends_on:
- redisserver
networks:
- mynet
networks:
mynet:
name: mynet-network
```
En este archivo hemos definido dos servicios: `redisserver` y `webapp`. El primero expone a la red interna `mynet` el puerto 6379 y el segundo el puerto 5000. Además, el segundo se construye a partir del archivo Dockerfile que se puede encontrar en la carpeta (_context_) `webapp` y el resultado de la construcción será una imagen etiquetada como `webapp:latest`. Además este servicio depende de `redisserver` y monta la carpeta `./webapp/app` dentro la carpeta `/app` del contenedor, como hacíamos anteriormente para facilitar las labores de desarrollo.
Podemos poner en marcha toda la arquitectura haciendo:
```bash
$ docker-compose up
Creating network "mynet-network" with the default driver
Creating intro-flask_redisserver_1 ... done
Creating intro-flask_webapp_1 ... done
Attaching to intro-flask_redisserver_1, intro-flask_webapp_1
...
```
Sin embargo, no podrá cargar la página web porque el puerto 5000 no está proyectado en el anfitrión. Podríamos modificar esto sustituyendo el `expose` de las líneas 16 y 17 por:
```dockerfile=
ports:
- "5000:5000"
```
Sin embargo para lo siguiente que vamos a hacer, nos conviene no hacer esta proyección.
Antes de continuar, vamos a introducir algunas operaciones básicas con docker-compose. La primera es simplemente abortar la ejecución anterior de `docker-compose up` pulsando `Ctrl-C`. Si queremos arrancar todos los contenedores pero en segundo plano, podemos usar el modificador `-d` de la orden `up`:
```bash
$ docker-compose up -d
```
Podemos comprobar qué contenedores están en marcha haciendo `docker-compose ps`, y detenerlos todos haciendo `docker-compose stop`
Incluso podemos indicar que queremos más de un contenedor de un servicio concreto, por ejemplo podríamos crear 3 instancias de la aplicación `webapp` para repartir su carga de trabajo. Para ello haremos `docker-compose up -d --scale webapp=3`. Si ahora hacemos `docker-compose ps` veremos el resultado:
```bash
$ docker-compose ps
Name Command State Ports
-----------------------------------------------------------------------------
intro-flask_redisserver_1 docker-entrypoint.sh redis ... Up 6379/tcp
intro-flask_webapp_1 /bin/sh -c python main.py Up 5000/tcp
intro-flask_webapp_2 /bin/sh -c python main.py Up 5000/tcp
intro-flask_webapp_3 /bin/sh -c python main.py Up 5000/tcp
```
Sin embargo, necesitamos un componente que reparta las peticiones al puerto 5000 de la máquina anfitrión entre las instancias de nuestros contenedores. Para ello añadiremos a continuación a nuestra configuración un sencillo balanceador de carga.
### Un sencillo balanceador de carga con `nginx`
Es posible configurar un servidor `nginx` como proxy inverso, de forma que reparta las peticiones que le llegan entre nuestras aplicaciones web. Para ello vamos a añadir un nuevo servicio al archivo `docker-compose.yml`
```dockerfile=24
loadbalancer:
build:
context: ./loadbalancer
image: loadbalancer:latest
ports:
- "80:80"
depends_on:
- webapp
networks:
- mynet
```
Se trata de un contenedor basado en la imagen oficial de Nginx, al que modificaremos ligeramente para convertirlo en un proxy inverso con balanceo de carga. Para hacer estas modificaciones a la imagen oficial crearemos la carpeta `loadbalancer` y pondremos en ella el siguiente archivo `Dockerfile`:
```dockerfile=
FROM nginx
COPY default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
```
Como se puede observar, la única modificación consiste en sustituir el archivo de configuración `default.conf` por el siguiente:
```nginx=
upstream localhost {
# These are references to our backend containers, facilitated by
# Compose, as defined in docker-compose.yml
server intro-flask_webapp_1:5000;
server intro-flask_webapp_2:5000;
server intro-flask_webapp_3:5000;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://localhost;
proxy_set_header Host $host;
}
}
```
Con todos estos cambios, nuestro proyecto tendrá la siguiente estructura de archivos:
```
intro-flask/
├── docker-compose.yml
├── loadbalancer
│ ├── Dockerfile
│ └── default.conf
└── webapp
├── Dockerfile
├── app
│ ├── app
│ ├── main.py
│ ├── static
│ └── templates
│ └── index.html
└── requirements.txt
```
Para poner en marcha todo el sistema haremos:
````
docker-compose up -d --scale webapp=3
````
Ahora abrimos un navegador en la máquina anfitriona y lo apuntaremos a `http://localhost`. Nótese que ahora ya no especificamos el puerto 5000 porque el balanceador de carga escucha en el puerto 80. Cada vez que recargemos la página observaremos que no solo se incrementa el contador de visitas, sino que ahora el nombre de la máquina que sirve el contenido cambia ya que el balanceador desvía la petición a cada una de las tres instancias de la aplicación web.