- FLAME vise une montée en charge élastique fine en encapsulant certaines parties du code applicatif dans des fonctions exécutées sur des copies temporaires de l’application, sans déplacer le code vers un runtime séparé
- L’approche FaaS classique peut multiplier HTTP, S3, SQS, API Gateway, encodeurs/décodeurs et orchestration de workflow, même pour des tâches comme la génération de miniatures vidéo
- La bibliothèque flame d’Elixir délègue l’exécution d’une fonction à un runner distant via
FLAME.call, etFLAME.FlyBackendde Fly.io démarre une nouvelle Fly Machine avec la même image Docker, qui se connecte au nœud parent en environ 3 secondes - En développement et en test,
LocalBackendexécute le code dans le même runtime ; en production,min,max,max_concurrencyetidle_shutdown_afterpermettent d’ajuster le scale-to-zero et le maintien bref d’un état hot - Plutôt que de supprimer les files de tâches, FLAME sépare les garanties de durabilité de l’exécution élastique : la file gère dispatch, commit et retry, tandis que les tâches CPU-intensives sont traitées dans un appel FLAME
Le problème visé par le modèle FLAME
- L’auto-scaling élastique réduit la charge d’administration des serveurs et promet un coût à l’usage, mais adopter du FaaS peut aussi faire exploser la complexité avec des files séparées, du stockage, du glue code et davantage de complexité en développement, test et CI
- FLAME signifie Fleeting Lambda Application for Modular Execution : l’idée est de traiter toute l’application comme une lambda, en n’exécutant que certaines tâches modulaires sur une infrastructure éphémère
- Les objectifs se résument en trois points
- réduire la gestion des serveurs avec les flux de déploiement existants comme
fly deploy,git push herokuoukubectl - permettre une montée en charge granulaire à la demande sur certaines parties seulement du code applicatif
- éviter de réécrire l’application ou de déplacer une partie du code vers un runtime propriétaire
- réduire la gestion des serveurs avec les flux de déploiement existants comme
Déporter des fonctions existantes avec FLAME.call
- L’exemple présenté est une fonction
generate_thumbnailsdans une application Elixir, qui transforme une vidéo téléversée en miniatures avecffmpeg - La fonction existante crée un répertoire temporaire, exécute
ffmpeg, stocke les miniatures produites dans un stockage durable, puis enregistre leurs URL dans la base avecRepo.insert_all - Comme le transcodage vidéo CPU-intensif peut bloquer tout le service en production, le corps de la fonction est simplement encapsulé dans
FLAME.call(MyApp.FFMpegRunner, fn -> ... end)au lieu d’être déplacé vers un FaaS ou un microservice entier FLAME.callprend un nom de pool de runners et une fonction, puis cherche ou démarre une nouvelle copie complète de l’application pour n’exécuter que cette fonction- les variables capturées par la fermeture, comme la structure
%Video{}etinterval, sont transmises automatiquement - après démarrage, le runner FLAME se connecte au nœud parent, reçoit la fonction à exécuter, puis renvoie le résultat à l’appelant
- selon la configuration, le runner peut attendre d’autres tâches puis s’arrêter à l’état idle, ou se terminer immédiatement
- les variables capturées par la fermeture, comme la structure
- Comme l’application complète tourne, y compris la connexion à la base, on peut continuer à utiliser
Repo.insert_alltel quel
Complexité du FaaS et différence avec FLAME
- Le FaaS fournit des briques pour résoudre le problème, tandis que FLAME cherche plutôt à réduire la couche de communication séparée elle-même
- Dans l’exemple des miniatures vidéo, même en partant d’une simple AWS Lambda Function URL, la complexité apparaît vite
- pour retransmettre les miniatures à l’application via HTTP, il faut écrire un encodeur et un décodeur personnalisés de chaque côté
- si le transcodage vidéo ou le téléversement dépasse 15 minutes, le hard timeout de Lambda impose de découper la vidéo en morceaux et d’utiliser encore plus de fonctions Lambda
- il faut alors ajouter de l’orchestration de workflow et des services comme SQS et S3
- Une implémentation fondée sur le FaaS crée généralement les postes de coût suivants
- déclenchement de Lambda via un endpoint HTTP, S3 ou API Gateway
- écriture d’une Lambda dédiée au transcodage vidéo
- stockage du résultat des miniatures dans SQS
- écriture d’un consumer SQS côté application
- mise en place du stockage en base et d’un moyen de renvoyer les événements aux abonnés actifs connectés à d’autres instances
- FLAME permet de réutiliser le code interne de l’application, la base existante, PubSub et les fonctions de la plateforme, ce qui réduit les services séparés et les stockages intermédiaires destinés à récupérer les résultats
Backend Fly.io et exécution locale
- La bibliothèque flame pour Elixir est une implémentation du modèle FLAME et fournit par défaut
LocalBackendetFlyBackend FLAME.FlyBackenddémarre une nouvelle copie de l’application sur une Machine de l’infrastructure Fly.io et peut la connecter au nœud parent en environ 3 secondes pour recevoir des tâches- Comme Fly.io exécute les applications sous forme d’images Docker packagées, l’API Fly reçoit une demande de démarrage d’une nouvelle Machine avec la même image que l’application courante
- Sur l’infrastructure Fly.io, les runners FLAME peuvent être lancés dans la même région que le parent, ce qui réduit la latence entre parent et runner
FLAME.FlyBackendfait moins de 200 LOC, documentation comprise, et sa seule dépendance externe est le client HTTPreq- En développement et en test, LocalBackend exécute le code concerné dans le runtime existant du laptop ou du serveur CI
Transfert de fichiers et simplification du développement et des tests
- FLAME permet de réutiliser la logique métier, la configuration base de données, PubSub et les fonctions de la plateforme sans écrire de code en dehors de l’application
- En Elixir, les capacités distribuées de la VM Erlang permettent d’envoyer un flux de fichier du nœud parent vers l’application FLAME distante
- le nœud parent ouvre un flux de fichier sur le chemin de la vidéo
- l’enfant FLAME ouvre un flux de fichier temporaire et y copie le flux du parent
ffmpegutilise ensuite ce fichier temporaire sur le runner distant comme entrée pour générer les miniatures
- Cette approche permet de transférer les fichiers vers le serveur FLAME sans configurer séparément S3 ou une interface HTTP
- Elle réduit les déploiements de services séparés, la gestion d’endpoints, la récupération de résultats via S3/SQS et la configuration des dépendances en développement, test et CI
FLAME en dehors d’Elixir
- Elixir se prête bien au modèle FLAME grâce à sa supervision de processus et sa messagerie distribuée, mais d’autres langages disposant de primitives de concurrence raisonnables peuvent aussi adopter ce modèle
- Un proof of concept en JavaScript exécute une fonction applicative sur une autre machine Fly Machine : fly-run-this-function-on-another-machine
- Dans ce modèle d’appel FLAME côté JavaScript, la partie d’exécution du module est déplacée dans un nouveau fichier puis exécutée dans un pool de runners
- Si les arguments sont sérialisables en JSON, le flux global ressemble à l’exemple Elixir : le code applicatif s’exécute sur une instance éphémère
- Une bibliothèque FLAME complète devrait gérer les points suivants
- la logique de scale-up et scale-down d’un pool élastique
- la gestion du pool en tenant compte des démarrages hot et cold
- la supervision des runners distants pour éviter les ressources orphaned
- le maintien des déploiements à jour
Relation avec les files de tâches en arrière-plan
- FLAME peut fonctionner à l’intérieur d’un worker de tâches en arrière-plan, mais son rôle recoupe partiellement celui d’une file de tâches
- Une file de tâches sert généralement quand il faut une garantie de durabilité, et elle peut être ajustée pour traiter davantage de tâches selon la charge
- Les tâches durables et l’exécution élastique sont deux préoccupations distinctes
- si la file n’est utilisée que pour déporter l’exécution, il faut ajouter du glue code pour pousser les données dans la tâche et renvoyer le résultat à l’appelant ou à l’appareil de l’utilisateur
- si la génération de miniatures après un téléversement vidéo doit être garantie, la file peut prendre en charge dispatch, commit et retry
- le transcodage lui-même peut alors être exécuté via un appel FLAME à l’intérieur de la tâche, ce qui sépare la durabilité de l’exécution scalable
- Pour des traitements qui n’ont pas besoin de durabilité, comme un aperçu vidéo avant enregistrement ou l’exécution d’un modèle ML après que l’utilisateur a déjà quitté l’application, écrire la tâche dans un stockage durable peut ne pas être adapté
Pools de runners pour la montée en charge élastique
- L’implémentation Elixir de FLAME définit un pool élastique de runners qui prend en charge à la fois le scale-to-zero et les limites de concurrence
- Dans l’exemple de configuration,
FLAME.Poolest ajouté austart/2de l’applicationmin: 0autorise le scale-to-zeromax: 10permet de démarrer jusqu’à 10 runnersmax_concurrency: 5autorise 5 tâchesffmpegpar runneridle_shutdown_after: 30_000arrête le runner s’il n’y a aucun appel pendant 30 secondes
- Le serveur web Phoenix démarre de façon conditionnelle selon la présence ou non d’un parent FLAME
- il n’est pas nécessaire de lancer le serveur web sur un runner FLAME qui ne traite pas de trafic HTTP
- en revanche,
MyApp.Repoet la base doivent rester disponibles car ils sont utilisés dans le runner FLAME
- Avec
min: 1, on peut maintenir au moins un runnerffmpegà chaud dès le démarrage de l’application
Placement de processus avec état
- Dans une application Elixir, les parties avec état reposent sur des processus légers disposant d’une mailbox de messages
FLAME.calletFLAME.castconviennent à du code relativement stateless, tandis queFLAME.place_childpermet de démarrer une spécification de processus existante sur un runner FLAME plutôt qu’en localFLAME.place_childpeut être utilisé partout où l’on emploie des interfaces commeTask.Supervisor.start_childouDynamicSupervisor.start_child- Dans l’exemple de génération de miniatures pendant un téléversement LiveView, les chunks uploadés sont envoyés à un processus
ThumbnailGenerator, qui communique ensuite avecffmpeg- lorsqu’il trouve un délimiteur PNG dans la sortie standard de
ffmpeg, il envoie un message image au processus LiveView - LiveView reçoit ce message dans
handle_infoet ajoute la nouvelle image à l’interface
- lorsqu’il trouve un délimiteur PNG dans la sortie standard de
- En remplaçant
DynamicSupervisor.start_child(@sup, spec)parFLAME.place_child(Thumbs.FFMpegRunner, spec), le processusThumbnailGenerators’exécute sur un runner FLAME - Comme les processus peuvent échanger des messages quelle que soit leur localisation, si le processus se termine à la fin de l’upload ou à la fermeture de l’onglet du navigateur, le serveur FLAME détecte l’arrêt et s’éteint à l’état idle s’il n’a plus d’autre travail
Supervision distante et gestion des pannes
- Une infrastructure éphémère a besoin de mécanismes de sécurité pour éviter les ressources orphaned
- Lorsqu’un parent lance un runner, celui-ci doit pouvoir s’arrêter lui-même à l’état idle quand il n’a plus de travail, et déclencher un arrêt de secours s’il ne peut plus contacter le nœud parent
- Quand un nouveau déploiement remplace le parent, les runners doivent aussi s’arrêter afin de garantir que le cluster exécute bien le même code partout
- Un appelant actif qui attend le résultat d’un runner doit prendre en compte le fait que ce runner peut s’arrêter pour n’importe quelle raison
- Les primitives fournies par la VM Erlang simplifient cette implémentation
- supervision et monitoring de processus locaux et distants
- node monitoring pour détecter l’arrivée et la disparition de nœuds
- flux de shutdown contrôlant l’ordre de démarrage et d’arrêt des applications afin de laisser aux runners actifs le temps de terminer leur travail lors d’un nouveau déploiement
- Les détails internes de l’implémentation seront abordés dans un autre article ; en attendant, on peut consulter le code source de flame
État actuel et prochaines étapes
- La bibliothèque FLAME pour Elixir en est encore à ses débuts, mais elle peut déjà être testée
- Des techniques plus avancées de croissance de pool et une analyse approfondie de l’implémentation Elixir sont prévues par la suite
- Il est également possible de discuter d’implémentations du modèle FLAME dans d’autres langages
1 commentaires
Avis sur Hacker News
Après avoir subi, ces 4 dernières années, la douleur et la complexité d’une appli composée de plus de 100 fonctions Lambda, je pense que cet article met précisément le doigt sur les défauts des architectures serverless FaaS
Au début, ces défauts ne sont pas évidents. Au contraire, quand l’usage est faible, les avantages sont nets : c’est presque gratuit et il n’y a quasiment pas de maintenance
Ce n’est que plus tard, quand les workflows Lambda s’entremêlent et deviennent de plus en plus rigides à cause des interdépendances, qu’on regrette de ne pas être parti sur un monolithe et de ne pas avoir payé quelques centaines de dollars de plus pour l’administrer soi-même. De nos jours, avec des services comme fly.io, cela pourrait même coûter moins cher
Je me demande ce que cela donnerait si l’on n’utilise pas Elixir
Au moment où la situation se dégrade, c’est souvent aussi le moment de chercher un nouveau poste. Surtout si, environ un an après son arrivée, on regrette déjà le processus que l’on a introduit. Je l’ai vu plusieurs fois avec de mauvais managers, des « tests unitaires » bancals, Scrum, etc.
Cela dit, je ne sais pas si les gens identifient clairement la cause de leur malaise au travail, ou s’ils le ressentent simplement comme « il est temps de partir ». Quand j’ai essayé de nommer ce qui n’allait pas, j’ai rencontré beaucoup de résistance, et depuis que j’ai lu Good to Great, cela me coûte beaucoup moins émotionnellement. Personne n’a envie de dire : « ah, c’est la conséquence de mes actes »
Les personnes qui avaient effectivement créé le système à la Rube Goldberg que je maintiens sont parties les premières. Le capitaine de ce navire a demandé à un collègue ce qu’il penserait d’ouvrir notre moteur en open source, et celui-ci lui a répondu que personne ne voudrait utiliser un système qui réinventait une roue déjà existante et meilleure. Cette personne est partie volontairement dans les 3 à 4 mois
Si l’on peut écrire du code orienté services classique où des Lambda peuvent s’appeler entre elles, il n’est pas nécessaire de découper l’application en centaines de petits morceaux à exécution courte. Mais si l’on doit payer aussi pour le temps passé à attendre, c’est impossible. Si Lambda pouvait suspendre l’exécution pendant les attentes d’E/S, le problème serait résolu. C’est pourquoi je pense que l’exécution durable pourrait être la réponse
Ces dernières semaines, j’écrivais un article qui montre cela : https://restate.dev/blog/suspendable-functions-make-lambda-t...
Il sera difficile d’obtenir toute l’ergonomie qu’Elixir fournit gratuitement, comme les fonctions avec sérialisation des variables capturées, mais dans des langages comme JavaScript, on devrait pouvoir atteindre environ 90 % du résultat en déplaçant le corps d’exécution du module dans un nouveau fichier plutôt qu’en l’enveloppant dans une closure
La personne qui implémente une bibliothèque FLAME devra aussi écrire les parties pooling, monitoring et communication distante. Elixir fournit beaucoup de choses gratuitement côté messagerie distribuée et monitoring. Les fonctionnalités liées au placement des processus sont, en pratique, assez spécifiques à Elixir
Nous avons fini par migrer vers un gros monolithe Lambda où une seule Lambda gère plusieurs endpoints
Autrement dit, on peut partir sur un monolithe tout en minimisant les coûts AWS. Ce n’est pas un choix exclusif entre les deux
En ce moment, j’utilise asp.net, et même une appli assez grosse déployée en ready-to-run, avec un modèle EF optimisé, démarre relativement vite
Je suis l’auteur. Je suis ravi de pouvoir enfin le publier et je répondrai volontiers aux questions. J’espère que plusieurs personnes seront suffisamment inspirées pour implémenter le pattern FLAME en JavaScript, Go ou dans d’autres langages
Il existe déjà, au-dessus d’un bus d’événements, des mécanismes de scaling réactif et d’exécution asynchrone via Future et coroutines, ainsi que la sérialisation et la distribution des données à l’échelle d’un cluster. Comme il existe aussi des clients de bus d’événements pour plusieurs langages populaires, on pourrait construire des applications en mélangeant plusieurs langages
Quand on présente le problème comme « …un sort pire que la mort » et la solution comme tellement simple et indolore que ne pas abandonner les autres approches ferait passer pour idiot, ce genre d’article risque facilement d’être classé comme un pur argumentaire commercial. C’est la méthode des vendeurs de remèdes miracles
Le début de l’article identifie bien le problème lui-même ; une comparaison objective beaucoup moins émotionnelle aurait sans doute été moins rebutante pour les personnes curieuses de meilleures approches
Le fond de l’article était intéressant, mais la manière de le présenter m’a refroidi. J’espère que ce sera pris comme un retour constructif
Et je me suis senti très légèrement coupable d’avoir réservé ffmpeg.fly.dev
Le passage « imaginez qu’il suffise d’envelopper n’importe quelle partie du code existant de votre appli dans une fonction pour qu’elle scale automatiquement, et que ce bloc de code s’exécute dans une copie temporaire de l’appli » est intéressant
On dirait ce que fait fork, mais conçu pour le serverless. Beau travail
Il y a quelques années, j’ai utilisé un service qui faisait pratiquement ça. PiCloud a malheureusement été absorbé par Dropbox, mais avant cela, il avait exactement ce modèle consistant à répartir les tâches de façon transparente vers des workers. Le code était empaqueté puis exécuté sur les workers.
Un exemple se trouve ici. On voit que c’est exactement le même modèle : https://github.com/picloud/basic-examples/blob/master/exampl...
Je n’ai jamais utilisé Elixir, mais j’ai utilisé Erlang il y a des décennies, et BEAM ne semble pas avoir fondamentalement beaucoup changé. Comme cela fait partie du cœur de sa conception, ça paraît bien mieux adapté à ce genre de tâches. Cela dit, ce n’est pas non plus un déjeuner totalement gratuit : pendant l’attente, il y a bien un risque que le processus principal meure, non ?
Je suis d’accord avec l’argument général. Chez https://www.windmill.dev, nous avons adopté une autre approche : nous considérons l’unité d’abstraction au niveau du code source, et non au niveau du conteneur.
Nous analysons la fonction
mainet les imports pour extraire les arguments et les dépendances, puis nous exécutons le code tel quel dans le runtime souhaité (TypeScript, Python, Go, Bash). Le point clé consiste à gérer le cache efficacement afin que les workers restent toujours chauds, quels que soient les imports.Cette approche n’est pas aussi intégrée à la codebase que FLAME, mais elle vise des utilisateurs différents. Nos utilisateurs créent de zéro des workflows complexes, des tâches cron, ou des scripts ponctuels accompagnés d’une UI générée automatiquement.
Dans FLAME, il semble que tout le contexte soit pris en snapshot puis restauré sur la VM cible. Une autre approche consiste à introduire une syntaxe permettant de préciser quel contexte est nécessaire ou non, afin de ne charger que le minimum. C’est quelque chose que nous explorons actuellement pour mieux intégrer Windmill aux codebases existantes et ne pas dépendre d’appels HTTP.
Dans ce processus, l’envoi du seul contexte nécessaire est géré implicitement. L’article l’explique aussi ainsi : « FLAME.call prend le nom d’un pool de runners et une fonction. Il trouve ensuite, ou démarre, une nouvelle copie de toute l’application, puis y exécute la fonction. Les variables capturées par la fonction sous forme de closure, par exemple la structure %Video{} et
interval, sont automatiquement transmises avec elle. »J’aime l’objectif du projet. J’espère vraiment que Windmill deviendra une meilleure alternative open source à Retool/Airtable.
J’aime le fait que « dans FLAME, les runners de développement et de test s’exécutent simplement sur le backend local ». Du serverless avec une expérience de développement locale correcte, c’est appréciable.
Si « une nouvelle copie de toute l’application est ensuite trouvée ou démarrée, et la fonction s’y exécute », est-ce que chaque
Flame.callredémarre tout le processus de l’app et y copie le contexte d’exécution ?C’est une solution très simple du point de vue de la scalabilité, mais elle doit aussi avoir des inconvénients.
Si le temps de démarrage de l’app augmente de 10 ms, cela ajoute 10 ms à chaque point
Flame.callde l’application, et j’imagine que c’est pareil pour la mémoire.Il faut probablement tenir compte de ces préoccupations quand on utilise ce système.
Sous charge, le pool est déjà chaud, donc on paie à peine le coût du cold start. Nous ajoutons aussi ensuite à la bibliothèque Elixir des techniques plus sophistiquées de croissance du pool, afin d’éviter de tomber sur un runner saturé et de devoir en démarrer un nouveau à froid.
Sur un runner chaud, la seule surcharge est la latence entre le parent et l’enfant. Comme ils doivent être dans le même datacenter, cela devrait être de l’ordre de 1 ms ou moins.
Excellent. Ça ressemble à une version très légère, propre à Elixir, de ce que nous avons construit chez https://www.inngest.com/.
Les deux approches se ressemblent en ce qu’elles enveloppent du code existant pour le rendre utilisable dans des fonctions serverless et, en substance, appelable via du RPC distant.
Ce genre de code s’exécute souvent comme une suite d’étapes impératives. Chaque étape peut être exécutée en série ou en parallèle dans une Lambda supplémentaire. Mais entre les étapes, il existe un état implicite capturé dans des variables. C’est ce qui transforme la fonction en workflow. Dans le modèle d’Inngest, cet état est capturé puis réinjecté dans la fonction pour la rendre durable.
Du point de vue de la durabilité, ce type de processus doit s’appuyer sur une queue. L’intérêt de ce modèle est qu’une queue est peu coûteuse. Si l’on rend une queue aussi peu coûteuse qu’une ligne de code, tout devient plus simple. N’importe quel développeur peut écrire du code fiable sans se soucier de l’infrastructure.
Le monitoring et l’observabilité sont également importants. Les dead letter queues sont vraiment horribles, et il faut pouvoir gérer et relancer les fonctions ou étapes qui échouent.
Il existe aussi des différences entre FLAME et Inngest. Inngest est basé sur des queues, piloté par les événements, et peut être servi en HTTP depuis n’importe quel langage. Comme Inngest stocke l’état à l’extérieur, on peut écrire un workflow en Elixir, puis le réécrire en TypeScript et le redéployer, tandis qu’une fonction en cours d’exécution peut être migrée en temps réel entre langages backend, un peu comme avec CRIU.
Une approche event-driven permet aussi de contrôler les flux. Debounce, batching, throttling, fan-out : tout cela peut être géré depuis n’importe quel runtime ou langage. Par exemple, une application Elixir sur Fly peut envoyer un événement pour déclencher l’exécution d’une fonction en TypeScript + Lambda.
J’ai hâte de voir où va FLAME. Je pense que certains objectifs se recoupent.
En Elixir, quand on a besoin de durabilité, de retries et de workflows, on utilise généralement Oban, et c’est ce que l’on continuera à faire ici. Une tâche Oban appellera FLAME pour gérer l’exécution élastique.
C’est l’une des raisons pour lesquelles je déteste vraiment la casse des titres à l’américaine imposée par HN. Serverless donne l’impression qu’il s’agit de repenser l’entreprise serverless.com avec un S majuscule, et non de repenser le principe du serverless avec un s minuscule
Cela dit, j’aimerais que quelqu’un repense effectivement le serverless
Je ne sais pas si tu as regardé https://sst.dev/, mais ils semblent avoir fait exactement cela. Par exemple, avec Live Lambda Development, il devient vraiment facile de faire du développement local, car la boucle de feedback est fortement réduite sans avoir à envoyer le code dans le cloud et attendre le déploiement
L’idée est plutôt chouette, et l’API est excellente
Concernant le passage « les tâches bound CPU comme le transcodage vidéo peuvent rapidement mettre tout un service de production à l’arrêt », est-ce qu’il ne suffirait pas simplement de faire de l’autoscaling basé sur le CPU ?
Pour traiter une tâche ponctuelle qui chauffe, on finit par scaler toute l’application, serveur web compris. Ce que l’on veut, et la raison pour laquelle on se tourne vers le FaaS, c’est une scalabilité élastique fine. L’idée ici est de permettre ce type de scaling fin sur le code applicatif existant, plutôt que d’appuyer frénétiquement sur le bouton de scaling du serveur web ou des workers en espérant que tout se passe bien
Ou bien il peut aussi falloir un GPU
Le service principal peut très bien se contenter d’un ou deux serveurs, tout en devant scaler à la demande vers des dizaines, des centaines ou des milliers de machines pour une tâche qui ne se produit qu’environ une fois par jour