La aplicación de 12 factores

La aplicación de 12 factores

En esta entrada se hablará sobre todos los puntos considerados actualmente como «mejores practicas» a la hora de desarrollar una aplicación web. Cumplir dichos puntos ayudará tanto al equipo de desarrollo como al equipo de administradores de sistemas a realizar sus respectivos trabajos. Como bonus adicional, tras cumplir con dichos puntos, obtendremos una aplicación que podrá ser escalada de manera horizontal.

El mundo del desarrollo de software es complejo y que una determinada pieza de software funcione correctamente requiere algo más que buen código. Requiere de la integración y colaboración de dos equipos: programadores y administradores de sistemas.

Los programadores quieren tener comodidad a la hora de desarrollar y muchas veces tienden a desarrollar sin tener en cuenta los requisitos que se van a tener que cumplir para desplegar lo que están produciendo. Por otro lado, los administradores de sistemas quieren ser capaces de realizar sus tareas de la manera más efectiva posible, lo cual, en ocasiones, puede entrar en conflicto con el resultado que los programadores han generado.

Tras mucho ensayo y error, programadores y administradores de sistemas han llegado a la conclusión de que la mejor manera, desde todos los puntos de vista, es separar las aplicaciones en pequeños servicios. De esta manera los programadores pueden dividirse el trabajo y ejecutar solo los componentes sobre los que deben trabajar, mientras que los administradores de sistemas tienen más libertad a la hora de desplegar y manejar los distintos componentes, al ser estos más modulares.

Así es como nace el concepto de SaaS (Software-as-a-Service). La idea es tener varias piezas que se unan, mediante alguna vía de comunicación, y formen un producto final.

Por ejemplo, una tienda online puede estar formada por un frontend hecho en un framework de JavaScript y un backend hecho en Python. El frontend se puede alojar en AWS CloudFront, mientras que el backend se puede alojar en una instancia de GCP Compute Engine. Frontend y backend se comunicarían mediante una API REST, dando lugar así a una tienda online completamente funcional.

En este ejemplo se puede ver de manera clara cómo la separación en varios componentes nos da la libertad de usar dos proveedores de servicios en la nube, sin que ello suponga ningún problema, siempre y cuando sigamos una serie de consejos o recomendaciones que nos permitirán conseguir esa encapsulación y separación de los distintos componentes (o servicios) de la aplicación.

# 1. Código

El código de cada servicio ha de estar en su propio repositorio y se ha de utilizar algún tipo de esquema de versiones, por ejemplo, los tags de Git. Si dos o más servicios requieren de código común, se deberá crear una librería. Esto quiere decir que, tanto si estamos desplegando al entorno de pruebas, como si estamos desplegando al entorno de producción, el código debe venir siempre del mismo sitio.

Podemos apoyarnos en las características especificas del software de control de versiones que estemos usando para destinar el código a los distintos entornos; siguiendo con el ejemplo de Git, podemos usar la metodología Git Flow.

# 2. Dependencias

Independientemente del lenguaje de programación que estamos utilizando para el desarrollo, las dependencias del proyecto tienen que estar declaradas de manera explicita (por ejemplo, usando el archivo requirements.txt de Python / Pip). Esto permitirá a otros programadores unirse al proyecto sin que ello suponga un infierno; también permitirá a los administradores de sistemas desplegar el código, evitando dicha situación.

Es muy importante destacar que, además de declarar las dependencias del proyecto, es de vital importancia aislar dichas dependencias del resto del sistema y, de existir, los demás servicios que se estén ejecutando. Siguiendo con el ejemplo de Python / Pip, esto se podría conseguir gracias a virtualenv, una herramienta que instala librerías adicionales de manera localizada en lugar de instalarlas directamente en el sistema. Casi todos los lenguajes de programación tienen herramientas tanto para declarar e instalar dependencias, como para aislar dichas dependencias.

# 3. Configuración

Lo más normal es que nuestros servicios necesiten de algún tipo de configuración, como podrían ser los datos de acceso para la base de datos. También es muy normal que dicha configuración varíe entre los distintos entornos (obviamente no vamos a usar la misma base de datos para el entorno de pruebas y para el entorno de producción).

Es una buena idea, tanto desde un punto de vista de seguridad como desde un punto de vista de manejabilidad, mantener las configuraciones aisladas del código en vez de almacenarlas dentro del mismo. Una manera de hacerlo es leer estas configuraciones desde variables de entorno del sistema y actuar, desde el propio código, de una manera u otra (ejemplo, definir la variable de entorno DEBUG para provocar la salida de información de depuración por la consola desde la que estamos ejecutando el servicio). Esto nos permite cumplir con el punto 1, ya que de esta manera podemos tener solo un almacén de código, cuyo contenido clonaremos, pero no cambiaremos, independientemente del entorno al que lo estemos exponiendo.

# 4. Servicios externos

Para conseguir que la aplicación sea realmente modular, es necesario aislar y desacoplar los distintos servicios. Puede que nuestro código necesite de distintos componentes o servicios, entre otros:

  • Base de datos (AWS RDS)
  • Almacén de acceso rápido / cache (AWS ElasticCache)
  • Servicio de notificaciones (AWS SES)
  • Cola de tareas (AWS SQS)

Nuestro código se comunica con cada uno de estos componentes a través de la red, usando como parámetros de acceso lo que hemos explicado en el punto 3. La ventaja de que sea así es que nuestra aplicación está completamente desacoplada de estos servicios, lo cual quiere decir que en cualquier momento podemos modificar la configuración de, por ejemplo, el enlace a AWS ElasticCache y cambiarlo por GCP Cloud MemoryStore o incluso un contenedor de Docker con la imagen de Redis, y todo seguirá funcionando igual, sin que haga falta cambiar ni una sola línea del código.

# 5. Separación de etapas

Hay muchas maneras de automatizar y optimizar el trabajo de los desarrolladores y de los administradores de sistemas, como ya describimos en otro post donde hablábamos de los pipelines de CI/CD. Lo verdaderamente importante a la hora de usar pipelines de integración continua y despliegue continuo es la separación de etapas. Hay 3 etapas que deben estar muy bien aisladas unas de otras. La etapa de construcción, en la cual trabajaremos únicamente con nuestro código. La etapa de distribución, en la cual uniremos el resultado final de la etapa anterior con la manera que hemos definido del punto 3, para obtener la configuración deseada para el entorno en el que queramos ejecutar nuestro código. Y por ultimo, la etapa de ejecución, que consiste en ejecutar el resultado de la etapa anterior.

La encapsulación de las etapas nos ofrece la ventaja de poder usarlas como piezas de lego. Por ejemplo, podríamos hacer que la primera etapa se ejecute siempre que ocurra alguna modificación en nuestro software de control de versiones, mientras que la segunda y la tercera se ejecuten solo cuando esos cambios se realicen en la principal rama de desarrollo o cuando se cumpla alguna condición especial.

# 6. Servicios sin estado

El ejemplo del punto 4 es posible porque hemos tratado el almacén de caché como un servicio independiente. Es decir, hemos programado nuestra aplicación para que no almacene la caché en su propio entorno (en el disco duro o en la memoria de la máquina donde se esta ejecutando), sino que use un servicio externo. Si hacemos lo mismo para los archivos que se suben por los usuarios al interactuar con nuestra aplicación (podemos guardarlos en AWS S3), con las cookies (podemos guardarlas en AWS ElasticCache) y con cualquier otro contenido generado durante el tiempo de ejecución de nuestra aplicación, conseguiremos que los servicios, incluido nuestro propio código, no compartan estado.

Esto tiene una vital importancia, ya que cumpliendo esto conseguiremos que nuestros servicios sean más estables y más rápidos. Un reinicio del servidor, un error crítico en la aplicación o cualquier otra situación inesperada no podrá corromper o eliminar el estado global de la aplicación. Nuestra aplicación opera sobre los datos, pero los datos en sí están almacenados y gestionados por servicios externos. Es decir, nuestra aplicación no debe preocuparse por el consumo de I/O del disco duro, ya que el servicio externo de almacenamiento de datos (por ejemplo, AWS S3) está encargado de dicha tarea. Tampoco ha de preocuparse por el acceso rápido a memoria, ya que esta función la hemos delegado al servicio externo de caché (por ejemplo, AWS ElasticCache). Nuestra aplicación no comparte ni compite con otros servicios por los recursos de la máquina donde se está ejecutando y nuestro código puede iniciarse rápidamente, sin tener que esperar a servicios externos.

# 7. Exposición de los servicios

Como ya hemos visto en los puntos 4 y 6, la comunicación entre los distintos servicios es muy importante. Precisamente por eso conviene definir en el código una serie de parámetros que, junto con lo expuesto en el punto 3, nos permitirán configurar fácilmente la exposición de cada servicio, es decir, protocolo y puerto por el que dicho servicio va a estar accesible por los demás servicios.

# 8. Concurrencia

Cumpliendo los puntos anteriores, automáticamente obtenemos otro beneficio. La concurrencia. Si nuestro código está aislado y encapsulado del resto de servicios (base de datos, caché, almacén de archivos, etc.), podemos escalar en horizontal nuestro código y multiplicar el numero de instancias de nuestro proceso que se ejecutan de manera simultánea. Podemos delegar todos los problemas de concurrencia a los servicios externos anteriormente mencionados (estos, normalmente, ya vendrán más que preparados y probados para dicho funcionamiento).

# 9. Disponibilidad

Si el punto 8 se cumple, podemos conseguir el objetivo que toda app modular desea: instancias desechables de nuestro código. Ya hemos dicho que nuestro código no guarda su estado en la máquina en la que se ejecuta (punto 6), y que podemos lanzar y parar tantas instancias de nuestro código como deseemos (punto 8). Por lo tanto, y por definición, ya podemos escalar nuestra aplicación en horizontal: podemos ejecutarla en tantos servidores como deseemos, y si la carga de la aplicación disminuye, eliminar los servidores sobrantes que ejecutan instancias de nuestro código. Esto nos permitirá balancear la carga entre las distintas máquinas, ofrecer alta disponibilidad al poder disponer de múltiples puntos que actúen de respaldo, realizar despliegues azul/verde con una ventana de mantenimiento mínima ya que nuestro código puede iniciarse de manera rápida (punto 6), despliegues en rollo y despliegues canarios basándonos en cualquier parámetro que queramos (carga de los servidores, ubicación geográfica, grupo de usuarios, etc.).

# 10. Paridad entre entornos

En el punto 3 hablábamos de las configuraciones para los distintos entornos donde se ejecutará nuestro código. Normalmente estos entornos serán, entre otros:

  • Desarrollo. Probablemente nuestra máquina local
  • Preproducción. Donde subiremos nuestro código y le haremos pruebas para ver si se comporta de la manera deseada.
  • Producción. El entorno productivo, donde nuestra aplicación tendrá que hacer frente al flujo de datos reales, atacantes, usuarios, situaciones no esperadas, etc.

Cuanto mas homogéneos sean los distintos entornos y cuantas menos diferencias existan, más fácil nos será detectar y corregir posibles problemas. Si nuestro entorno de preproducción ejecuta una base de datos A y nuestro entorno de producción ejecuta una base de datos B, y si ocurren errores solo en uno de los dos entornos puede que no seamos capaces de aislar rápidamente el problema, al no poder garantizar que el problema esté provocado por nuestro código o por un fallo o incompatibilidad en el servicio externo. Conviene ejecutar siempre los mismos servicios e incluso las mismas versiones.

# 11. Historial de eventos

Es importante y útil almacenar los eventos de cada uno de los servicios que estamos usando, incluido nuestro propio código. Sin embargo, siempre debemos cumplir el punto 6, lo cual significa que no podemos almacenar el historial de eventos en la misma máquina que está ejecutando nuestra aplicación. Podemos usar varias herramientas para redireccionar la salida de nuestra aplicación directamente a un servicio externo dedicado a la recolección de eventos.

# 12. Tareas de mantenimiento

En ocasiones, los cambios en nuestro código, introducidos a lo largo del tiempo, pueden requerir que hagamos ciertas operaciones como, por ejemplo, la recolección de recursos estáticos o alteraciones en la estructura de la base de datos. Estas tareas se deben realizar siempre desde el mismo entorno al que están destinadas, y para ser más exactos, los entornos virtuales descritos en el punto 2.

image


Referencia del contenido - https://12factor.net/

¡Hola! ¿Te ha gustado el contenido de esta entrada? ¿Te ha aclarado las dudas que tenias sobre el tema? Si crees que podemos ayudarte a resolver los problemas técnicos en tu negocio o si crees que podemos trabajar juntos en tus proyectos o mejorarlos de alguna manera, escríbenos a [email protected].