O blog da AWS
AWS Lambda para o desenvolvedor de contêineres
Introdução
Ao criar um aplicativo na AWS, um dos pontos de decisão que os clientes encontram é construir no AWS Lambda versus construir usando um produto de contêineres como o Amazon Elastic Container Service (Amazon ECS) ou o Amazon Elastic Kubernetes Service (Amazon EKS). Para tomar essa decisão, há muitos fatores a serem considerados, como custo, características de escala e a quantidade de controle que o desenvolvedor tem sobre as opções de hardware. Nem o modelo funcional nem o modelo baseado em serviços são objetivamente melhores ou piores. Pelo contrário, é uma questão de ajuste entre a aplicação e o produto subjacente. Mas uma das dimensões mais confusas dessa escolha é a diferença no modelo de programação entre o paradigma centrado em funções do AWS Lambda e o paradigma tradicional baseado em serviços do Amazon ECS ou do Amazon EKS.
A diferença no modelo de programação entre o AWS Lambda e o Amazon ECS ou o Amazon EKS é frequentemente discutida. Mas o que queremos dizer com modelo de programação? O modelo de programação de um produto tem dois aspectos. A primeira é a maneira pela qual o chamador (caller) emite sua solicitação a um aplicativo. A segunda é a maneira pela qual o código dentro do aplicativo recebe uma solicitação do serviço e fornece a resposta correspondente. Neste post, discutiremos o primeiro, mas focaremos no segundo. Examinamos os bastidores de um aplicativo do AWS Lambda e buscamos entender a mecânica interna com a qual um aplicativo interage com o serviço AWS Lambda para receber e responder às solicitações.
Nosso objetivo neste post é duplo. Primeiro, esperamos desmistificar o modelo de programação do AWS Lambda e mostrar o quanto da “mágica do Lambda” é, na verdade, um contrato simples entre o aplicativo e o serviço. Em segundo lugar, esperamos mostrar que, para pessoas com experiência em contêineres tradicionais, o AWS Lambda realmente não é tão diferente. Todos os produtos de computação estabelecem algum contrato entre o código do aplicativo e o serviço. Mover aplicativos entre produtos de computação envolve, na verdade, mudanças — esperançosamente pequenas — no aplicativo, de forma que ele faça a aderência ao modelo de programação do produto.
Passo a passo
Vamos começar!
Como todos sabemos, o AWS Lambda é executado em servidores (!) e aceita o código do aplicativo por meio de pacotes ZIP ou pacotes da Open Container Initiative (OCI). Embora possamos usar o empacotamento ZIP para fazer as mesmas coisas (falaremos mais sobre isso no final), neste post, configuramos nosso AWS Lambda com uma imagem de contêiner. No que diz respeito à carga de trabalho, vamos construir com uma das linguagens de programação mais simples disponíveis: um script bash. Nós realmente queremos chegar o mais próximo possível desses servidores para demonstrar a interação entre o código em execução no contêiner e o modelo de programação do serviço AWS Lambda.
Para começar, usaremos este Dockerfile simples:
Se você já pensou no AWS Lambda como algo esotérico, pense novamente. Este é um Dockerfile padrão que começa a partir de uma imagem padrão do Amazon Linux 2023 e instala um conjunto de ferramentas nela (a interface de linha de comando da AWS [AWS CLI], tar, git etc.) Sim, o AWS Lambda executa essa imagem de contêiner e o script startup.sh da mesma forma que você executaria em seu laptop (ou no AWS Fargate).
Há três dimensões que tornam um contêiner especial no AWS Lambda:
- As restrições da instância do contêiner;
- O que inicia a instância do contêiner;
- O que executamos no script startup.sh (e no script businesscode.sh).
Vamos examiná-los individualmente.
As restrições do contêiner
A máquina ou máquina virtual ao redor de um contêiner ditará suas capacidades. Se você lançar um contêiner em seu laptop, provavelmente não terá uma unidade de processamento gráfico (GPU) à sua disposição. Se você iniciar um contêiner usando o AWS Fargate, não poderá executar contêineres privilegiados. Todo ambiente de execução tem suas restrições. O ambiente de execução do AWS Lambda tem seu próprio:
- i
- sua vida útil de execução é (artificialmente) limitada;
- seu tamanho é configurado por meio de um parâmetro de memória e a capacidade da CPU é alocada proporcionalmente;
- o contêiner é executado com um sistema de arquivos raiz somente leitura (/tmp é o único caminho gravável);
- você não pode executar contêineres privilegiados;
- você não pode expor uma GPU para uso do seu contêiner.
Muitas dessas restrições são comuns em serviços tradicionais gerenciados de contêineres e/ou execuções locais. A restrição de vida útil e a restrição do sistema de arquivos somente para leitura são as mais relevantes para esta postagem e voltaremos a elas mais tarde.
O que executa o contêiner
Nesta seção, discutiremos o primeiro aspecto do modelo de programação de um serviço — como o chamador (caller) chama o aplicativo. Cada ambiente tem sua própria maneira de orquestrar o lançamento de contêineres. Se você quiser lançar um contêiner em seu laptop, provavelmente usará um docker run ou um finch run. Se você quiser executar um contêiner no AWS Fargate, provavelmente usará uma API do Amazon ECS, como RunTask ou CreateService. O AWS Lambda, em sua essência, é um sistema orientado por eventos e tudo (incluindo o lançamento do contêiner acima) acontece por causa de eventos. O AWS Lambda oferece suporte a centenas de eventos diferentes provenientes de diversos serviços da AWS. Um evento clássico pode ser uma mensagem em uma fila do Amazon SQS como parte de um aplicativo assíncrono. Mas um evento também pode ser uma chamada HTTP do Amazon API Gateway (ou AWS Elastic Load Balancing) como parte de um aplicativo web interativo. De uma forma ou de outra, o evento é disponibilizado para o AWS Lambda para processamento (mais sobre esse mecanismo posteriormente). Um único contêiner do AWS Lambda processa no máximo um evento por vez. No entanto, ele pode processar muitos eventos sequencialmente durante sua vida útil.
A orquestração de contêineres do AWS Lambda segue esse fluxo em resposta a um evento de entrada:
- Se houver um contêiner inicializado e inativo para executar um evento, o AWS Lambda atribuirá o evento a esse contêiner.
- Se não houver contêineres inicializados e inativos para executar um evento, o AWS Lambda lançará um novo contêiner.
- O AWS Lambda pode optar por manter esse contêiner por mais tempo do que a execução única, para que eventos futuros não precisem criar um novo contêiner.
- Se vários eventos ocorrerem simultaneamente, o AWS Lambda lançará instâncias de contêiner em paralelo para cada evento, até atingir os limites da função, conta configurada ou limites de burst.
O que executamos no script startup.sh
Até agora, abordamos o ambiente de execução de um contêiner AWS Lambda (suas restrições) e o ciclo de vida desse ambiente de execução (a orquestração). Agora, consideraremos o que o código executado dentro do contêiner realmente faz (o modelo de programação).
Você já deve ter ouvido falar sobre as APIs de tempo de execução do Lambda. A maneira mais fácil de pensar sobre essas APIs é oferecer ao aplicativo uma forma de obter um evento e responder a um evento. Pense na sua instância de contêiner como um processo de longa duração que verifica repetidamente se há um evento a ser processado e, se houver, ele faz alguma coisa e, em seguida, informa ao AWS Lambda os resultados desse trabalho.
Com esse modelo de alto nível em mente, escrevemos um script startup.sh que implementa o fluxo acima. Em nosso exemplo, nossa necessidade de negócio é que nosso AWS Lambda clone um repositório do GitHub com um site Hugo, o construa um conjunto de artefatos de JavaScript e copie os resultados em um bucket do Amazon Simple Storage Service (Amazon S3). Devido à nossa falta de imaginação, capturamos essa lógica de negócios em um script chamado businesscode.sh. O script startup.sh chama o businesscode.sh, atuando como uma ponte entre o modelo de programação do AWS Lambda e nossa lógica de negócios. A lógica de negócios não precisa saber nada sobre o AWS Lambda.
Importante: o caso de uso em si não é importante, concentre-se no fluxo e na mecânica de como o código é executado, e não nos comandos reais e no que eles fazem.
Este é o conteúdo do startup.sh:
Este é o businesscode.sh:
O script startup.sh começa com uma seção que é executada somente quando o contêiner é iniciado. Essa parte do script (init) é o que determina a inicialização a frio da instância do contêiner AWS Lambda. Em nosso exemplo, ele baixa a versão mais recente do binário Hugo em tempo de execução. Poderíamos ter adicionado a configuração desse binário ao Dockerfile mostrado anteriormente, mas isso nos forçaria a reconstruir a imagem toda vez que quiséssemos a versão mais recente do arquivo. Aqui, aproveitamos o fato de que o AWS Lambda executa o código de inicialização em cada lançamento de contêiner a nosso favor, trazendo a versão mais recente do Hugo de forma dinâmica. Seu caso de uso específico determina se um trecho de código deve estar no Dockerfile, na fase de inicialização ou na sua lógica de negócios.
Observe que precisamos operar dentro da pasta /tmp porque essa é a única pasta gravável dentro do contêiner Lambda. Por esse motivo, foi mais fácil instalar algumas das ferramentas em nosso Dockerfile.
A próxima seção do script (chamada “Processing the invocations in the container”) é onde o código entra em um loop infinito que dura toda a vida útil do contêiner. O código verifica continuamente (por meio de um curl no endpoint local da API de tempo de execução do AWS Lambda) se há um evento a ser processado. É aí que está a mágica do AWS Lambda: ele expõe e mantém o endpoint da API em cada ambiente de execução. Ele passa os eventos de volta ao endpoint pesquisado à medida que eles chegam e, se não houver um evento aguardando, o AWS Lambda pausa o ambiente de execução até que um evento chegue. Ao receber cada evento, nosso código captura e continua executando a próxima parte do script (chamada “Run my arbitrary program”). Essa é a parte do script independente do AWS Lambda e onde executamos nossa lógica de negócios (o script businesscode.sh). Essa parte está sujeita ao tempo limite de execução do AWS Lambda (configurável em até 15 minutos). Isso significa que o código executado como parte da seção “Run my arbitrary program” não pode ser executado por mais do que o tempo limite configurado.
Depois que a lógica de negócios é concluída, o script retorna uma mensagem por meio de um HTTP POST para o mesmo endpoint para informar ao serviço AWS Lambda que o processamento de eventos foi concluído. Observe que o AWS Lambda não se importa com o que você devolve, desde que você devolva algo. Em nosso script, retornamos {“StatusCode”: 200}, porque estamos usando um API Gateway para acionar essa função e o API Gateway espera esse código em troca. Em vez disso, alguém poderia ter retornado um texto como “Ei, terminei” e o AWS Lambda teria concordado com isso (menos o API Gateway).
Não confunda a vida útil da execução do contêiner com o tempo limite do AWS Lambda. O primeiro define por quanto tempo um contêiner continua executando o loop após seu lançamento. Essa vida útil não faz parte do “contrato” do AWS Lambda e um desenvolvedor não deve presumir por quanto tempo o contêiner estará em funcionamento até que seja desligado. O tempo limite do AWS Lambda é, de fato, parte do contrato e, no momento em que este artigo foi escrito, pode ser configurado em até 15 minutos no máximo. Se, ao receber um evento do runtime da API, o código do contêiner demorar mais do que o tempo limite configurado para publicar sua resposta, a solicitação será retornada ao chamador (caller) como timeout e o contêiner será reiniciado.
Outra coisa que vale a pena notar nesse script é que ignoramos a carga útil do evento, que carrega as informações reais do evento. Em outras palavras, estamos interessados apenas no gatilho do evento e não no que o gatilho traz consigo. Analisamos os HEADERS para extrair o ID da solicitação que usamos no final do loop para informar ao serviço AWS Lambda que processamos o evento. Em uma arquitetura mais clássica baseada em eventos, teríamos analisado a carga útil e a usado para influenciar o que nossa lógica de negócios faz com a solicitação.
Esse diagrama é uma representação visual das seções do código acima:
Vamos juntar tudo
Agora vamos descrever em alto nível o que acontece nos bastidores quando você implanta esse AWS Lambda e o executa.
Você cria uma imagem de contêiner, a partir do Dockerfile acima, e cria uma função do AWS Lambda com a imagem. Em seguida, você configura dois gatilhos (triggers) para esse AWS Lambda: um endpoint do API Gateway e uma fila do Amazon SQS. Neste estágio, nada está funcionando e nenhum contêiner foi lançado.
Agora você acessa o endpoint do API Gateway com uma solicitação de um terminal (curl <api gateway endpoint>). O API Gateway traduz essa solicitação HTTP em um evento do AWS Lambda e lança um contêiner em resposta ao evento. O contêiner passa por sua fase de inicialização (pegando o binário Hugo em nosso caso). Em seguida, ele entra no ciclo de eventos e pega o evento que o AWS Lambda está segurando. O contêiner passa alguns segundos clonando o repositório, constrói o site e copia seu conteúdo para o Amazon S3. Uma vez feito isso, o contêiner informa ao runtime do AWS Lambda que o código terminou de ser executado por meio de um HTTP POST. O AWS Lambda notifica o API Gateway de forma síncrona e o terminal vê o prompt de volta (não haverá nada em resposta porque não estamos retornando um corpo na mensagem do nosso HTTP POST no script).
Observe que esse processo levará aproximadamente 30 segundos porque, em nosso caso de uso, estamos usando o Lambda como algum tipo de sistema de compilação. Talvez não seja assim que você usaria o AWS Lambda em um padrão tradicional de solicitação/resposta síncrona. Se você fosse utilizar a “lógica de negócios” provavelmente seria mais enxuta: pense em um serviço web respondendo em milissegundos. Novamente, esse caso de uso é usado apenas de forma ilustrativa para mostrar o que acontece dentro da mecânica da instância de um Lambda.
Nesse momento, o contêiner chamou o runtime da API para seu próximo evento e está aguardando a resposta do runtime. O contêiner agora está pausado até que outro evento chegue, período durante o qual você não está pagando por ele. Se você agora colocar uma mensagem na fila, o AWS Lambda sabe que há um ambiente de execução ativo e ocioso, interrompe o contêiner e direciona o evento para ele. O contêiner recebe o evento como uma resposta à chamada de API e passa pelo mesmo processo de execução da lógica de negócios e respondendo com os resultados.
Nesse caso, a carga útil (payload) do evento será diferente do evento gerado pelo API Gateway, mas, para nosso caso de uso, não nos importamos porque nem mesmo lemos o evento enviado para o contêiner. Só nos preocupamos com o gatilho e não com o conteúdo do evento em si.
Depois de algum tempo sem receber solicitações, o AWS Lambda encerra o contêiner acima e não há contêineres em execução por trás da função. Nesse ponto, você atinge o endpoint do API Gateway com 100 solicitações simultâneas. O AWS Lambda vê as 100 solicitações chegando e lança 100 contêineres em paralelo para processar essas solicitações (ou seja, todas passando por uma pequena inicialização a frio). Depois que as solicitações são processadas e o site foi construído e copiado 100 vezes, os 100 contêineres continuam funcionando por um período indefinido, prontos para capturar mais eventos por meio de seu loop de execução (até que o AWS Lambda decida novamente desligá-los).
Executando a imagem do contêiner fora do Lambda
Você deve ter notado que o Dockerfile que usamos não é diferente dos Dockerfiles tradicionais que você encontra por aí. A maior peculiaridade está na forma como o script startup.sh inicializa o contêiner e na forma como ele interage com as APIs do AWS Lambda (tanto para capturar o evento quanto para publicar os resultados dentro do loop). Essa parte é extremamente específica para o modelo de programação do AWS Lambda. Dito isso, construímos esses scripts de forma que a lógica de negócios (businesscode.sh) seja separada do modelo de programação (startup.sh). Por causa disso, seria fácil usar a mesma imagem de contêiner e executá-la em outro lugar, ignorando as especificações do AWS Lambda e iniciando diretamente a lógica de negócios. Uma maneira fácil de fazer isso é executá-lo localmente com este comando do Docker:
Só precisávamos ajustar nosso entrypoint e apontar para o script de código business.sh.
O leitor astuto pode ter percebido que estamos mapeando uma pasta local na pasta /tmp do contêiner e está se perguntando por quê. Como contornamos a fase de inicialização, a imagem do contêiner não instala o binário hugo na inicialização. Em vez disso, estamos transmitindo-o dinamicamente de um binário existente que temos em /tmp do nosso laptop. Em um cenário real, você pode criar o binário Hugo na imagem do contêiner ou dividir o código de download do startup.sh para que ele possa ser executado fora do contexto do AWS Lambda. Novamente, este exemplo é para fins de demonstração e traduzi-lo para o mundo real depende do seu caso de uso.
Mas espere, esse não é o AWS Lambda que conhecemos e amamos!
Certo. Conforme prometido, esse foi um tour pela mecânica de baixo nível do AWS Lambda, onde o modelo de execução encontra o modelo de programação. Se você conhece ou já usou o AWS Lambda, você foi abstraído de todos esses detalhes. É interessante observar como o AWS Lambda começou com essas abstrações quando foi lançado e lentamente introduziu o suporte para obter visibilidade total do que discutimos neste blog post. Como podemos conciliar o que descrevemos aqui com as abstrações de alto nível que você conhece e ouviu falar? Vamos partir do que descrevemos nesta postagem até o AWS Lambda que você conhece.
A maioria dos desenvolvedores não deseja lidar com loops, HTTP Gets e Posts enquanto escrevem seu código comercial. É aqui que entram as abstrações e convenções que você costuma ver no Lambda — o AWS Lambda Runtime Interface Client (RIC). O RIC é um utilitário (binário ou biblioteca) fornecido pela AWS para linguagens de programação específicas que implementa o loop que intercepta eventos. A forma como esses eventos fluem para o seu código é por meio de objetos passados para uma função do programa. O RIC pega os HEADERS e o BODY mencionados acima. Ele analisa o conteúdo do evento e o contexto do ambiente de execução e os transmite como objetos para sua função. Em outras palavras, o contêiner é iniciado com o RIC como programa principal e, a cada evento, o RIC chama uma função com informações do evento. Seguindo essa convenção, o desenvolvedor encontra o evento diretamente dentro da função sem precisar chamar um endpoint ou analisar HEADERS e BODY.
Construímos efetivamente um script bash (startup.sh) que faz parte da lógica que o RIC implementa. Observe que não queríamos imitar uma convenção de “função” em nosso exemplo porque queríamos errar mais no lado de “este é um contêiner normal com algumas peculiaridades” do que no lado de “é assim que você pode reimplementar um RIC no bash”. Por falar nisso, este tutorial na documentação do Lambda (que inspirou esta postagem) faz exatamente isso e mostra como você pode criar uma função bash que você importa para o seu script principal!
Sim, apesar do AWS Lambda ser Function as a Service (FaaS), toda a noção de uma função no contexto do AWS Lambda é apenas uma convenção que criamos com base em um loop e duas operações de curl em um contêiner que abstraímos para uma experiência limpa do desenvolvedor.
Voltando ao tópico do RIC, temos o RIC autônomo (para idiomas selecionados), se você quiser criar sua própria imagem de contêiner, ou fornecemos as imagens base gerenciadas do AWS Lambda (que incluem o RIC e muito mais) que você pode usar para criar os contêineres. Independentemente do que você escolher, ao usar uma imagem de contêiner, você é responsável por sua manutenção. Em outras palavras, você precisa se preocupar com o deploy de imagens atualizadas para sua função.
Um mecanismo alternativo e um nível mais alto de abstração é empacotar um runtime e uma lógica de negócios personalizados como um arquivo ZIP e permitir que a AWS gerencie o sistema operacional em que sua função é executada.
Para obter o nível máximo de abstração e experiência gerenciada, você pode empacotar somente sua lógica de negócios como um arquivo ZIP e deixar que a AWS organize e gerencie todo o tempo de execução em seu nome. Conforme discutido anteriormente, foi aqui que o AWS Lambda começou e houve um longo processo para adicionar mais flexibilidade e controle à stack. Começamos a adicionar suporte para camadas e, eventualmente, adicionamos suporte para imagens de contêiner.
Ao longo dos anos, a comunidade Lambda criou abstrações adicionais sobre o modelo de programação descrito acima. Uma dessas abstrações é o Lambda Web Adapter, que permite aos clientes executar aplicativos web tradicionais no ambiente Lambda. Você pode pensar no Web Adapter como um runtime personalizado que faz interface entre o modelo de programação Lambda e uma estrutura tradicional de aplicativos da Web. Usando esse modelo, a natureza orientada por eventos do Lambda é abstraída, praticamente desacoplando a infraestrutura do modelo de programação.
Teste este protótipo você mesmo
Para as mentes curiosas que querem sujar as mãos, criamos um repositório no GitHub com todo o código e as instruções de configuração necessários para recriar esse protótipo. Visite este link se você quiser implantar este exercício sozinho.
Conclusão
Neste post, descrevemos o AWS Lambda de uma perspectiva diferente da usual. Embora o exemplo e o caso de uso específicos que usamos não sejam convencionais e possam não corresponder a um cenário real do AWS Lambda, esperamos que este post tenha ajudado os clientes a apreciarem mais o funcionamento interno do serviço. Também esperamos ter fornecido mais clareza sobre as diferenças entre o AWS Lambda e os sistemas de contêineres tradicionais. Descobrir as diferenças não é tão exótico quanto parece à primeira vista.
Este artigo foi traduzido do Blog da AWS em Inglês.