8 min read

Microservicios: Parte I

Microservicios: Parte I
Microservices: Part I

¿El problema?

Hoy en día, los sistemas han alcanzado un nivel de complejidad mucho mayor, integrando una amplia variedad de servicios en el lado del servidor. Con este crecimiento y la creciente necesidad de soluciones altamente eficientes, el desarrollo de software tradicional resulta limitado en términos de rendimiento y escalabilidad.

Para entenderlo mejor, revisemos cómo funcionaban los sistemas hace algunos años y por qué ese enfoque ya no es la mejor opción para aplicaciones modernas que necesitan crecer con facilidad. Un sistema web tradicional suele basarse en una arquitectura que, en esencia, se describe a continuación.

sequenceDiagram participant Client participant Server Client->>Server: Request Server-->>Client: Response

Este enfoque funciona más que bien en la mayoría de casos cuando los sistemas no necesitan escalar demasiado y solucionan un problema muy específico. Pero, ¿qué pasa si el sistema debe escalar? Vamos a considerar un sistema que ahora además de comunicarse con una API transaccional, necesita también enviar correos electrónicos; y dicha API ha escalado horizontalmente, ahora maneja usuarios, inventarios, e incluso tiene algún modelo de inteligencia artificial corriendo. Siguiendo con el enfoque tradicional, nuestro sistema se vería algo así.

graph LR C[Client] --> B subgraph B["Monolithic Backend Service"] U["Users Module"] P["Products Module"] M["Mailing Module"] A["AI Module"] end

El enfoque monolítico puede funcionar y entregar un sistema completo; sin embargo, ¿qué ocurre cuando un servicio falla? Si el módulo de correos se cae o el de productos presenta un error de usuario, el riesgo es que toda la aplicación se vea afectada. Además, cada actualización suele implicar recompilar y desplegar el proyecto completo.

Aunque una arquitectura tradicional puede ser útil en etapas iniciales, a medida que el sistema crece y aumenta su complejidad, también crece la probabilidad de fallos críticos. En un monolito, la caída de un módulo puede arrastrar al resto a la misma suerte.

Los problemas de rendimiento y escalabilidad agravan el escenario: si el módulo de correos recibe un pico de peticiones, el sistema completo se ralentiza. Si muchos usuarios intentan iniciar sesión y necesitas más capacidad, estámos obligados a escalar todo el monolito por igual, aun cuando el cuello de botella esté en un único componente. En otras palabras, escalar un monolito es posible, pero arrastramos fricción innecesaria y costos operativos crecientes. ¡Un caos!

¿Y cómo ayudan los microservicios?

Ok, tenemos claro el problema. Los sistemas monolitos son como una pieza enorme de tecnología a la cuál debemos tratar como un todo, aunque en realidad se componga de varios servicios internos. ¿La solución para escalar? Simple, hay que fragmentar el sistema en piezas más pequeñas, que al unirse se comporten como un todo, pero que no dejen de ser independientes.

Tomemos como ejemplo una bicicleta. Una bicicleta es muy similar a un sistema que usa la arquitectura de microservicios. La bicicleta está formada por piezas más pequeñas con un rol específico (frenos, neumáticos, aros, pedales, cadena, etc.), pero que si las separamos aún pueden valerse por sí mismas y no dependen de la bicicleta, ni la bicicleta depende de ella.

gray fixie bike leaning on black wall
Photo by Robert Bye / Unsplash

Si queremos cambiarle los frenos no necesitamos reemplazar la bicicleta entera, solo los frenos; si queremos ponerle llantas para barro, no necesitamos mandar a fabricar una bicicleta complemente desde cero diseñada para el barro, por lo general con un cambio de neumáticos basta; si se nos dañan los pedales, ese daño no afectará a las ruedas o los frenos. Todo es independiente, pero en conjunto trabajan en armonía. Esta es la esencia de los microservicios. Un sistema complejo compuesto por piezas más pequeñas que trabajan en conjunto para dar solución a un problema.

Volviendo a nuestro ejemplo original, podemos fragmentarlo en puezas pequeñas, cada una especializada en un trabajo específico. Ahora ya no nos comunicamos con un sistema rústico el cuál decide fallar y arrastrar consigo todos sus servicios justo cuando más apurados andamos. Ahora si un servicio se cae, los demás siguen funcionando de forma independiente. Nuestro sistema se ha hecho más robusto.

graph LR Client[Client] --> Users["Users Service"] Client --> Products["Products Service"] Client --> Mailing["Mailing Service"] Client --> AI["AI Service"]

Ahora solo necesitamos que el cliente sepa a qué microservicio hacer cada solicitud. Cada vez que un microservicio cambie de ruta el cliente también deberá actualizarse ya que debe hacer peticiones a la nueva ruta. Si se agrega un nuevo servicio, el cliente debe actualizarse una vez más para agregarlo y hacer las peticiones necesarias. Hemos mejorado, ¿no? Aún hay más, si un servicio crece en complejidad y se separa en varios microservicios, entonces el cliente debe cambiar toda la lógica para ahora hacer las peticiones correctas a los microservicios correctos. ¿Y si un microservicio necesita los datos de otro microservicio? No hay problema, el cliente hace la petición al microservicio A, la procesa, y se la envía al microservicio B. Ya con esto tenemos nuestros microservicios funcionando... pero, ¿qué hay de servicios privados que no necesitan ser públicos? Bueno, no queda de otra, ¡ahora serán públicos!

Nuestro backend ahora es más robusto, más ágil y fácil de escalar, sin embargo, la complejidad del cliente se ha disparado en un abrir y cerrar de ojos. ¿Existe algún componente que nos ayude a quitarle esta complejidad que el cliente se ha ganado solo por existir? Sí, su nombre es Api Gateway.

El API Gateway

El API gateway no es más que un intermediario entre el cliente y los microservicios, con la misión de absorver toda la complejidad que le fue dada al cliente, y además, simplificarla, dejando un sistema mucho más robusto y seguro. La incorporación del API Gateway en nuestra arquitectura se vería así.

graph LR Client[Client] --> GW["API Gateway"] subgraph Microservices direction TB Users["Users Service"] Products["Products Service"] Mail["Mailing Service"] AI["AI Service"] end GW --> Users GW --> Products GW --> Mail GW --> AI %% Single entry point: auth, routing, rate limiting, observability

En términos técnicos, el API Gateway es otro microservicio, con la tarea específica de organizar los demás microservicios del sistema y ayudar al cliente a enviar las peticiones a la ruta correcta. Ahora el cliente no conoce la existencia de los microservicios, ya que se comunicará únicamente con la API Gateway de ahora en adelante. Podemos ver a la API Gateway como una recepcionista de hotel que puede ayudarnos con las ubicaciones de las diferentes instalaciones del mismo según lo necesitemos.

La API Gateway puede ayudarnos en muchas más tareas que solo saber a qué microservicio redireccionar los requerimientos del cliente, también puede incorporar otras componentes como:

  • Rate limiting: protegiendo los servicios de estrés innecesario.
  • Control de roles: de forma que se valida los permisos del usuario incluso antes de alcanzar cualquier otro microservicio, aumentando la velocidad de respuesta al no hacer peticiones innecesarias.
  • Caching: puede mejorar el rendimiento del sistema al cachear información que se está solicitando de forma frecuente bajo los mismos parámetros.
  • Generación de tokens: aunque no suele ser lo ideal, también se puede utlizar para firmar tokens de sesión para el cliente.

En futuros posts vamos a integrar una API gateway desde cero utilizando https://www.krakend.io/.

Manejo de colas

Nuestra arquitectura ya es capaz de mantenerse operativa incluso si uno de sus microservicios llega a fallar, además de haber incrementado los tiempos de respuesta usando una API Gateway, ¡y el cliente ni siquiera se ha modificado! Pero, ¿cómo hacemos la comunicación entre diferentes microservicios? Sabemos que los microservicios pueden fallar, ¿qué hacemos si justo necesitamos comunicarnos con un microservicio que está caido? ¿Cómo agilizamos los tiempos de respuesta eliminando esperas innecesarias de procesos pesados? Para todo esto necesitamos agregar otro componente al sistema especializado de hacer esta tarea... sí, otro microservicio.

Pongámonos en contexto. Tenemos un restaurante, con muchos clientes, varios meseros y un solo chef. Cada vez que un cliente necesita algo, el mesero se dirige a la cocina, llama al chef, y le explica el plato que pidió el cliente. Así, el chef está siendo interrumpido de forma frecuente por los diferentes meseros, siéndole imposible concentrarse en cocinar. Si un mesero tiene una lista amplia de pedidos debido a que atendió una mesa con muchos invitados, nuestro chef deberá escucharlo atento todo el tiempo que este necesite para explicarle el pedido lo más detalladamente posible, mientras los demás meseros deben esperar su turno para hablarle al chef, y el chef debe hablar con todos los meseros para poder irse nuevamente a cocinar. Nuestro cliente, Pedro, que pidió una simple hamburguesa lleva ya esperando tres horas sentado porque hay muchas personas en el local y el chef no se pone a cocinar. Anita, que venía a desayunar para ir a su trabajo, lleva esperando sus huevos revueltos desde hace 2 hora, y sabiendo que llegó después que Pedro, ya empieza a perder la fe... de hecho le acaban de notificar que ha sido despedida de su trabajo, el día de ayer también llegó tarde porque pidió una taza de café y se la sirvieron después de 5 horas.

Nuestro enfoque no es monolítico, claramente tenemos microservicios. Tenemos meseros que trabajan de forma independiente; tenemos un chef que también es un individuo independiente; tenemos mesas que existen sin importar lo que hagan los meseros o el chef; y tenemos un guardia de seguridad que mira el caos en silencio, que también puede vivir sin los meseros o el chef. Podemos cambiar a los meseros o al chef sin problema; puede el chef ausentarse un momento y los meseros seguirían tomando órdenes; puede un mesero irse y aún así el chef seguirá cocinando; puede el guardia retirarse y el restaurante seguiría en pie. Entonces, ¿en qué fallamos?

El dueño del restaurante, después de varias amenazas contra su integridad por parte de personas que han perdido su empleo, decide actuar. Él se da cuenta de que el problema es que se interrumpe demasiado el trabajo del chef, no lo dejan trabajar a gusto, y por ende, todo se retrasa. No sirve de nada que él haya adoptado un enfoque de microservicios si tiene un cuello de botella tan grave. El dueño, después de pensar como si se le fuese la vida en ello, lleva un pisapapeles y lo ubica en un punto estratégico en el restaurante, para que los meseros tomen órdenes y en vez de llamar al chef, simplemente las dejan en el pisapapeles. Mágicamente todo empieza a fluir. El chef feliz de la vida porque ha dejado de socializar ahora puede concentrarse en cocinar. Se le ve más feliz. Ahora los platos de comida salen con más rapidez. Los meseros ya no necesitan explicarle al chef, solo dejar sus notas en el pisapapeles. ¡Incluso las empresas han vuelto a tener trabajadores!

Así, incorporando el sistema de colas en nuestra arquitectura de microservicios, tenemos lo siguiente.

graph LR Client[Client] --> GW["API Gateway"] subgraph Microservices direction TB Users["Users Service"] Products["Products Service"] Mail["Mailing Service"] AI["AI Service"] end subgraph Queue["Queue Manager"] end %% API Gateway routes requests to microservices GW --> Users GW --> Products GW --> Mail GW --> AI %% Microservices communicate via the queue Users <--> Queue Products <--> Queue Mail <--> Queue AI <--> Queue

Ahora, volviendo a nuestro sistema, cada vez que se necesite enviar un correo el usuario ya no debe esperar a que este proceso se ejecute, la API responde inmediatamente mientras nuestro microservicio (nuestro mesero) envía el requerimiento "enviar correo" al manejador de colas (nuestro pisapapeles) hasta que el microservicio objetivo, en este caso el de mensajería por correos (nuestro chef) esté libre y tome el requerimiento. ¿Y si el servicio de microservicios falla? No hay problema, nuestro gestor de colas va a volver a intentar al cabo de cierto tiempo, cuando el microservicio esté en línea de nuevo.

En futuras publicaciones vamos a implementar un gestor de colas usando RabbitMQ desde cero. Puedes ir revisando su documentación en su web oficial https://www.rabbitmq.com/.

En la parte II de esta serie de publicaciones sobre microservicios vamos a ponernos manos a la obra haciendo una implementación real de microservicios utilizando Nodejs y Rust, ya que sí, esta arquitectura también tiene la capacidad de trabajar con diferentes tecnologías al mismo tiempo sin problemas, siendo este uno de los tantos beneficios que ofrece, ya que podemos elegir la mejor tecnología para el microservicio que vayamos a implementar, contrastando con la arquitectura monolítica en donde por lo general estamos limitados a un solo lenguaje.