Manejo de 1 millón de solicitudes por minuto con Golang

He estado trabajando en la industria de antispam, antivirus y antimalware durante más de 15 años en algunas compañías diferentes, y ahora sé cuán complejos podrían terminar estos sistemas debido a la gran cantidad de datos que manejamos diariamente. .

Actualmente soy CEO de smsjunk.com y Chief Architect Officer de KnowBe4, ambos en empresas activas en la industria de la ciberseguridad.

Lo interesante es que durante los últimos 10 años más o menos como ingeniero de software, todo el desarrollo de backend web en el que he estado involucrado se ha realizado principalmente en Ruby on Rails. No me malinterpretes, me encanta Ruby on Rails y creo que es un entorno increíble, pero después de un tiempo comienzas a pensar y diseñar sistemas a la manera de Ruby, y olvidas lo eficiente y simple que podría haber sido tu arquitectura de software si podría aprovechar múltiples subprocesos, paralelización, ejecuciones rápidas y pequeña sobrecarga de memoria. Durante muchos años, fui desarrollador de C / C ++, Delphi y C #, y comencé a darme cuenta de lo menos complejas que podrían ser las cosas con la herramienta adecuada para el trabajo.

No soy muy grande en las guerras de lenguaje y marco por las que siempre están luchando los interwebs. Creo que la eficiencia, la productividad y el mantenimiento del código se basan principalmente en lo simple que puede diseñar su solución.

El problema

Mientras trabajábamos en una parte de nuestro sistema anónimo de telemetría y análisis, nuestro objetivo era poder manejar una gran cantidad de solicitudes POST de millones de puntos finales. El controlador web recibiría un documento JSON que puede contener una colección de muchas cargas útiles que deben escribirse en Amazon S3, para que nuestros sistemas de reducción de mapas funcionen posteriormente con estos datos.

Tradicionalmente, buscaríamos crear una arquitectura de nivel de trabajador, utilizando cosas como:

  • Sidekiq
  • Resque
  • DelayedJob
  • Nivel de trabajador Elasticbeanstalk
  • RabbitMQ
  • y así…

Y configure 2 clústeres diferentes, uno para el front-end web y otro para los trabajadores, para que podamos ampliar la cantidad de trabajo de fondo que podemos manejar.

Pero desde el principio, nuestro equipo sabía que deberíamos hacer esto en Go porque durante las fases de discusión vimos que esto podría ser un sistema de tráfico potencialmente muy grande. He estado usando Go durante aproximadamente 2 años más o menos, y hemos desarrollado algunos sistemas aquí en el trabajo, pero ninguno que pudiera obtener esta cantidad de carga.

Comenzamos creando algunas estructuras para definir la carga útil de la solicitud web que recibiríamos a través de las llamadas POST, y un método para cargarla en nuestro bucket S3.

Enfoque ingenuo a las rutinas Go

Inicialmente tomamos una implementación muy ingenua del controlador POST, solo tratando de paralelizar el procesamiento del trabajo en una simple rutina:

Para cargas moderadas, esto podría funcionar para la mayoría de las personas, pero rápidamente demostró que no funciona muy bien a gran escala. Estábamos esperando muchas solicitudes, pero no en el orden de magnitud que comenzamos a ver cuando implementamos la primera versión en producción. Subestimamos completamente la cantidad de tráfico.

El enfoque anterior es malo de varias maneras diferentes. No hay forma de controlar cuántas rutinas de ida estamos generando. Y dado que recibimos 1 millón de solicitudes POST por minuto, por supuesto, este código se bloqueó y se quemó muy rápidamente.

Intentando otra vez

Necesitábamos encontrar una forma diferente. Desde el principio, comenzamos a analizar cómo necesitábamos mantener la vida útil del controlador de solicitudes muy corta y generar el procesamiento en segundo plano. Por supuesto, esto es lo que debe hacer en el mundo de Ruby on Rails, de lo contrario, bloqueará todos los procesadores web de trabajadores disponibles, ya sea que esté usando puma, unicornio, pasajero (no entremos en la discusión de JRuby, por favor). Entonces habríamos necesitado aprovechar soluciones comunes para hacer esto, como Resque, Sidekiq, SQS, etc. La lista continúa ya que hay muchas formas de lograrlo.

Entonces, la segunda iteración fue crear un canal protegido donde pudiéramos poner en cola algunos trabajos y subirlos a S3, y dado que podíamos controlar el número máximo de elementos en nuestra cola y teníamos suficiente RAM para poner en cola los trabajos en la memoria, pensé que estaría bien simplemente almacenar trabajos en la cola del canal.

Y luego, para eliminar los trabajos y procesarlos, estábamos usando algo similar a esto:

Para ser honesto, no tengo idea de lo que estábamos pensando. Esto debe haber sido una noche llena de Red Bulls. Este enfoque no nos compró nada, hemos cambiado la concurrencia defectuosa con una cola almacenada temporalmente que simplemente posponía el problema. Nuestro procesador síncrono solo estaba cargando una carga útil a la vez en S3, y dado que la tasa de solicitudes entrantes era mucho mayor que la capacidad del procesador único para cargar en S3, nuestro canal almacenado estaba alcanzando rápidamente su límite y bloqueando la capacidad del controlador de solicitudes para poner en cola más artículos.

Simplemente estábamos evitando el problema y finalmente comenzamos una cuenta regresiva hasta la muerte de nuestro sistema. Nuestras tasas de latencia siguieron aumentando en una tasa constante minutos después de implementar esta versión defectuosa.

La mejor solución

Hemos decidido utilizar un patrón común al usar canales Go, para crear un sistema de canales de 2 niveles, uno para trabajos en cola y otro para controlar cuántos trabajadores operan en JobQueue simultáneamente.

La idea era paralelizar las cargas a S3 a una tasa algo sostenible, una que no paralizaría la máquina ni comenzaría a generar errores de conexiones desde S3. Por lo tanto, hemos optado por crear un patrón de trabajo / trabajador. Para aquellos que están familiarizados con Java, C #, etc., piensen en esto como la forma de Golang de implementar un Worker Thread-Pool utilizando canales en su lugar.

Hemos modificado nuestro manejador de solicitudes web para crear una instancia de estructura de trabajo con la carga útil y enviarla al canal JobQueue para que los trabajadores la recojan.

Durante la inicialización de nuestro servidor web creamos un Dispatcher y llamamos a Run () para crear el grupo de trabajadores y comenzar a escuchar los trabajos que aparecerían en JobQueue.

despachador: = NewDispatcher (MaxWorker)
dispatcher.Run ()

A continuación se muestra el código para nuestra implementación del despachador:

Tenga en cuenta que proporcionamos el número máximo de trabajadores que se instanciarán y se agregarán a nuestro grupo de trabajadores. Como hemos utilizado Amazon Elasticbeanstalk para este proyecto con un entorno Go dockerizado, y siempre tratamos de seguir la metodología de 12 factores para configurar nuestros sistemas en producción, leemos estos valores de las variables de entorno. De esa forma podríamos controlar cuántos trabajadores y el tamaño máximo de la Cola de trabajos, para que podamos ajustar rápidamente estos valores sin requerir la reinstalación del clúster.

var (
  MaxWorker = os.Getenv ("MAX_WORKERS")
  MaxQueue = os.Getenv ("MAX_QUEUE")
)

Inmediatamente después de implementarlo, vimos que todas nuestras tasas de latencia cayeron a números insignificantes y nuestra capacidad para manejar solicitudes aumentó drásticamente.

Minutos después de que nuestros Balanceadores de carga elásticos se calentaron completamente, vimos que nuestra aplicación ElasticBeanstalk atendía cerca de 1 millón de solicitudes por minuto. Por lo general, tenemos unas pocas horas durante las horas de la mañana en las que nuestro tráfico aumenta a más de un millón por minuto.

Tan pronto como implementamos el nuevo código, el número de servidores se redujo considerablemente de 100 a unos 20 servidores.

Después de haber configurado correctamente nuestro clúster y la configuración de autoescalado, pudimos reducirlo aún más a solo 4x EC2 c4. Grandes instancias y el conjunto Elastic Auto-Scaling para generar una nueva instancia si la CPU supera el 90% durante 5 minutos seguidos

Conclusión

La simplicidad siempre gana en mi libro. Podríamos haber diseñado un sistema complejo con muchas colas, trabajadores en segundo plano, implementaciones complejas, pero en su lugar decidimos aprovechar el poder del autoescalado Elasticbeanstalk y la eficiencia y el enfoque simple de concurrencia que Golang nos proporciona de inmediato.

No todos los días tiene un clúster de solo 4 máquinas, que probablemente sean mucho menos potentes que mi MacBook Pro actual, que maneja solicitudes POST que se escriben en un bucket de Amazon S3 1 millón de veces por minuto.

Siempre existe la herramienta adecuada para el trabajo. A veces, cuando su sistema Ruby on Rails necesita un controlador web muy potente, piense un poco fuera del ecosistema ruby ​​para obtener soluciones alternativas más simples pero más potentes.

Antes de que te vayas…

Realmente agradecería que nos sigas en Twitter y compartas esta publicación con tus amigos. Puedes encontrarme en Twitter en http://twitter.com/mcastilho