Cómo escalamos nuestra infra para hacer millones de requests a instituciones financieras
En Fintoc tenemos la filosofía de diseñar manteniendo siempre la simplicidad (KISS). Esto nos sirve tanto para avanzar rápido como también para que nuestro software sea mantenible y entendible por todos los devs.
Pero, a medida que escalamos, las soluciones tienen que complejizarse inevitablemente, ya que los supuestos del diseño original ya no se cumplen.
Desafío: escala
Hoy en día manejamos más de 4.000.000 de requests diarios realizados por jobs de pagos y actualizaciones de cartolas bancarias. En algunas instituciones esto significó un 5x más de requests comparándolo con principio de año.
Cada uno de estos procesos requiere de infraestructura distinta:
- En pagos necesitamos rapidez porque el usuario interactúa en tiempo real con nuestro widget (interfaz).
- Necesitamos poder de cómputo y algoritmos eficientes cuando queremos actualizar las cartolas bancarias de nuestros clientes.
Pero hay un aspecto común para ambos: la inmensa cantidad de requests que debemos hacer a instituciones financieras, lo cual evidentemente nos trajo problemas.
En este post me referiré al flujo de un pago o al flujo de actualización de cuenta como un job.
Restricciones
Las instituciones financieras a las que realizamos requests tienen implementados sistemas de seguridad para evitar sobrecarga mal intencionada y, a pesar de nuestros acuerdos, estas restricciones aplican incluso a nosotros.
Uno de esos sistemas de seguridad es el Rate Limit, el cual restringe la cantidad de tráfico a un sitio web. Este se puede implementar de distintas formas, pero la más común es por IP: si la misma IP realiza demasiados requests a una página, se bloquea su acceso por un tiempo configurable.
Al encontrarnos con esta limitante, tuvimos que diseñar una forma de mantener nuestra escala adecuándonos a las restricciones.
Distribuyendo el tráfico desde distintas IPs
Dado que la restricción principal es a nivel de IP, ¿cómo distribuimos el tráfico en distintas IPs?, pues, teniendo a disposición hartas IPs de salida.
Para tener varias IPs de salidas usamos proxies. Un proxy es, en palabras simples, un servidor que intercepta requests para luego re-dirigirlas al sitio web:
Con este intermediario, la IP que verán los terceros es la IP del proxy y, por ende, si tenemos muchos proxies, tendremos hartas IPs sobre las cuales distribuir los requests.
Pool de proxies: mismo job, misma IP
Uno de los requisitos del diseño era que, para un mismo job (o sesión) en la web del tercero, debíamos mantener la misma IP en todos los requests para evitar problemas de comunicación en medio del proceso.
Para hacerlo, decidimos levantar un servidor de proxy HTTP/HTTPS (con Tinyproxy) en varias máquinas virtuales (en adelante VM) de Compute Engine y a cada una de ellas asignarle una IP externa ephemeral. Además, cada una de estas máquinas tendría una IP interna fija.
Con ambas IPs podemos:
- Comunicar nuestros servicios internos con el proxy a través de nuestra red interna. Nosotros no exponemos nuestros servicios directamente a internet.
- Comunicarnos a través de internet entre el proxy y el tercero.
Así, para que un job pueda tener una IP externa fija, solo teníamos que escoger una de las IPs internas de las VMs disponibles y rutear todos los requests de ese job a través de la IP interna:
Performance: distribución de la carga
Teniendo los proxies, necesitamos un mecanismo para poder distribuir la carga sobre esos proxies y aplicar reglas. Los requisitos más relevantes que debíamos cumplir eran los siguientes:
- Deben haber proxies exclusivos para un tipo de job.
Ej: ocupar proxies X e Y para un job de pago para la institución Z. - Debe haber una denylist de proxies para un tipo de job (inverso del punto anterior).
Ej: no ocupar proxies X e Y para job de movimientos de la institución Z.
La elección de proxies debe ser uniforme, es decir, evitar que se usen unos más que otros.
Con estos requerimientos, necesitábamos una entidad que se encargara de saber qué proxies asignar dado el contexto.
Para definir las restricciones, creamos modelos en base de datos (usamos PostgreSQL) que representaran tanto a un proxy como a las restricciones de estos. De esta forma, tanto devs como otras áreas podían modificarlas fácilmente desde un dashboard interno.
Los proxies tendrían un atributo que representa el estado actual, por ejemplo: running
o deleted
(internamente manejamos más estados). Los estados, además de entregarnos información, nos ayudaron a manejar casos bordes (que veremos más adelante).
Para distribuir homogéneamente el uso de proxies, decidimos usar una lista circular que contiene los id
s en base de datos de los proxies. Cada vez que inicia un job, saca un id
de la lista y lo agrega al final de esta para que solo sea usado cuando todos los otros proxies fueron usados. El proxy que sacó, es el que usará para conectarse al tercero:
La mejor herramienta que encontramos para esto fue Redis: es rápido y tenía implementado Linked Lists, estructura de datos que nos permite hacer las operaciones que necesitábamos eficientemente: LPOP
y RPUSH
.
Combinando Redis con los modelos en base de datos, teníamos lo necesario:
- En Postgres guardamos los proxies y sus restricciones.
- En Redis guardamos las listas de rotación de los proxies (representados por su
id
en base de datos).
Tenemos listas de proxies comunes para todos los jobs y listas exclusivas por tipo de job. Cada una de estas listas tienen keys distintas. En adelante solo nos referiremos a la lista común common_proxies
.
Ambas fuentes de información estarían sincronizadas. Entonces, cada vez que un job necesite un proxy, podrá consultarlo desde Redis:
El proceso sería el siguiente:
- Job pide un proxy al servicio de proxies
ProxyService
(nótese que este es un módulo en código, no un microservicio). ProxyService
obtiene elid
de un proxy de la lista común (keycommon_proxies
) y lo agrega al final de la lista. A esta acción la llamamos rotar un proxy.ProxyService
le responde al job con elid
del proxy escogido y así el job puede buscar la información necesaria en Postgres.
El id escogido (A
) no se volverá a usar hasta que todos los otros proxies ya se hayan usado.
Control de la infraestructura
Teniendo todo lo anterior se puede crear un proxy tanto en Compute Engine, como en Postgres para empezar a ocuparlo (las modificaciones en Redis se gatillan desde cambios en base de datos).
Para poder reaccionar rápido a peaks de flujo, automatizamos el manejo del ciclo de vida de un proxy tanto en Compute Engine como en nuestras bases de datos. Básicamente queríamos soportar dos operaciones: crear y eliminar proxies (internamente tenemos otras).
Ambas acciones tienen detalles con los cuales hay que tener cuidado, pero nos centraremos en eliminar un proxy, por lo que necesitamos:
- Eliminarlo de Postgres
- Eliminarlo de Redis
- Eliminarlo de Compute Engine
Todo esto sin afectar los miles de jobs ejecutándose, lo cual, a pesar de que no lo parece, tiene detalles que lo hacen complejo.
Concurrencia y atomicidad
Para lograr manejar la escala de Fintoc, necesitamos paralelizar jobs. Esto tiene como consecuencia que muchos jobs podrían estar editando recursos al mismo tiempo, produciéndose así condiciones de carrera inesperadas.
Siguiendo con la acción de eliminación, consideremos el siguiente ejemplo en donde se muestra la interacción concurrente con Redis de un proceso que está rotando un proxy y un proceso que lo está eliminando usando las operaciones LPOP
y RPUSH
:
Se puede notar que justo después que se quitó el proxy A
de la lista circular en (2), otro proceso quiso eliminar el proxy A
en (3). Como el proxy A
no existe en la lista en ese momento, Redis responde OK, pero luego el proceso rotando proxies agrega A a la lista circular nuevamente.
Consecuencias:
- Inconsistencia entre Redis, Postgres y Compute Engine.
- Miles de jobs podrían fallar si ocupan el proxy
A
porque el proceso de eliminación eliminaría la VM en Compute Engine.
Por suerte, Redis tiene operaciones atómicas que nos permiten rotar un elemento de una lista: LMOVE. Esta operación puede hacer exactamente lo mismo que LPOP
y RPUSH
pero evitando que otra operación se ejecute entre medio.
Sincronización de fuentes de datos
Tenemos tres bases de datos con la misma información (pero en formatos distintos):
- Redis guarda
id
s de proxies en sus listas circulares. - La base de datos guarda la información detallada de los proxies y sus restricciones.
- Compute Engine tiene la información de las VMs.
Sincronizar datos parece fácil, pero tiene muchos casos bordes. Durante la creación/eliminación de proxies las 3 fuentes de datos son puntos de falla que hay que manejar.
Siguiendo con el ejemplo de eliminación de proxy, consideremos el siguiente caso simplificado:
En este caso logramos eliminar el proxy A
de Redis y Postgres pero no recibimos la respuesta de Cloud, por lo tanto, no sabemos qué pasó con la VM. Esto no solo puede ocurrir en Cloud, sino que en Redis y Postgres.
Para hacernos cargo de estos casos, primero hay que notar que Cloud es la fuente de verdad, por ende, podemos ocuparla para corregir procesos a medias con un job de sincronización. Este job obtendrá un snapshot de los proxies activos o inactivos en Cloud y actualizará Redis y Postgres en base a esa información.
Considerando el caso de la imagen:
- Si la VM fue eliminada, entonces ✅ el job de sincronización no la encontrará y todo seguirá igual.
- Si la máquina no fue eliminada, el job de sincronización podrá volver a crear el proxy tanto en Postgres como en Redis y la eliminación se puede reintentar más tarde.
A pesar de que este proceso es simple tenemos que tener cuidado con el orden de las operaciones: no podemos eliminar una VM de Cloud si el proxy aún está disponible en Redis porque otros jobs podrían intentar usarlo.
Además, tenemos que asegurarnos que no hay otro proceso modificando el mismo proxy. Consideremos el caso en que alguien ejecuta la eliminación de un proxy y al mismo tiempo está corriendo el job de sincronización:
Nuevamente nos encontramos con una condición de carrera porque el último que actualiza la base de datos es el job de sincronización. Notar que si eliminamos el proxy en vez de actualizar su estado en el paso 3 es el mismo problema: el job de sincronización creará el proxy de nuevo.
Actualmente nos apoyamos de locks en Postgres para estar seguros de que el proceso que tomó el lock es el único modificando el proxy:
En este caso, como el proceso eliminando proxy A
toma el lock del proxy antes que el job de sincronización, puede continuar con el proceso sabiendo que es el único modificando el recurso.
Un aspecto interesante es que el job de sincronización obtiene un lock de todos los proxies antes de obtener el snapshot de los proxies en Cloud. Si lo hacemos después, alguien puede modificar el estado de un proxy y el snapshot del job de sincronización estaría desactualizado. Un ejemplo es el siguiente:
- El job de sincronización obtiene snapshot de proxies de Cloud. En ese momento el proxy
A
yB
están activos. - Un proceso distinto marca como “eliminado” al proxy
A
de Cloud. - Como el job de sincronización no sabe que
A
fue eliminado, marca como “activo” a proxyA
en base de datos.
Lo cual dejaría inconsistente la base de datos.
Otro punto: en la imagen aparece un error cuando el job de sincronización trata de obtener el lock de los proxies porque otro proceso ya tiene tomado el lock de un proxy. Este caso se puede manejar de dos formas:
- Lanzar error si es que alguno de los proxies está siendo modificado (caso de la imagen).
En Postgres:SELECT ... FOR UPDATE OF table_name NOWAIT
. - Esperar a que ningún proxy tenga tenga un lock activo.
En Postgres:SELECT ... FOR UPDATE OF table_name
(sin elNOWAIT
).
Más información sobre esto la puedes encontrar en la documentación de Postgres.
Generalmente elegimos la segunda opción porque nuestros locks no duran mucho tiempo. Así evitamos la lógica de retries y la solución queda más simple.
Estos cuidados no solo aplican para la eliminación de un proxy, sino para cualquier operación que se quiera realizar sobre ellos.
Últimas palabras
Los proxies es un proyecto que fue evolucionando con la escala de Fintoc. Como dije antes, nos gusta partir simple y solo complejizar el diseño cuando hace sentido. En este caso hizo mucho sentido.
A pesar de que busquemos soluciones simples, ponemos mucho cuidado a los detalles para evitar la mayor cantidad de casos borde posibles, como en los casos de condiciones de carrera por concurrencia o sincronización de datos. Lo bueno es que casi todos estos casos los detectamos al momento de diseñar la solución con nuestros ERDs.
Aunque hemos avanzado harto, este proyecto aún no termina. Hay detalles que nos gustaría mejorar y siempre habrán más desafíos de infraestructura a medida que crecemos. Por ejemplo, puede ser que en el futuro el job de sincronización dure mucho y hacer un lock sobre todos los proxies bloquee operaciones críticas.
Si te gustan los problemas de escala, estamos contratando. Puedes ver las vacantes y postular acá.