Arranque en frío: 30% más rápido con una línea de código
Los arranques en frío de AWS Lambda pueden mitigarse con paquetes de despliegue más pequeños usando empaquetadores como ncc sin afectar costos.
Published Mar 25, 2024
Last Modified Apr 2, 2024
Hola a todos comunidad, en este artículo abordaremos los arranques en frío de las funciones Lambda, un aspecto recurrente y "crítico" dentro del ámbito serverless. Analizaremos cómo el tiempo de respuesta al invocar nuestras funciones se ve afectado, dado que estos servicios generan entornos de ejecución efímeros, los cuales necesitan un proceso de creación y configuración antes de su utilización.
Para este artículo, asumiré que están familiarizados con AWS Lambda, pero es pertinente repasar su funcionamiento. AWS Lambda nos facilita ejecutar código en respuesta a eventos, como llamadas HTTP, al sincronizarse con otros servicios, manteniendo una gestión integral por parte de AWS en términos de escalabilidad, actualizaciones de seguridad y administración de la infraestructura subyacente. Esto permite que los equipos de desarrollo se concentren exclusivamente en la codificación.
En este contexto, AWS Lambda crea entornos de ejecución aislados que agrupan todos los elementos necesarios para ejecutar nuestro código. Esto abarca un paquete de despliegue (código más dependencias), el entorno de ejecución, el motor de ejecución, extensiones para personalizar el entorno, y un sistema de APIs que facilita la interacción entre los componentes del entorno de ejecución y el servicio de AWS Lambda.
La creación de un entorno de ejecución forma parte de una serie de fases. AWS Lambda, en su documentación, define 3 fases secuenciales del ciclo de vida de los entornos de ejecución. La fase de inicio (INIT), la fase de invocación (INVOKE) y la fase de apagado (SHUTDOWN), cada una con sus "sub-fases" que igualmente deben ser concluidas antes de avanzar de una fase a otra.
Podemos entender el ciclo de vida como el viaje en un coche. Este coche hipotético tiene toda la maquinaria necesaria para avanzar, sin embargo, está apagado y el dueño del coche drena la gasolina cada vez que se apaga el coche.
- Llenado del tanque (paquete .zip de despliegue y layers): Así como se llena el tanque de gasolina de un coche, en Lambda, se descarga el código desde la fuente, que puede ser AWS Lambda, un bucket de S3 o una imagen de ECR si se usa un contenedor de Docker para el despliegue.
- Aparatos periféricos (extensiones): Equiparables a los sistemas eléctricos y sensores en un coche, las extensiones en Lambda añaden funcionalidades o monitoreo adicional.
- Preparación del motor (runtime): Similar a preparar el motor de un coche, AWS Lambda prepara el runtime que ejecutará nuestro código.
- Preparación del motor (ejecución del código de iniciación): Es importante dejar que el motor caliente antes de conducir, así como en Lambda, el código que envuelve a la función handler se carga y se ejecuta.
- Conducción del coche (ejecución del código en el handler): Una vez que el coche está en marcha y todo está listo, se puede conducir hacia el destino. En Lambda, esto equivale a la ejecución del handler para procesar los eventos entrantes.
- Detención sin apagar el coche (periodo donde no se ejecuta el código): Si el coche se detiene sin apagarse, se puede reanudar la marcha sin esperar que se caliente de nuevo. En Lambda, tras una invocación, el entorno de ejecución puede mantenerse "caliente", facilitando invocaciones subsiguientes más rápidas.
- Apagar el coche (eliminar el entorno de ejecución): Al igual que se apaga el coche después de esperar mucho tiempo para no gastar gasolina, AWS Lambda terminará el entorno de ejecución después de un período de inactividad, liberando los recursos.
Esta última fase del ciclo de vida involucra tener que encender el coche y esperar a que todo el sistema esté listo antes de un nuevo viaje, así como la función lambda tendrá que descargar nuevamente nuestro código, iniciar las extensiones, iniciar el runtime y ejecutar el código de iniciación del handler antes de poder ejecutar el código de nuestro handler.
La fase INIT se desencadena en tres circunstancias específicas: la primera invocación de la función, cuando un entorno de ejecución previamente existente ha sido eliminado, o ante un escalado horizontal que requiere la creación de nuevos entornos para gestionar un incremento en el tráfico. Estas situaciones implican la generación de nuevos entornos de ejecución, asociados comúnmente con los "arranques en frío", debido a la necesidad de realizar toda la configuración inicial. Por lo tanto, la fase INIT es de gran importancia, ya que en ella podemos realizar optimizaciones que reduzca el tiempo de arranque en frío.
Numerosos textos en Internet abordan este tema y proponen diferentes concejos para reducir el tiempo de arranque en frío, a pesar de que este tipo de invocaciones suelen representar menos del 1% de las invocaciones totales en un entornos productivos con patrones regulares de invocación. Entre los consejos más comunes están:
- Uso de paquetes de despliegue más pequeños,
- Aumento de los recursos computacionales,
- Evitar su lanzamiento dentro de una VPC (aunque este efecto se ha reducido enormemente desde la incorporación de la tecnología AWS Hyperplane en 2019),
- Evitar el uso de variables de entorno (sí, el uso de variables de entorno afecta el tiempo de inicio).
Aunque varias de los tips mencionados en esos blogs pueden ser algo "forzados", existe uno en especial que me parece realmente útil y lógico: disminuir el tamaño del archivo .zip de despliegue.
Reducir el tamaño del archivo de despliegue es crucial para optimizar los tiempos de arranque en frío en AWS Lambda, donde el límite para el paquete de despliegue descomprimido es de 250 MB, y de 50 MB para el comprimido. En entornos Node.js, la carpeta node_modules puede exceder fácilmente los 100 MB, incluso después de eliminar dependencias de desarrollo con
npm prune --production
. Herramientas como webpack, esbuild y ncc extraen y empaquetan solo el código esencial en un solo archivo, reduciendo considerablemente el tamaño del paquete de despliegue para proyectos desarrollados en JavaScript. En mi experiencia, esta estrategia ha permitido reducir el tamaño del paquete de despliegue hasta en un 100x menor en entornos productivos, lo que se traduce en una disminución de hasta el 40% en los tiempos de arranque en frío, eliminando así la descarga de módulos innecesarios.
Sin embargo, el lenguaje que más suelo utilizar en mi día a día es TypeScript pero AWS Lambda aún no soporta TypeScript nativamente, esto implica transpilar mi código a JavaScript, . Es crucial entender que la transpilación por sí sola no compacta el código se necesita un empaquetador adicional para este fin.
Nuestro código puede ser transpilado y empaquetado simultaneamente usando ncc de vercel, una librería que permite realizar empaquetados y que trabaja nativamente con TypeScripts. Como mencionan en la documentación solo requerimos dos cosas: un archivo tsconfig.json que utilizará el transpilador de typescript y ejecutar el comando de compilación. El archivo tsconfig.json de por si es necesario en proyectos con typescript ya que sirve para indicar como será transpilado el código, es decir, con qué sintaxis, que sistema de módulos se utilizará, cómo se realizará la resolución del sistema de módulos, se establecen los alias para las dependencias, entre otras cosas.
Para realizar el empaquetado de nuestro paquete de despliegue tenemos muchas opciones, pero ncc de vercel nos permite fácilmente trabajar con TypeScript por lo que podemos crear el empaquetado con una sola línea de código.
Empecemos con un proyecto sencillo, una función lambda desencadenada por un API Gateway con un único EP utilizado para crear un registro de un cliente nuevo. Un ejemplo sencillo, no vale la pena complicarnos en el código, ya que lo interesante de este post será crear justamente el empaquetado.
index.ts
Como mencioné, es una aplicación muy sencilla que nos permite realizar una inserción a una tabla de DynamoDB de forma muy sencilla al utilizar dynamoose, un ORM que podemos utilizar para interactuar con DynamoDB y que nos permite no tener que involucrarnos con el uso del cliente del SDK.
tsconfig.json
A continuación, usaremos serverless framework, una herramienta que nos permite generar nuestra infraestructura como código de forma sencilla a través de la generación de un archivo de configuración en formato yml. El archivo serverless.yml para la creación de los recursos.
serverless.yml
Finalmente, con todo lo anterior configurado adecuadamente, podemos ejecutar este comando para realizar el despliegue:
ncc build src/index.ts -o dist && serverless deploy
Y listo, tenemos una lambda empaquetada con un tamaño de paquete de despliegue minimizado, en este caso, 100x menor que sin usar esta técnica. Ahora, esto no necesariamente significa que tiene un impacto del 100x en el tiempo de arranque en frío ya que existen más sub-fases durante la fase INIT, pero podemos medirlo.
Para esto generé un cron job que se ejecuta cada 5 min y realiza una petición a nuestra lambda de crear usuario desplegado utilizando el empaquetador y otra función que no utiliza el empaquetador, además, fuerzo la fase INIT de la lambda con otro cron job que actualiza las variables de entorno de ambas funciones de crear cliente cada 2 min, lo que provoca que el entorno de ejecución creado sea eliminado y por lo tanto, la lambda siempre tenga que realizar un arranque en frío. El siguiente gráfico compara el arranque en frío de la lambda sin el empaquetador (color azul) y con ncc (color naranja).
Parece poco tiempo por ejecución, ya que la diferencia es en promedio de 100 milisegundos (aproximadamente un 30% más rápido), pero al final el tiempo ahorrando va sumando hasta convertirse en minutos y después a horas de latencia ahorradas con unas simples líneas de código simplemente con reducir el tamaño del paquete de despliegue.
Realmente no hay ningún ahorro en costos ya que los arranques en frio no forman parte del tiempo cobrado por la ejecución de nuestras funciones, ya que este solo es sobre el tiempo de invocación.
Obviamente en el ejemplo que realicé para este post existen otros servicios que generan cargos, ya sean las escrituras de DynamoDB o las consultas por API Gateway, sin embargo, el usar un empaquetador no tiene el más mínimo impacto en estos costos.
En resumen, los arranques en frío en AWS Lambda representan un reto en la optimización de la ejecución serverless, causados por la naturaleza efímera de los entornos de ejecución que necesitan ser creados para cada invocación después de periodos de inactividad o en la primera invocación usualmente debido a la necesitad de implementar entornos de ejecución concurrentes causados por un aumento en el tráfico.
Para mitigar este efecto, se recomienda el uso de paquetes de despliegue más pequeños, la optimización de recursos y estrategias como evitar ejecuciones en VPC cuando sea posible. Las herramientas de empaquetado como ncc de Vercel para TypeScript pueden reducir significativamente el tamaño de los paquetes de despliegue, lo cual es crucial en la fase de inicio, y por ende, reduce los tiempos de arranque en frío. Aunque estos arranques en frío representan una pequeña fracción de las invocaciones totales en un entorno productivo, optimizar este proceso puede acumular ahorros significativos en tiempo de ejecución, sin afectar los costos directos, ya que los arranques en frío no se cobran pero sí afectan la latencia general del sistema.
En conclusión, la gestión eficiente de los entornos de ejecución y la optimización de los paquetes de despliegue son esenciales para mejorar la experiencia serverless y minimizar los impactos de los arranques en frío en AWS Lambda.