Previamente, en posts anteriores hemos identificado los componentes clave de la arquitectura de microservicios de Spring Cloud y Netflix y hemos profundizado en las posibilidades que Eureka como microservicio de descubrimiento nos proporciona. En este post analizaremos a fondo Ribbon, la librería encargada de realizar el balanceo de carga en cliente.

Como hemos hecho en entradas anteriores, analizaremos Ribbon integrado con Eureka para el hallazgo de las instancias que componen un microservicio, conformando así una arquitectura de microservicios como la mencionada.

Configuración inicial y funcionamiento

En este primer apartado vamos a ejemplificar una configuración y funcionamiento sencilla de Ribbon. Para ello contaremos con dos microservicios funcionales con un total de tres instancias:

En esta situación utilizaremos Ribbon como librería en customer-backend para balancear la carga entre las dos instancias de security. Además de esto, lógicamente contaremos con nuestro servidor Eureka para el registro de microservicios. Las siguientes imágenes muestran los microservicios presentes y la comunicación entre los mismos:

MicroS4 1

Cuando desde customer-backend se desee realizar una petición a security lo que ocurrirá será lo siguiente:

  1. Ribbon en cuanto recibe una petición a un sistema externo solicitará a Eureka el registro de microservicios (en la práctica esta consulta no se realiza ya que customer-backend al ser un eureka-client dispondrá de una caché local con el registro que se actualiza cada 30 segundos; este es el comportamiento por defecto).
  2. Ribbon con la información del registro disponible utiliza su balanceador y reglas de balanceo para decidir a qué instancia de security enviar la petición.

Configurar Ribbon es realmente sencillo si estamos trabajando con spring-boot y spring-cloud-netflix. Al añadir la dependencia de spring-cloud-starter-eureka esta incluirá las librerías de Ribbon. A continuación se muestra la dependencia de Eureka:

MicroS4 2

Y si navegamos por los poms del spring-cloud-starter-eureka podremos ver que incluye las siguientes dependencias de Ribbon:

MicroS4 3

Una vez incluidas las dependencias en nuestro proyecto, solo deberemos inyectarnos la instancia de RestTemplate. Durante su configuración en la clase LoadBalancerAutoConfiguration se creará una instancia de RestTemplate a la que le setea un RibbonLoadBalancerClient como interceptor, de forma que todas las peticiones realizadas con el RestTemplate pasarán por él. RibbonLoadBalancerClient se encargará de utilizar el balanceador configurado para obtener la instancia final a la que se enviará la petición.

MicroS4 4

En este caso podemos ver cómo se indica el nombre del microservicio security directamente. RibbonLoadBalancerClient como interceptor será el encargado de reconstruir la URL sustituyendo el nombre del microservicio por la instancia concreta que se haya seleccionado para esta petición. Además se encargará de recoger las estadísticas sobre la ejecución de las peticiones.

Arquitectura de Ribbon

Ribbon utiliza básicamente dos elementos a la hora de decidir cuál será la instancia a la que finalmente derivará la petición, estos dos elementos son los balanceadores (con sus filtros asociados) y las reglas.

Así, en primer lugar Ribbon utilizará un balanceador y filtros para descartar una serie de instancias del microservicio a invocar, en base a diversos criterios: que las instancias estén caídas, que estén en una zona con una alta carga de peticiones…

Una vez pasada esta primera etapa quedarán una serie de instancias que son las que cumplen las condiciones implementadas en los filtros, y ahí entrarán en juego las reglas de balanceo para determinar a cual de esas instancias enviar la petición.

Ribbon dispone de diferentes filtros y reglas así como la posibilidad de implementar los que deseemos, cosa que haremos en las siguientes secciones.

Cómo cambiar la configuración estándar proporcionada por Ribbon.

En esta sección explicaremos qué compone y cómo cambiar la configuración estándar proporcionada por Ribbon.

La configuración por defecto de Ribbon (integrado con Eureka) se pueden ver en la clase RibbonClientConfiguration. Esta clase define los siguientes beans (entre paréntesis se incluye la interfaz que implementan):

En nuestro caso queremos cambiar dicha configuración para poder probar diferentes reglas de balanceo, para ello deberemos crear nuestra propia clase de configuración para sobreescribir los beans deseados. A continuación se muestra un ejemplo de configuración en la que se indica que la regla de balanceo esté compuesta por RetryRule y WeightedResponseTimeRule que serán explicadas en apartados posteriores:

MicroS4 5

Esta clase de configuración se puede utilizar para sobreescribir los beans que queramos de los previamente listados.

Para poder utilizar esta configuración deberemos indicarlo en nuestra clase main por medio de la anotación @RibbonClient como se muestra en la siguiente imagen:

MicroS4 6

Reglas de balanceo

Como hemos explicado previamente las reglas de balanceo son las reglas que se aplican a la lista de instancias devueltas por el balanceador una vez que éste haya comprobado cuales están disponibles y descartado las que correspondan de acuerdo a sus filtros. Muchas de las reglas y balanceadores utilizan lógica que distingue zonas, así que para poder explicarlas y probarlas hemos definido la estructura de zonas que se puede ver en la siguiente imagen:

MicroS4 7

Como se puede ver existen dos zonas: defaultZone en la que se encuentran eureka, customer-backend y una instancia de security, e ireland compuesto por dos instancias de security. Así el cluster de instancias de security cuenta con una instancia en defaultZone y dos en ireland.

Como se ha mencionado en un post anterior sobre Eureka, la zona de un microservicio se define con la propiedad eureka.instance.metadataMap.zone.

A continuación se explican las diferentes reglas disponibles y su funcionamiento:

La lógica de esta regla se basa en descartar zonas según la disponibilidad de las instancias de microservicio en dicha zona. Los dos criterios de descarte implementados son el porcentaje de instancias no disponibles y el número de peticiones activas por servidor.

En esta situación dicha zona será descartada y las instancias disponibles serán las que existan en las restantes zonas. Para escoger cual de esas instancias será la invocada se utilizará el algoritmo RoundRobin.

Así por ejemplo, en una situación como la de la anterior imagen suponiendo que la instancia de defaultRegion sea descartada, se aplicará un RoundRobin sobre las dos instancias de la zona ireland.

En una situación en la que tuviésemos una zona adicional usa (como la ireland??!!) y ésta fuese descartada por el motivo que fuera, el algoritmo RoundRobin se aplicaría sobre las tres instancias disponibles en las zonas defaultZone e ireland.

IMPORTANTE: hay un detalle a tener en cuenta a la hora de utilizar este filtro. Si partimos de una configuración por defecto no podremos hacerlo funcionar debido a que la lógica inicialmente diseñada por Netflix es ligeramente diferente a la utilizada por el OSS (sistemas de soporte a las operaciones) Spring Cloud-Netflix. Esto se explica posteriormente en el apartado de ZonePreferenceServerListFilter de la sección ‘Balanceador y filtros de balanceado’.

Esta regla es la más sencilla de todas. Aplicará el conocido algoritmo RoundRobin que alternará las peticiones entre las diferentes instancias disponibles.

Esta regla está basada en el tiempo de respuesta medio de la instancia. A cada instancia se le asignará un peso en función de su tiempo medio de respuesta. Cuanto mayor sea el tiempo, menor peso tendrá la instancia. La instancia se elige de forma aleatoria entre las posibles con sus valores ajustados de forma acorde al peso.

El ajuste de los pesos se realiza cada 30 segundos en base a las estadísticas de peticiones, por lo que este proceso no se realiza para cada petición.

El proceso de cálculo de pesos consiste en sumar los tiempos medios de todas las instancias y a partir de ese valor el peso se calcula como:

(suma del tiempo medio de todas las instancias) - (tiempo medio de la instancia)

Por lo que a tiempo medio más bajo, se tendrá más peso. Una vez asignados los pesos se sumará el valor de los mismos. A cada instancia se le asignará un rango entre cero y la suma de los pesos igual al valor de su peso. Como paso final se generará un número aleatorio y según en qué rango se sitúe, la instancia que corresponda será la elegida.

Durante las primeras peticiones al no haber estadísticas de tiempos medios de respuesta y por tanto no haber pesos disponibles se utiliza el algoritmo RoundRobin.

Ejemplo:
Supongamos que tenemos tres servidores con los siguientes tiempos medios A = 0.25s, B = 0.35s, C = 0.75s
Se calcula la suma total de los tiempos siendo un total de 1.35s
Se adjudican los pesos A = 1.1, B = 1.0, C = 0.6 y la suma de los mismos será 2.7
El cálculo de los rangos adjudicados a cada instancia será: A = [0, 1.1], B = (1.1, 2.1] y C (2.1, 2.7]

Como se puede ver en el ejemplo cuanto menor sea el tiempo medio de respuesta de una instancia, mayor será su peso y por tanto su rango. De esta forma la instancia con mejor tiempo medio de respuesta será la que tenga más opciones de ser elegida, pero el factor aleatorio permite que también la instancia más lenta sea la elegida; en la proporción en que se diferencia su tiempo medio del de las demás.

Hay también que resaltar que como consecuencia de este algoritmo dos instancias con un tiempo medio muy similar tendrán unas posibilidades muy similares de ser elegidas, así como el hecho de que si una instancia tiene un tiempo medio muy alto será muy difícil que salga elegida (para el ejemplo anterior si la instancia C tuviese un tiempo medio de 5s nos encontraríamos con unos pesos A = 5.35, B = 5.25, C = 0.6 de forma que cualquiera de las otras dos instancias tiene casi diez veces más posibilidades de ser escogida).

Esta regla añade lógica de reintentos a cualquiera de las otras reglas existentes. Se puede utilizar conjuntamente con cualquiera de las otras reglas disponibles como se muestra en la siguiente configuración:

MicroS4 8

En caso de no indicar ninguna otra regla, RetryRule utiliza por defecto la RoundRobinRule. Otro parámetro que nos permite configurar esta regla es un timeout para reintentos de forma que se intentará encontrar una instancia válida durante el periodo determinado por ese timeout. En caso de no definirse, el valor por defecto es de 500 milisegundos.

Hay que tener en cuenta que esta política de reintento es para localizar una instancia, por lo que aunque nuestra petición retorne un error si el estado de la instancia en Eureka es correcto no se aplicará política de reintento, ya que dicha política es para localizar una instancia válida, no para reintento de peticiones.

Como ejemplo, si quisiéramos invocar al microservicio security y tuviésemos una situación como la de la imagen donde la instancia está registrada pero está caída:

MicroS4 9

La política de reintento intentaría recuperar una instancia con estado UP durante el timeout que hayamos definido. En caso de no encontrarlo lógicamente la petición fallaría.

Esta situación la hemos provocado con la siguiente implementación en nuestro microservicio security y configurando la propiedad eureka.client.healthcheck.enabled=true tal y como se explicó en un post anterior sobre Eureka:

MicroS4 10

Implementar nuestra propia regla

Otra posibilidad que siempre está presente es, en caso de que ninguno de las reglas de balanceo nos satisfaga, implementar la nuestra propia. A modo de ejemplo hemos implementado una regla basada en el concepto de la WeightedResponseTimeRule, solo que en este caso en vez de asignar pesos en base al tiempo medio de respuesta y escoger el servidor aleatoriamente en base a esos pesos, escogeremos directamente el servidor que tenga el mejor tiempo de respuesta. A continuación se puede ver el código fuente de dicha regla:

MicroS4 11

Esta regla se ha implementado con finalidades didácticas pero no sería válida para un entorno productivo real, ya que podría provocar que ciertas instancias no fuesen elegibles en un largo periodo de tiempo. Supongamos por ejemplo que tenemos una instancia A y estamos arrancando la segunda, B. Si a la instancia B se le deriva una petición y se produce un pequeño retardo de red, o la instancia no ha tenido una petición de warmup y por tanto tarda más en responder o cualquier otro tipo de situación, su tiempo medio de respuesta estaría compuesto únicamente por el tiempo de esa petición, que en este caso sería muy alto.

Esto provocaría que la instancia A, al tener mejor tiempo medio de respuesta, fuese siempre la elegida y se le enviasen todas las peticiones hasta que por sobrecarga de las mismas llegase a tener un tiempo medio de respuesta mayor al de B. En ese momento se empezarían a derivar peticiones a la instancia B.

Obviamente esto nos supone una situación no deseada, ya que en vez de realizar un balanceo lógico entre las dos instancias de forma que la carga de peticiones sea compartida entre las dos, lo que ocurre es que una instancia es ‘descartada’ hasta que la otra tiene un comportamiento tan malo como ésta, momento en el cual se cambian y la nueva empiece a recibir todas las peticiones hasta que tenga un comportamiento peor que la primera.

¿Entre que instancias?

Como se ha comentado en secciones previas, las reglas de balanceo son las encargadas de escoger la instancia a la que se enviará la petición, pero entre qué instancias se aplicará la regla está determinado previamente por el balanceador y los filtros de balanceo.

Este balanceador y sus filtros implementan lógica de zonas, es decir, su función es determinar qué zona o zonas son válidas para enviar las peticiones. Una vez escogidas las zonas se aplicará las reglas de balanceo entre todas las instancias que componen dichas zonas. La lógica de selección de zonas, en la que profundizaremos más adelante, se basa en descartar zonas que no cumplan unas condiciones mínimas (carga, número de instancias...) y en intentar fomentar la afinidad de zona, esto es, escoger la zona en la que se encuentra la instancia que va a realizar la petición.

El proceso de selección de zonas se compone básicamente de dos subprocesos:

  1. Cada treinta segundos DynamicServerListLoadBalancer, clase padre de ZoneAwareLoadBalancer, lanza un proceso que ejecutará el filtro ZonePreferenceServerListFilter de cara a dar preferencia a la zona en la que se encuentra la instancia que realiza la petición. Como resultado de este proceso tendremos un listado de zonas que se utilizará en el siguiente paso.
  2. Cada vez que se realiza una petición ésta pasará por el ZoneAwareLoadBalancer que utilizará el resultado realizado en el proceso 1, para, en caso de que se haya seleccionado más de una zona, evaluar cada una de ellas de cara a descartar las que no cumplan unas condiciones mínimas. De entre las zonas restantes se seleccionará una por medio de un proceso pseudoaleatorio y se aplicará la regla de balanceo entre las instancias que compongan dicha zona.

En esta sección analizaremos el proceso anteriormente mencionado en el punto 1 por el que se dará prioridad a la zona en la que se encuentra la instancia que realiza la petición.

ZonePreferenceServerListFilter extiende de la clase ZoneAffinityServerListFilter. Aquí es importante distinguir que la primera fue desarrollada por el equipo OSS de Spring Cloud-Netflix y la segunda por el equipo de Netflix, ya que proporcionan una lógica ligeramente diferente.

Este proceso de selección de zona es lanzado cada 30 segundos por el DynamicServerListLoadBalancer, clase padre del ZoneAwareLoadBalancer.

ZonePreferenceServerListFilter en la primera fase del proceso pedirá a su clase padre ZoneAffinityServerListFilter que obtenga la lista de servidores en base a afinidad de zona. ZoneAffinityServerListFilter, en primera instancia utilizará el predicado ZoneAffinityPredicate para encontrar de entre todas las instancias del microservicio destino las que se encuentran en la misma zona que la instancia que realiza la petición. A partir de ahí el filtro evalúa la zona en base a tres criterios:

  1. Cantidad de instancias disponibles en la zona (propiedad security.ribbon.zoneAffinity.minAvailableServers). Por defecto exige tener dos instancias disponibles como mínimo.
  2. Carga media de las instancias (propiedad security.ribbon.zoneAffinity.maxLoadPerServer). Por defecto exige que sea inferior al 60%.
  3. Porcentaje de instancias no disponibles (propiedad security.ribbon.zoneAffinity.maxBlackOutServesrPercentage). Por defecto exige que esté por debajo del 80%.

En caso de que la zona no cumpla los mínimos determinados no se aplicará la afinidad de zona y en dicho caso se devolverán todas las instancias disponibles correspondientes a todas las zonas. En caso de que la zona sí que cumpla estos criterios mínimos, el listado de instancias disponibles se compondrá con las existentes en dicha zona.

Una vez obtenido este resultado ZonePreferenceServerListFilter aplica una segunda fase de filtrado sobre el resultado devuelto por ZoneAffinityServerListFilter que consiste en que solo se escogerán las instancias correspondientes a la misma zona de la instancia que realiza la petición, o se devolverán todas sin filtrar en caso de que no haya ninguna en la misma zona.

Aquí conviene pararse un momento a analizar las diferencias entre ambos. ZoneAffinityServerListFilter es el filtro desarrollado por Netflix que busca la afinidad de zona, pero exigiendo unos mínimos criterios a dicha zona y el cual se puede deshabilitar (propiedad security.ribbon.EnableZoneAffinity). En contraposición, ZonePreferenceServerListFilter fue desarrollado por Spring Cloud-Netflix (como se puede ver por su nombre de paquete) y escogerá siempre la zona en la que se encuentra la instancia que realiza la petición en caso de tener alguna instancia del microservicio invocado, independientemente de si esta zona cumple o no ciertos criterios. Además, no es configurable, por lo que siempre se dará preferencia a la zona de la instancia que realiza la llamada. Así ZonePreferenceServerListFilter sobreescribirá el comportamiento de ZoneAffinityServerListFilter por mucho que internamente lo invoque.

Esto provoca que si tenemos una o varias instancias en la misma zona que la que realiza la petición todas las peticiones se dirigirán a esas instancias, descartando las de las demás zonas. Tiene sus ventajas, para las cuales fue diseñada, como la reducción del tiempo de respuesta debido a la red. Pero también presenta defectos como es el hecho de que al no tener en cuenta ningún otro factor más allá de la localización, podríamos encontrarnos en una situación con las instancias de una zona sobrecargadas por peticiones mientras no se deriva ninguna petición a las otras zonas. Por tanto consideramos que este comportamiento no es el óptimo y que lo mejor será utilizar directamente como filtro ZoneAffinityServerListFilter que sí que tiene en cuenta otras consideraciones (en la sección ‘Configuración avanzada de Ribbon’ se explica cómo indicar qué filtro utilizar).

Su finalidad es evaluar las diferentes zonas disponibles en base al número medio de peticiones activas y al número de instancias disponibles, y en función de dichas métricas descartar la peor zona.

ZoneAwareLoadBalancer extiende la clase DynamicServerListLoadBalancer que se caracteriza por ser un balanceador que permite un cambio dinámico del listado de instancias que componen un microservicio. Dicho cambio dinámico es la evaluación periódica que comentamos en el apartado anterior realizada por los filtros. Así mismo, DynamicServerListLoadBalancer extiende BaseLoadBalancer que extiende a su vez AbstractLoadBalancer que implementará la interfaz ILoadBalancer conformando así la jerarquía de clases del balanceador.

ZoneAwareLoadBalancer se ejecutará cada vez que se recibe una petición. Utiliza como entrada el listado de zonas resultado del proceso realizado previamente por los filtros. De esta forma nos encontramos con dos posibles situaciones a la entrada:

  1. El proceso de filtrado ha devuelto como resultado una única zona: ya sea porque es la zona afín, porque solo hay instancias del microservicio destino en esa zona o porque las demás zonas han sido descartadas.
  2. El proceso de filtrado ha devuelto como resultado varias zonas: en este caso podemos asumir que no se ha aplicado la afinidad de zona, ya que si fuera así solo podría haber una.

Si nos encontramos en la situación 1 el ZoneAwareLoadBalancer no aplicará ningún tipo de lógica y derivará directamente todas las instancias que componen esa zona a la regla de balanceo para que decida a cual invocar.

Si nos encontramos en la situación 2, ZoneAwareLoadBalancer aplicará lógica de selección de zonas. Para ello utilizará la clase ZoneAvoidanceRule (ya explicada en la sección ‘Reglas de balanceo’) para determinar la zona a descartar en base al porcentaje de instancias no disponibles y al número de peticiones activas por instancia para cada zona (esta lógica es aplicada independientemente de cual sea luego la regla configurada para elegir entre instancias).

El porcentaje de instancias no disponibles se puede establecer con la propiedad ZoneAwareNIWSDiscoveryLoadBalancer.security.avoidZoneWithBlackoutPercetage que por defecto tiene el valor 0.99999 de forma que todas las instancias tendrán que estar no disponibles para que se descarte la zona. El número de peticiones activas por instancia se establece con la propiedad ZoneAwareNIWSDiscoveryLoadBalancer.security.triggeringLoadPerServerThreshold cuyo valor por defecto es 0.2. Este proceso descartará las zonas que no cumplan dichas condiciones. En caso de que ninguna lo haga se descartará la peor.

Si el resultado posterior a este proceso está compuesto por una única zona, se enviarán las instancias de ésta a la regla de balanceo para determinar cual invocar. En caso de que el resultado sean varias zonas se escogerá una de las mismas de forma aleatoria distribuyendo las posibilidades de forma acorde al número de instancias existentes en cada zona.

La lógica de evaluación y descarte de zonas se puede deshabilitar con la propiedad ZoneAwareNIWSDiscoveryLoadBalancer.enabled, de esta forma la regla de balanceo siempre se aplicará sobre las zonas resultantes del proceso realizado por los filtros.

El siguiente .gif muestra el proceso de evaluación de zonas realizado por el ZoneAwareLoadBalancer, en él se puede ver cómo se calculan los ratios de zona en base a las peticiones pendientes, instancias existentes e instancias disponibles:

MicroS4 ZALB-animated

Como hemos comentado anteriormente, este comportamiento de descarte de zonas no podrá ser evaluado con la configuración por defecto, ya que el ZonePreferenceServerListFilter escogerá siempre la zona en la que se encuentre la instancia que ha realizado la petición. Para evitar esto existen dos posibilidades, o que todas las instancias del microservicio destino estén en zonas que no sean la de la instancia del microservicio que va a realizar la petición o, teniendo en cuenta que el ZonePreferenceServerListFilter no se puede deshabilitar, no configurar ningún filtro. La imagen que se muestra a continuación contiene la configuración necesaria para no utilizar ningún filtro. De esta forma se podrá probar la lógica de descarte de zonas:

MicroS4 12

De la misma forma, si se quieren realizar pruebas, se recomienda cambiar la configuración por defecto, para que por ejemplo no sea necesario tener como mínimo dos instancias por zona para que ésta sea considerada como válida por el ZoneAffinityServerListFilter. La siguiente imagen muestra una configuración con la finalidad de identificar propiedades relevantes de cara a la gestión de zonas:

MicroS4 13

% block:html
% endblock

Conclusiones

Como hemos visto Ribbon está diseñado completamente con una filosofía cloud, teniendo en cuenta que la lista de instancias puede cambiar dinámicamente, considerando el concepto de zona y cómo éste afecta a las peticiones realizadas. Además se integra con otras librerías y microservicios de Spring Cloud-Netflix como Eureka e Hystrix. La variedad de reglas de balanceo existentes y la posibilidad de definir nuestras propias reglas, filtros y balanceadores convierten a Ribbon en una poderosa y versátil librería para realizar balanceo de carga en cliente.

Fuentes

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.