IT & Engineering
Gubernator: limitación de tasa distribuida y nativa de la nube para microservicios
Hoy, a Mailgun le entusiasma lanzar como código abierto Gubernator, un microservicio de limitación de tasa distribuido y de alto rendimiento. ¿Qué hace Gubernator? Buena pregunta:
Características de Gubernator
- Gubernator distribuye uniformemente las peticiones de limitación de tasa en todo el clúster, lo que significa que puedes escalar el sistema con solo añadir más nodos.
- Gubernator no depende de cachés externas como Memcache o Redis, por lo que no hay sincronización de implementación con un servicio dependiente. Esto hace que aumentar o reducir dinámicamente el clúster en un sistema de orquestación como Kubernetes o Nomad sea trivial.
- Gubernator no mantiene ningún estado en el disco, el cliente le pasa su configuración por cada petición.
- Gubernator proporciona acceso GRPC y HTTP a su API.
- Puede ejecutarse como sidecar para los servicios que necesitan limitación de tasa o como un servicio independiente.
- Puede usarse como biblioteca para implementar un servicio de limitación de tasa específico de dominio.
- Admite de forma opcional una distribución de limitación de tasa con consistencia eventual para entornos de rendimiento extremadamente alto.
- Gubernator es la pronunciación en inglés de “gobernador” en ruso, y además suena genial.
Seguro que tienes muchas preguntas sobre por qué decidimos lanzar Gubernator como código abierto. Así que, antes de entrar en cómo funciona Gubernator, vamos a responder primero a algunas de esas preguntas. Es probable que la más importante que te estés haciendo sea…
¿Por qué no usar Redis?
Nos llamaron la atención un par de cosas al evaluar Redis.
- Usar la implementación básica de limitación de tasa de Redis supondría idas y venidas de red adicionales incluso con canalización.
- Podríamos reducir las idas y venidas usando https://redis.io/commands/eval y escribiendo un script en LUA que tendríamos que mantener por cada algoritmo que implementáramos.
- Cada petición daría como resultado al menos una ida y vuelta a Redis. Si combinamos eso con al menos una ida y vuelta a nuestro microservicio, significa que hay al menos dos idas y venidas por petición a nuestro servicio.
La solución óptima para Redis es escribir un script en LUA que implemente el algoritmo de limitación de tasa. Ese script se almacena en el servidor de Redis y se llama por cada petición de limitación de tasa. En este escenario, Redis hace la mayor parte del trabajo y nuestro microservicio es básicamente un proxy para acceder a Redis. Siendo así, tenemos dos opciones:
- Crear Gubernator como una biblioteca de limitación de tasa que proporcione acceso a Redis. Todos los servicios que necesiten limitar la tasa utilizarían esta biblioteca.
- Eliminar Redis e implementar los algoritmos de distribución, almacenamiento en caché y limitación en un microservicio de limitación de tasa con un cliente GRPC ligero para que lo utilice cada servicio.
¿Por qué un microservicio?
Mailgun es una empresa políglota, y Python y Golang conforman la mayor parte de nuestro código fuente. Si decidimos implementar la limitación de tasa como una biblioteca, se requiere como mínimo una versión de la biblioteca en Python y otra en Golang. Ya hemos seguido este camino a nivel interno con una versión en Python y otra en Golang de la misma biblioteca. Según nuestra experiencia, las bibliotecas compartidas entre servicios tienen las siguientes desventajas.
- Las actualizaciones de errores y características de la biblioteca pueden suponer, en el mejor de los casos, una actualización de la dependencia. En el peor de los casos, requiere modificar todos los servicios que usan la biblioteca en todos los lenguajes compatibles.
- Rara vez el equipo de desarrollo quiere mantener o escribir características nuevas en ambos lenguajes. Esto suele traducirse en que una versión de la biblioteca tenga más características o un mejor mantenimiento que la otra.
A medida que el número de microservicios y lenguajes de nuestro ecosistema sigue creciendo, estos problemas se acumulan y empeoran. En comparación, las bibliotecas GRPC y HTTP se crean y mantienen fácilmente para cada lenguaje que necesite acceder a Gubernator.
Para los microservicios, se pueden añadir actualizaciones de errores y nuevas características sin interrumpir los servicios dependientes. Siempre y cuando no se permitan cambios que rompan la API, los servicios dependientes pueden optar por las nuevas características sin necesidad de actualizar todos los servicios dependientes.
La característica clave de Gubernator como microservicio es que crea un punto de sincronización para las múltiples peticiones que entran en el sistema. Las peticiones que se reciben con pocos microsegundos de diferencia pueden optimizarse y coordinarse en lotes, lo que reduce el ancho de banda general y la latencia de ida y vuelta que utiliza el servicio bajo una carga pesada. Varios servicios que se ejecutan en un único host con la misma biblioteca ejecutándose en sus respectivos procesos no tienen esta capacidad.
¿Por qué Gubernator no tiene estado?
Gubernator no tiene estado en el sentido de que no requiere espacio en el disco para funcionar. Ningún dato de configuración o caché se sincroniza nunca en el disco, y esto se debe a que cada petición a Gubernator incluye la configuración para la limitación de tasa.
Al principio, podrías pensar que esto supone una sobrecarga innecesaria para cada petición. Sin embargo, en realidad, la configuración de limitación de tasa se compone de solo cuatro números enteros de 64 bits. La configuración se compone de límite, duración, algoritmo y comportamiento (consulta más abajo los detalles sobre cómo funciona). Debido a esta sencilla configuración, Gubernator se puede utilizar para proporcionar una gran variedad de casos de uso de limitación de tasa que pueden emplear los clientes. Algunos de estos casos de uso son:
- Limitación de entrada: limitación típica basada en HTTP del tipo 402 demasiadas peticiones
- Descarte de tráfico: deniega solo las peticiones nuevas o sin autenticar cuando tu API esté en mal estado.
- Limitación de salida: bombardear servidores SMTP externos con millones de mensajes no es bueno.
- Procesamiento de colas: sabe cuándo una petición se puede gestionar de inmediato o si debe ponerse en cola y procesarse en el orden en que se recibió.
- Gestión de capacidad de la API: establece límites globales sobre el número total de peticiones que puede gestionar un sistema API colectivo. Deniega o pone en cola las peticiones que superen la capacidad de funcionamiento normal del sistema.
Además de los casos de uso mencionados, un diseño sin configuración tiene implicaciones importantes para el diseño y la implementación de microservicios:
- Sin sincronización de la configuración en la implementación. Cuando se implementa un servicio que usa Gubernator, no hay ninguna configuración de limitación de tasa que se deba implementar previamente en Gubernator.
- Los servicios que usan Gubernator poseen su modelo de dominio de limitación de tasa para su ámbito del problema. Esto mantiene el conocimiento específico del dominio fuera de Gubernator, para que Gubernator pueda centrarse en lo que mejor hace: ¡limitar tasas!
Dejando a un lado esas preguntas, vamos a hablar más sobre Gubernator en su conjunto, empezando por cómo funciona.
Cómo funciona Gubernator

Gubernator está diseñado para ejecutarse como un clúster distribuido de pares que utilizan una caché en memoria de todos los límites de tasa actualmente activos, por lo que nunca se sincronizan datos en el disco. Dado que la mayoría de las duraciones de límites de tasa basadas en la red se mantienen solo durante unos segundos, perder la caché en memoria durante un reinicio o un tiempo de inactividad programado no es un gran problema. Para Gubernator, elegimos el rendimiento por encima de la precisión, ya que es aceptable que un pequeño subconjunto de tráfico haga demasiadas peticiones durante un corto periodo de tiempo (generalmente segundos) en caso de pérdida de caché.
Cuando se hace una petición de limitación de tasa a Gubernator, la petición recibe una clave y se aplica un algoritmo de hash consistente para determinar cuál de los pares será el propietario de la petición de limitación de tasa. Elegir un único propietario para un límite de tasa hace que los incrementos atómicos de los recuentos sean muy rápidos y evita la complejidad y latencia que supone distribuir recuentos de forma consistente en un clúster de pares.
Aunque es sencillo y tiene un buen rendimiento, este diseño podría ser susceptible a una avalancha de peticiones, ya que un único coordinador es responsable de, posiblemente, cientos de miles de peticiones hacia un límite de tasa.
Para combatir esto, los clientes pueden solicitar `Behaviour=BATCHING`, lo que permite a los pares tomar múltiples peticiones en un margen especificado (500 microsegundos por defecto) y agruparlas en una única petición al par, lo que reduce drásticamente el número total de peticiones a través de la red a un solo par de Gubernator.
Para garantizar que cada par del clúster calcule con precisión el hash correcto para una clave de límite de tasa, la lista de pares del clúster se debe distribuir a cada uno de ellos de forma oportuna y coherente. En la actualidad, Gubernator admite el uso de etcd o la API de puntos de conexión de Kubernetes para descubrir pares de Gubernator.
Funcionamiento de Gubernator
Cuando un cliente o servicio hace una petición a Gubernator, el cliente proporciona la configuración del límite de tasa con cada petición. La configuración de limitación de tasa se almacena entonces con el estado actual del límite de tasa en la caché local del propietario del límite de tasa. Los límites de tasa y su configuración que se almacenan en la caché local solo existirán durante la duración especificada de la configuración del límite de tasa.
Una vez transcurrido el tiempo de duración y si no se volvió a solicitar el límite de tasa en ese periodo, se descarta de la caché. Las peticiones posteriores para el mismo par de nombre y clave única volverán a crear la configuración y el límite de tasa en la caché y el ciclo se repetirá. Por otro lado, las peticiones posteriores con configuraciones diferentes sobrescribirán la configuración anterior y aplicarán la nueva de inmediato.
Un ejemplo de petición de limitación de tasa enviada a través de GRPC sería así:
rate_limits:rn # Scopes the request to a specific rate limit rn - name: requests_per_secrn # A unique_key that identifies this rate limit requestrn unique_key: account_id=123|source_ip=172.0.0.1rn # The number of hits we are requestingrn hits: 1rn # The total number of requests allowed for this rate limitrn limit: 100rn # The duration of the rate limit in millisecondsrn duration: 1000rn # The algorithm used to calculate the rate limit rn # 0 = Token Bucketrn # 1 = Leaky Bucketrn algorithm: 0rn # The behavior of the rate limit in gubernator.rn # 0 = BATCHING (Enables batching of requests to peers)rn # 1 = NO_BATCHING (Disables batching)rn # 2 = GLOBAL (Enable global caching for this rate limit)rn behavior: 0
Un ejemplo de respuesta sería:
rate_limits:rn # The status of the rate limit. OK = 0, OVER_LIMIT = 1rn - status: 0,rn # The current configured limitrn limit: 10,rn # The number of requests remainingrn remaining: 7,rn # A unix timestamp in milliseconds of when the rate limit will reset,rn # or if OVER_LIMIT is set it is the time at which the rate limitrn # will no longer return OVER_LIMIT.rn reset_time: 1551309219226,rn # Additional metadata about the request the client might find usefulrn metadata:rn # This is the name of the node that owns this requestrn "owner": "api-n03.staging.us-east-1.mailgun.org:9041"
Comportamiento global
Dado que a los límites de tasa de Gubernator se les aplica un hash y los gestiona un único par del clúster, los límites de tasa que se aplican a todas las peticiones de un centro de datos harían que un solo par se encargara de la petición de limitación de tasa para todo el centro de datos.
Por ejemplo, considera un límite de tasa con name=requests_per_datacenter y un unique_id=us-east-1. Imagina ahora que se hace una petición a Gubernator con este límite de tasa por cada petición HTTP que entra en el centro de datos us-east-1. Podrían ser cientos de miles, o incluso potencialmente millones de peticiones por segundo a las que un único par del clúster aplica el hash y gestiona. Debido a este posible problema de escalabilidad, Gubernator introduce un behavior configurable llamado GLOBAL.
Cuando se configura un límite de tasa con behavior=GLOBAL, la petición de límite de tasa que se recibe de un cliente no se reenviará al par propietario. En su lugar, se responderá desde una caché interna gestionada por el par que recibió la petición. El par receptor agrupará los Hits hacia el límite de tasa en lotes y los enviará de forma asíncrona al par propietario, donde se sumarán los impactos y se calculará el OVER_LIMIT. A continuación, es responsabilidad del par propietario actualizar cada par del clúster con el estado actual del límite de tasa, de manera que las cachés internas de los pares se actualicen de forma rutinaria con el estado del límite de tasa más reciente del propietario.
Efectos secundarios del comportamiento global
Dado que los Hits se agrupan en lotes y se reenvían al par propietario de forma asíncrona, la respuesta inmediata al cliente no incluirá el recuento restante (remaining) más preciso. Ese recuento solo se actualizará una vez que se haya completado la llamada asíncrona al par propietario y este haya tenido tiempo de actualizar todos los pares del clúster. Como resultado, el uso de GLOBAL permite una mayor escalabilidad, pero a costa de la consistencia. Usar GLOBAL puede aumentar la cantidad de tráfico por petición de límite de tasa si el clúster es lo suficientemente grande. GLOBAL solo se debe usar para límites de tasa de volumen extremadamente alto que no escalan bien con el comportamiento no GLOBAL tradicional.
Rendimiento de Gubernator
En nuestro entorno de producción, por cada petición a nuestra API, enviamos dos peticiones de límite de tasa a Gubernator para que evalúe el límite de tasa; una para evaluar la petición HTTP y la otra para evaluar el número de destinatarios a los que un usuario puede enviar un email dentro de la duración específica. Bajo esta configuración, un solo nodo de Gubernator responde a más de 2000 peticiones por segundo y la mayoría de las respuestas por lotes se devuelven en menos de un milisegundo.

Las peticiones de pares que se reenvían a los nodos propietarios suelen responder en menos de 30 microsegundos.

Puesto que muchas de nuestras API públicas están escritas en Python, ejecutamos muchas instancias del intérprete de Python en un solo nodo. Esas instancias de Python reenviarán las peticiones a nivel local a la instancia de Gubernator, que luego agrupa en lotes y reenvía las peticiones a los nodos propietarios.
Gubernator permite a los usuarios elegir un comportamiento sin procesamiento por lotes, lo que reduciría aún más la latencia de las peticiones de límite de tasa del cliente. Sin embargo, debido a los requisitos de rendimiento, nuestro entorno de producción usa Behaviour=BATCHING con el margen predeterminado de 500 microsegundos. En producción, hemos observado tamaños de lotes de 1000 durante el uso máximo de la API. Otros usuarios que no tengan las mismas exigencias de tráfico alto podrían desactivar el procesamiento por lotes y verían latencias más bajas, pero a costa del rendimiento.
Gubernator como biblioteca
Si estás usando Golang, puedes usar Gubernator como biblioteca. Esto es útil si quieres implementar encima un servicio de limitación de tasa con tu propio modelo específico de la empresa. Hacemos esto internamente aquí en Mailgun con un servicio que, de forma creativa, hemos llamado ratelimits y que hace un seguimiento de los límites impuestos a cada cuenta. De esta manera, puedes aprovechar la potencia y velocidad de Gubernator, pero seguir añadiendo lógica comercial e integrando problemas específicos del dominio en tu servicio de limitación de tasa.
Cuando usas la biblioteca, tu servicio se convierte en un miembro de pleno derecho del clúster que participa en el mismo hashing y almacenamiento en caché consistentes que lo haría un servidor Gubernator independiente. Lo único que tienes que hacer es proporcionar la instancia del servidor GRPC e indicar a Gubernator dónde se encuentran los pares de tu clúster.
Conclusión
Usar Gubernator como un servicio de limitación de tasa de propósito general nos permite confiar en la arquitectura de microservicios sin comprometer la independencia del servicio ni duplicar el trabajo que requieren las soluciones de limitación de tasa habituales. Esperamos que, al lanzar este proyecto como código abierto, todo el mundo pueda colaborar y beneficiarse del trabajo que hemos empezado aquí.
¿Te interesa trabajar en Mailgun? ¡Buscamos gente! Y tenemos varios puestos de desarrollo disponibles. Echa un vistazo a nuestras ofertas de trabajo actuales aquí.