En el presente repositorio se provee un esqueleto básico de cliente/servidor, en donde todas las dependencias del mismo se encuentran encapsuladas en containers. Los alumnos deberán resolver una guía de ejercicios incrementales, teniendo en cuenta las condiciones de entrega descritas al final de este enunciado.
El cliente (Golang) y el servidor (Python) fueron desarrollados en diferentes lenguajes simplemente para mostrar cómo dos lenguajes de programación pueden convivir en el mismo proyecto con la ayuda de containers, en este caso utilizando Docker Compose.
El repositorio cuenta con un Makefile que incluye distintos comandos en forma de targets. Los targets se ejecutan mediante la invocación de: make <target>. Los target imprescindibles para iniciar y detener el sistema son docker-compose-up y docker-compose-down, siendo los restantes targets de utilidad para el proceso de depuración.
Los targets disponibles son:
| target | accion |
|---|---|
docker-compose-up |
Inicializa el ambiente de desarrollo. Construye las imágenes del cliente y el servidor, inicializa los recursos a utilizar (volúmenes, redes, etc) e inicia los propios containers. |
docker-compose-down |
Ejecuta docker-compose stop para detener los containers asociados al compose y luego docker-compose down para destruir todos los recursos asociados al proyecto que fueron inicializados. Se recomienda ejecutar este comando al finalizar cada ejecución para evitar que el disco de la máquina host se llene de versiones de desarrollo y recursos sin liberar. |
docker-compose-logs |
Permite ver los logs actuales del proyecto. Acompañar con grep para lograr ver mensajes de una aplicación específica dentro del compose. |
docker-image |
Construye las imágenes a ser utilizadas tanto en el servidor como en el cliente. Este target es utilizado por docker-compose-up, por lo cual se lo puede utilizar para probar nuevos cambios en las imágenes antes de arrancar el proyecto. |
build |
Compila la aplicación cliente para ejecución en el host en lugar de en Docker. De este modo la compilación es mucho más veloz, pero requiere contar con todo el entorno de Golang y Python instalados en la máquina host. |
Se trata de un "echo server", en donde los mensajes recibidos por el cliente se responden inmediatamente y sin alterar.
Se ejecutan en bucle las siguientes etapas:
- Servidor acepta una nueva conexión.
- Servidor recibe mensaje del cliente y procede a responder el mismo.
- Servidor desconecta al cliente.
- Servidor retorna al paso 1.
se conecta reiteradas veces al servidor y envía mensajes de la siguiente forma:
- Cliente se conecta al servidor.
- Cliente genera mensaje incremental.
- Cliente envía mensaje al servidor y espera mensaje de respuesta.
- Servidor responde al mensaje.
- Servidor desconecta al cliente.
- Cliente verifica si aún debe enviar un mensaje y si es así, vuelve al paso 2.
Al ejecutar el comando make docker-compose-up y luego make docker-compose-logs, se observan los siguientes logs:
client1 | 2024-08-21 22:11:15 INFO action: config | result: success | client_id: 1 | server_address: server:12345 | loop_amount: 5 | loop_period: 5s | log_level: DEBUG
client1 | 2024-08-21 22:11:15 INFO action: receive_message | result: success | client_id: 1 | msg: [CLIENT 1] Message N°1
server | 2024-08-21 22:11:14 DEBUG action: config | result: success | port: 12345 | listen_backlog: 5 | logging_level: DEBUG
server | 2024-08-21 22:11:14 INFO action: accept_connections | result: in_progress
server | 2024-08-21 22:11:15 INFO action: accept_connections | result: success | ip: 172.25.125.3
server | 2024-08-21 22:11:15 INFO action: receive_message | result: success | ip: 172.25.125.3 | msg: [CLIENT 1] Message N°1
server | 2024-08-21 22:11:15 INFO action: accept_connections | result: in_progress
server | 2024-08-21 22:11:20 INFO action: accept_connections | result: success | ip: 172.25.125.3
server | 2024-08-21 22:11:20 INFO action: receive_message | result: success | ip: 172.25.125.3 | msg: [CLIENT 1] Message N°2
server | 2024-08-21 22:11:20 INFO action: accept_connections | result: in_progress
client1 | 2024-08-21 22:11:20 INFO action: receive_message | result: success | client_id: 1 | msg: [CLIENT 1] Message N°2
server | 2024-08-21 22:11:25 INFO action: accept_connections | result: success | ip: 172.25.125.3
server | 2024-08-21 22:11:25 INFO action: receive_message | result: success | ip: 172.25.125.3 | msg: [CLIENT 1] Message N°3
client1 | 2024-08-21 22:11:25 INFO action: receive_message | result: success | client_id: 1 | msg: [CLIENT 1] Message N°3
server | 2024-08-21 22:11:25 INFO action: accept_connections | result: in_progress
server | 2024-08-21 22:11:30 INFO action: accept_connections | result: success | ip: 172.25.125.3
server | 2024-08-21 22:11:30 INFO action: receive_message | result: success | ip: 172.25.125.3 | msg: [CLIENT 1] Message N°4
server | 2024-08-21 22:11:30 INFO action: accept_connections | result: in_progress
client1 | 2024-08-21 22:11:30 INFO action: receive_message | result: success | client_id: 1 | msg: [CLIENT 1] Message N°4
server | 2024-08-21 22:11:35 INFO action: accept_connections | result: success | ip: 172.25.125.3
server | 2024-08-21 22:11:35 INFO action: receive_message | result: success | ip: 172.25.125.3 | msg: [CLIENT 1] Message N°5
client1 | 2024-08-21 22:11:35 INFO action: receive_message | result: success | client_id: 1 | msg: [CLIENT 1] Message N°5
server | 2024-08-21 22:11:35 INFO action: accept_connections | result: in_progress
client1 | 2024-08-21 22:11:40 INFO action: loop_finished | result: success | client_id: 1
client1 exited with code 0
En esta primera parte del trabajo práctico se plantean una serie de ejercicios que sirven para introducir las herramientas básicas de Docker que se utilizarán a lo largo de la materia. El entendimiento de las mismas será crucial para el desarrollo de los próximos TPs.
Definir un script de bash generar-compose.sh que permita crear una definición de Docker Compose con una cantidad configurable de clientes. El nombre de los containers deberá seguir el formato propuesto: client1, client2, client3, etc.
El script deberá ubicarse en la raíz del proyecto y recibirá por parámetro el nombre del archivo de salida y la cantidad de clientes esperados:
./generar-compose.sh docker-compose-dev.yaml 5
Considerar que en el contenido del script pueden invocar un subscript de Go o Python:
#!/bin/bash
echo "Nombre del archivo de salida: $1"
echo "Cantidad de clientes: $2"
python3 mi-generador.py $1 $2
En el archivo de Docker Compose de salida se pueden definir volúmenes, variables de entorno y redes con libertad, pero recordar actualizar este script cuando se modifiquen tales definiciones en los sucesivos ejercicios.
Modificar el cliente y el servidor para lograr que realizar cambios en el archivo de configuración no requiera reconstruír las imágenes de Docker para que los mismos sean efectivos. La configuración a través del archivo correspondiente (config.ini y config.yaml, dependiendo de la aplicación) debe ser inyectada en el container y persistida por fuera de la imagen (hint: docker volumes).
Crear un script de bash validar-echo-server.sh que permita verificar el correcto funcionamiento del servidor utilizando el comando netcat para interactuar con el mismo. Dado que el servidor es un echo server, se debe enviar un mensaje al servidor y esperar recibir el mismo mensaje enviado.
En caso de que la validación sea exitosa imprimir: action: test_echo_server | result: success, de lo contrario imprimir:action: test_echo_server | result: fail.
El script deberá ubicarse en la raíz del proyecto. Netcat no debe ser instalado en la máquina host y no se pueden exponer puertos del servidor para realizar la comunicación (hint: docker network). `
Modificar servidor y cliente para que ambos sistemas terminen de forma graceful al recibir la signal SIGTERM. Terminar la aplicación de forma graceful implica que todos los file descriptors (entre los que se encuentran archivos, sockets, threads y procesos) deben cerrarse correctamente antes que el thread de la aplicación principal muera. Loguear mensajes en el cierre de cada recurso (hint: Verificar que hace el flag -t utilizado en el comando docker compose down).
Las secciones de repaso del trabajo práctico plantean un caso de uso denominado Lotería Nacional. Para la resolución de las mismas deberá utilizarse como base el código fuente provisto en la primera parte, con las modificaciones agregadas en el ejercicio 4.
Modificar la lógica de negocio tanto de los clientes como del servidor para nuestro nuevo caso de uso.
Emulará a una agencia de quiniela que participa del proyecto. Existen 5 agencias. Deberán recibir como variables de entorno los campos que representan la apuesta de una persona: nombre, apellido, DNI, nacimiento, numero apostado (en adelante 'número'). Ej.: NOMBRE=Santiago Lionel, APELLIDO=Lorca, DOCUMENTO=30904465, NACIMIENTO=1999-03-17 y NUMERO=7574 respectivamente.
Los campos deben enviarse al servidor para dejar registro de la apuesta. Al recibir la confirmación del servidor se debe imprimir por log: action: apuesta_enviada | result: success | dni: ${DNI} | numero: ${NUMERO}.
Emulará a la central de Lotería Nacional. Deberá recibir los campos de la cada apuesta desde los clientes y almacenar la información mediante la función store_bet(...) para control futuro de ganadores. La función store_bet(...) es provista por la cátedra y no podrá ser modificada por el alumno.
Al persistir se debe imprimir por log: action: apuesta_almacenada | result: success | dni: ${DNI} | numero: ${NUMERO}.
Se deberá implementar un módulo de comunicación entre el cliente y el servidor donde se maneje el envío y la recepción de los paquetes, el cual se espera que contemple:
- Definición de un protocolo para el envío de los mensajes.
- Serialización de los datos.
- Correcta separación de responsabilidades entre modelo de dominio y capa de comunicación.
- Correcto empleo de sockets, incluyendo manejo de errores y evitando los fenómenos conocidos como short read y short write.
Modificar los clientes para que envíen varias apuestas a la vez (modalidad conocida como procesamiento por chunks o batchs). Los batchs permiten que el cliente registre varias apuestas en una misma consulta, acortando tiempos de transmisión y procesamiento.
La información de cada agencia será simulada por la ingesta de su archivo numerado correspondiente, provisto por la cátedra dentro de .data/datasets.zip.
Los archivos deberán ser inyectados en los containers correspondientes y persistido por fuera de la imagen (hint: docker volumes), manteniendo la convencion de que el cliente N utilizara el archivo de apuestas .data/agency-{N}.csv .
En el servidor, si todas las apuestas del batch fueron procesadas correctamente, imprimir por log: action: apuesta_recibida | result: success | cantidad: ${CANTIDAD_DE_APUESTAS}. En caso de detectar un error con alguna de las apuestas, debe responder con un código de error a elección e imprimir: action: apuesta_recibida | result: fail | cantidad: ${CANTIDAD_DE_APUESTAS}.
La cantidad máxima de apuestas dentro de cada batch debe ser configurable desde config.yaml. Respetar la clave batch: maxAmount, pero modificar el valor por defecto de modo tal que los paquetes no excedan los 8kB.
Por su parte, el servidor deberá responder con éxito solamente si todas las apuestas del batch fueron procesadas correctamente.
Modificar los clientes para que notifiquen al servidor al finalizar con el envío de todas las apuestas y así proceder con el sorteo.
Inmediatamente después de la notificacion, los clientes consultarán la lista de ganadores del sorteo correspondientes a su agencia.
Una vez el cliente obtenga los resultados, deberá imprimir por log: action: consulta_ganadores | result: success | cant_ganadores: ${CANT}.
El servidor deberá esperar la notificación de las 5 agencias para considerar que se realizó el sorteo e imprimir por log: action: sorteo | result: success.
Luego de este evento, podrá verificar cada apuesta con las funciones load_bets(...) y has_won(...) y retornar los DNI de los ganadores de la agencia en cuestión. Antes del sorteo no se podrán responder consultas por la lista de ganadores con información parcial.
Las funciones load_bets(...) y has_won(...) son provistas por la cátedra y no podrán ser modificadas por el alumno.
No es correcto realizar un broadcast de todos los ganadores hacia todas las agencias, se espera que se informen los DNIs ganadores que correspondan a cada una de ellas.
En este ejercicio es importante considerar los mecanismos de sincronización a utilizar para el correcto funcionamiento de la persistencia.
Modificar el servidor para que permita aceptar conexiones y procesar mensajes en paralelo. En caso de que el alumno implemente el servidor en Python utilizando multithreading, deberán tenerse en cuenta las limitaciones propias del lenguaje.
Se espera que los alumnos realicen un fork del presente repositorio para el desarrollo de los ejercicios y que aprovechen el esqueleto provisto tanto (o tan poco) como consideren necesario.
Cada ejercicio deberá resolverse en una rama independiente con nombres siguiendo el formato ej${Nro de ejercicio}. Se permite agregar commits en cualquier órden, así como crear una rama a partir de otra, pero al momento de la entrega deberán existir 8 ramas llamadas: ej1, ej2, ..., ej7, ej8.
(hint: verificar listado de ramas y últimos commits con git ls-remote)
Se espera que se redacte una sección del README en donde se indique cómo ejecutar cada ejercicio y se detallen los aspectos más importantes de la solución provista, como ser el protocolo de comunicación implementado (Parte 2) y los mecanismos de sincronización utilizados (Parte 3).
Se proveen pruebas automáticas de caja negra. Se exige que la resolución de los ejercicios pase tales pruebas, o en su defecto que las discrepancias sean justificadas y discutidas con los docentes antes del día de la entrega. El incumplimiento de las pruebas es condición de desaprobación, pero su cumplimiento no es suficiente para la aprobación. Respetar las entradas de log planteadas en los ejercicios, pues son las que se chequean en cada uno de los tests.
La corrección personal tendrá en cuenta la calidad del código entregado y casos de error posibles, se manifiesten o no durante la ejecución del trabajo práctico. Se pide a los alumnos leer atentamente y tener en cuenta los criterios de corrección informados en el campus.
Para el ejercicio 1, se genero un script generar-compose.sh que en su interior llama a un script de python generar-compose.py. Dicho archivo almacena templates de los elementos que componen al archivo docker-compose, dichos elementos: Server, Network y Clients. Para este ejercicio el Cliente es un template con formato el cual se rellena para agregar el valor del id del cliente.
Para generar un archivo de manera dinamica, se necesita llamar la ejecucion del archivo de la siguiente manera:
./generar-compose.sh <archivo-destino> <cantidad-de-clientes>
La ejecucion finaliza con alguno de los siguientes codigos:
-
0: Para indicar la correcta ejecucion del programa y la generacion del archivo docker-compose con n cantidad de clientes
-
1: Para indicar que el numero pasado por parametro no era valido
-
2: Para indicar un error inesperado.
Dichos errores son logeados manera mas comoda en la terminal del usuario mostrando los siguientes mensajes:
Para el codigo 0:
✅ docker compose file generated successfully
Para el codigo 1:
❌ Error: Please provide valid arguments
Para el codigo 2:
❌ Unexpected error occurred with exit code $exit_code
Para el ejercicio 2, se agregan dos volumenes, uno para el cliente y otro para el servidor. Para esto se modificaron las constantes de templates encontradas en el generar-compose.py
Para el servidor:
volumes:
- ./server/config.ini:/config.ini
Para el cliente:
volumes:
- ./client/config.yaml:/config.yaml
Estas lineas estan compuestas por: ubicacion del archivo : mapeo ubicacion destino dentro del contenedor. Estas lineas automaticamente montan los volumenes en cada uno de los contenedores levantados permitiendo asi no tener la necesidad de reconstruir las imagenes tanto del server como del cliente
Referencia usada de docker volume
Para el ejercicio 3, se genero un script validar-echo-server que tiene definida una variable de mensaje y el puerto del servidor a donde debe comunicarse. Dicho server ejecuta run de docker que utiliza un contenedor que tiene la herramienta netchat instalada, evitando asi tener que instalarlo en la maquina en la cual se corra el TP.
La imagen utilizada es busybox:lastest la cual se consiguio mediante la revision de los archivos entregados por la catedra para la elaboracion del TP, vease, que dicha imagen se encuentra en el Dockerfile file del cliente. Busybox es una imagen ligera que contiene herramientas del sistema unix que pueden ser aprovechadas, en este caso se utiliza el comando nc
Por lo tanto, se crea y se ejecuta el contenedor de docker el cual con el flag --rm se le indica que se elimine automaticamente al finalizar la ejecucion y mediante la conexion a la red tp0_testing_net se envia un mensaje al servidor definido previamente en la variable y se analiza el resultado de dicha respuesta
Para probar el funcionamiento basta con tener el servidor levantado mediante
make docker-compose-up
y desde otra terminal ejecutar el script
./validar-echo-server.sh
Tanto como para el cliente, como para el sevidor se generaron manejadores para las señales
Se genero una funcion que cierra los recursos que puedan estar siendo usados, en este caso se puede estar en alguna de las siguientes situaciones:
-
El servidor se encuentra bloqueado aceptando: Por lo tanto se tiene que hacer el cierre correspondiente del socket para eliminar el bloqueo y por supuesto para dejar de usar el recurso
-
El servidor se encuentra atendiendo un cliente: Por lo tanto se debe cerrar el socket del cliente y liberar los recursos utilizados en el sistema
En ambos casos python setea el manejador programado en el constructor del objeto servidor y aplica la funcion indicada
Golang por otro lado no es tan simple a la hora de hacer el manejo de la señal, requiere un Channel el cual sea del tipo os.Signal, el mismo debe tener un buffer para evitar que se bloquee el contralador en caso que la señal llegue antes de que este seteada la configuracion del manejador
Para manejar la señal primero se debe indicar que la señal en particular se quiera manejar sea recibida en el Channel y posteriormente se debe lanzar un hilo que exclusivamente se encarge de esperar la posible llegada de la señal y que en caso de llegar, cierre los recursos que estan siendo utilizados
Para la comunicacion establecer la nueva comunicacion se genero un protocolo sencillo para poder pasar las apuestas
El cliente como solo debe enviar su apuesta, genera la apuesta, la serializa a string y se la entrega al protocolo. El protocolo envia un mensaje al server haciendo lo siguiente:
-------------------------------------------
| Cod op | longitud | apuesta-serializada |
-----------------------------------------
| 1 byte | 4 bytes | longitud bytes |
-------------------------------------------
Siguiendo la secuencia
Client -> Se conecta con el server -> Server
Client -> Envia paquete de apuesta -> Server
Client <- Espera respuesta server
Server <- Procesa apuesta
Server -> Pasa un codigo de exito o error segun sea el caso -> Cliente
Esta vez se ajusta el protocolo ya existente para pasar batches en ves de apuestas individuales, ahora se arman paquetes que tienen el siguiente formato:
-------------------------------------------------------------------------------
| Cod op | longitud batch | logitud apuesta 1 | apuesta 1 | logitud apuesta 2 |
-------------------------------------------------------------------------------
| 1 byte | 4 bytes | 4 bytes | variable | 4 bytes |
-------------------------------------------------------------------------------
Siguiendo la secuencia:
Client -> Envia batches -> Server
Server <- Procesa batch
Server -> Confirma recepcion correcta o incorrecta de batches
Client -> Indica la finalizacion de la transmision de batches
Server -> Mensaje de fin de procesamiento
Para el sorteo se usa toda la base del protocolo ya existente, esta vez se configuro en una constante en el generador del docker compose como variable de entono para definir la cantidad de clientes.
Una vez que se alcanza la cantidad de clientes esperada, simplemente se procede a hacer el envio de los mensajes del sorteo a cada cliente considerando enviarle a cada cliente solamente los ganadores de su loteria y no todos los elementos, el nuevo es bastante parecido a la a la logica del intercambio de las apuestas.
El mensaje tiene un codigo de mensaje, una longitud y los dnis de los ganadores de la loteria
Para el ejercicio 8 se agrego soporte para atender multiples clientes a la vez, si bien es cierto de que es mucho mas pesado y dado a las condiciones del uso de CPU y las operaciones I/O hechas, permite que la arquitectura sea construida con multithreading, pero, por simplemente por practicar y por mayor robustes se hace la arquitectura usando multiprocessing.
Para sincronizar el envio de mensajes con todos los clientes a la hora de realizar el sorteo se usa un barrier que es un IPC que cumple perfectamente con la idea de sincronizar procesos en un mismo punto comun