Equipo:
- Gabriel Hernández Rodríguez C411
- Deborah Famadas Rodríguez C412
- David Manuel García Aguilera C411
La arquitectura base utilizada en el proyecto es MAster-Slave, donde hay un controlador centralizado(Master) que contien la mayor parte de la lógica y los slave se limitan a comunicarse con este para realizar determinados trabajos.
En el sistema este master es llamado Dispatcher ya que su principal trabajo es manejar las urls pendientes a recuperar su contenido. Para apoyarse utiliza un nodo Cache que funciona como caché(por supuesto) y como message broker.
Los slaves que se conectan a él pueden ser clientes o trabajadores. Los clientes son aquellos que encolan tareas, en este caso los clientes son un servidor con una APIRest. Los trabajadores son los que procesan las tareas, es decir, hacen las peticiones HTTP para obtener el contenido de las páginas.
De esta forma se definen 4 roles para cada nodo que forma parte del sistema:
- Dispatcher (master)
- Cache (también Message Broker)
- API (slave/cliente)
- Worker (slave/trabajador)
Los roles API y Worker tienen conocimiento del nodo Dispatcher con el que interactúan, no ocurriendo esto en el sentido contrario, es decir, estos se preconfiguran y conocen la dirección en la que se encuentra este, realizan llamadas RPC que se encargan de enviar trabajos y esperar resultados, y esperar trabajo y enviar resultados respectivamente. Este esquema de trabajo hace que el balanceo de carga ocurra de manera natural, una vez los trabajadores ya no estén realizando otra tarea, disminuyendo la carga de asignación de tareas en el master.
Los trabajadores siempre que no estén realizando trabajo alguno se mantienen pidiendo trabajo al master, de la misma forma que la API se mantiene pidiendo el contenido de las urls de una request.
Las técnicas particulares implementadas para tolerancia a fallos en este sistema se clasifican en 2 categorías según el tipo de conflicto al que atienden, los conflictos que impiden el procesamiento de urls y los conflictos que afectan el procesamiento actual de estas. Por ejemplo, tienen distintas implicaciones los siguientes eventos:
- Una API al querer enviar urls para procesar se percata que no tiene dispatcher al que conectarse debido a que este dejó de operar. (1er tipo)
- Una API envió urls para procesar al dispatcher y durante el trabajo de los trabajadores uno de estos dejó de funcionar dejando el procesado de esta url a medias. (2do tipo)
Estos eventos hacen que nos tengamos que alejar de un método clásico de programación donde cada componente actúa de una forma determinista y opera siempre de manera correcta en el proceso general. Con el uso de RPC estos componentes se encuentran en procesos distintos y quizás en computadoras distintas, siendo afectados por disímiles sucesos desde problemas en la asignación de recursos en el sistema operativo, hasta desconexión de red, destrucción de la computadora remota en la que se ejecuta ese componente o mordedura del cable de red entre ambos nodos por un ratón, pez o operador hambriento.
Para manejar eventos del primer tipo se utilizan dos capas de resolución de componentes, la capa estática y la capa dinámica.
La capa estática constituye de una serie de configuraciones que permiten al sistema conectarse en un inicio de la manera deseada. Estas se explicaran con más detalle en 2.2. Este sistema está pensado para que se utilicen otras tecnologías de automatización que permitan inicializar cada nodo de la manera deseada y con la configuración deseada, permitiendo que incluso ante fallos del sistema, este se comporte de manera fácil de controlar, predecir y poder actuar en consideración.
Esta capa estática se basa en la preconfiguración de direcciones de respaldo a las que conectarse en caso de que el Dispatcher, que es el centro operacional de una instancia de este sistema, deje de operar por completo. Se centra en atender a este rol específicamente por dos razones: primero, es el principal cuello de botella del sistema y además su componente más débil; y segundo, como se decía anteriormente, los nodos slave necesitan conocer la dirección en que este se encuentra para operar correctamente.
Una vez que el dispatcher principal para el que se configuraron deja de funcionar la API y los Workers entonces comienzan un proceso de resolución del nuevo dispatcher que sigue estás reglas por orden.
- Comprueba la configuración MBB(Must be brave). Si la cantidad de intentos está configurada en 0 el nodo se mantiene intentando reconectar con el Dispatcher hasta lograrlo.
- La cantidad de intentos es mayor que 0, entonces el nodo intentará conectar esa cantidad de veces con el dispatcher principal configurado con determinado tiempo de espera entre un intento y otro.
- Superó la cantidad de intentos establecida, entonces comprueba los Dispatcher de Backup configurados. Existen dos tipos de dispatcher de backup configurados:
- Una dirección y puerto brindados por el Dispatcher principal. Al conectar, el dispatcher principal tenía configurado un backup y se lo comunicó a todos aquellos slaves que interactuaron con él. Este tiene la primera prioridad al buscar un Dispatcher Backup.
- Una dirección y puerto brindados por la configuración propia del nodo. Esta no tiene prioridad y solo se utiliza en caso de que el Dispatcher principal no tenga configurada una o la comunicación con este no se logró ni una sola vez.
- En caso de no encontrarse respaldo o no poder conectar con este el nodo se queda huérfano y procede de manera distinta según su rol. En caso de ser un Worker, este deja de funcionar automáticamente. En el caso de la API entonces se actúa de manera dinámica como se explicará más adelante.
Si un nodo se conecta a un dispatcher de respaldo este siempre sabrá que es de respaldo y cuando este falle buscará al principal e iniciará el mismo proceso inicial en caso de no funcionar. Un caso especial es cuando el Dispatcher de backup tiene configurado un Dispatcher de backup, entonces el nodo tomará este como su nuevo backup en caso de necesitar resolver nuevamente la conexión.
La capa dinámica se encarga de cubrir las debilidades que deja la estática. ¿Si un slave no logra conectar con ningún Dispatcher que puede hacer? ¿Qué pasa si un dispatcher no tiene trabajadores conectados a él o caché a la cual conectarse?
Lograr que el sistema "se mantenga funcionando" no es más que hacer que siga atendiendo peticiones de los clientes y procesándolas. Si un worker deja de funcionar no implica mucho a no ser que solo exista uno, otro caso distinto es si el Dispatcher o la cache dejara de funcionar.
Para contrarrestar esto el dispatcher es capaz de crear(Spawn) nodos de cache y trabajadores en la misma máquina en la que este se ejecuta. Para los trabajadores posee un comportamiento que registra los accesos de estos y así poder estimar la cantidad de workers conectados a él, una vez que hace más de determinado tiempo que un Worker no se conecta a él, asume que no está funcionando. Una vez que llega una url para ser procesada, si la cantidad de trabajadores estimada está por debajo de cierto umbral comienza a crear nodos Workers y conectarlos a él .
De manera similar ocurre con la cache, en caso de perder conexión con la cache establecida, o esta estar ausente en un principio crea un nodo con este rol y se conecta a él.
La API por su parte es el componente que está en contacto con el usuario, así que también necesita un nodo dispatcher a toda costa, de la misma manera que este necesita un nodo cache. Una vez que pasa la capa estática sin encontrar Dispatcher el nodo API crea un nodo cache localmente, este a su vez se encontrará sin Workers y sin cache y por tanto creará estos nodos localmente, encontrándose un sistema completo localmente en la máquina que contiene la API.
Los nodos Spawn no poseen fácil acceso y están pensados como último recurso, una vez termina el proceso del nodo que los creó los procesos de estos también terminan.
En caso de que el nodo API deje de funcionar queda por parte de quien utilice el sistema utilizar protocolos de resolución de DNS que permitan reconectar con otro IP y por tanto otro nodo API y trabajar esto en los clientes.
Para evitar el mal funcionamiento del sistema ante interrupciones en el procesado de una url se propone una arquitectura ligera que dependa mínimamente de datos almacenados aún si estos no poseen persistencia (ante un reinicio del sistema se pierden).
Una vez que el nodo API atiende una request, este mantiene la lista de las urls que se piden y recorre una por una para brindarle el contenido a su cliente por streaming. Por cada url se comunica de manera "insistente" con el Dispatcher pidiendo resultados, lo cual se realiza enviando la url de la cual se esperan resultados.
Cuando el dispatcher recibe una url, no importa si es primera vez o si es solo la API siendo insistente, él intenta encolarla para su procesado, esto permite que en caso de perder la url sin haber recuperado su contenido esta vuelva al sistema si aún el cliente no ha terminado con ella. De esta forma no importa si desaparecen todos los trabajadores, si la cache se borra por completo, o si además deja de funcionar el Dispatcher, la url siempre será procesada en algún momento.
Por lo mencionado anteriormente pudiera pensarse que el sistema carga de peticiones al dispatcher innecesariamente y además asigna una misma url demasiadas veces a los trabajadores, pero el sistema está centrado en rendimiento y para evitar esto se implementan varias técnicas.
Para evitar cargar al Dispatcher con peticiones innecesarias se utiliza un sistema de espera en los nodos slave que, cuando el resultado de la comunicación con este no sea satisfactorio, se procede a esperar un tiempo antes de la próximo RPC que va en aumento según el número de llamadas insatisfactorias continuas.
Para evitar sobre-procesar una misma url se declara una regla y es que en la cola a procesar(cola de pendientes), no puede estar una misma url dos veces, para esto, una vez que entra una url a la cola se almacena en la cache como pendiente y se retira de este estado una vez que sale de la cola y se le asigna a un trabajador.
Aún con este método, puede darse el caso en el que la url ya se procesó, está en la cola de pendiente, y todos los trabajadores la están procesando; pero esta situación es altamente improbable ya que tienen que cumplirse muchas condiciones para llegar a este punto, y en la mayoría de los casos, donde las url a recuperar su contenidos son muchísimas, esto no debe ocurrir.
Para acelerar las respuestas del sistema además se almacenan los resultados de los urls al procesarlas, lo que permite dar estas como respuesta al cliente sin enviarlas necesariamente a procesar a los trabajadores, aún así las urls que al procesar tengan respuesta que no sea satisfactoria se le da el resultado como respuesta al cliente pero se envían a procesar de nuevo en caso de que este resultado pueda cambiar por ser un error temporal, al igual que de manera aleatoria algunas url con respuesta 200 que pudieran haber cambiado.
Este sistema solo requiere 1 nodo de cada rol para funcionar, y como mínimo una sola computadora. El sistema puede extenderse añadiendo más trabajadores para que las urls se procesen con mayor mayor fluidez y, además, sencillamente poniendo en funcionamiento otra instancia independiente del sistema, permitiendo que los fallos no escalen entre instancias.
Una instancia puede ser considerada como un nodo Dispatcher, un nodo Cache,
El sistema está programado en Python. Para implementar RPC y garantizar comunicación entre nodos se utiliza la librería Pyro5 de manera límitada, ya que se realizan conexiones directas y no se utilizan las funcionalidades que provee de Name Server, los nodos Dispatcher y Cache son objetos de Pyro5 que se utilizan como una sola instancia del objeto por Daemon.
Para facilitar la implementación del CLI base de la app que permite escoger el rol que se ejecuta se utiliza la librería Fire, y el nodo API es un servidor sobre el framework FastAPI. Este último da respuesta a las peticiones del endpoint /scrap
haciendo un streaming por SSE(Server sent events), el cual siempre que mantenga el cliente la conexión abierta puede dar los contenidos de las urls a medida que se van procesando. La API brinda además un endpoint para eliminar la cache de determinadas urls(/reset
).
Para poder ejecutar un nodo del sistema solo es necesario instalar Python version 3.10 o mayor y las librerías necesarias, las cuales están referenciadas en el archivo requirements.txt
adjunto.
La configuración de la aplicación se realiza a través de variables de entorno y está pensada para tener implicaciones en el funcionamiento de una instancia del sistema de manera que pueda ser una parte compartida por cada nodo de esta.
La siguiente tabla muestra cada variable de entorno, el valor por defecto que se asume en caso de no definirse y la implicación que tiene en el funcionamiento de cada rol.
Variable | Valor por defecto | API | Dispatcher | Cache | Worker |
---|---|---|---|---|---|
HOSTNAME |
El nombre de la máquina que lo está ejecutando. Resultado de socket.gethostname() |
En caso de no definirse API_HOST se utiliza como dirección de hosting | En caso de no definirse DISPATCHER se utiliza como dirección de hosting | En caso de no definirse CACHE_SERVER_HOST se utiliza como dirección de hosting | |
HOSTPORT |
8000 | En caso de no definirse API_PORT se utiliza como puerto de hosting | En caso de no definirse DISPATCHER_PORT se utiliza como puerto de hosting | En caso de no definirse CACHE_SERVER_PORT se utiliza como puerto de hosting | |
DISPATCHER |
El valor en HOSTNAME o su valor por defecto |
Determina la dirección del Dispatcher principal al que conectarse | Se utiliza como dirección de hosting | Determina la dirección del Dispatcher principal al que conectarse | |
DISPATCHER_PORT |
El valor en HOSTPORT o su valor por defecto |
Determina el puerto del Dispatcher principal al que conectarse | Se utiliza como puerto de hosting | Determina el puerto del dispatcher principal al que conectarse | |
API_HOST |
El valor de HOSTNAME o su valor por defecto |
Se utiliza como dirección de hosting | |||
API_PORT |
El valor de HOSTPORT o 6900 |
Se utiliza como puerto de hosting | |||
CACHE_SERVER_URL |
El valor por defecto es construido a partir de CACHE_SERVER_HOST y CACHE_SERVER_PORT con el formato de URIs de Pyro |
Se utiliza como URI exacta para conectar con el objeto de Pyro que funciona como cache | |||
CACHE_SERVER_HOST |
El valor de HOSTNAME o su valor por defecto |
En caso de no estar definido CACHE_SERVER_URL define la dirección en la que se buscará la cache |
Se utiliza como dirección de hosting | ||
CACHE_SERVER_PORT |
El valor de HOSTPORT o 6380 |
En el caso de no estar definido CACHE_SERVER_URL define el puerto en el que se buscará la cache |
Se utiliza como puerto de hosting | ||
BACKUP_DISPATCHER |
En caso de estar definido BACKUP_DISPATCHER_PORT HOSTNAME o su valor por defecto, en caso contrario None |
Se utiliza como dirección del Dispatcher de backup si no brinda otro el Dispatcher principal | Define la dirección del Dispatcher de repuesto que este comunica a los slaves que se conecten a él | Se utiliza como dirección del Dispatcher de backup si no brinda otro el Dispatcher principal | |
BACKUP_DISPATCHER_PORT |
En caso de estar definido BACKUP_DISPATCHER HOSTPORT o su valor por defecto, en caso contrario None |
Se utiliza como puerto del Dispatcher de backup si no brinda otro el Dispatcher principal | Define el puerto del Dispatcher de repuesto que este comunica a los slaves que se conecten a él | Se utiliza como puerto del Dispatcher de backup si no brinda otro el Dispatcher principal | |
WORKER_TIMEOUT |
60 segundos | Número se segundos que deben pasar desde la última comunicación para que el Dispatcher considere que un trabajador está desconectado. | |||
WORKER_AMOUNT |
3 | Número mínimo de workers que el Dispatcher considera que debería tener para funcionar correctamente. Si la cantidad estimada de workers en servicio está por debajo de este número se crean trabajadores | |||
MBB_RETRIES |
5 | Intentos de conexión con el Dispatcher principal una vez que deja de operar antes de buscar Backup | Intentos de conexión con el Dispatcher principal una vez que deja de operar antes de buscar Backup | ||
MBB_TIME |
1 | Tiempo entre intentos de conexión con el Dispatcher principal una vez que deja de operar antes de buscar Backup | Tiempo entre intentos de conexión con el Dispatcher principal una vez que deja de operar antes de buscar Backup | ||
CACHE_MAX_SIZE |
100 000 | Cantidad de urls máxima que guardará la cache | |||
PENDING_QUEUE_MAXSIZE |
10 000 | Cantidad de urls máxima que guardará la cola de pendientes |
Para ejecutar un nodo del sistema debe primero configurar las variables de entorno de la manera deseada y luego llamar al script principal de la siguiente forma:
python xscrap.py <role>