loading...
Cover image for Serverless Framework: Otimizando Lambda "cold starts" com serverless-webpack

Serverless Framework: Otimizando Lambda "cold starts" com serverless-webpack

oieduardorabelo profile image Eduardo Rabelo ・10 min read

Créditos da Imagem

Ultimamente, a AWS tem feito um excelente trabalho para reduzir os tempos de inicialização frio (cold starts) do Lambda em todos os runtimes (ex: Node.js, Go, Python etc), como melhorias na rede VPC e adicionando suporte nativo para manter automaticamente seus containers Lambda aquecidos.

Essas mudanças reduziram o número de coisas que um desenvolvedor precisa fazer para melhorar o desempenho de inicialização do Lambda.

Mas ainda há uma variável sob o controle do desenvolvedor que afeta significativamente a inicialização fria - o tamanho do pacote zippado.

Por que o tamanho do pacote ainda é importante

Apesar das melhorias que a AWS fez, seu Lambda ainda pode ser iniciado a frio. Esse tempo extra de inicialização afetará a capacidade de "resposta-percebida" das funções voltadas para seus usuários, como APIs ou aplicativos Slack.

Se você estiver usando Serverless Framework com JavaScript, ele empacota seu código com todas as dependências npm por padrão, o que pode criar funções Lambda muito pesadas. Grande parte desse código geralmente não é usado, mas como está no pacote do aplicativo, ele ainda precisa ser copiado e extraído no sistema de arquivos toda vez que um novo container é provisionado. O resultado é um tempo de resposta mais lento para o seu cliente.

Onde os desenvolvedores de back-end precisam aprender rapidamente

O tamanho do código compactado tem sido uma métrica de importância para os desenvolvedores da web. Muitas horas são gastas otimizando o JavaScript, imagens, CSS e HTML entregues a um aplicativo da Web - a quantidade de tempo necessária para o download afeta o tempo da primeira pintura e o tempo da primeira interação e pode ter impactos reais na retenção e conversão do cliente.

Os desenvolvedores de back-end conseguiram ignorar o tamanho do código por um longo tempo, devido à maneira como ele foi empacotado e executado. Aplicativos monolíticos geralmente são implantados em um servidor de longa execução, iniciados uma vez e deixados em execução por dias, portanto, os longos tempos de inicialização não eram realmente uma preocupação.

Normalmente, esses aplicativos são agrupados em cluster para dimensionar horizontalmente o desempenho e ajustar elasticamente a capacidade de alterar a carga do cliente. Como os tempos de inicialização já são ruins, os clusters normalmente são superprovisionados para compensar. Os desenvolvedores conseguiram esconder seus maus tempos de inicialização de aplicativos por trás disso.

Com Serverless em geral, as preocupações de arquitetura do front-end chegam ao back-end. Embora tenha liberado os desenvolvedores de pensar em várias preocupações operacionais, como fornecimento de infraestrutura e otimização de recursos, agora eles precisam considerar coisas como inicialização fria e gerenciamento de conexões de recursos que não eram necessárias no passado.

O tamanho do pacote afeta diretamente o tempo de inicialização de um Lambda, portanto, a otimização da quantidade de código implementada com um bundler ajudará a reduzir o tempo de inicialização fria.

Felizmente para desenvolvedores de JavaScript, as ferramentas usadas para agrupar o código são bastante maduras no front-end - agora existem muitos empacotadores para JavaScript e a maioria deles é adequada para empacotar código de back-end em execução no Node.js também!

Como um empacotador ajuda nisso?

Os empacotadores trabalham examinando as importações e exportações em seu código e nos pacotes npm referenciados, criando uma árvore de dependência e gerando um único arquivo JavaScript que contém todo o código acumulado.

Isso traz vários benefícios:

  • ‌Inclua apenas o código usado por sua função

Você pode ter várias funções Lambda no mesmo projeto Serverless Framework que compartilham algum código, mas é improvável que todas as funções usem exatamente o mesmo conjunto de dependências compartilhadas. É possível otimizar o tamanho do seu Lambda incluindo apenas o código importado pela sua função e não por todas as suas funções.

  • Otimize suas dependências npm

Você pode estar usando apenas algumas de suas dependências do npm, ou mesmo apenas parte desse código referenciado. Algumas de suas dependências do npm podem existir duas vezes porque são referenciadas por vários módulos.

Um empacotador pode seguir o gráfico de dependência em um módulo npm e pegar apenas os arquivos referenciados transitivamente pelo seu código, além de desduplicar (onde existem versões compatíveis).

  • ‌Use um único arquivo para o seu código fonte

Usar um único arquivo ao invés de vários arquivos pode reduzir a quantidade de acessos ao sistema de arquivos necessários ao Node.js. para carregar seu código.

Esse aprimoramento geralmente é pequeno, mas é um efeito colateral do webpack, onde o empacotamento de um único arquivo para a web faz uma enorme diferença na rede.

O resultado é geralmente uma função Lambda muito menor do que se você tivesse zippado todo o seu código e feito o deploy você mesmo.

Começando com serverless-webpack

O primeiro passo é instalar o plugin e registrá-lo no seu arquivo serverless.yaml.

Mude para o diretório do projeto e instale o módulo serverless-webpack junto com o webpack:

npm install --save-dev serverless-webpack@5.3.1 webpack@4.42.1

Adicione o plugin ao seu arquivo serverless.yaml:

plugins:
  - serverless-webpack
  ...
  # se você estiver usando o `serverless-offline-plugin`
  # se certifique de colocá-lo APÓS o `serverless-webpack`
  - serverless-offline-plugin

Por fim, adicione um arquivo webpack.config.js na raiz do seu projeto:

const slsw = require('serverless-webpack');

module.exports = {
  target: 'node',
  entry: slsw.lib.entries,
  mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
  node: false,
  optimization: {
    minimize: false,
  },
  devtool: 'inline-cheap-module-source-map',
};

Neste ponto, configuramos o plugin serverless-webpack e oferecemos uma configuração mínima do webpack.

Se você leu a documentação do serverless-webpack, deve ter notado que incluí algumas opções extras que não aparecem nos exemplos.

  • node: false - é incomum, mas achei necessário para minhas configurações.

Definir node como target normalmente é suficiente para que o webpack seja compilado para o Node.js - ele usa o Common.js require() para dependências e garante que as dependências internas não sejam substituídas por stubs. No entanto, descobri que ele ainda interfere com o process.env global, necessário para usar variáveis ​​de ambiente no Lambda.

  • minimize: false - desativa a minimização de código (uglificação).

O ofuscamento torna mais difícil a leitura dos stack traces do Lambda, ele reduz um pouco o tamanho do pacote, mas não o suficiente para justificar seu uso com projetos Serverless Framework) e não é realmente necessário onde o código não é distribuído diretamente aos usuários (como na Web).

Minha configuração desativa a minimização, porque reduz bastante a quantidade de CPU e memória necessária no tempo de compilação e ajuda a evitar erros 'out-of-memory'.

Crie um único pacote ou um pacote por Lambda

Nesse ponto, se você executar serverless package, seu terminal irá mostrar relatórios típicos do webpack, mostrando os tempos de compilação e os tamanhos dos pacotes.

Por padrão, Serverless Framework gera um único pacote com todo o seu código e o implementa para todas as suas funções Lambda. O serverless-webpack também faz o mesmo, com uma diferença que ele cria um arquivo JavaScript por função Lambda, com todo o código necessário para cada Lambda, incluindo dependências npm, agrupadas e compactadas.

Você pode encontrar seu projeto empacotado em <project>/.serverless. Você descobrirá que essa ainda é uma enorme redução no tamanho em comparação com o padrão para aplicativos não triviais.

Se você deseja otimizar ainda mais e criar um pacote específico para cada função do Lambda, é possível dizer ao Serverless Framework para empacotar os Lambdas individualmente, configurando a seguinte opção em serverless.yaml:

package:
  individually: true

O serverless-webpack também utiliza esse valor e criará um pacote separado para cada Lambda com o único pacote de webpack em cada um. Isso leva muito mais tempo, mas o resultado é uma função mais otimizada.

Usando Babel

A documentação do plugin descreve como usar o babel na transpilação (mas incluí algumas das minhas próprias alterações para garantir que você aproveite ao máximo).

Primeiramente, vamos instalar as dependências do Babel:

npm install --save-dev @babel/core@7.9.0 @babel/preset-env@7.9.5 babel-loader@8.1.0 corejs@3.6.5

Inclua a seguinte entrada no seu webpack.config.js (certifique-se de definir a versão de execução do Node.js que você está usando no Lambda):

module.exports = {
  ...
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: [
                [
                  '@babel/preset-env',
                  { targets: { node: '12' }, useBuiltIns: 'usage', corejs: 3 }
                ]
              ]
            }
          }
        ]
      }
    ]
  },
  ...
}

Comparadas à documentação do serverless-webpack, as principais diferenças com minha configuração são:

  • adicionando a @babel/preset-env para garantir que não incluamos transformações ou polyfills para recursos que já estão disponíveis no Node.js.
  • definido useBuiltins: 'usage' para que todos os polyfills usados ​​sejam incluídos caso a caso
  • configurado corejs: 3 para que o core-js v2 antigo e com bugs não seja usado para fornecer polyfills

Copiando outros arquivos para o pacote

Como o Serverless Framework normalmente copia tudo para o pacote de deploy, você pode ficar surpreso quando seu código repentinamente deixa de funcionar por carregar arquivos arbitrários que não são parte do seu código e sim do sistema de arquivos.

Como mencionado anteriormente, o serverless-webpack inclui apenas o código-fonte, ignorando efetivamente o que está definido nas opções include e exclude na seção package do serverless.yaml. Você deve configurar o webpack para copiar os arquivos que não são de origem, porém necessários, usando o copy-webpack-plugin.

Vamos instalar com o npm:

npm install --save-dev copy-webpack-plugin

No seu webpack.config.js inclua e configure o plugin com os caminhos de arquivo e globs que você deseja copiar:

const CopyPlugin = require('copy-webpack-plugin`);
...
module.exports = {
  ...
  plugins: [
    new CopyPlugin([
      'path/to/specific/file',
      'recursive/directory/**',
    ]),
  ],
  ...
};

Problemas Comuns

Sem Memória / Out of Memory

O uso do webpack para empacotar seu código usa muito mais CPU e memória do que o normal, e não é incomum o Node.js relatar um erro de falta de memória. O serverless-webpack executa uma instância do webpack por função e cada um deve combinar e minimizar sua saída:

Serverless: Bundling with Webpack...


<--- Last few GCs --->

[8233:0x393fa70]   207373 ms: Scavenge 1885.9 (2042.4) -> 1885.9 (2042.9) MB, 9.3 / 0.0 ms  (average mu = 0.262, current mu = 0.159) allocation failure
[8233:0x393fa70]   207386 ms: Scavenge 1886.4 (2042.9) -> 1886.4 (2043.6) MB, 11.4 / 0.0 ms  (average mu = 0.262, current mu = 0.159) allocation failure
[8233:0x393fa70]   207404 ms: Scavenge 1887.0 (2043.6) -> 1886.9 (2044.1) MB, 15.8 / 0.0 ms  (average mu = 0.262, current mu = 0.159) allocation failure


<--- JS stacktrace --->

==== JS stack trace =========================================

    0: ExitFrame [pc: 0x1374fd9]
    1: StubFrame [pc: 0x13afd14]
Security context: 0x39352fbc08a1 <JSObject>
    2: replace [0x39352fbccf51](this=0x2c60c4e7f591 <String[#27]\: \n//# sourceMappingURL=[url]>,0x16535c280e41 <JSRegExp <String[#7]: \[url\]>>,0x16535c280e79 <JSFunction (sfi = 0x35c9e77a48b9)>)
    3: /* anonymous */(aka /* anonymous */) [0x17c38da35a9] [/home/chris/dev/slack-app/bot/node_modules/webpack/lib/SourceMapDevToolPlu...

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
 1: 0x9da7c0 node::Abort() [node]
 2: 0x9db976 node::OnFatalError(char const*, char const*) [node]
 3: 0xb39f1e v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, bool) [node]
 4: 0xb3a299 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, bool) [node]
 5: 0xce5635  [node]
 6: 0xce5cc6 v8::internal::Heap::RecomputeLimits(v8::internal::GarbageCollector) [node]
 7: 0xcf1b5a v8::internal::Heap::PerformGarbageCollection(v8::internal::GarbageCollector, v8::GCCallbackFlags) [node]
 8: 0xcf2a65 v8::internal::Heap::CollectGarbage(v8::internal::AllocationSpace, v8::internal::GarbageCollectionReason, v8::GCCallbackFlags) [node]
 9: 0xcf5478 v8::internal::Heap::AllocateRawWithRetryOrFail(int, v8::internal::AllocationType, v8::internal::AllocationAlignment) [node]
10: 0xcc30c6 v8::internal::Factory::NewRawOneByteString(int, v8::internal::AllocationType) [node]
11: 0x103253a v8::internal::Runtime_StringBuilderConcat(int, unsigned long*, v8::internal::Isolate*) [node]
12: 0x1374fd9  [node]

A solução é aumentar a quantidade de espaço de heap disponível para o Node.js ao iniciar o comando de deploy do Serverless Framework (isso funciona apenas no Node.js v8 e posterior):

node --max-old-space-size=4096 \
    node_modules/.bin/serverless package --stage dev ...

Observe também que precisamos invocar o Serverless Framework pelo caminho completo na pasta node_modules/bin, porque estamos iniciando o Node.js. diretamente.

Se você não quiser chamar o Node.js diretamente, poderá definir as opções do Node.js usando uma variável de ambiente:

export NODE_OPTIONS=--max-old-space-size=4096
npx serverless package --stage dev ...

Número de linha correto e nomes das funções nos stack traces

Como o webpack transforma seu código e o empacota no mesmo arquivo, os stack traces nos logs do CloudWatch refletirão o que é empacotado por padrão. Isso pode prejudicar sua capacidade de depurar seu código.

O webpack usa a opção devtool para controlar mapas de origem (source maps), que são comentários extras incluídos no código usado para ajudar depuradores e geradores de stack trace a converter essas referências ao código-fonte do pacote de volta ao código-fonte original ou transformado.

Em nosso exemplo webpack.config.js, configuramos a devtool como 'inline-cheap-module-source-map', que deve renderizar stack traces com referências de linha ao código-fonte original.

Essa opção pode afetar a velocidade de criação do webpack, portanto, se você quiser usar outra coisa, consulte a documentação do webpack sobre devtool.

Créditos

Discussion

markdown guide