Infraestructura para 10 millones de dispositivos

A ponerse los cinturones, porque de aquí en adelante esperamos que nuestros artículos entreguen bastante información técnica sobre cómo funcionan nuestros sistemas. Aspiramos a que nuestras soluciones puedan servir para resolver problemas similares.

El problema

Prey nació como una solución que permite ejecutar acciones remotas sobre un dispositivo, con la esperanza de poder obtener su ubicación o incluso bloquear el equipo en caso de que no se pueda recuperar. Originalmente, los dispositivos de escritorio con Prey instalado se sincronizaban cada 20 minutos para obtener los comandos pendientes de ejecución. Lo que desde un punto de vista técnico se presentaba como una ventaja y una desventaja. La ventaja era que contábamos con ventanas de 20 minutos para ir optimizando la infraestructura, pero, curiosamente, la desventaja era que cada 20 minutos recibíamos una avalancha de conexiones que consumían todos los recursos disponibles.

Esto también generaba una mala experiencia de usuario, porque si un usuario quisiera bloquear su dispositivo AHORA, ¿por qué tendría que esperar 20 minutos?.

¿Cómo podríamos distribuir notificaciones en tiempo real y a escala? Manteniendo una conexión abierta por cada dispositivo que tenga Prey instalado. Con más de 10 millones de dispositivos registrados: nos esperaba un buen problema a resolver.

Un poco de historia

La solución al problema de mantener miles y cientos de miles de conexiones fue una gran incógnita por mucho tiempo. Desde el clásico Problema C10k hasta el límite de 65.535. Hoy en día este problema debería ser trivial utilizando las herramientas adecuadas.

TCP/IP

Una conexión TCP está compuesta, principalmente, por una dirección de origen y una dirección de destino. Cada dirección corresponde a un par compuesto por una dirección IP y puerto. El puerto es un número entero de 16 bit en el rango 1–65.535. En los sistemas POSIX, cada conexión se representa como un descriptor de archivo. En la implementación de TCP se mantiene una tabla que permite ubicar el descriptor para cualquier conexión establecida entre un origen y destino, utilizando los pares como parte de una llave compuesta: origen(IP:puerto) -> destino(IP:puerto).

Esto es importante, porque a menos que la IP de origen sea la misma para 65.535 puertos, el límite de conexiones estaría dado en la práctica por la cantidad de descriptores disponibles y por la memoria RAM disponible en sistema.

Proxy

Los límites anteriores se notan cuando la configuración de los servicios utilizan proxy.

Por ejemplo, en la clásica configuración de balanceo de carga.

Como la IP del balanceador es siempre la misma, se genera un límite en la cantidad de conexiones que puede mantener contra cada servidor en la medida que asigne un puerto único para cada conexión entrante. Inclusive si las conexiones fueran reutilizadas, nunca se podrá contar con más de 65.535 puertos disponibles.

Bajo ese escenario, escalar la cantidad de conexiones es relativamente simple. Basta con agregar balanceadores.

Y así hasta el infinito, podríamos aumentar los límites de conexiones a nuestro grupo de servidores mientras más balanceadores tengamos disponibles.

En nuestro caso, necesitaríamos al menos 150 balanceadores para obtener una capacidad de 10 millones de conexiones concurrentes.

Reinventando la rueda

Cuando hicimos ese cálculo sobre la servilleta nos preguntamos ¿y si no necesitáramos balanceadores? Si nuestros servidores se expusieran directamente a internet, podríamos aprovechar la enorme variabilidad de IPs por dispositivos y no tener que preocuparnos por la cantidad de puertos.

Al comienzo sonaba como una buena idea, pero, ¿qué pasaría si un servidor se cae? ¿cómo actualizaríamos rápidamente el DNS? ¿cómo podríamos actualizar las direcciones en los clientes?, ¿necesitaríamos 150 IPs públicas?, etc, etc.

La mejor solución para el momento

La receta que más nos hizo sentido fue exponer nuestros servidores directamente a Internet¹ y así poder aprovechar la variabilidad para no tener que preocuparnos de los límites. En efecto, decidimos tomar esta idea principal y encontrar soluciones a las problemáticas que presentaría.

La solución final se compone por tres piezas fundamentales de infraestructura:

Una IP global

Utilizamos Load Balancing de Google Compute Platform, que nos permite contar con una IP pública² y escalar hasta un límite teórico de 268.300.290 conexiones concurrentes. Además nos permite hacer uso de herramientas de configuración y monitoreo en tiempo real que provee la plataforma.

Servidores web propios

Desarrollamos y mantenemos nuestros propios servidores web, lo que nos permite tener completo control sobre cada conexión e implementar funciones de alto nivel en la capa de red. Esto es demasiado importante, porque nos genera un tremendo aprendizaje de Ingeniería de Software, que a la larga nos posiciona como un equipo sólido y capaces de reaccionar mejor y rápido ante incidencias.

Preyfrontend (PFE) es el nombre de nuestro servidor web. Implementado en Go sobre net/http. Nos permite montar funciones sobre URLs basadas en expresiones regulares. Por ejemplo, todas las solicitudes a /api/v2/dispositivos/123.json deberían ser procesadas por el sistema de notificaciones mientras que /api/v2/nueva_cuenta.json debería ser procesada por nuestros servidores de aplicación.

Adicionalmente, hemos implementado sistemas sobre PFE que nos permiten, entre otras cosas, configurar experimentos multifactoriales, capturar muestras de solicitudes para análisis de sistemas y mecanismos de recuperación automática. Todos los servicios de Prey expuestos al público utilizan PFE y por tanto heredan esta serie de funcionalidades “gratis”.

Notificaciones

Investigamos, diseñamos y programamos un sistema de distribución de mensajes en tiempo real directamente sobre nuestro servidor web.

El sistema de notificaciones es un módulo implementado sobre PFE que nos permite mantener cientos de miles de conexiones HTTP abiertas por servidor. La mayoría de los clientes implementan long polling, pero también soporta RPC, SSE y WebSockets.

El sistema está compuesto por dos sub-sistemas: el registro y el mapa global.

Cada conexión se almacena en el registro local de cada PFE. Cuando un dispositivo se conecta, su información de registro es enviada a través de un llamado RPC al mapa global. Para enviar una notificación se obtiene el ID del registro para el dispositivo desde el mapa global y se envía un mensaje al servidor que corresponda en un segundo llamado RPC, de esta forma, el servidor de registro obtiene la conexión del cliente y entrega el mensaje. La gran mayoría de notificaciones se entregan de forma global en menos de un segundo.

Esquema de alto nivel describiendo el sistema

Cada servidor de registro es capaz de mantener hasta 250.000 conexiones activas con 99.94% de disponibilidad³. Las especificaciones de los servidores de registro son: 8 CPU y 30 GB de RAM. Generalmente, se utiliza 10% de CPU y 30% de RAM. La razón detrás de la sobrecapacidad es porque los registros se configuran en células de 3 servidores con disponibilidad N+2, lo que significa que si dos registros se cayeran al mismo tiempo, un registro podría asumir toda la carga manteniendo el sistema nominal.

Ejemplo de disponibilidad N+2. Cada línea representa la cantidad de conexiones activas en una célula. Paramos un registro de producción especialmente para ustedes :)

Resultados

El sistema fue puesto en servicio en Agosto de 2015 en modo “canario”. Este modo nos permitió migrar desde la vieja infraestructura al nuevo sistema en etapas controladas. A comienzos de Septiembre el servicio demostró suficiente estabilidad para comenzar a procesar el 100% del tráfico de dispositivos. Luego de meses de análisis y después de más de 36 revisiones, el servicio fue promovido a “producción”. A la fecha, el sistema ha procesado más de ~62.944.915.534,9 conexiones con una disponibilidad de 99.94% para 2016 y 99.4% para 2015.

Estadísticas de disponibilidad reportadas por pingdom.com

Próximos pasos

  • Extender el módulo de notificaciones. Terminar la implementación de multi-tenencia, para que otros productos de Prey puedan utilizar el sistema (hola Arduinos).
  • Completar la automatización del proceso de compilación y distribución de binarios a N instancias.
  • Permitir re-configuración “en caliente” a través de KAPI (nuestro sistema de coordinación basado en etcd — que describiremos en un próximo artículo).
  • Extender la disponibilidad del mapa global a 99.999% y habilitar particionamiento de los registros para los próximos 10 millones de dispositivos.

Recursos

Una prueba de concepto hecha en Go, disponible en GitHub.


¹ Nos hizo mucho sentido porque en nuestra investigación descubrimos que Dropbox implementó un sistema similar para sus notificaciones. http://dl.acm.org/citation.cfm?id=2398827

² Google Load Balancing dispone de 4.096 servidores dedicados a la distribución de tráfico vía proxy dentro de una red GCE. Google recientemente publicó un paper sobre Maglev, el sistema interno que utilizan para gestionar su infraestructura global, la misma que utiliza google.com. http://research.google.com/pubs/pub44824.html

³ Hemos alcanzado hasta 500.000 conexiones activas en pruebas sintéticas, pero con menor disponibilidad.


Gracias a Carlos Yaconi, Miguel Michelson, Ricardo Fuentes y Sebastian Saavedra por revisar este artículo.