- FLAME busca uma expansão elástica granular ao envolver partes do código de aplicações existentes em funções e executá-las em cópias temporárias da aplicação, sem mover o código para um runtime separado
- A abordagem tradicional de FaaS pode criar uma estrutura com HTTP, S3, SQS, API Gateway, encoder/decoder e orquestração de workflow até em tarefas como geração de miniaturas de vídeo
- A biblioteca flame do Elixir delega a execução de funções a runners remotos com
FLAME.call, e oFLAME.FlyBackendda Fly.io inicializa uma nova Fly Machine com a mesma imagem Docker e a conecta ao nó pai em cerca de 3 segundos - Em desenvolvimento e testes, usa-se o
LocalBackendpara executar no mesmo runtime; em produção, scale-to-zero e a manutenção breve de estado hot são ajustados commin,max,max_concurrencyeidle_shutdown_after - Em vez de eliminar filas de tarefas, o FLAME separa garantia de durabilidade e execução elástica: a fila cuida de dispatch, commit e retry, enquanto trabalhos intensivos em CPU são processados dentro de chamadas FLAME
O problema que o padrão FLAME quer resolver
- O autoescalonamento elástico reduz a carga de administrar servidores e promete custos proporcionais ao uso, mas usar FaaS pode aumentar junto a complexidade com filas separadas, armazenamento, glue code e mais dificuldade em desenvolvimento, testes e CI
- FLAME significa Fleeting Lambda Application for Modular Execution e trata a aplicação inteira como uma lambda, executando apenas alguns trabalhos modulares em uma infraestrutura efêmera
- O objetivo se resume em três pontos
- reduzir a administração de servidores com fluxos de deploy existentes como
fly deploy,git push herokuekubectl - aplicar escala granular sob demanda apenas a partes específicas do código da aplicação
- não reescrever a aplicação nem mover parte do código para um runtime proprietário
- reduzir a administração de servidores com fluxos de deploy existentes como
Movendo funções existentes com FLAME.call
- O exemplo é a função
generate_thumbnails, que converte um vídeo enviado em miniaturas comffmpegem uma aplicação Elixir - A função original cria um diretório temporário, executa o
ffmpeg, salva as miniaturas geradas em armazenamento durável e registra as URLs no banco comRepo.insert_all - Como a transcodificação de vídeo intensiva em CPU pode parar o serviço inteiro em produção, em vez de mover todo o código para FaaS ou microserviços, o corpo da função é envolvido por
FLAME.call(MyApp.FFMpegRunner, fn -> ... end) FLAME.callrecebe o nome do pool de runners e uma função, encontra ou inicializa uma nova cópia completa da aplicação e executa apenas aquela função- variáveis capturadas pelo closure, como a struct
%Video{}einterval, são passadas automaticamente - depois de iniciar, o runner FLAME se conecta ao nó pai, recebe a função a ser executada e devolve o resultado ao chamador
- dependendo da configuração, o runner espera mais trabalhos e depois desliga ao ficar idle, ou encerra imediatamente
- variáveis capturadas pelo closure, como a struct
- Como a aplicação inteira é executada, incluindo a conexão com o banco, é possível usar
Repo.insert_allexatamente como no código original
Complexidade de FaaS e diferença do FLAME
- FaaS fornece componentes para resolver o problema, mas o FLAME está mais próximo de uma abordagem que reduz a própria camada de comunicação separada
- No exemplo de miniaturas de vídeo, mesmo começando com uma simples AWS Lambda Function URL, a complexidade logo aparece
- para transmitir as miniaturas de volta ao app por HTTP, é preciso escrever encoder e decoder customizados nos dois lados
- se a transcodificação ou o upload do vídeo passar de 15 minutos, o hard timeout do Lambda obriga a dividir o vídeo em chunks e usar mais Lambdas
- passam a ser necessários serviços adicionais como orquestração de workflow e SQS/S3
- Implementações baseadas em FaaS normalmente criam os seguintes pontos de custo
- disparar Lambda por HTTP endpoint, S3 e API Gateway
- escrever uma Lambda sob medida para transcodificação de vídeo
- salvar o resultado das miniaturas no SQS
- escrever um consumer de SQS no lado da aplicação
- salvar no banco e montar uma forma de reenviar o evento a assinantes ativos conectados a outras instâncias
- O FLAME permite reutilizar o código interno da aplicação, o banco existente, PubSub e recursos da plataforma, reduzindo serviços separados e armazenamento para recuperar resultados
Backend da Fly.io e execução local
- A biblioteca flame para Elixir é uma implementação do padrão FLAME e oferece por padrão
LocalBackendeFlyBackend - O
FLAME.FlyBackendinicializa uma nova cópia da aplicação em uma Machine na infraestrutura da Fly.io e consegue conectá-la ao nó pai em cerca de 3 segundos para receber trabalho - Como a Fly.io executa aplicações empacotadas como imagens Docker, ela solicita à API da Fly o boot de uma nova Machine com a mesma imagem do app atual
- Na infraestrutura da Fly.io, os runners FLAME podem ser iniciados na mesma região do nó pai, reduzindo a latência entre pai e runner
- O
FLAME.FlyBackendtem, com documentação incluída, menos de 200 LOC, e sua única dependência de biblioteca é o cliente HTTPreq - Em desenvolvimento e testes, o LocalBackend executa esse código dentro do runtime já existente no notebook ou no servidor de CI
Transferência de arquivos e simplificação de desenvolvimento e testes
- O FLAME permite reutilizar lógica de negócio, configuração de banco, PubSub e recursos da plataforma sem escrever código fora da aplicação
- Em Elixir, graças aos recursos distribuídos da Erlang VM, é possível enviar um stream de arquivo do nó pai para a aplicação FLAME remota
- no nó pai, abre-se um stream do arquivo do caminho do vídeo
- no filho FLAME, abre-se um stream de arquivo temporário e copia-se o stream do pai
- depois, o
ffmpegusa o arquivo temporário do runner remoto como entrada para gerar as miniaturas
- Nessa abordagem, não é preciso configurar separadamente S3 ou uma interface HTTP para entregar arquivos ao servidor FLAME
- Isso reduz deploys de serviços separados, gestão de endpoints, recuperação de resultados via S3/SQS e a configuração de dependências para desenvolvimento, testes e CI
FLAME fora do Elixir
- O Elixir combina bem com o modelo FLAME por oferecer supervisão de processos e mensageria distribuída, mas linguagens com primitivas de concorrência razoáveis também podem usar esse padrão
- Um exemplo de prova de conceito em JavaScript executa funções da aplicação em outra máquina Fly Machine: fly-run-this-function-on-another-machine
- O fluxo geral de uma chamada FLAME em JavaScript funciona movendo a parte de execução do módulo para um novo arquivo e executando-a em um pool de runners
- Se os argumentos forem serializáveis em JSON, o fluxo geral fica semelhante ao exemplo em Elixir, com o código da aplicação rodando em instâncias efêmeras
- Uma biblioteca FLAME completa precisaria lidar com
- lógica de scale-up e scale-down do pool elástico
- gerenciamento do pool considerando hot startup e cold startup
- monitoramento de runners remotos para evitar recursos órfãos
- uma forma de manter os deploys atualizados
Relação com filas de tarefas em background
- O FLAME também funciona dentro de processadores de tarefas em background, mas há alguma sobreposição de papel com filas de trabalho
- Filas de tarefas normalmente são usadas quando é preciso garantia de durabilidade, e podem ser ajustadas para processar mais tarefas conforme a carga varia
- Trabalho durável e execução elástica são preocupações separadas
- se a fila for usada apenas para offload de execução, será necessário glue code para colocar dados na tarefa e devolver o resultado ao chamador ou ao dispositivo do usuário
- se for necessário garantir a geração de miniaturas após o upload do vídeo, a fila pode cuidar de dispatch, commit e retry
- a transcodificação real pode ser executada por uma chamada FLAME dentro do job, separando durabilidade de execução escalável
- Trabalhos que não exigem durabilidade, como pré-visualização de vídeo antes de salvar ou execução de modelo de ML quando o usuário já saiu do app, podem não combinar com uma arquitetura que grava tarefas em armazenamento durável
Pool de runners para expansão elástica
- A implementação FLAME em Elixir define um pool elástico de runners para oferecer scale-to-zero e limite de concorrência ao mesmo tempo
- A configuração de exemplo adiciona
FLAME.Poolaostart/2da aplicaçãomin: 0permite scale-to-zeromax: 10inicializa no máximo 10 runnersmax_concurrency: 5permite 5 trabalhos deffmpegpor runneridle_shutdown_after: 30_000faz idle down após 30 segundos sem chamadas
- O servidor web Phoenix é iniciado de forma condicional dependendo da existência do pai FLAME
- em runners FLAME que não processam tráfego web, não há necessidade de iniciar o servidor web
MyApp.Repoe outros componentes como banco devem permanecer, pois são usados dentro do runner FLAME
- Com
min: 1, é possível manter pelo menos um runner deffmpegem estado hot desde o início da aplicação
Posicionando processos com estado
- Partes com estado em aplicações Elixir são estruturadas em torno de primitivas de processos leves com mailbox de mensagens
FLAME.calleFLAME.castsão adequados para código relativamente stateless, enquantoFLAME.place_childpermite iniciar uma especificação de processo existente em um runner FLAME em vez de localmenteFLAME.place_childpode ser usado nos mesmos pontos em que se usaria interfaces comoTask.Supervisor.start_childeDynamicSupervisor.start_child- No exemplo de geração de miniaturas durante upload com LiveView, chunks do upload são enviados ao processo
ThumbnailGenerator, que se comunica com offmpeg- ao encontrar um delimitador PNG no stdout do
ffmpeg, ele envia uma mensagem de imagem ao processo LiveView - o LiveView recebe a mensagem em
handle_infoe adiciona a nova imagem à UI
- ao encontrar um delimitador PNG no stdout do
- Ao trocar uma chamada existente
DynamicSupervisor.start_child(@sup, spec)porFLAME.place_child(Thumbs.FFMpegRunner, spec), o processoThumbnailGeneratorpassa a rodar em um runner FLAME - Como processos podem trocar mensagens independentemente de onde estejam, se o processo terminar por fim do upload ou fechamento da aba do navegador, o servidor FLAME detecta o encerramento e faz idle down quando não houver mais trabalho
Monitoramento remoto e tratamento de falhas
- Infraestrutura efêmera precisa de failsafes para evitar recursos órfãos
- Quando o pai inicia um runner, o runner deve desligar sozinho ao ficar idle e também fazer failsafe shutdown se não conseguir mais se comunicar com o nó pai
- Quando um novo deploy substitui o pai, o runner também deve encerrar para garantir que o cluster continue executando o mesmo código
- Chamadores ativos que aguardam o resultado de um runner precisam considerar a possibilidade de ele ser desligado por qualquer motivo
- As primitivas fornecidas pela Erlang VM simplificam essa implementação
- monitoramento e supervisão de processos locais e remotos
- node monitoring para detectar nós entrando e saindo
- fluxo de shutdown que controla a ordem de início e término da aplicação para dar tempo de runners ativos concluírem o trabalho durante um novo deploy
- Os detalhes internos da implementação ficarão para outro texto, e por enquanto é possível examinar o código-fonte do flame
Estado atual e próximos passos
- A biblioteca FLAME para Elixir ainda está em estágio inicial, mas já pode ser experimentada agora
- Estão previstos no futuro técnicas mais avançadas de crescimento de pool e uma análise aprofundada da implementação em Elixir
- Também é possível discutir implementações do padrão FLAME em outras linguagens
1 comentários
Opiniões no Hacker News
Depois de passar, nos últimos 4 anos, pela dor e pela complexidade de uma app composta por mais de 100 funções Lambda, acho que este texto acerta em cheio nas desvantagens da arquitetura serverless FaaS
Quando se começa, essas desvantagens não ficam muito visíveis. Pelo contrário: se o uso é baixo, as vantagens são claras — é praticamente grátis e quase não exige manutenção
Só mais tarde, quando os workflows de Lambda ficam todos entrelaçados e cada vez mais rígidos por causa das interdependências, você se arrepende de não ter ido de monólito e gastado algumas centenas de dólares a mais para gerenciar por conta própria. Hoje em dia, em lugares como a fly.io, talvez custe até menos
Fico curioso para saber como fica se você não usa Elixir
Quando a situação começa a piorar, também costuma ser a hora de procurar um novo emprego. Especialmente se, cerca de um ano depois de entrar, a pessoa já se arrepende do processo que ela mesma introduziu. Vi isso várias vezes com chefes ruins, “testes unitários” bagunçados, Scrum etc.
Só não sei se as pessoas identificam claramente a causa do desconforto que sentem no trabalho, ou se simplesmente encaram como um vago “está na hora de sair”. Quando eu tentava dar nome ao que era incômodo, recebia muita resistência; depois de ler Good to Great, passei a gastar muito menos energia emocional com isso. Ninguém quer dizer: “ah, isso é consequência das minhas ações”
As pessoas que efetivamente criaram o sistema estilo Rube Goldberg que eu mantenho foram as primeiras a ir embora. O capitão daquele navio perguntou a um colega se deveríamos abrir o código do nosso engine, e o colega respondeu que ninguém iria querer usar um sistema que reinventava, de forma pior, uma roda que já existia. Essa pessoa saiu por vontade própria em 3–4 meses
Se você puder escrever código comum orientado a serviços em que Lambdas chamam umas às outras, não precisa dividir a aplicação em centenas de pedaços de curta duração. Mas isso é inviável se você tiver de pagar também pelo tempo de espera. Se a Lambda pudesse pausar a execução enquanto aguarda E/S, o problema estaria resolvido. Por isso acho que execução durável (durable execution) pode ser a resposta
Nas últimas semanas eu estava escrevendo um artigo mostrando isso: https://restate.dev/blog/suspendable-functions-make-lambda-t...
Seria difícil obter toda a usabilidade que Elixir dá de graça, como funções com serialização de variáveis capturadas, mas em linguagens como JavaScript acho que dá para chegar a uns 90% movendo a parte de execução do módulo para um novo arquivo, em vez de envolvê-la em uma closure
Quem implementar uma biblioteca FLAME também terá de escrever as partes de pooling, monitoramento e comunicação remota. Elixir ganha muita coisa de graça em mensageria distribuída e monitoramento. Os recursos relacionados a posicionamento de processos também são, na prática, específicos de Elixir
No fim migramos para um monólito Lambda gordo, em que uma única Lambda tratava vários endpoints
Dito de outro modo, dá para ir de monólito e ainda minimizar os custos na AWS. Não é uma escolha de um ou outro
Hoje estou usando asp.net, e até uma app bem grande, implantada como ready-to-run com um modelo EF otimizado, inicia relativamente rápido
Sou o autor. Fico feliz por finalmente publicar isto e vou responder se houver perguntas. Espero que algumas pessoas se sintam suficientemente motivadas a implementar o padrão FLAME em JavaScript, Go e outras linguagens
Ele já tem mecanismos para lidar com escalonamento reativo, Future e execução assíncrona por meio de corrotinas sobre um event bus, além de dar suporte à serialização e distribuição de dados por todo o cluster. Como também há clientes de event bus para várias linguagens populares, seria possível criar aplicações misturando várias linguagens
Quando o problema é apresentado como “um destino pior que a morte” e a solução parece tão fácil e indolor que qualquer outra abordagem faz você parecer idiota, textos assim tendem a ser descartados como puro texto de vendas. É o estilo de vendedor de panaceia
O início do texto identifica bem o problema em si; se houvesse uma comparação objetiva muito menos emocional, acho que as pessoas curiosas por uma abordagem melhor teriam menos resistência
O conteúdo substantivo do texto foi interessante, mas a forma de apresentação me afastou. Espero que você encare isso como feedback construtivo
E fiquei com um pouquinho de culpa por ter reservado ffmpeg.fly.dev antes
Achei interessante a parte: “imagine simplesmente envolver qualquer trecho do código da app existente em uma função, fazê-lo escalar automaticamente, e esse bloco de código ser executado em uma cópia temporária da app”
Parece o que o fork faz, mas criado para serverless. Excelente trabalho
Alguns anos atrás, usei um serviço que fazia basicamente isso. PiCloud infelizmente foi absorvido pelo Dropbox, mas antes disso tinha exatamente esse modelo de espalhar tarefas de forma transparente para workers. O código era empacotado e executado nos workers
O exemplo está aqui. Dá para ver que é exatamente o mesmo modelo: https://github.com/picloud/basic-examples/blob/master/exampl...
Nunca usei Elixir, mas usei Erlang décadas atrás, e o BEAM não parece ter mudado fundamentalmente muita coisa. Como isso é uma parte central do design, imagino que seja muito mais adequado para esse tipo de trabalho. Ainda assim, não existe almoço grátis completo: acho que há a possibilidade de o processo principal morrer enquanto está esperando
Concordo com o argumento geral. Em https://www.windmill.dev adotamos uma abordagem diferente: vemos a unidade de abstração no nível do código-fonte, não no nível do contêiner
Fazemos o parsing da função main e dos imports para extrair argumentos e dependências e então executamos o código como está no runtime desejado (TypeScript, Python, Go, Bash). O segredo principal é gerenciar o cache de forma eficiente para que os workers permaneçam sempre quentes, independentemente dos imports
Essa abordagem não é tão integrada à codebase quanto o FLAME, mas o público-alvo é diferente. Nossos usuários criam do zero workflows complexos, tarefas cron ou scripts pontuais com UI gerada automaticamente
No FLAME, parece que todo o contexto é capturado em snapshot e restaurado novamente na VM de destino. Outra abordagem é introduzir uma sintaxe para especificar qual contexto é necessário e qual não é, carregando apenas o mínimo. Estamos explorando isso no momento para integrar melhor codebases existentes ao Windmill e não depender de chamadas HTTP
Nesse processo, o envio apenas do contexto necessário é tratado implicitamente. O texto também explica assim: “FLAME.call recebe o nome de um pool de runners e uma função. Em seguida, encontra ou inicializa uma nova cópia da aplicação inteira e executa a função lá. As variáveis capturadas pela função como closure, por exemplo a struct %Video{} e interval, são enviadas automaticamente junto”
Gosto do objetivo do projeto. Realmente espero que o Windmill se torne uma alternativa open source melhor ao Retool/Airtable
Gosto do fato de que “no FLAME, os runners de desenvolvimento e teste simplesmente rodam no backend local”. Serverless com uma experiência de desenvolvimento local decente é ótimo
Se “em seguida ele encontra ou inicializa uma nova cópia da aplicação inteira e essa função é executada lá”, então a cada
Flame.callele inicia de novo todo o processo da app e copia o contexto de execução para dentro?Do ponto de vista de escalabilidade, é uma solução muito simples, mas parece que também teria desvantagens
Se o tempo de inicialização da app aumentar em 10 ms, isso acrescentaria 10 ms a todos os pontos de
Flame.callda aplicação, e imagino que o mesmo valha para memóriaParece que seria preciso considerar essas preocupações ao usar esse sistema
Sob carga, o pool já estará aquecido, então praticamente não se paga o custo de cold start. Além disso, estamos adicionando técnicas mais sofisticadas de crescimento de pool à biblioteca Elixir, para evitar também a situação de encontrar um runner cheio e ter que fazer um novo cold start
Em runners quentes, o único overhead é a latência entre pai e filho. Como devem estar no mesmo datacenter, deve ser 1 ms ou menos
Excelente. Parece uma versão específica para Elixir bem leve do que criamos em https://www.inngest.com/
Ambos são parecidos no sentido de envolver código existente com alguma coisa para que ele possa ser usado em funções serverless e, essencialmente, chamado via RPC remoto
Esse tipo de código frequentemente roda como uma sequência de etapas imperativas. Cada etapa pode ser executada em série ou em paralelo como uma Lambda adicional. Mas há estado implícito capturado em variáveis entre as etapas. Por causa disso, a função vira um workflow. No modelo da Inngest, capturamos esse estado e o injetamos de volta na função para torná-la durável
Em termos de durabilidade, esse tipo de processo deve se basear em uma fila. O bom desse modelo é que filas são baratas. Se você torna uma fila tão barata quanto uma linha de código, tudo fica fácil. Qualquer desenvolvedor consegue escrever código confiável sem se preocupar com infraestrutura
Monitoramento e observabilidade também são importantes. Dead letter queues são realmente horríveis, e é preciso conseguir gerenciar e reexecutar funções ou etapas que falham
Também há diferenças entre FLAME e Inngest. A Inngest é baseada em filas, orientada a eventos e pode ser disponibilizada via HTTP em qualquer linguagem. Como a Inngest armazena o estado externamente, você pode escrever um workflow em Elixir, depois reescrevê-lo em TypeScript e fazer redeploy, e funções em execução podem ser migradas em tempo real entre linguagens de backend, de forma semelhante ao CRIU
Com uma abordagem orientada a eventos, também é possível fazer controle de fluxo. Debounce, batch, throttling e fan-out podem ser tratados em qualquer runtime ou linguagem. Por exemplo, uma app Elixir no Fly pode enviar um evento para executar uma função em TypeScript + Lambda
Estou curioso para ver para onde o FLAME vai. Vejo pontos em que os objetivos são parecidos
Quando preciso de durabilidade, retentativas e workflows em Elixir, normalmente uso Oban, e aqui continuarei fazendo isso. Um job do Oban chamará o FLAME para lidar com a execução elástica
Este é um dos motivos pelos quais eu realmente detesto a capitalização de títulos em estilo americano que o HN impõe. Serverless parece significar repensar a empresa Serverless.com, com S maiúsculo, e não repensar o princípio serverless, com s minúsculo.
Além disso, eu gostaria que alguém repensasse Serverless.
Não sei se você viu https://sst.dev/, mas parece que eles fizeram exatamente isso. Por exemplo, com Live Lambda Development, não é preciso subir o código para a nuvem e esperar o deploy, reduzindo bastante o ciclo de feedback e tornando o desenvolvimento local bem mais fácil.
A ideia é bem legal e a API também é excelente.
Na parte que diz que “tarefas CPU-bound, como transcodificação de vídeo, podem rapidamente paralisar um serviço inteiro em produção”, não bastaria simplesmente fazer autoescalonamento baseado em CPU?
Para lidar com uma tarefa específica que está pegando fogo, você acaba escalando a aplicação inteira, incluindo o servidor web. O que queremos, e o motivo pelo qual buscamos FaaS, é escala elástica granular. A ideia aqui é permitir esse tipo de escalonamento granular para o código existente da aplicação, em vez de sair apertando botões de escalar servidor web ou worker e torcer para dar certo.
Ou talvez precise de GPU.
O serviço principal pode funcionar bem com 1 ou 2 servidores, mas uma tarefa que acontece talvez uma vez por dia pode precisar escalar para dezenas, centenas ou milhares de máquinas quando necessário.