Poesía Binaria

Monta microservicios web rápidamente en Python con web.py


Python es uno de los lenguajes de moda. En sus múltiples usos: para escritorio, aplicaciones científicas, web, scripting y mucho más. Algo que también está de moda son los microservicios. Grosso modo, un microservicio es un componente independiente que implementa una funcionalidad de nuestra aplicación. Será una pieza de un puzzle mayor que, dadas unas especificaciones, podremos mejorar, reescribir, cambiar de lenguaje, utilizar bases de datos diferentes, etc.

Y como ambas tecnologías están de moda, vamos a juntar lo mejor de los dos mundos y combinarlo. Porque gracias a versatilidad de Python, de la cantidad y calidad de muchas de las bibiliotecas que podemos utilizar, se convierte en un lenguaje con el que rápidamente podemos desarrollar proyectos mucho más grandes.

Diseño con microservicios versus diseño monolítico

Históricamente, las aplicaciones web se han diseñado de forma que funcionan como un todo. Tanto la gestión de usuarios, contenidos, cuentas, etc. Utiliza una base común, o un framework y todo funciona dentro del mismo proyecto de código. Normalmente el rendimiento de esto es muy bueno y no nos dará muchos problemas. Aunque cuando las aplicaciones web se van haciendo más y más grandes, y junto con ellas, los equipos de trabajo van apareciendo algunos problemas:

Para ello, podemos optar por un diseño basado en APIs web que se van conectando las unas con las otras. Algunas APIs serán públicas, tendrán acceso desde el exterior; otras, en cambio, serán privadas, y sólo serán accesibles dentro de la red local donde estén situadas.

Lo primero que podemos pensar es que este enfoque es lento. Que lo es. Es decir, no es lo mismo que nuestra aplicación monolítica llame a una función para que inserte un registro en una base de datos a que la aplicación llame a una función que haga una petición HTTP a otra máquina para que ésta inserte el registro en base de datos. Además, para hacer la petición HTTP, en el lado del cliente (el que pide) y el servidor (el que inserta el registro) debe haber un tratamiento y filtrado de datos que propicie el envío y recepción de datos. Al final, un proceso que tarda menos de 1ms se transforma en unos 2 o 3ms. Así que tenemos que tener claro los pros y los contras de nuestro caso concreto antes de optar por un diseño u otro. Y, aunque la velocidad de los servicios puede verse afectada cuando hay pocos usuarios, tal vez las posibilidades de escalado que nos brinda la nueva arquitectura pueda hacer que el sistema no se resienta demasiado cuando el número de usuarios crezca.

REST contra el mundo

Aunque un sistema REST no es que sea lo más rápido del mundo. Aunque podemos aprovecharnos de la madurez y longevidad de un montón de bibliotecas y sistemas que utilizan HTTP para comunicarse. Este tipo de comunicación está muy documentada y podemos crear rápidamente sistemas que se comuniquen de esta forma. Hay muchos sistemas que utilizan protocolos propios para realizar la comunicación y el intercambio de datos. Pero si lo que realmente queremos es diseñar un sistema en poco tiempo y que funcione bien deberíamos optar por sistemas con cierta madurez. Si diseñamos un protocolo desde cero tendremos que implementar el cliente y el servidor.

Podríamos utilizar otros protocolos basados en HTTP, como SOAP, aunque no será demasiado costoso implementarlo a partir del ejemplo de abajo.

Crear servicios con Python

Python es un lenguaje que podemos utilizar para casi cualquier cosa. Muchos programas de escritorio, móviles, incluso de IoT se han desarrollado en Python. Y el ecosistema web no iba a ser menos. Aunque tenemos varias opciones, he optado por utilizar web.py por su simplicidad y su documentación.

Primero vamos a instalar la biblioteca web.py con pip:

pip install web.py

¡Vamos al código! Quiero realizar un ejemplo sencillo, porque esto se puede complicar hasta decir basta. El ejemplo tendrá un listado de países con un código numérico, nombre y código ISO. El código se podría mejorar utilizando una base de datos en lugar de un diccionario, incluyendo el método PATCH, o creando una clase padre para unificar un poco el código, pero en esta ocasión quiero mostrar el funcionamiento básico del servicio REST:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# coding=utf-8

import web
import json

urls = (
    '/paises(/.*)?', 'paises',
)

application = web.application(urls, globals()).wsgifunc()

class paises:
    paises = { 34: { 'España', "ES"}, 33: {"Francia", "FR"} }
    codes = { 400 : '400 Bad Request',
              404 : '404 Not Found',
              405 : '405 Method Not Allowed',
              409 : '409 Conflict'
              }
    def __init__(self):
        web.header('Content-Type', 'application/json', unique=True)

    def GET(self, pais=None):
        try:
            columns = [ 'codigo', 'nombre', 'iso' ]
        if pais is None:
                output = []
        for i,v in self.paises.items():
                    output.append(dict(zip(columns, [i] + list(v))))
        else:
                pais = int(pais[1:])
                if self.paises[pais] is None:
                    raise Exception('Pais no encontrado', 404)
                else:
                    output = []
                    output.append(dict(zip(columns, [pais] + list(self.paises[pais]))))

            return json.dumps(output, ensure_ascii=False, encoding='utf8')
        except Exception, e:
            msg, code = e.args if len(e.args)==2 else (e.args, 404)
            raise web.HTTPError(self.codes[code], data="Error: " + str(msg) + "\n")

    def POST(self, pais=None):
        try:
            if pais is not None:
                raise Exception('No permitido', 404)
           
            input = web.input(code=None, nombre=None, iso=None)
            if not input['code'] or not input['nombre'] or not input['iso']:
                raise Exception("Faltan datos de entrada", 400)

            pais = int(input['code'])
            if pais in self.paises:
                raise Exception("Elemento existente", 409)
           
            self.paises[pais] = { input['nombre'], input['iso'] }
            web.created()
            web.header('Location', '/paises/'+str(pais))
            return ''
        except Exception, e:
            msg, code = e.args if len(e.args)==2 else (e.args, 404)
            raise web.HTTPError(self.codes[code], data="Error: " + str(msg) + "\n")

    def PUT(self, pais=None):
        try:
        if pais is None or len(pais)==1:
                raise Exception('Pais no indicado', 405)
           
            pais = int(pais[1:])
            input = web.input(nombre=None, iso=None)
            if not input['nombre'] or not input['iso']:
                raise Exception("Faltan datos de entrada", 400)
           
            if pais not in self.paises:
                raise Exception("Elemento no encontrado", 404)
           
            self.paises[pais] = { input['nombre'], input['iso'] }
            return ''

        except Exception, e:
            msg, code = e.args if len(e.args)==2 else (e.args, 404)
            raise web.HTTPError(self.codes[code], data="Error: " + str(msg) + "\n")

    def DELETE(self, pais=None):
        try:
        if pais is None or len(pais)==1:
                raise Exception('Pais no indicado', 405)
           
            pais = int(pais[1:])          
            if pais not in self.paises:
                raise Exception("Elemento no encontrado", 404)
           
            del self.paises[pais]
            return ''

        except Exception, e:
            msg, code = e.args if len(e.args)==2 else (e.args, 404)
            raise web.HTTPError(self.codes[code], data="Error: " + str(msg) + "\n")

if __name__ == "__main__":
    app = web.application(urls, globals())
    app.run()

Probando nuestro código

Para probar el código basta con ejecutar la aplicación:

python webservice.py
http://0.0.0.0:8080/

Se creará un servidor web en nuestra máquina utilizando el puerto 8080 y podremos utilizar cualquier navegador o cURL para hacer pruebas.
Como punto interesante, destaco que podemos hacer modificaciones en nuestro código sin necesidad de volver a ejecutar el programa. Es decir, podemos modificar nuestros ficheros y volver a cargar las URLs sin problemas. Además, incluye un buen depurador de Python que nos indicará los errores en el código y nos ayudará a solventarlos.

Montando el servicio en servidor


Si queremos montar un servidor para correr este servicio, recomiendo encarecidamente, utilizar un servidor web como Nginx o Apache en lugar de exponer al exterior el programa en Python. Ya que los servidores web colocarán una capa de filtrado y aislamiento entre el programa y el usuario final. Además, como son muy utilizados en producción, las versiones estables intentan tener todos los fallos corregidos.
No es raro que algún servidor de desarrollo, como puede ser este creado por web.py, no se entienda bien con algunos datos de entrada que no cumplan las normas y provoque un fallo en nuestro sistema. Mientras que un Apache, suele estar bien preparado (casi siempre) ante atacantes avispados.

En Apache, podríamos utiliza FastCGI o mod_wsgi y con Nginx podríamos utilizar un proxy HTTP o WSGI. Un punto positivo de la utilización de Python en un entorno web es que los scripts están cargados en memoria y ejecutándose todo el tiempo, por lo que podemos tener alguna información precargada o conexiones a base de datos abiertas sin necesidad de ejecutar todo desde cero a cada petición entrante. Aunque, para atender muchas peticiones entrantes, y a veces concurrentes, es necesario que dispongamos de varias ejecuciones del script. O, al menos, un programa que controle dichas ejecuciones, lance y destruya instancias de la aplicación. En mi caso, en algún servicio que he hecho he preferido utilizar uWSGI para gestionar las instancias de mi aplicación en Python.

UWSGI cuenta con un protocolo de comunicación tanto para Apache como Nginx que harán que estos servidores web hablen con uWSGI cuando entre una petición enviándole los datos de entorno y usuario. UWSGI verá si hay alguna instancia en ejecución de la aplicación que no esté atendiendo a nadie y le mandará los datos a ésta. Si no hay ninguna, intentará lanzar una nueva, dentro de los límites que establezcamos o esperará que haya una libre durante un tiempo para atender la petición. Cuando la aplicación haya generado una salida, uWSGI la recibirá y éste se la enviará al servidor web que ya es el que se peleará con el usuario. De todas formas el servidor web siempre tendrá la primera y la última palabra. Y con esto me refiero a que el servidor web debe contar con sus propios sistemas de seguridad y filtros que decidirán si pasar o no la petición a uWSGI, e incluso si son ficheros estáticos los pueden servir ellos mismos. Y una vez pasada la petición, antes de enviarla al usuario, podrán modificar o insertar cabeceras, incluso filtrar o comprimir el contenido generado.

proxy con protocolo uWSGI

UWSGI permite configuración en archivos ini, xml, json o yaml. La configuración es muy similar en lo que se refiere a los nombres de los campos de configuración y el ejemplo lo haré con archivos ini. Para ello crearemos el archivo /etc/uwsgi/apps-available/webservice.ini donde webservice es el nombre de nuestro servicio. Con el siguiente contenido:

1
2
3
4
5
6
7
8
9
10
11
[uwsgi]
socket = 127.0.0.1:9091
chdir = /var/www/myapplication/webservice/
wsgi-file = /var/www/myapplication/webservice/www/webservice.py
pp=/var/www/myapplication/webservice/www
module=webservice
processes = 4
threads = 2
stats = 127.0.0.1:9191
pidfile = /var/run/webservice.pid
callable=application

Se lanzan 4 procesos con 2 hilos cada uno. Y el servidor uWSGI escuchará por el puerto 9091 dentro de la máquina local. Es importante establecer el callable adecuado. Como vemos en nuestra aplicación Python, la línea:

1
application = web.application(urls, globals()).wsgifunc()

Crea el objeto de aplicación llamado application. Ese será nuestro callable. Por último, es recomendable activar las stats. Será otro servidor web escuchando en el puerto 9191 y sólo para conexiones locales. Esto servirá para monitorizar la salud de nuestro servidor y que tengamos algo de control y posibilidad de mejorar el servicio y saber cómo se está comportando.

Luego, haremos un enlace desde /etc/uwsgi/apps-available/webservice.ini a /etc/uwsgi/apps-enabled/webservice.ini, como vemos, es muy parecido a otros sistemas en los que podemos activar y desactivar servicios mediante la creación y borrado de enlaces.

Luego en Nginx, la configuración que colocaremos en /etc/nginx/sites-available/webservice podría ser algo como esto:

1
2
3
4
5
6
7
8
server {
  listen 80;
  server_name apps.example.com;
    location /webservice/ {
    uwsgi_pass 127.0.0.1:9091;
    include uwsgi_params;
  }
}

Tras esto, creamos un enlace desde /etc/nginx/sites-available/webservice a /etc/nginx/sites-enabled/webservice y reiniciamos el servidor Nginx.

Lo bueno del protocolo utilizado por uWSGI es que es mucho más rápido que el HTTP. Por lo que la comunicación entre el servidor web y uWSGI será muy rápido. UWSGI es un protocolo binario, y si viéramos el contenido de dicha comunicación nos costaría mucho entender algo (como humanos), aunque no es un protocolo estándar, y algunas veces no podremos utilizarlo. Hace poco tuve un caso particular en el que no podía instalar el plugin uWSGI para Nginx y tuve que configurar uWSGI para utilizar HTTP para la comunicación con el servidor web.

UWSGI como servidor HTTP

Y este caso utilizaré para el ejemplo a continuación. Dado que uWSGI será un servidor privado no es necesario configurar una conexión HTTPs entre el servidor web y uWSGI. Esta conexión será local o estará dentro de una red interna, por lo que estaríamos introduciendo complejidad extra e innecesaria en la comunicación. De todas formas, la comunicación entre el servidor web y el usuario sí que puede ser HTTPs.

Lo primero, una vez instalado el paquete uWSGI y el plugin para Python es crear el archivo /etc/uwsgi/apps-available/webservice.ini donde webservice es el nombre de nuestro servicio. El contenido del archivo será el siguiente:

1
2
3
4
5
6
7
8
9
10
11
12
[uwsgi]
plugin=python,http
http = webservice:4480
chdir = /var/www/myapplication/webservice/
wsgi-file = /var/www/myapplication/webservice/www/webservice.py
pp=/var/www/myapplication/webservice/www
module=webservice
processes = 4
threads = 2
stats = 127.0.0.1:9191
pidfile = /var/run/webservice.pid
callable=application

Para este caso, se creará un servidor HTTP en el puerto 4480 que utilizará la aplicación situada en /var/www/myapplication/webservice/www/webservice.py. Finalmente en /etc/nginx/sites-available/webservice configuramos la URL /webservice/ como proxy inverso al puerto 4480, ponemos algo así:

1
2
3
4
5
6
7
server {
  listen 80;
  server_name apps.example.com;
    location /webservice/ {
        proxy_pass http://127.0.0.1:4480/;
  }
}

Ya sólo queda crear un enlace desde /etc/nginx/sites-available/webservice a /etc/nginx/sites-enabled/webservice y reiniciar el servidor Nginx.

Foto principal: frank mckenna

También podría interesarte....