- FLAME busca un escalado elástico granular envolviendo partes del código de una aplicación existente como funciones para ejecutarlas en copias temporales de la aplicación, sin mover el código a un runtime separado
- El enfoque tradicional de FaaS puede terminar creando una estructura con HTTP, S3, SQS, API Gateway, codificadores/decodificadores y orquestación de flujos de trabajo, incluso para tareas como generar miniaturas de video
- La librería flame de Elixir delega la ejecución de funciones a un runner remoto con
FLAME.call, yFLAME.FlyBackendde Fly.io arranca una nueva Fly Machine con la misma imagen Docker y la conecta al nodo padre en unos 3 segundos - En desarrollo y pruebas, usa
LocalBackendpara ejecutar en el mismo runtime, y en producción ajusta scale-to-zero y períodos cortos en estado hot conmin,max,max_concurrencyeidle_shutdown_after - En lugar de eliminar las colas de trabajo, FLAME separa la garantía de durabilidad de la ejecución elástica: la cola se encarga de dispatch, commit y retry, y el trabajo intensivo de CPU se procesa dentro de una llamada a FLAME
El problema al que apunta el patrón FLAME
- El autoescalado elástico reduce la carga de administrar servidores y promete costos basados en uso, pero usar FaaS también puede aumentar la complejidad con colas separadas, almacenamiento, glue code y más dificultad en desarrollo, pruebas y CI
- FLAME significa Fleeting Lambda Application for Modular Execution y trata a toda la aplicación como si fuera una lambda, ejecutando solo algunos trabajos modulares en infraestructura efímera
- El objetivo se resume en tres puntos
- Reducir la administración de servidores usando flujos de despliegue existentes como
fly deploy,git push herokuykubectl - Escalar de forma granular y bajo demanda solo partes específicas del código de la aplicación
- No reescribir la aplicación ni mover parte del código a un runtime propietario
- Reducir la administración de servidores usando flujos de despliegue existentes como
Mover funciones existentes con FLAME.call
- El ejemplo es la función
generate_thumbnails, que convierte videos subidos en miniaturas conffmpegdentro de una aplicación Elixir - La función original crea un directorio temporal, ejecuta
ffmpeg, guarda las miniaturas generadas en almacenamiento duradero y registra las URL en la base de datos conRepo.insert_all - Como la transcodificación de video intensiva en CPU puede detener todo el servicio en producción, en vez de mover todo el código a FaaS o a un microservicio, se envuelve el cuerpo de la función en
FLAME.call(MyApp.FFMpegRunner, fn -> ... end) FLAME.callrecibe el nombre del pool de runners y una función, luego encuentra o arranca una nueva copia completa de la aplicación y ejecuta solo esa función- Variables capturadas por el closure, como la estructura
%Video{}yinterval, se transfieren automáticamente - El runner de FLAME se conecta al nodo padre tras arrancar, recibe la función a ejecutar y devuelve el resultado al llamador
- Según la configuración, el runner espera más trabajo y luego baja al quedar idle, o termina de inmediato
- Variables capturadas por el closure, como la estructura
- Como se ejecuta toda la aplicación, incluida la conexión a la base de datos, se puede seguir usando
Repo.insert_alltal como estaba
Complejidad de FaaS y diferencia con FLAME
- FaaS ofrece componentes para resolver el problema, mientras que FLAME se acerca más a reducir la propia capa de comunicación separada
- En el ejemplo de miniaturas de video, incluso si se empieza con un simple AWS Lambda Function URL, pronto aparece la complejidad
- Para volver a transmitir por HTTP las miniaturas a la app, hay que escribir codificadores y decodificadores personalizados en ambos lados
- Si la transcodificación o la subida del video supera 15 minutos, el hard timeout de Lambda obliga a dividir el video en chunks y usar más Lambdas
- Se necesitan más servicios como orquestación de workflows y componentes adicionales como SQS y S3
- Una implementación basada en FaaS normalmente crea estos puntos de costo
- Disparar la Lambda con un endpoint HTTP, S3 o API Gateway
- Escribir una Lambda personalizada para la transcodificación de video
- Guardar los resultados de las miniaturas en SQS
- Escribir un consumer de SQS del lado de la app
- Guardar en la base de datos y armar una forma de reenviar eventos a suscriptores activos conectados a otras instancias
- FLAME permite seguir usando el código interno de la aplicación, la base de datos existente, PubSub y las funciones de la plataforma, reduciendo servicios externos y almacenamiento intermedio para recuperar resultados
Backend de Fly.io y ejecución local
- La librería flame de Elixir implementa el patrón FLAME y ofrece por defecto
LocalBackendyFlyBackend FLAME.FlyBackendarranca una nueva copia de la aplicación en una Machine de Fly.io y puede conectarla al nodo padre para recibir trabajo en unos 3 segundos- Como Fly.io ejecuta aplicaciones empaquetadas como imágenes Docker, se le pide a la API de Fly que arranque una nueva Machine con la misma imagen que la app actual
- En la infraestructura de Fly.io, los runners de FLAME pueden iniciarse en la misma región que el padre, reduciendo la latencia entre ambos
FLAME.FlyBackendtiene menos de 200 LOC, incluida la documentación, y su única dependencia de librería es el cliente HTTPreq- En desarrollo y pruebas, LocalBackend ejecuta ese código dentro del runtime existente de la laptop o del servidor de CI
Transferencia de archivos y simplificación de desarrollo y pruebas
- FLAME reutiliza la lógica de negocio, la configuración de base de datos, PubSub y las funciones de la plataforma sin escribir código fuera de la aplicación
- En Elixir, gracias a las funciones distribuidas de la VM de Erlang, se puede enviar el stream de un archivo desde el nodo padre a la aplicación FLAME remota
- En el nodo padre, se abre el stream del archivo de la ruta del video
- En el hijo FLAME, se abre un stream de archivo temporal y se copia el stream del padre
- Después,
ffmpegusa el archivo temporal del runner remoto como entrada para generar miniaturas
- Con este enfoque, se puede pasar el archivo al servidor FLAME sin tener que configurar aparte interfaces S3 o HTTP
- También se reduce la necesidad de desplegar servicios separados, administrar endpoints, recuperar resultados por S3/SQS y configurar dependencias para desarrollo, pruebas y CI
FLAME fuera de Elixir
- Elixir encaja bien con el modelo FLAME por ofrecer supervisión de procesos y mensajería distribuida, pero otros lenguajes con primitivas razonables de concurrencia también pueden usar este patrón
- Hay una prueba de concepto en JavaScript que ejecuta funciones de una aplicación en otra máquina Fly Machine: fly-run-this-function-on-another-machine
- El flujo general de una llamada tipo FLAME en JavaScript consiste en mover la parte que ejecuta el módulo a un archivo nuevo y correrla en un pool de runners
- Si los argumentos son serializables a JSON, el flujo completo se parece al ejemplo de Elixir: el código de la aplicación se ejecuta en una instancia efímera
- Una librería FLAME completa tendría que encargarse de lo siguiente
- Lógica de scale-up y scale-down de pools elásticos
- Gestión del pool considerando hot startup y cold startup
- Monitoreo de runners remotos para evitar recursos huérfanos
- Mecanismos para mantener los despliegues actualizados
Relación con las colas de trabajos en segundo plano
- FLAME también funciona dentro de procesadores de trabajos en segundo plano, pero hay cierta superposición de funciones con las colas de trabajo
- Las colas de trabajo suelen usarse cuando se necesita garantía de durabilidad, y pueden ajustarse para procesar más trabajos según cambie la carga
- El trabajo duradero y la ejecución elástica son preocupaciones separadas
- Si la cola se usa solo para offload de ejecución, hace falta glue code para meter los datos del trabajo y devolver los resultados al llamador o al dispositivo del usuario
- Si se necesita garantizar que la generación de miniaturas tras subir un video tenga éxito, la cola puede encargarse de dispatch, commit y retry
- La transcodificación real puede ejecutarse con una llamada a FLAME dentro del trabajo, separando durabilidad y ejecución escalable
- Tareas que no necesitan durabilidad, como una vista previa de video antes de guardar o ejecutar un modelo de ML cuando el usuario ya salió de la app, pueden no encajar con una arquitectura que escribe trabajos en almacenamiento duradero
Pools de runners para escalado elástico
- La implementación de FLAME en Elixir define un pool elástico de runners que soporta tanto scale-to-zero como límites de concurrencia
- El ejemplo de configuración agrega
FLAME.Poolalstart/2de la aplicaciónmin: 0permite scale-to-zeromax: 10arranca hasta 10 runners como máximomax_concurrency: 5soporta 5 trabajos deffmpegpor runneridle_shutdown_after: 30_000hace idle down si no hay llamadas durante 30 segundos
- El servidor web de Phoenix se inicia de forma condicional según exista o no el padre de FLAME
- En runners FLAME que no manejan tráfico web, no hace falta iniciar el servidor web
MyApp.Repoy componentes similares se dejan activos porque deben usarse dentro del runner FLAME
- Si se define
min: 1, se puede mantener al menos un runner deffmpegen estado hot desde el arranque de la aplicación
Ubicación de procesos con estado
- Las partes con estado de una aplicación Elixir suelen construirse alrededor de primitivas de procesos livianos con mailbox de mensajes
FLAME.callyFLAME.castson adecuados para código relativamente stateless, mientras queFLAME.place_childarranca una especificación de proceso existente en un runner FLAME en vez de hacerlo localmenteFLAME.place_childpuede usarse en lugares donde normalmente se usarían interfaces comoTask.Supervisor.start_childoDynamicSupervisor.start_child- En el ejemplo de generar miniaturas durante una subida con LiveView, los chunks del upload se envían a un proceso
ThumbnailGenerator, que se comunica conffmpeg- Cuando encuentra un delimitador PNG en
stdoutdeffmpeg, envía un mensaje con la imagen al proceso de LiveView - LiveView recibe el mensaje en
handle_infoy agrega la nueva imagen a la UI
- Cuando encuentra un delimitador PNG en
- Si se cambia una llamada existente como
DynamicSupervisor.start_child(@sup, spec)porFLAME.place_child(Thumbs.FFMpegRunner, spec), el procesoThumbnailGeneratorpasa a ejecutarse en un runner FLAME - Como los procesos pueden enviarse mensajes sin importar dónde estén, si el proceso termina porque concluyó la subida o se cerró la pestaña del navegador, el servidor FLAME detecta la finalización y baja al quedar idle si no hay más trabajo
Monitoreo remoto y manejo de fallos
- La infraestructura efímera necesita mecanismos de seguridad para evitar recursos huérfanos
- Cuando el padre levanta un runner, este debe apagarse solo al quedar idle y también ejecutar un failsafe shutdown si ya no puede comunicarse con el nodo padre
- Si un nuevo despliegue reemplaza al padre, los runners también deben apagarse para garantizar que el clúster ejecute el mismo código
- Los llamadores activos que esperan resultados de un runner deben considerar que el runner puede caer por cualquier motivo
- Las primitivas que ofrece la VM de Erlang simplifican esta implementación
- Monitoreo y supervisión de procesos locales y remotos
- Node monitoring para detectar cuándo un nodo sube o baja
- Flujos de shutdown que controlan el orden de inicio y cierre de aplicaciones, dando tiempo a que runners activos terminen su trabajo durante un nuevo despliegue
- Los detalles internos de implementación seguirán en otro artículo; por ahora se puede revisar el código fuente de flame
Estado actual y siguientes pasos
- La librería FLAME de Elixir todavía está en una etapa temprana, pero ya se puede probar
- Más adelante se planean técnicas más avanzadas de crecimiento del pool y un análisis en profundidad de cómo se implementa en Elixir
- También se puede discutir cómo implementar el patrón FLAME en otros lenguajes
1 comentarios
Comentarios de Hacker News
Después de haber sufrido el dolor y la complejidad de una app con más de 100 funciones Lambda durante los últimos 4 años, creo que este artículo apunta exactamente a las desventajas de la arquitectura serverless FaaS
Cuando uno empieza, estas desventajas no se notan mucho. Más bien, si el uso es bajo, sobresalen claramente las ventajas de que sea casi gratis y requiera casi nada de mantenimiento
Más adelante, cuando los flujos de trabajo de Lambda se enredan y se vuelven cada vez más rígidos por las interdependencias, uno termina arrepintiéndose y pensando que habría sido mejor ir por un monolito y autoadministrarlo pagando unos cientos de dólares más. Hoy en día, con algo como fly.io, incluso podría costar menos
Me pregunto cómo sería esto si no usas Elixir
Para cuando la situación se pone fea, también suele ser el momento de buscar otro trabajo. Sobre todo si, después de alrededor de un año en la empresa, ya te arrepientes del proceso que tú mismo introdujiste. Lo he visto muchas veces con malos jefes, ‘pruebas unitarias’ desastrosas, Scrum y demás
Aun así, no sé si la gente identifica con claridad la causa de la incomodidad que siente en el trabajo, o si simplemente lo interpreta como “ya es hora de irme”. Cuando he intentado ponerle nombre a lo incómodo, he recibido mucha resistencia, y después de leer Good to Great, eso me desgasta mucho menos. Nadie quiere decir “ah, son las consecuencias de mis actos”
Las personas que realmente construyeron los sistemas tipo Rube Goldberg que ahora me toca mantener fueron las primeras en irse. El capitán de ese barco le preguntó a un colega qué le parecía open source nuestro motor, y el colega respondió que nadie querría usar un sistema que reinventa una rueda que ya existe y además es mejor. Esa persona se fue por voluntad propia en 3 o 4 meses
Si se pudiera escribir código orientado a servicios normal donde las Lambdas se llamen entre sí, no haría falta dividir la aplicación en cientos de fragmentos de ejecución corta. Pero si tienes que pagar incluso por el tiempo de espera, eso no es viable. Si Lambda pudiera pausar la ejecución mientras espera E/S, el problema se resolvería. Por eso creo que la ejecución durable (durable execution) puede ser la respuesta
En las últimas semanas estuve escribiendo un artículo para mostrar esto: https://restate.dev/blog/suspendable-functions-make-lambda-t...
Tal vez no se pueda obtener toda la usabilidad que en Elixir viene gratis, como funciones con serialización de variables capturadas, pero en lenguajes como JavaScript parece que se podría llegar al 90% moviendo el cuerpo ejecutable del módulo a otro archivo, en vez de envolverlo en un closure
Quien implemente la librería FLAME también tendrá que escribir la parte de pooling, monitoreo y comunicación remota. Elixir ofrece gratis muchas cosas del lado de la mensajería distribuida y el monitoreo. Las funciones relacionadas con la ubicación de procesos, en la práctica, también son algo casi exclusivo de Elixir
Al final migramos a un Lambda monolítico y gordo donde una sola Lambda maneja varios endpoints
Dicho de otro modo, puedes ir por un monolito y aun así minimizar el costo en AWS. No es un caso donde haya que elegir solo una de las dos cosas
Últimamente uso asp.net, y hasta apps bastante grandes desplegadas como ready-to-run, incluyendo modelos de EF optimizados, arrancan relativamente rápido
Soy el autor. Me alegra por fin poder publicarlo y responderé preguntas si las hay. Espero que esto motive lo suficiente a algunas personas para implementar el patrón FLAME en JavaScript, Go y otros lenguajes
Ya tiene mecanismos para manejar ejecución asíncrona mediante extensiones reactivas, Future y corrutinas sobre un event bus, y también soporta serialización y distribución de datos en todo el clúster. Además, como hay clientes de event bus para varios lenguajes populares, debería ser posible construir una aplicación mezclando varios lenguajes
Si presentas el problema como una “…condena peor que la muerte” y la solución como algo tan fácil e indoloro que cualquiera que no abandone otros enfoques parece tonto, este tipo de artículo se descarta fácilmente como simple texto de ventas. Esa es la forma de los vendedores de panaceas
Al principio del artículo sí se planteó bien el problema, así que para quienes sienten curiosidad por un mejor enfoque, creo que una comparación objetiva mucho menos emocional habría generado menos rechazo
El contenido sustancial del artículo me pareció interesante, pero la forma de expresarlo me echó para atrás. Espero que lo tome como retroalimentación constructiva
Y también me hizo sentir un poquito culpable haber apartado ffmpeg.fly.dev
La parte de “imagina que cualquier parte del código de tu app existente se puede envolver como una función, escalar automáticamente, y que ese bloque de código se ejecuta en una copia temporal de la app” me pareció interesante
Suena como si hubieran adaptado lo que hace fork para serverless. Excelente trabajo
Hace algunos años usé un servicio que básicamente hacía esto. PiCloud lamentablemente fue absorbido por Dropbox, pero antes tenía exactamente este modelo, donde distribuía transparentemente el trabajo entre workers. Funcionaba empaquetando el código y ejecutándolo en el worker.
Aquí hay un ejemplo. Se puede ver que es exactamente el mismo modelo: https://github.com/picloud/basic-examples/blob/master/exampl...
Nunca he usado Elixir, pero sí usé Erlang hace décadas, y BEAM no parece haber cambiado mucho en lo fundamental. Como eso forma parte del núcleo del diseño, parecería mucho más adecuado para este tipo de trabajo. Aun así, no creo que sea un almuerzo gratis completo, porque supongo que el proceso principal podría morir mientras espera
Estoy de acuerdo con el argumento general. En https://www.windmill.dev tomamos otro enfoque: consideramos que la unidad de abstracción no está al nivel del contenedor, sino al nivel del código fuente.
Parseamos la función main y los imports para extraer parámetros y dependencias, y luego ejecutamos el código tal cual en el runtime que quieras (TypeScript, Python, Go, Bash). El truco principal es administrar el caché de forma eficiente para que los workers siempre permanezcan calientes, independientemente de los imports.
Este enfoque no se integra tanto con la base de código como FLAME, pero apunta a un usuario diferente. Nuestros usuarios crean desde cero workflows complejos, tareas cron o scripts de una sola vez con UI autogenerada.
En FLAME, parece que se toma un snapshot de todo el contexto y luego se restaura en la VM de destino. Otro enfoque es introducir una sintaxis para especificar qué contexto se necesita y cuál no, para cargar solo lo mínimo. Lo estamos explorando actualmente para integrar mejor las bases de código existentes con Windmill y no depender de llamadas HTTP.
En ese proceso, solo se transmite implícitamente el contexto necesario. El artículo lo explica así: “FLAME.call recibe el nombre del pool de runners y una función. Luego busca o arranca una nueva copia de toda la aplicación, y ejecuta la función allí. Las variables capturadas por cierre en la función, por ejemplo la estructura %Video{} y interval, también se transfieren automáticamente”
wextra. Para quienes lo estén buscando, la URL es esta: https://www.windmill.dev/Me gusta el objetivo del proyecto. De verdad espero que Windmill se convierta en una mejor alternativa open source a Retool/Airtable
Me gusta que “en FLAME los runners de desarrollo y prueba simplemente se ejecutan en el backend local”. Qué bueno un serverless con una experiencia de desarrollo local decente.
Si “luego busca o arranca una nueva copia de toda la aplicación y esa función se ejecuta allí”, entonces ¿cada
Flame.callinicia de nuevo todo el proceso de la app y copia dentro el contexto de ejecución?Desde el punto de vista de la escalabilidad, parece una solución muy simple, pero imagino que también tendrá desventajas.
Si el tiempo de arranque de la app aumenta 10 ms, eso significa 10 ms extra en todos los puntos de
Flame.callde la aplicación, y con la memoria parecería pasar algo parecido.Supongo que al usar este sistema hay que tener en cuenta esas preocupaciones.
Cuando hay carga, el pool ya está caliente, así que casi no pagas costo de cold start. Además, estamos agregando a la librería de Elixir una técnica más sofisticada para aumentar el pool, para evitar también la situación en la que te topas con un runner lleno y tienes que hacer otro cold start.
En un runner caliente, el único overhead es la latencia entre padre e hijo. Como tienen que estar en el mismo datacenter, será de 1 ms o menos
Excelente. Se siente como una versión solo para Elixir y mucho más ligera de lo que construimos en https://www.inngest.com/.
Ambos se parecen en que envuelven código existente en algo que se puede usar en funciones serverless, y esencialmente permiten invocarlo mediante RPC remota.
Este tipo de código a menudo se ejecuta como una secuencia de pasos imperativos. Cada paso puede ejecutarse en serie o en paralelo como Lambdas adicionales. Pero entre los pasos existe un estado implícito capturado en variables. Por eso la función se convierte en un workflow. En el modelo de Inngest, ese estado se captura y se vuelve a inyectar en la función para darle durabilidad.
Desde el punto de vista de la durabilidad, este tipo de proceso debería basarse en una cola. Lo bueno de este modelo es que las colas son baratas. Si logras que una cola sea tan barata como una línea de código, todo se vuelve más fácil. Cualquier desarrollador puede escribir código confiable sin preocuparse por la infraestructura.
El monitoreo y la observabilidad también son importantes. Las dead-letter queues son realmente horribles, y necesitas poder administrar y volver a ejecutar funciones o pasos que fallen.
También hay diferencias entre FLAME e Inngest. Inngest está basado en colas, es impulsado por eventos y puede exponerse por HTTP desde cualquier lenguaje. Como Inngest guarda el estado externamente, puedes escribir un workflow en Elixir, reescribirlo en TypeScript y volver a desplegarlo, y las funciones en ejecución pueden migrarse en vivo entre lenguajes backend, de forma parecida a CRIU.
Al ser un enfoque impulsado por eventos, también permite control de flujo. Debounce, batching, throttling y fanout pueden manejarse en cualquier runtime o lenguaje. Por ejemplo, una app de Elixir en Fly puede emitir un evento para ejecutar una función en TypeScript + Lambda.
Tengo curiosidad por ver hacia dónde va FLAME. Creo que hay partes del objetivo que son similares.
Cuando se necesita durabilidad, reintentos y workflows en Elixir, normalmente se usa Oban, y aquí también seguiríamos haciéndolo. Un job de Oban sería el que invoque FLAME para encargarse de la ejecución elástica
Esta es una de las razones por las que de verdad odio la capitalización estilo estadounidense de los títulos que impone HN. Serverless parece significar “replantear Serverless”, la empresa con S mayúscula de serverless.com, y no “replantear serverless” como principio con s minúscula
Dicho eso, ojalá alguien replanteara Serverless
No sé si viste https://sst.dev/, pero parece que ellos hicieron exactamente eso. Por ejemplo, tienen Live Lambda Development, así que el desarrollo local se vuelve realmente fácil al reducir muchísimo el ciclo de retroalimentación, sin tener que subir el código a la nube y esperar el despliegue
La idea está bastante buena y la API es excelente
La parte de “las tareas CPU-bound como la transcodificación de video pueden detener rápidamente todo el servicio en producción” me hace pensar: ¿no bastaría con hacer autoescalado según CPU?
Terminas escalando toda la aplicación, incluido el servidor web, para manejar una tarea puntual que se calentó. Lo que queremos, y por lo que uno termina buscando FaaS, es un escalado elástico granular. La idea aquí es permitir ese tipo de escalado fino sobre el código existente de la app, en vez de solo aplastar los botones de escalar webservers o workers y esperar que salga bien
O quizá necesites una GPU
El servicio principal puede funcionar bien con 1 o 2 servidores, pero para una tarea que ocurre quizá una vez al día podrías necesitar escalar a decenas, cientos o miles de máquinas cuando haga falta