De qué hablamos?

Daniel Mondaca

06 Sep 2016

Crónica de un desarrollo

Hace algún tiempo nos topamos con que uno de nuestro proveedores renovó la forma en que debían conectarse terceros a sus sistemas, cambió los términos de uso, cambió las librerías y forma en que debían ejecutarse las llamadas a sus servicios. Hasta ahí todo bien, un pequeño desarrollo para actualizar la conexión de nuestros sistemas con los suyos bastaría, o al menos eso parecía hasta que, quien les escribe, descubrió que las máquinas que se conectaban a los servicios de ese proveedor estaban obsoletas y las librerías nuevas de conexión ni siquiera podrían compilarse a mano por problemas de dependencias y lo que es peor, por motivos que no vienen al caso, no era factible en aquél entonces desconectar dichas máquinas para actualizar su sistema operativo y/o todas las dependencias nuevas que se requerían. De aquél caso, donde era mucho más factible partir de 0 que intentar parchar un sistema antiguo, surgió el problema que dió origen a este post, a saber crear un sistema que pudiera ejecutar una serie de tareas:

Sobre una serie arbitraria de máquinas, extraer y filtrar un conjunto de información seleccionando solo la relevante. Sobre las selecciones anteriores de cada máquina, tomarlas todas y volver a filtrar creando una “selección de selecciones” con la data final a enviar al proveedor. Por cada dato que se enviase al proveedor, éste entregaría cero o más respuestas. Cada respuesta debe ser filtrada y enviada a una o más máquinas de acuerdo a ciertos parámetros definidos en la etapa de selección de selecciones. Por cada máquina, ejecutar un nuevo filtro local para seleccionar solo algunas respuestas específicas para guardar.

Hasta aquí, un problema relativamente simple aunque no por eso menos complejo de implementar. Súmese a lo anterior requerimientos más técnicos:

-Cada etapa del sistema debía ser lo más independiente posible del resto. -Cada etapa del sistema debía ser modificable lo más rápidamente posible. -Las etapas de filtrado y selección eran críticas y debían tener en cuenta el rendimiento como elemento clave. -El sistema en su totalidad debía ser lo más “portable” posible.

Es importante en una planificación distinguir claramente las necesidades reales del proyecto, por ejemplo en lo anterior todas las etapas requerían ser rápidamente modificables pero solo algunas necesitaban concentrarse en el rendimiento, y es que claro, no podemos pedir que algo sea bueno, rápido de hacer, rápido de modificar, con eficiencia a nivel de código nativo en C y con uso de ram y procesador cercano a cero. No. Una buena planificación debe ser realista, los desarrolladores deben tener claro cuándo alguna de las anteriores características son importantes y cuándo no, así de todo lo anterior, surgió una primera solución:

Python como lenguaje principal para permitir modificaciones rápidas.

Redis para ejecutar las etapas de filtrado velozmente (eran operaciones de conjuntos).

Rabbitmq para interconectar las distintas etapas del sistema “desacopladamente”.

Elasticseach para analizar las respuestas del proveedor -eran textos-.

Hasta ahí todo bien, sacrificaría algo de velocidad para conseguir código fácil de leer y rápido de modificar con Python, sacrificaría memoria ram para conseguir rapidez con Redis y finalmente sacrificaría simplicidad para conseguir una mejor separación entre tareas con Rabbitmq (que implica código y dependencias extra).

A todo lo anterior, habría que sumar ahora un par de problemas:

Las máquinas se conectaban con varios proveedores, un conflicto de versiones y dependencias entre el software de un proveedor y otro era un riesgo a tomar en cuenta. Los algoritmos de filtrado que usaban redis se conectaban a una sola base de datos “0”, si múltiples etapas de filtrado ocurrían en paralelo sobre la misma base de datos, se produciría inconsistencia.

¿Cómo podía aislar los servicios tipo Redis para que pareciese que cada proceso tenía acceso único y exclusivo a una misma base de datos “0”? ¿Cómo mantener en una misma máquina múltiples versiones de múltiples librerías para que un proceso de un proveedor determinado use unas y otro proceso otras?

Podía resolver todos esos problemas agregando más códigos a la interacción con redis para seleccionar dinámicamente la base de datos (esperando que no ocurriese una “race condition” que afectase esta sincronización y selección); podía compilar distintas librerías de distintas versiones junto a sus decenas de dependencias en distintos directorios y cambiar el “path” en cada proceso apuntando a la versión adecuada, y es que con las librerías del sistema operativo no es muy cómodo el asunto, no es como con librerías de python puras donde uno levanta un ambiente virtual y listo… todas estas medidas sin embargo al final dificultarían la mantención, la lectura del código y lo harían innecesariamente complejo.

La solución más elegante era aislar cada proceso o grupo de procesos según las librerías de sistema que necesitasen (junto a sus versiones correspondientes) y a los servicios que ocupasen, a saber, siguiendo el caso anterior:

Cada proceso de filtrado tendría su redis exclusivo, eso permitiría conectar todos los script a la base de datos 0, mantener el código unificado, simple e independiente de la máquina a la que se conectara. Solo los procesos que efectivamente ejecutaran llamadas a los servicios del proveedor tendrían librerías del sistema con una versión modificada distinta del resto para operar correctamente. Cada pieza de software dado lo anterior, debía estar empaquetada de modo que incluyese o indicase qué servicios y librerías necesitaba (dependencias).

Y bueno, ¿cómo empaquetamos uno o más procesos con distintas versiones de múltiples librerías junto a uno o más servicios completos?

San google y la comunidad online fueron tajantes: Docker.

Con docker, los procesos junto a sus dependencias se almacenan y ejecutan dentro de ambientes aislados denominados “contenedores” donde existen dos enfoques principales:

A nivel de proceso, vale decir, un contenedor de docker contiene el kernel junto a la aplicación a ejecutar más sus respectivas dependencias y nada extra -no X11, no ssh, no editores, no bash (solo sh), etc…-

Tipo máquina virtual, vale decir, el kernel junto a algún software que se ejecute al comienzo y lance a su vez uno o más procesos en paralelo. El software de inicio puede ser supervisord, systemd, algún script hecho a mano, o cualquier cosa con la condición de que debe ejecutarse ininterrumpidamente mientras el contenedor esté operativo. Con este enfoque podemos levantar múltiples servicios al inicio en un solo contenedor, ssh, redis, rabbitmq, etc…

En docker una vez que el proceso principal se detiene, todos los procesos hijos de este proceso y el contenedor completo a su vez se detienen.

Siguiendo el primer enfoque en nuestro proyecto, tendría contenedores con redis, con rabbitmq, elasticsearch y al final con cada uno de los scripts. Y aunque los contenedores son sumamente ligeros, volviendo un par de líneas atrás recordaremos que la idea era agrupar procesos allí donde fuese conveniente.

El segundo enfoque sería por tanto el más conveniente, ahora bien, este sistema seguiría la política de registrar o “logear” solo ciertos errores graves y cierta información específica a un sistema externo donde se manejan los logs, pero adicionalmente a eso durante su funcionamiento cada etapa “imprimiría” por pantalla una fuerte cantidad de información con errores no graves y datos circunstanciales que no deberían almacenarse dado que enviar o guardar eso sería contraproducente, se perdería mucha velocidad y estresaría el disco. ¿A qué viene esto?, pues a que docker por defecto “logea” la salida de cada proceso al disco duro en pro de luego con el comando “docker logs” poder revisar esa información. Bueno, no hay problema, podía desactivar el logging por defecto de docker… pero, ¿cómo podría entonces visualizar la información general sobre la ejecución de cada etapa sin docker logs? La documentación de docker fue esclarecedora: Quitando la ejecución del inicio y lanzando cada etapa o script por separado con “docker exec -it”

Con el comando docker exec se puede ejecutar un comando en un container que ya esté corriendo y observar el proceso tal cual si se estuviese ejecutando en el host. Al final, la cosa

Sumando a esto “screen” para dejar corriendo comandos en background, con un script en el host de para levantar los procesos llegué al resultado final:

A día de hoy este sistema se ejecuta sin problemas 24/7 y sirve para recordar algunos conceptos importantes como tratar de aislar distintas etapas con distintas necesidades, utilizar la herramienta adecuada para el problema adecuado y no tenerle miedo a probar nuevas tecnologías. Dejo esto acá a modo de ejemplo sobre un sistema donde utilizar docker resultó particularmente beneficioso, el hecho de tener todo empaquetado y el bajísimo consumo de recursos de los contenedores permitía levantar la infraestructura completa en la misma oficina, en un pc normal común y corriente sin problema alguno; más demoraba la descarga de las imágenes desde docker hub que todo lo demás, y esa descarga se hacía una sola vez. Solo una palabra de advertencia, no es particularmente sencillo aprender a utilizar docker desde 0, aunque una buena interfaz como docker ui https://github.com/kevana/ui-for-docker sin duda puede ayudar y cuando salga la versión 1.2 de rancher http://rancher.com/rancher/ que será compatible con la versión 2 de docker-compose, será mucho más fácil aún.

¿Qué opinas? Comenta y comparte!

Relacionados