En 2019, en Connectif, emprendimos un importante reto al realizar el proceso de migración desde nuestro SaaS de standalone a multi-instancias con el fin de obtener un escalado horizontal. Sudamos la gota gorda, pero lo logramos
Y aquí te contamos cómo lo hicimos.
Antes de empezar, dejamos un apunte para que puedas situarte:
Connectif Artificial Intelligence es la plataforma de automatización de marketing de más rápido crecimiento en España, conocida por su enfoque conversacional omnicanal para la experiencia de compra y la personalización. La hemos diseñado para que cualquier profesional del marketing pueda comenzar a usarla para su eCommerce en pocos minutos y en cualquier canal, vinculando datos, diseñando recorridos y actuando en tiempo real.
¿Por qué decidimos migrar de standalone a multi-instancias?
El corazón de Connectif, donde el workflow, los segmentos y tantas otras funcionalidades cobran vida, se estructura en una aplicación server monolito en Node.Js. En los inicios del producto, el enfoque standalone nos permitió simplificar la arquitectura y conseguir un robusto M.V.P. (producto mínimo viable) con el cual salir al mercado.
Conforme el tiempo pasaba, y el producto iba madurando, crecía la necesidad de permitir escalar la aplicación a múltiples instancias y servidores. Teníamos que poder satisfacer la demanda de un número cada vez mayor de clientes que veían a Connectif como la mejor herramienta de Marketing Automation para sus necesidades.
Había que asegurar la capacidad de crecimiento de la aplicación para atender a un número creciente de solicitudes y usuarios con total normalidad y sin degradaciones de servicio. Necesitábamos un escalado horizontal.
Ventajas del escalado horizontal
El escalado horizontal nos iba a traer muchos beneficios:
- Alta disponibilidad: El escalado horizontal implica que, si la aplicación está ejecutándose en múltiples servidores y uno de ellos sufre una caída, la aplicación sigue operando correctamente con el mínimo impacto sobre el servicio a los usuarios. También es posible realizar las tareas de mantenimiento y actualizaciones de los servidores sin impacto, garantizando un servicio 24/7.
- Zero Downtime Deploys: hace posible desplegar sin ningún tipo de caída, ya que se pueden tener diferentes versiones de la aplicación levantadas en el mismo momento y enrutar el tráfico a la nueva versión cuando ésta está lista.
- Rendimiento y mejor aprovechamiento de los recursos: el runtime de Node.js y su funcionamiento mediante single thread y event loop proporcionan un rendimiento extraordinario, aunque en aplicaciones standalone no permiten aprovechar de lleno los servidores con múltiples CPU. Pero ese problema queda resuelto al ejecutar múltiples instancias del mismo proceso.
El proceso de migración ¿Por dónde empezamos?
Adaptar una aplicación tan grande como Connectif para que pudiera trabajar de forma eficiente en múltiples instancias no fue una tarea sencilla.
Lo primero que hicimos fue realizar un análisis Top Down para identificar todas las partes a revisar / re-implementar, y sus dependencias entre ellas.
Muchos componentes se habían diseñado para ser lo más eficientes posibles en un contexto standalone, pero sin ser compatibles con multi-instancias. Necesitábamos revisar (¡y en muchos casos re-implementar desde cero!) áreas grandes y críticas de la aplicación para que pudieran funcionar en un sistema distribuido.
Algunos ejemplos de esos componentes que tuvieron que ser revisados fueron:
-
Caché: para conseguir un alto rendimiento, Connectif utiliza muchas cachés en memoria, lo que supone una menor latencia y mayor velocidad en el motor de workflow.
-
Colas: tenemos un complejo sistema de colas donde el estado en memoria es necesario para coordinar diferentes acciones.
-
Jobs: Connectif realiza un total de más de 20 procesos masivos diferentes que pueden durar desde unos pocos minutos a varias horas. Por ejemplo, la creación de segmentos, el arranque de workflow de campañas de email masivas, etc.
-
Task Scheduler: sirve para ejecutar miles de tareas por segundo como carritos abandonados y timers.
-
Etc.
¿Qué estrategia seguimos para el proceso de migración?
Una vez tuvimos delante el análisis Top Down, y una estimación de alto nivel que indicaba de cinco a seis meses de trabajo, la siguiente gran pregunta fue: ¿Cómo llevar a cabo tantos cambios a producción?
¿Qué tuvimos en cuenta?
Para responder a esta pregunta, lo primero que hicimos fue establecer los requisitos mínimos que la solución a elegir tenía que cumplir:
- Iterativo: el proceso de migración debía ser iterativo. Esto es, debería permitirnos llevar los progresos a producción y obtener el feedback de forma periódica (semana tras semana y día a día). Lo que queríamos evitar a toda costa era un enfoque donde trabajáramos durante meses y meses, para luego migrar todo a producción de golpe y, solo al final, obtener el feedback de producción.
- Feature toggling: el proceso debería permitir que las nuevas versiones pudieran activarse / desactivarse con un simple flag, con el fin de poder rectificar y volver rápidamente a la versión anterior en caso de que nos encontráramos con algún problema tras su despliegue en producción.
Opciones de estrategias de migración disponibles
A continuación, estuvimos barajando varias opciones:
1. Microservicios: esta opción se definía por romper el monolito en microservicios que pudiesen trabajar en múltiples instancias.
A pesar de que esta opción cubría los requisitos mínimos, también implicaba una gran desventaja, y es que el proceso de migración a microservicios introduciría una elevada complejidad en la arquitectura/diseño de Connectif. Esta complejidad ya la estábamos experimentado en otras áreas del producto y no estábamos dispuestos a incrementar y asumir, teniendo en cuenta el reducido tamaño del equipo en aquel momento. Por eso descartamos esta opción rápidamente.
2. Monolito escalable: esta opción consistía en re-implementar poco a poco partes de la aplicación llevándolas a producción, pero manteniendo el proceso standalone.
Una vez que todos los componentes estuviesen listos para trabajar en multi-instancia, se ejecutaría más de 1 proceso de la aplicación en producción.
Este enfoque tenía dos problemáticas:
a. No cumplía el requisito de iterativo: aunque el código podía subirse a producción de forma periódica, realmente no estaría funcionando en más de 1 proceso hasta que todo estuviese terminado. Podía, por lo tanto, encubrir fallos de diseño, problemas de concurrencia y bugs. Todos estos problemas aparecerían de golpe al final del proceso de migración, creando la receta perfecta para el fracaso.
b. Bajada del rendimiento: el rediseño de muchas partes podía suponer que resultase menos óptimo ejecutar el monolito escalable como un proceso, respecto a la versión standalone. Por lo tanto, podríamos experimentar bajadas de rendimiento no viables para la carga de producción actual. Por estas razones decidimos descartar, también, esta segunda vía.
3. Monolito compuesto por dos procesos diferentes: esta opción suponía mantener el enfoque monolito pero teniendo dos procesos diferentes, el proceso standalone y un proceso multi-instancias.
La idea era la siguiente: a medida que se fuesen cambiando componentes de la aplicación para dar soporte a multi-instancias, éstos se irían moviendo desde el proceso standalone a los procesos multi-instancias. Así, llegaría un momento en el que todo quedaría corriendo en multi-instancia y standalone terminaría borrándose.
Esta opción cumplía nuestros dos requisitos mínimos. Es decir que, al tener dos o más instancias en producción del proceso tipo multi-instancia, y una instancia del proceso tipo standalone, podíamos ir migrando poco a poco nuestro monolito de forma iterativa y con feature toggling.
Finalmente optamos por esta estrategia y os adelantamos que fue la ganadora.
Diferencia entre el enfoque de monolito compuesto y microservicios
Más que nada para aclarar posibles dudas, vale la pena explicar que el enfoque de microservicios implica servicios con un grado de autonomía e independencia muy alto.
En cambio, con el enfoque de monolito compuesto, lo que hicimos fue, simplemente, romper la aplicación en diferentes procesos con diferentes responsabilidades. Ahora bien, la base de datos era compartida y había un ÚNICO deploy que siempre desplegaba la versión existente de ambos tipos de procesos.
¿Cómo decidimos organizar el código?
Una vez elegida la estrategia para el proceso de migración, tocaba ver cómo organizar el código. El reto estaba servido porque había que sacar, de una única aplicación, dos aplicaciones con, potencialmente, mucho código a reutilizar y compartir.
Estuvimos valorando varias opciones:
- Paquetes NPM: paquetizar partes comunes en paquetes NPM (Node Package Manager).
Esta opción se descartó rápidamente ya que implicaba muchísimo trabajo, una gestión de dependencias nada trivial y añadir mucha complejidad en el día a día de trabajo de todas las personas del equipo.
- Código shared: cambiar la estructura de la app en:
- app/: código aplicación standalone
- instance/: código aplicación multi-instancia
- shared/: código compartido
Esta segunda opción tenía menos complejidad respecto a la primera, pero seguía requiriendo mover una cantidad muy grande de código a una carpeta shared, para luego volver a mover todo otra vez a la carpeta instance una vez el proceso de migración hubiese terminado.
También descartamos esta opción.
- Multiple app entry points: en esta opción todo el código quedaba en su lugar original, pero se introducía un nuevo fichero main en el repositorio. Antes teníamos esta estructura:
- src/
- app.ts
- modules/
- src/
Ahora la nueva estructura sería:
- src/
- app.ts # entry point standalone
- instance.ts # entry point instance
- modules/
La idea era que, a medida que un nuevo componente se migrara, su inicialización se movería de app.ts a instance.ts. Así, poco a poco, hasta que en app.ts no quedase nada.
Este enfoque permitía, con el mínimo esfuerzo, poder migrar todo nuestro monolito a un monolito escalable de forma fácil e incremental. Teníamos un único artefacto en cada release, que desplegamos a producción ejecutando los diferentes procesos con sus entrypoints correspondientes: 2 instances y 1 app.
Así pues, esa fue la opción que elegimos para organizar el código. Y funcionó.
To be continued…
Todo esto que acabas de leer fue, solamente, el comienzo de un camino apasionante aquí en Connectif y en el que nos hemos ido encontrando numerosos nuevos desafíos.
Hoy lo dejamos aquí, pero si te apetece seguir conociendo cómo hemos hecho frente a cada reto que nos ha surgido, síguenos a través de este blog porque tendremos más artículos para contártelo. ¡Gracias por leernos y hasta pronto!