Blog de Amazon Web Services (AWS)

Comparando enfoques de diseño al crear microservicios serverless

Esta publicación ha sido escrita por Luca Mezzalira, Principal SA, Matt Diamond, Principal SA y traducido por Manuel Ortiz Bey, Senior SA.

Diseñar una carga de trabajo con AWS Lambda genera en ocasiones parálisis decisional para los desarrolladores debido a la modularidad que se puede expresar a nivel de código y de infraestructura. El uso de sistemas sin servidor (“serverless”) para ejecutar código, requiere una planificación adicional para abstraer la lógica empresarial de los componentes funcionales subyacentes. Este esquema de “separación de responsabilidades” deliberado, garantiza una modularidad sólida, lo que allana el camino para arquitecturas evolutivas.

Esta publicación se centra en las cargas de trabajo sincrónicas, pero se pueden aplicar consideraciones similares a otro tipo de cargas. Tras identificar el contexto limitado de su API y acordar los contratos de API con los consumidores del mismo, es necesario estructurar la arquitectura de ese contexto limitado y su infraestructura asociada.

Las dos formas más comunes de estructurar una API basada en funciones de Lambda lo son Responsabilidad Limitada o “Single Responsibility” y “Lambdalito” o “Lambda-Lith”. Sin embargo, este blog explora una tercera alternativa a estos enfoques que puede ofrecer lo mejor de ambos mundos.

Funciones Lambda de Responsabilidad Limitada

Las funciones Lambda de responsabilidad limitada, están diseñadas para ejecutar una tarea específica o gestionar una operación determinada provocada por un evento dentro de una arquitectura serverless:

Funciones Lambda de Responsabilidad Simple

Este enfoque proporciona una clara separación de preocupaciones entre la lógica empresarial y las capacidades. Así puede probar de forma aislada capacidades específicas, desplegar funciones Lambda de forma independiente por capacidad, reducir la superficie de errores y facilitar la depuración de los problemas en Amazon CloudWatch.

Además, el tener estas Lambdas independientes, permite una asignación eficiente de los recursos, ya que cada función de Lambda escala automáticamente en función de la demanda, lo que optimiza el consumo de recursos y minimiza los costos. Esto significa que puede modificar el tamaño de la memoria, la arquitectura de procesador y cualquier otra configuración disponible por función específica basado en sus necesidades. Además, solicitar un aumento a los límites de ejecución simultánea (“concurrency limits”) de Lambdas mediante un ticket de soporte resulta más fácil, ya que no se agrega el tráfico a una sola función que gestiona todas las solicitudes; sino que se puede solicitar un aumento específico en función del tráfico de una sola.

Otra ventaja es la optimización y reducción en tiempo de ejecución. Si se tiene en cuenta la lógica empresarial de una Lambda diseñada para una sola tarea, se puede optimizar el tamaño de ésta con mayor facilidad, sin necesidad de bibliotecas adicionales como las que se requieren en otros enfoques. Esto ayuda a reducir el tiempo de arranque en frío (“cold starts”) debido a un tamaño de paquete más pequeño.

A pesar de estas ventajas, existen algunos problemas al confiar únicamente en las funciones de Lambda de un solo propósito. Si bien se reduce el tiempo de arranque en frío, es posible que se produzca un mayor número de éstos; especialmente en el caso de funciones con invocaciones esporádicas o poco frecuentes. Por ejemplo, una función que elimina usuarios de una tabla de Amazon DynamoDB, probablemente no se active con tanta frecuencia como una que lee los datos de los usuarios. Además, depender en gran medida de Lambdas de un solo propósito, puede aumentar la complejidad del sistema, especialmente a medida que aumenta el número de funciones.

Una buena separación de lógica empresarial, ayuda a mantener la base de código a costa de la falta de cohesión. Sin embargo, en funciones con tareas similares, como las operaciones de escritura de una API (POST, PUT, DELETE), es posible duplicar el código y los comportamientos en varias funciones. Además, la actualización de bibliotecas comunes que se comparten a través de Lambda Layers u otros sistemas de administración de dependencias requiere varios cambios en cada función, en lugar de un cambio atómico en un solo archivo. Esto también es válido para cualquier otro cambio en la configuración de las funciones como, por ejemplo, la actualización de la versión del lenguaje de ejecución o cambios en variables de ambiente.

Lambda-lito: utilizar una sola Lambda para todo

Cuando muchas cargas de trabajo utilizan funciones Lambda de un solo propósito, los desarrolladores acaban teniendo una proliferación de Lambdas en una sola cuenta de AWS. Uno de los principales desafíos a los que se enfrentan los desarrolladores es actualizar las configuraciones de funciones o dependencias comunes. A menos que se implemente una estrategia de gobierno clara para abordar este problema (por ejemplo, usar Dependabot para hacer cumplir la actualización de las dependencias); los desarrolladores pueden optar por una estrategia diferente.

Como resultado, muchos equipos de desarrollo actúan en la dirección opuesta y agrupan todo el código relacionado con una API dentro de la misma función de Lambda.

Lambdalito - Una sola función Lambda

Este enfoque suele denominarse Lambda-lito o “Lambdalith” porque reúne todos los verbos HTTP que componen una API y, a veces, varias API en la misma función.

Esto le permite tener una mayor cohesión y ubicación del código en las diferentes partes de la aplicación. En este caso, la modularidad se expresa a nivel de código, donde se aplican patrones como la responsabilidad única, la inyección de dependencias y la fachada (“façade”) para estructurar el código. La disciplina y las mejores prácticas aplicadas por los equipos de desarrollo, son cruciales para mantener bases de código de gran tamaño.

Sin embargo, teniendo en cuenta la reducción del número de funciones de Lambda, es más fácil actualizar una configuración o implementar un nuevo estándar en varias API en comparación con el enfoque de responsabilidad única.

Además, dado que cada solicitud invoca la misma Lambda para cada verbo HTTP, es más probable que las partes del código que se utilizan poco tengan un mejor tiempo de respuesta, ya que es más probable que haya un entorno de ejecución disponible para atender la solicitud.

Otro factor a tener en cuenta es el tamaño de la función. Este aumenta cuando se colocan verbos en la misma función con todas las dependencias y la lógica empresarial de una API. Esto puede afectar al arranque en frío de las funciones cuando las cargas de trabajo son muy intensas. Los clientes deben evaluar las ventajas de este enfoque; especialmente cuando las aplicaciones tienen acuerdos de nivel de servicio restrictivos, lo que se vería afectado por los arranques en frío. Los desarrolladores pueden mitigar este problema prestando atención a las dependencias utilizadas e implementando técnicas como “tree-shaking”, minificación y la eliminación del código muerto; siempre que el lenguaje de programación lo permita.

Este enfoque tan simple no le permitirá ajustar las configuraciones de sus funciones de forma individual. Por ello, deberá encontrar una configuración que reúna todas las capacidades del código, con un tamaño de memoria posiblemente mayor y permisos de seguridad menos estrictos, lo cual podría incumplir con los requisitos definidos por el equipo de seguridad.

Funciones de lectura y escritura

Ambos enfoques anteriores tienen ventajas y desventajas, pero hay una tercera opción que puede combinar sus beneficios.

A menudo, el tráfico de las API se inclina hacia un mayor número de lecturas o escrituras, lo que obliga a los desarrolladores a optimizar el código y las configuraciones más por un lado que por el otro.

Por ejemplo, considere la posibilidad de crear una API de usuarios que permita a los consumidores crear, actualizar y eliminar un usuario; pero también encontrar un usuario o una lista de usuarios. En este escenario, puedes editar un usuario a la vez, pero puedes obtener uno o más usuarios por cada solicitud de API. Al dividir el diseño de la API en operaciones de lectura y escritura, se obtiene la siguiente arquitectura:

Diseño de Lectura y Escritura

La cohesión del código para las operaciones de escritura (crear, actualizar y eliminar) es beneficiosa por muchas razones. Por ejemplo, es posible que tengas que validar el cuerpo de la solicitud y asegurarte de que contiene todos los parámetros obligatorios. Si la carga de trabajo de escritura es intensa, las operaciones menos utilizadas (por ejemplo, Delete) se benefician de los entornos de ejecución cálidos. Esta división de responsabilidades del código, permite la reutilización del mismo en acciones similares, lo que reduce la carga cognitiva necesaria para estructurar los proyectos con bibliotecas compartidas o “Lambda Layers”, por ejemplo.

Desde el punto de vista de las operaciones de lectura, puede reducir el código incluido con esta función, lo que permite un arranque en frío más rápido y optimizar considerablemente el rendimiento en comparación con una operación de escritura. También puede almacenar los resultados de una consulta parcial o total en la memoria del entorno para mejorar el tiempo de ejecución de una Lambda.

Este enfoque le ayuda evolucionar y escalar mucho más rápido. Imagínese si su API se hiciera mucho más popular. Ahora, debe optimizarla aún más, mejorando las lecturas y añadiendo un patrón de almacenamiento de memoria caché con ElastiCache y Redis. Además, ha decidido acelerar las consultas de lectura con una segunda base de datos que esté optimizada para la capacidad de lectura cuando no se utilice la memoria caché.

En cuanto a la escritura, usted ha convenido con los usuarios de esta API en que es esperado simplemente recibir la confirmación de que la creación o eliminación de usuarios ya está en camino (en lugar de esperar por que se ejecute para recibir una respuesta).

Bajo esa convención, usted puede mejorar aún más el tiempo de respuesta de las operaciones de escritura añadiendo una cola SQS antes de la Lambda. Puede actualizar la base de datos de escritura en lotes para reducir el número de invocaciones necesarias para gestionar las operaciones de escritura, en lugar de tratar cada solicitud de forma individual.

Diseño Funciones Lambda con CQRS

La segregación de responsabilidades de consultas de comandos (CQRS) es un patrón bien establecido que separa la mutación de datos, o la parte de comando de un sistema, de la parte de consulta. Puede usar el patrón CQRS para separar las actualizaciones de las consultas si tienen requisitos diferentes de rendimiento, latencia o coherencia.

Si bien no es obligatorio empezar con un patrón de CQRS completo, puedes evolucionar a partir de la infraestructura destacada en la implementación inicial de lectura y escritura, sin necesidad de refactorizar en gran medida tu API.

Comparación de los tres enfoques

He aquí una comparación de los tres enfoques:

Responsabilidad limitada

Lambda-Lito

Lectura y Escritura

Beneficios

  • Fuerte
    separación de preocupaciones
  • Configuración
    granular
  • Mejor
    depuración
  • Tiempo
    de ejecución rápido
  • Menos invocaciones de arranque
    en frío
  • Mayor
    cohesión del código
  • Mantenimiento
    más sencillo
  • Cohesión de código donde
    sea necesaria
  • Arquitectura
    evolutiva
  • Optimización de las
    operaciones de lectura y escritura

Problemas

  • Duplicación
    de código
  • Mantenimiento
    complejo
  • Invocaciones de arranque
    en frío más altas
  • Configuración muy general
    en lugar de específica.
  • Mayor tiempo de arranque
    en frío
  • Uso del CQRS con dos
    modelos de datos
  • El CQRS añade consistencia
    eventual (en lugar de instantánea) a su sistema

Conclusión

Los desarrolladores suelen pasar de las funciones de responsabilidad única a las Lambda-Lito a medida que sus arquitecturas evolucionan, pero ambos enfoques tienen ventajas y desventajas relativas. Este blog muestra cómo es posible aprovechar al máximo ambos enfoques; dividiendo las cargas de trabajo por operaciones de lectura y escritura.

Los tres enfoques son viables para diseñar APIs Serverless. Entender para qué se está optimizando, es la clave para tomar la mejor decisión. Recuerde que si entiende el contexto y los requisitos empresariales que deben plasmarse en sus aplicaciones, obtendrá las ventajas y desventajas que hay que considerar dentro de una carga de trabajo específica. Mantenga la mente abierta y busque la solución que resuelva el problema y equilibre la seguridad, la experiencia del desarrollador, el costo y la facilidad de mantenimiento.

Para obtener más recursos de aprendizaje sobre tecnologías Serverless, visite Serverless Land.

Este blog es una traducción del contenido original en inglés (disponible aquí).