Skip to main content

Como criar uma aplicação web sem servidor na AWS

Escrito por
blog hero iac drift blue

9 de maio de 2016

0 minutos de leitura

Aqui na Fugue, a equipe Web é pequena, mas apaixonada por JavaScript, 60 quadros por segundo e DevOps simples. Gostamos de experimentar e explorar novas abordagens de computação que priorizem conteúdo e elegância, em vez de modismos e firulas. Há algum tempo usamos o AWS Lambda com tópicos do SNS e votebots, mas ainda não tínhamos feito nada grande com ele. Até agora. O framework Serverless nos deu o impulso de que precisávamos. Nosso objetivo? Criar uma aplicação útil para uma área de negócios por meio de uma API desenvolvida com Lambda e API Gateway, sem prejudicar nenhuma instância EC2 nesse processo.

Vamos voltar um instante para explicar brevemente o AWS Lambda. Assim como o IBM OpenWhisk, o Google Cloud Functions e o Azure Functions, ele é um serviço “para executar código em resposta a eventos específicos, como o upload de um arquivo para o Amazon S3, um fluxo de eventos ou uma solicitação a um gateway de API”. Fintan Ryan, citado parcialmente, apresenta uma boa visão geral aqui. Seu código é escalado automaticamente para atender às solicitações. Esse modelo de computação direta está em plena expansão.

A aplicação

A aplicação web de front-end que criamos é simples. O usuário faz login e vê conteúdo sobre nossas compilações de software e URLs seguras para download. A aplicação fica no S3. Estamos criando uma API REST para viabilizar essa experiência. Veja a arquitetura da API:

Diagrama de arquitetura serverless na AWS mostrando um aplicativo web conectado ao API Gateway, ao autorizador do Lambda, a serviços de conteúdo e de usuários, a uma API externa e ao DynamoDB.

Observe que temos um conjunto de endpoints do API Gateway que acionam funções Lambda. A função Lambda do User Service armazena dados no DynamoDB. A função Lambda do Content Service se conecta à API do nosso serviço de distribuição para recuperar conteúdo, faz algumas transformações visuais e envia esse conteúdo de volta para a aplicação de front-end. A função Lambda de autorização valida os tokens de sessão dos usuários e permite o acesso aos endpoints protegidos.

Você poderia configurar todos esses serviços manualmente na AWS, mas usamos o framework Serverless como ferramenta de implantação e para dar estrutura ao nosso projeto.

Conheça o Serverless

O Serverless é um framework para criar aplicações com Lambda e API Gateway. Ele também permite gerenciar outros recursos da AWS por meio de modelos do CloudFormation. Ele é distribuído como uma CLI do Node.js bem documentada.

O Serverless é poderoso porque gerencia tanto seu código quanto sua configuração da AWS. Ele tem uma estrutura de projeto específica, com arquivos de configuração JSON para as funções Lambda. Esses arquivos incluem definições dos endpoints do API Gateway e de outros eventos que podem acionar a função. Para implantar seu projeto, você precisa criar um usuário Serverless na sua conta da AWS e conceder a ele permissões para os serviços que está usando. Depois, você pode usar a CLI para fazer a implantação. Ela configura os serviços da AWS que você utiliza e publica o código da sua função Lambda.

Resolvendo pontos problemáticos

O Serverless resolve com eficiência alguns dos problemas que encontramos ao tentar configurar esse tipo de projeto na AWS sem usar um framework:

1) As funções Lambda são independentes e não podem compartilhar código.

Se você está desenvolvendo um projeto com várias funções, provavelmente vai querer compartilhar algum código entre elas, mesmo que seja apenas uma biblioteca utils. As funções Lambda são implantadas em contêineres isolados, então não podem depender de um diretório de biblioteca comum. O Serverless resolve isso permitindo configurar o nível específico do diretório que queremos incluir no pacote de implantação. Assim, você pode incluir na função código de uma pasta em um diretório superior, que também pode ser usada por outras funções. Os arquivos incluídos serão compactados junto com o pacote de implantação.

2) As funções Lambda não oferecem suporte a variáveis de ambiente.

Como incluir credenciais secretas, como chaves de API, com segurança na sua função Lambda sem inseri-las diretamente no código e deixá-las visíveis para quem acessa seu repositório do Github? O Serverless armazena as definições das variáveis de ambiente em arquivos JSON em uma pasta _meta ignorada pelo Git e insere os valores nas funções durante o processo de implantação. Se você trabalha em equipe, o plugin Meta Sync sincroniza essas definições de variáveis com segurança pelo S3.

3) É difícil fazer o API Gateway se comunicar com o Lambda.

Cada endpoint do API Gateway precisa de um modelo de solicitação que especifique quais informações da solicitação ficarão disponíveis para a função Lambda acionada. Sua função Lambda provavelmente precisará saber o caminho e o método da solicitação, os parâmetros da URL, os parâmetros de consulta e, talvez, um cabeçalho personalizado ou o corpo de uma solicitação POST. Nada disso fica disponível sem um modelo de solicitação. Da mesma forma, por padrão, o API Gateway não interpreta a resposta de uma função Lambda. Portanto, se sua função retornar um erro, não haverá mapeamento automático para códigos de erro HTTP. Você precisará adicionar um modelo de resposta para mapear as mensagens de erro da função Lambda para o código de resposta adequado. O Serverless facilita esse processo com suporte à herança de modelos de solicitação e resposta. Assim, você pode definir modelos universais para todos os endpoints e substituí-los individualmente quando necessário.

Estrutura do projeto

Veja a estrutura de diretórios do projeto Serverless da nossa aplicação:

s-project.json s-resources-cf.json _meta functions |__lib |__utils.js |__user |__lib |__user.js |__event.json |__handler.js |__s-function.json |__content |__lib |__download.js |__event.json |__handler.js |__s-function.json |__authorizer |__event.json |__handler.js |__s-function.json

s-project.json

Esse arquivo contém o nome e a descrição do projeto, além dos plugins personalizados usados nele.

s-resources-cf.json

Esse é um modelo do CloudFormation para os recursos usados pelo projeto, além do Lambda e do API Gateway. No nosso caso, trata-se de uma tabela do DynamoDB e de uma função e uma política do IAM que permitem à função Lambda ler e gravar dados no DynamoDB.

_meta

Essa pasta contém arquivos JSON com variáveis de ambiente do projeto, que podem ser específicas da etapa de implantação e da região. Essa pasta é ignorada pelo Git.

functions

Essa pasta contém subpastas para cada uma das nossas funções Lambda. O Serverless não impõe uma estrutura organizacional, então você pode aninhar funções em pastas como preferir. A pasta de cada função deve conter um arquivo com o código da função (neste caso, handler.js) e um arquivo s-function.json, que contém a configuração da função e os endpoints que podem acioná-la. Ela também pode conter um arquivo event.json, que guarda um objeto de teste sobre o qual falaremos mais adiante. Como mencionamos, temos uma pasta lib ao lado das pastas das funções, que pode ser incluída por cada uma delas (ou seja, user e content).

Um olhar mais de perto nos componentes do projeto Serverless

O projeto Serverless tem três componentes arquitetônicos principais: funções Lambda, uma API REST do API Gateway e outros recursos da AWS, como nossa tabela do DynamoDB e a função do IAM. Vamos ver com mais detalhes como usamos esses componentes.

Funções Lambda

Além da função Lambda authorizer, sobre a qual falaremos em breve, dividimos o código em duas funções: content e user. Por quê?

Tecnicamente, você pode dividir o código Lambda como quiser. Todos os endpoints poderiam acionar uma única função, que analisaria a solicitação e decidiria como responder. Ou você pode criar funções para cada endpoint e evento do projeto. Optamos por uma abordagem híbrida, agrupando o código em funções de microsserviços após considerar alguns fatores:

Organização do código

Faz sentido atribuir endpoints semelhantes à mesma função. Todo o nosso código relacionado a usuários trabalha com a mesma tabela do banco de dados e usa as mesmas bibliotecas externas. Ao agrupar esse código semelhante em uma função, garantimos que as alterações no código sejam aplicadas imediatamente a todos os endpoints.

Desempenho

A documentação do Lambda indica que funções menores têm melhor desempenho. A latência da função é muito maior quando ela não é invocada há algum tempo — pela nossa experiência, quando não é chamada há cerca de cinco minutos. Após a primeira invocação, a latência cai drasticamente. A latência inicial, ou “tempo de inicialização a frio”, está diretamente relacionada ao tamanho da função. Funções menores têm tempos de inicialização a frio mais rápidos. (Você também pode reduzir esse tempo aumentando a alocação de memória das funções, o que aumenta proporcionalmente a CPU.)

Esse tempo de inicialização a frio mais lento não é um problema em casos de uso assíncrono do Lambda, mas é para uma API que recebe pouco tráfego. Nossa API precisa responder rapidamente, caso contrário, a experiência de uso da aplicação será prejudicada.

Agrupar funcionalidades relacionadas em funções Lambda maiores garante certo nível de aquecimento. Por exemplo, quando um usuário entra na conta e, em seguida, visualiza o perfil, isso pode gerar duas chamadas de API. A primeira pode ser lenta se a função não tiver sido invocada recentemente, mas a segunda será rápida se os dois endpoints acionarem a mesma função.

Observação: conversamos sobre manter nossas funções aquecidas enviando um ping a um endpoint a cada cinco minutos. Isso certamente funcionaria, mas não sentimos necessidade de implementar essa solução.

Manipulador de função

No arquivo s-function.json, definimos o manipulador da função. No nosso caso, é uma função chamada handler em handler.js. Essa é a função chamada quando a função Lambda é invocada. Veja como é o content da função handler.js:

var download = require('./lib/download'); module.exports.handler = function(event, context) { 
if(event.path === '/downloads' && event.method === 'GET') { 
  download.account(event).then(context.succeed).catch(context.fail); 
} 
else if(event.path === '/downloads/repos/{repo}' && event.method === 'GET') {
  download.repo(event).then(context.succeed).catch(context.fail); 
} 
else if(event.path === '/downloads/repos/{repo}/packages/{package}' && event.method === 'GET') {
  download.package(event).then(context.succeed).catch(context.fail); 
} 
else { 
  context.fail('Invalid route.'); 
} 
};

Como você pode ver, ele funciona basicamente como um roteador da nossa função. Todo o código importante fica separado em /lib/download.js, que não sabe que faz parte de uma função Lambda. Isso facilita a criação de testes de unidade para o código, já que /lib/download.js é apenas um arquivo JavaScript, sem dependências especiais do Lambda.

API REST do API Gateway

Os endpoints são configurados no arquivo s-function.json. Veja um exemplo de configuração de endpoint:

{"path": 
"downloads","method": 
"GET","type": 
"AWS","authorizationType": 
"CUSTOM","authorizerFunction": 
"authorization","apiKeyRequired": 
false,"requestParameters": 
{},"requestTemplates": 
"$${requestTemplate}","responses": 
"$${responseTemplate}" }

Esse endpoint ficará disponível em GET /downloads.

Modelos de solicitação e resposta

No exemplo de configuração de endpoint acima, você viu $${requestTemplate} e $${responseTemplate}. Isso indica que o endpoint downloads herdará os modelos requestTemplate e responseTemplate definidos em um arquivo global de modelos do projeto.

Os modelos de solicitação e resposta são representados em JSON e usam expressões JSONPath. É possível manipular expressões JSONPath com a Apache Velocity Template Language (VTL). A sintaxe é um pouco complicada.

O Serverless adicionou recentemente suporte a modelos YAML de solicitação e resposta, mas atualmente usamos o formato JSON. Veja nosso modelo de solicitação:

"requestTemplate": 
  {"application/json": 
  {"body": 
  "$input.json('$')","path": 
  "$context.resourcePath","method": 
  "$context.httpMethod","headers":
  "{

  #foreach($header in $input.params().header.keySet())"$header": 
  "$util.escapeJavaScript($input.params().header.get($header))"

  #if($foreach.hasNext),#end#end}","params": 
  "{

  #foreach($param in $input.params().path.keySet())"$param":
  "$util.escapeJavaScript($input.params().path.get($param))" 

  #if($foreach.hasNext),

  #end

  #end}","query": "{

  #foreach($queryParam in $input.params().querystring.keySet())"$queryParam":
  "$util.escapeJavaScript($input.params().querystring.get($queryParam))" 

  #if($foreach.hasNext),

  #end

  #end}", "authorizedUser": 
  "$context.authorizer.principalId"}}

Sim, ele parece um pouco maluco, como é de se esperar de um JSON escapado dentro de outro JSON, mas gera um objeto de evento para a função Lambda com todas as informações necessárias sobre a solicitação:

  • body contém o corpo JSON analisado da solicitação

  • path é o caminho do endpoint solicitado

  • method é o método HTTP usado na solicitação

  • headers contém todos os cabeçalhos HTTP da solicitação

  • params contém os parâmetros de caminho da solicitação

  • query contém os parâmetros de consulta da solicitação

  • authorizedUser é o nome de usuário autorizado, caso o endpoint exija autorização

E aqui está nosso modelo de resposta. Se a função Lambda for executada com sucesso, a string do resultado será enviada ao usuário como JSON (usando o fragmento de modelo response200). Se a função falhar, as expressões regulares tentarão corresponder à string do resultado. Retornamos códigos de erro HTTP diferentes de acordo com a mensagem de erro.

"responseTemplate": 
{"^(?!.*(Unauthorized|Process exited|Task timed out)).*..*": "$${response400}",
".*Unauthorized.*": "$${response401}",
".*Process exited.*": "$${response500}",
".*Task timed out.*": "$${response504}",
"default": "$${response200}"},
"response200": {"statusCode": "200",
"responseParameters": "$${responseParameters}",
"responseTemplates": {"application/json": ""}},
"response400": {"statusCode": "400",
"responseModels": {},
"responseTemplates": {"application/json": "{"error": "$input.path('$.errorMessage')"}"}},
"response401": {"statusCode": "401",
"responseModels": {},
"responseTemplates": {"application/json": "{"error": "$input.path('$.errorMessage')"}"}},
"response500": {"statusCode": "500",
"responseTemplates": {"application/json": "{"error": "Server Error"}"}},
"response504": {"statusCode": "504",
"responseTemplates": {"application/json": "{"error": "Gateway Timeout"}"}}

Como lidar com a autorização

Estamos usando dois recursos do API Gateway no componente de permissões da nossa aplicação: chaves de API e funções de autorização personalizadas. Esses dois recursos simplificam o código da aplicação, removendo as questões de autorização das funções Lambda principais.

Chaves de API

O API Gateway permite criar uma chave de API e atribuí-la a endpoints específicos. Usamos esse recurso para proteger determinados endpoints sensíveis que não devem ser acessados pelo cliente, como os de gerenciamento de usuários. O API Gateway procura um cabeçalho x-api-key nas solicitações aos endpoints selecionados e tenta comparar o valor com a chave de API criada. Se o cabeçalho estiver ausente ou incorreto, a solicitação retorna um erro 403 e a função Lambda nem chega a ser invocada.

Autorizadores personalizados

Recentemente, o API Gateway adicionou a autorização personalizada, que permite que uma função Lambda controle o acesso aos endpoints. Uma solicitação recebida invoca a função de autorização personalizada com um token de autorização enviado em um cabeçalho personalizado especificado. O autorizador personalizado valida o token (no nosso caso, um JSON Web Token) e retorna uma política do IAM para autorizar a solicitação. Se a política não for válida para a solicitação ou a autorização falhar, o API Gateway retornará um erro 403. Se a política for válida, a função Lambda associada ao endpoint será acionada.

Veja um exemplo da política do IAM retornada pelo nosso autorizador personalizado:

[{ Action: 'execute-api:Invoke', Effect: 'Allow', Resource: ['arn:aws:execute-api:us-east-1:xxxxx.xxxxx/prod/*/*']}]

Como nosso aplicativo web não tem permissões granulares, concedemos acesso a todos os endpoints da API que usam o autorizador personalizado. O API Gateway armazena em cache a política do IAM gerada junto com o token de autorização usado para criá-la. Nas solicitações seguintes a qualquer endpoint protegido, com o mesmo cabeçalho de token de autorização, essa política será aplicada automaticamente, sem passar pelo autorizador personalizado, durante o TTL especificado.

Fluxo de trabalho do projeto Serverless

Ambientes

No mínimo, nosso projeto precisa de ambientes separados de desenvolvimento, teste e produção.

O Serverless usa o conceito de “stages”, implementado de maneiras diferentes nos vários serviços da AWS, mas que, em conjunto, funciona como um ambiente.

Lambda: sempre que o código de uma função Lambda muda, uma nova versão é criada automaticamente. As funções Lambda podem ter aliases para as versões, e o Serverless usa aliases para apontar para versões diferentes em cada stage. Por exemplo, se o stage dev for publicado e se tornar a v1 da função Lambda, o Serverless também criará um alias dev que aponta para a v1. Se o stage prod da função for publicado em seguida, o código de produção se tornará a v2, mas o alias dev continuará apontando para a v1.

API Gateway: uma única API no API Gateway pode ter vários stages. Cada stage de um endpoint de API pode acionar um alias Lambda diferente. Assim, o stage dev do API Gateway pode usar o alias dev da função Lambda, seja qual for a versão para a qual ele estiver apontando. O nome do stage passa a fazer parte do caminho do endpoint.

Outros serviços da AWS, como tabelas do DynamoDB e buckets do S3, existem individualmente em cada stage.

Testes

Cada pasta de função Lambda pode conter um objeto de evento simulado para testes em um arquivo event.json:

{"path": "/downloads","method": "GET”}

serverless function run executa a função usando event.json como evento. Isso é útil para testar a função de ponta a ponta.

Há alguns plugins do Serverless que simulam o API Gateway localmente para testes, como o Offline e o Serve. Como o API Gateway ainda está adicionando recursos, descobrimos que esses plugins nem sempre ofereciam suporte aos recursos que queríamos usar. Por isso, acabamos fazendo a maior parte dos testes do API Gateway no Console da AWS.

Usamos Jasmine para testes unitários nos nossos arquivos JavaScript lib.

Implantação

Para que nosso projeto fique pronto para uso, é preciso implantar três itens: funções, endpoints e recursos do projeto (ou seja, os recursos da AWS listados em s-resources.json). Podemos implantá-los individualmente com estes comandos da CLI:

serverless resources deploy serverless function deploy serverless endpoint deploy

Ou podemos usar o prático painel interativo de implantação, com o comando serverless dash deploy:

Serverless: 
Select the assets you wish to deploy: 
authorization function - authorization download-service function - content-service endpoint - downloads - GET endpoint - downloads/repos/{repo} - GET endpoint - downloads/repos/{repo}/packages/{package} - GET user-service function - user-service endpoint - users - POST endpoint - users/{user} - GET endpoint - users/{user} - POST - - - - - Deploy Cancel

Com qualquer uma dessas opções, podemos fazer implantações bem granulares e atualizar uma função ou um endpoint sem alterar mais nada.

O painel interativo é ótimo para desenvolvimento e testes locais, mas usamos o primeiro conjunto de comandos com ferramentas de integração contínua. Por padrão, a CLI solicita as opções ausentes, mas você pode desativar essa interação definindo como true uma variável de ambiente chamada CI. Usamos o Travis CI para integração contínua e implantações. Assim, o Travis executa nossos testes unitários e depois usa a CLI no modo CI para implantar nosso código na AWS.

Consulte a referência da CLI do Serverless para saber mais.

Considerações finais

Há alguns meses, nosso CEO, Josh, compartilhou suas reflexões sobre o AWS Lambda e a evolução contínua da nuvem. Ele observou:

O Lambda é um serviço que define uma arquitetura de aplicação específica, ao contrário das ofertas tradicionais de PaaS. Ele não tenta ser um computador tradicional nem um cluster de computadores tradicionais. Em vez disso, o Lambda é naturalmente orientado a eventos e impõe a ausência de estado no nível da função. Isso significa que você precisará pensar de um jeito um pouco diferente sobre como compor uma aplicação. Em comparação com instâncias de computação ou contêineres, você ganha escalabilidade quase infinita e custos muito baixos. Como contrapartida, é preciso dedicar tempo ao aprendizado, mas é bem possível que você invente novos padrões durante a experimentação e a investigação.

Concordamos. Vale a pena investir tempo para aprender e colocar a mão na massa. Esperamos que o exemplo apresentado aqui seja útil. É provável que surjam outros frameworks como o Serverless em um futuro próximo, mas este é uma boa opção para começar e criar uma aplicação web sólida.

Segurança de IaC pensada para quem desenvolve

A Snyk protege sua infraestrutura como código do ciclo de vida do desenvolvimento de software até a execução na nuvem, com um mecanismo unificado de políticas como código para que todas as equipes possam desenvolver, implantar e operar com segurança.

Publicado em:

Leia mais

Blog

Verão nos estádios: a turnê Fan Zone do Snyk Connect

A turnê Fan Zone da Snyk levou workshops de segurança de IA, networking e competição amistosa a 8 cidades e 3 sessões virtuais. Os participantes desenvolveram habilidades, trocaram ideias e evoluíram juntos.

Blog

Snyk VulnBench JS 1.0: LLMs conseguem encontrar os mesmos bugs duas vezes?

Snyk VulnBench JS 1.0: 300 varreduras repetidas mostram que as descobertas de segurança dos LLMs variam a cada execução, enquanto SAST e modelos detectam diferentes lacunas de vulnerabilidade.

feature insights context
Blog

Construindo a segurança de IA com nossos clientes: 5 lições do programa de parceiros de design do Evo

Conheça 5 lições importantes do programa de parceiros de design do Evo, da Snyk. Descubra como a descoberta de IA, a inteligência de risco e a automação de políticas ajudam as equipes a proteger a IA generativa e controlar a proliferação de IA em grande escala.