O blog da AWS

Otimizando o desempenho da inicialização a frio do AWS Lambda usando estratégias avançadas de preparação com o SnapStart

Por Julian Wood, Developer Advocate Sênior na Amazon Web Services (AWS).

Apresentado no re:Invent 2022, o SnapStart é uma otimização de desempenho que facilita a criação de aplicativos altamente responsivos e escaláveis usando o AWS Lambda. O maior fator que contribui para a latência na inicialização (geralmente chamada de tempo de inicialização a frio – cold start) é o tempo gasto na inicialização de uma função. Isso inclui carregar o código da função e inicializar dependências. Para cargas de trabalho sensíveis à latência, como APIs e aplicativos de processamento de dados em tempo real, a alta latência de inicialização pode resultar em uma experiência insatisfatória para o usuário final. O Lambda SnapStart pode reduzir a duração da inicialização de vários segundos para menos de um segundo, com o mínimo ou nenhuma alteração no código. Esta postagem discute o ‘Priming’, uma técnica para otimizar ainda mais os tempos de inicialização das funções do AWS Lambda criadas usando Java e Spring Boot.

Os aplicativos feitos com Spring Boot normalmente apresentam alta latência de inicialização a frio durante a inicialização da JVM, onde um tempo significativo é gasto carregando classes e executando a compilação Just-In-Time (JIT) do bytecode Java. Esta postagem usa um aplicativo Spring Boot como exemplo que recupera 10 registros de uma tabela chamada ‘UnicornEmployee’ em um banco de dados Amazon RDS para PostgreSQL, onde cada registro de funcionário inclui nome, localização e data de contratação do funcionário.

O aplicativo de amostra usa o Amazon API Gateway, que aciona uma função do AWS Lambda que se conecta ao banco de dados por meio do RDS Proxy para retornar os dados dos funcionários. Embora esse aplicativo de exemplo use dados fictícios de funcionários para demonstração, os padrões e as técnicas de otimização discutidos nesta postagem são aplicáveis a cenários do mundo real com padrões de acesso a dados semelhantes. O código de exemplo para essa implementação pode ser encontrado em nosso repositório do GitHub em lambda-priming-crac-java-cdk.

Contexto: Como o SnapStart funciona

Esta postagem pressupõe que você tenha familiaridade com o SnapStart e fornece um breve histórico. Para obter detalhes adicionais, consulte a documentação do SnapStart.

Para recapitular rapidamente, a fase de inicialização de uma função Lambda envolve baixar o código da função, iniciar o tempo de execução e quaisquer dependências externas e executar o código de inicialização da função. Para funções que não usam o SnapStart, essa fase ocorre sempre que seu aplicativo se expande para criar um novo ambiente de execução. Quando o SnapStart é ativado, a fase INIT acontece quando você publica uma versão da função.

A imagem a seguir mostra uma comparação do ciclo de vida de uma solicitação do Lambda com e sem o SnapStart.

Figura 1 — comparação do ciclo de vida de uma solicitação Lambda com e sem o SnapStart

Figura 1 — comparação do ciclo de vida de uma solicitação Lambda com e sem o SnapStart

No final da fase de inicialização, o Lambda executa seus hooks de runtime antes do processo de verificação. O Lambda então captura snapshots da memória e do estado do disco do ambiente de execução inicializado, persiste o snapshot criptografado e o armazena em cache para acesso de baixa latência. Quando a função é invocada novamente, novos ambientes de execução são retomados a partir do snapshot em cache (durante a fase RESTORE), acelerando a inicialização da função.

Figura 2 — novos ambientes de execução são retomados a partir do snapshot em cache.

Figura 2 — novos ambientes de execução são retomados a partir do snapshot em cache.

Você pode validar essa aceleração comparando a duração de RESTORE com a duração de INIT registrada antes do SnapStart no Amazon CloudWatch Logs da sua função Lambda. Conforme demonstrado na tabela a seguir, habilitar o SnapStart reduz a latência de inicialização do nosso aplicativo Spring Boot de amostra em 4,3 vezes, de 6,1 para 1,4 s. A latência de inicialização a frio de 6.1s para ON_DEMAND se deve principalmente à combinação de (1) inicialização da estrutura JVM e Spring Boot, (2) compilação JIT do código de aplicativo carregado lentamente durante a invocação inicial e (3) ao tempo necessário para estabelecer uma conexão de banco de dados com o RDS por meio do Amazon RDS Proxy. Ao habilitar o SnapStart, o Lambda inicializa a JVM e o Spring Boot antes da invocação da função, resultando em uma latência significativamente reduzida para 1,4s.

Method Cold Start Invocations p50 P90 P99 p99.9
PrimingLogGroup-1_ON_DEMAND 128 5047.94 ms 5386.78 ms 6158.80 ms 6195.84 ms
PrimingLogGroup-2_SnapStart_NO_PRIMING 111 1177.87 ms 1288.73 ms 1419.94 ms 1425.63 ms

Você pode reduzir ainda mais as inicializações a frio de seus aplicativos Spring Boot sensíveis à latência usando técnicas de Priming nas funções Lambda. Vamos explorar como implementar técnicas de Priming.

Explicação do priming

O Priming é o processo de pré-carregar dependências e inicializar recursos durante a fase INIT, em vez de durante a fase INVOKE, para otimizar ainda mais o desempenho da inicialização com o SnapStart. Isso é necessário porque as estruturas Java que usam injeção de dependência carregam classes na memória quando essas classes são invocadas explicitamente, o que normalmente acontece durante a fase INVOKE do Lambda. Você pode carregar classes de forma proativa usando hooks de runtime Java, que fazem parte do projeto CrAC (Coordinated Restore at Checkpoint) de código aberto. Esta postagem demonstra como usar esse hook, chamado beforeCheckpoint (), para ativar funções Java habilitadas para Snapstart, de duas maneiras:

  1. Invoke Priming: Essa abordagem envolve invocar diretamente os endpoints ou métodos do aplicativo em seu hook de pré-snapshot para que eles sejam compilados pelo JIT durante a fase INIT e incluídos no snapshot. Isso pode incluir operações como invocar endpoints do API Gateway ou buscar dados de um bucket do S3 ou banco de dados do RDS para executar proativamente os caminhos do código, garantindo que as classes subjacentes sejam incluídas no snapshot.
  2. Preparação de classes: essa abordagem envolve a inicialização proativa das classes durante a fase de inicialização, garantindo que elas sejam incluídas no snapshot da função sem correr o risco de alterações indesejadas no estado ou nos dados do aplicativo. Isso pode ser feito aproveitando o método forName () do Java, que carrega, vincula e inicializa a classe especificada. A inicialização se refere ao processo da JVM de carregar a definição da classe na memória, verificar o bytecode, preparar campos estáticos com valores padrão e executar inicializadores estáticos. Isso é diferente da instanciação, que cria objetos da classe usando construtores. Para gerar uma lista das classes necessárias para o pré-carregamento, você pode usar a seguinte opção de VM, gravando a lista em um arquivo chamado classes-loaded.txt: -Xlog:class+load=info:classes-loaded.txt

Embora o Invoke Priming possa oferecer melhor desempenho, ele exige um esforço adicional para garantir que as ações executadas sejam idempotentes e não tenham efeitos colaterais indesejados, por exemplo, o processamento de transações financeiras em um aplicativo bancário. Por esse motivo, invocar o priming só deve ser usado quando o código executado durante o Priming é idempotente ou não modifica o estado. Para cenários em que isso não é possível, a preparação de classes fornece uma alternativa mais segura ao inicializar apenas as classes sem executar seus métodos. Observe que isso pressupõe que seu aplicativo não execute código de modificação de estado durante a inicialização da classe.

Com esse contexto, vamos ver como implementar o Invoke and Class Priming para um aplicativo de exemplo do Spring Boot.

Exemplo de implementação de preparação usando hooks de um runtime CRaC antes de tirar um snapshot do Lambda

Esta postagem demonstra o Priming Invoke e o Priming Class usando o exemplo do aplicativo Spring Boot. A escolha entre as duas abordagens depende dos requisitos e complexidades específicos do seu aplicativo.

Etapa 1: configure seu aplicativo Spring Boot usando o aws-serverless-springboot3-archetype, conforme explicado em nosso guia de início rápido do Spring Boot3, adicionando o código de conectividade do banco de dados ou simplesmente clonando o projeto de amostra do repositório do GitHub.

  1. Crie um aplicativo Spring Boot.
// src/main/java/software/amazon/awscdk/examples/unicorn/UnicornApplication.java
package software.amazon.awscdk.examples.unicorn;@Import({ UnicornConfig.class })
@SpringBootApplication
public class UnicornApplication {

    private static final Logger log = LoggerFactory.getLogger(UnicornApplication.class);

    public static void main(String... arguments) {
        SpringApplication.run(UnicornApplication.class, arguments);
    }

}
  1. Adicione todas as dependências necessárias do Maven para Spring Boot, AWS Lambda e Database Connection em seu arquivo pom.xml. A dependência a seguir, destacada, contém as classes necessárias para usar os hooks de tempo de execução do CrAC.
...
        <dependency>
            <groupId>org.crac</groupId>
            <artifactId>crac</artifactId>
        </dependency>
...
  1. Configurar conexão de banco de dados — Configure os detalhes da conexão do banco de dados em application.properties.
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD} 
spring.datasource.url=${SPRING_DATASOURCE_URL} 
spring.datasource.username=postgres 
spring.datasource.hikari.maximumPoolSize=1 

Etapa 2: Implemente o manipulador (handler) de funções Lambda com hooks de tempo de execução CrAc e a abordagem Invoke Priming:

Crie o Lambda Function Handler e integre os hooks de runtime do CRaC para executar os métodos beforeCheckpoint () e afterRestore () em seu aplicativo antes de tirar e depois de restaurar o snapshot.

  1. Implemente a interface RequestHandler<UnicornRequest, UnicornResponse> na classe de manipulador de funções Lambda.
  2. Implemente a interface de recursos do CrAC com dois métodos: beforeCheckpoint () e afterRestore (), que definem as ações realizadas antes de o Lambda criar o snapshot e depois que o snapshot for restaurado.
  3. Adicione invoke Priming criando um objeto UnicornRequest com uma solicitação GET para um endpoint específico (como /unicorn) e chame o método handleRequest (unicornRequest, null).

Isso garante que os caminhos de código associados ao endpoint especificado sejam compilados e otimizados no JIT para uma execução mais rápida durante a primeira invocação após a restauração do snapshot.


/src/main/java/software/amazon/awscdk/examples/unicorn/handler/InvokePriming.java
package software.amazon.awscdk.examples.unicorn.handler;

import org.crac.Core;
import org.crac.Resource;
...
public class InvokePriming implements RequestHandler<APIGatewayV2HTTPEvent, APIGatewayV2HTTPResponse>, Resource {
...

@Override
public APIGatewayV2HTTPResponse handleRequest(APIGatewayV2HTTPEvent event, Context context) {
var awsLambdaInitializationType = System.getenv("AWS_LAMBDA_INITIALIZATION_TYPE");
var unicorns = getUnicorns();
var body = gson.toJson(unicorns);
return APIGatewayV2HTTPResponse.builder().withStatusCode(200).withBody(body).build();
}

@Override
public void beforeCheckpoint(org.crac.Context<? extends Resource> context)
throws Exception {
var event = APIGatewayV2HTTPEvent.builder().build();
handleRequest(event, null);
}
...
}

Etapa 3: Implementar a abordagem de preparação de classes:

A abordagem de preparação de classes se concentra no pré-carregamento das classes necessárias para alcançar o desempenho ideal. Para implementar a preparação de classes, gere a lista de classes que são carregadas durante a inicialização do aplicativo e a execução da função executando o aplicativo localmente usando o seguinte argumento da JVM: -Xlog:class+load=info:classes-loaded.txt

  1. Certifique-se de que as classes de aplicativos incluídas no arquivo classes-loaded.txt gerado não estejam mudando de estado durante a inicialização estática.
    Observação: o classes-loaded.txt gerado contém entradas de classe no seguinte formato:
[0.068s][info][class,load] software.amazon.awscdk.examples.unicorn.handler.ClassPriming source: file:/var/task/
  1. Extraia somente os nomes de classes totalmente qualificados de cada linha e remova as informações adicionais de registro. Por exemplo:
software.amazon.awscdk.examples.unicorn.handler.ClassPriming
  1. Use o método utilitário ClassLoaderUtil.loadClassesFromFile () para extrair as entradas de classe geradas.
    	     //src/main/java/software/amazon/awscdk/examples/unicorn/service/ClassLoaderUtil.java
    package software.amazon.awscdk.examples.unicorn;
    	...
    public class ClassLoaderUtil {
    	...
        public static void loadClassesFromFile() {
            log.info("loadClassesFromFile->started");
            Path path = Paths.get("classes-loaded.txt");
    
            try (BufferedReader bufferedReader = Files.newBufferedReader(path)) {
                Stream<String> lines = bufferedReader.lines();
                lines.forEach(line -> {
                    var index1 = line.indexOf("[class,load] ");
                    var index2 = line.indexOf(" source: ");
    
                    if (index1 < 0 || index2 < 0) {
                        return;
                    }
    
                    var className = line.substring(index1 + 13, index2);
                    try {
                        Class.forName(className, true,
                                ClassPriming.class.getClassLoader());
                    } catch (Throwable ignored) {
                    }
                });
    
                log.info("loadClassesFromFile->finished");
            } catch (IOException exception) {
                log.error("Error on newBufferedReader", exception);
            }
        }
    ...
    }
  1. Leia um arquivo (como /classes-loaded.txt) que contém uma lista de classes que foram carregadas durante a execução do aplicativo no método beforeCheckpoint ().
  2. Use o método Class.forName () para carregar e inicializar a classe, garantindo que ela esteja pronta durante o snapshot.
    Nota: ao pré-carregar sistematicamente essas classes, a abordagem de preparação de classes simplifica o processo de otimização e reduz as complexidades associadas à preparação Invoke.
//src/main/java/software/amazon/awscdk/examples/unicorn/handler/ClassPriming.java
package software.amazon.awscdk.examples.unicorn.handler;

...
import org.crac.Core;
import org.crac.Resource;

public class ClassPriming implements RequestHandler<APIGatewayV2HTTPEvent, APIGatewayV2HTTPResponse>, Resource {

...
        ConfigurableApplicationContext configurableApplicationContext =
				SpringApplication.run(UnicornApplication.class);

        this.unicornService = configurableApplicationContext.getBean(UnicornService.class);
        this.gson = configurableApplicationContext.getBean(Gson.class);

        Core.getGlobalContext().register(this);
    }

    @Override
    public APIGatewayV2HTTPResponse handleRequest(APIGatewayV2HTTPEvent event, Context context) {
        var unicorns = getUnicorns();
        var body = gson.toJson(unicorns);

        return APIGatewayV2HTTPResponse.builder().withStatusCode(200).withBody(body).build();
    }

    @Override
    public void beforeCheckpoint(org.crac.Context<? extends Resource> context)
            throws Exception {

        ClassLoaderUtil.loadClassesFromFile();

    }
...
}

Etapa 4: Configuração da infraestrutura do AWS CDK

Antes de continuar, revise os pré-requisitos no arquivo README do projeto.

A pilha CDK implanta um aplicativo Serverless e a infraestrutura necessária para testar diferentes estratégias de otimização do Lambda. Ele cria uma VPC com sub-redes privadas, uma instância do RDS para PostgreSQL com um proxy de banco de dados e cinco funções Lambda implementando diferentes abordagens de otimização (ON_DEMAND sem SnapStart, SnapStart sem priming, SnapStart com invoke priming e SnapStart com priming de classe). Cada função do Lambda é integrada ao API Gateway para acesso HTTP, configurada com o tempo de execução Java 21 na arquitetura ARM64 e inclui grupos de log do CloudWatch para monitoramento.

Siga estas etapas para implantar a infraestrutura:

  1. Clone o repositório de exemplo:
git clone https://github.com/aws-samples/lambda-priming-crac-java-cdk.git
  1. Implante a pilha CDK:
cd lambda-priming-crac-java-cdk/infrastructure
cdk synth
cdk deploy --require-approval never --all 2>&1 | tee cdk_output.txt
  1. Salve os URLs do API Gateway:
    A implantação produzirá cinco URLs neste formato:
# ON_DEMAND endpoint (without SnapStart)
LambdaPrimingCracJavaCdkStack.PrimingJavaRestApi1ONDEMANDEndpoint = https://[id].execute-api.us-east-1.amazonaws.com/prod/

# SnapStart without priming endpoint
LambdaPrimingCracJavaCdkStack.PrimingJavaRestApi2SnapStartNOPRIMINGEndpoint = https://[id].execute-api.us-east-1.amazonaws.com/prod/

# SnapStart with invoke priming endpoint
LambdaPrimingCracJavaCdkStack.PrimingJavaRestApi3SnapStartINVOKEPRIMINGEndpoint = https://[id].execute-api.us-east-1.amazonaws.com/prod/

# SnapStart with class priming endpoint
LambdaPrimingCracJavaCdkStack.PrimingJavaRestApi4SnapStartCLASSPRIMINGEndpoint = https://[id].execute-api.us-east-1.amazonaws.com/prod/

# Database setup endpoint
LambdaPrimingCracJavaCdkStack.PrimingJavaRestApi5DBLOADEREndpoint = https://[id].execute-api.us-east-1.amazonaws.com/prod/
  1. Extraia os URLs em variáveis para teste:
ONDEMAND_URL=$(grep -oE 'https://[a-zA-Z0-9.-]+\.execute-api\.[a-zA-Z0-9-]+\.amazonaws\.com/prod/' "cdk_output.txt" | head -n 1) \

NOPRIMING_URL=$(grep -oE 'https://[a-zA-Z0-9.-]+\.execute-api\.[a-zA-Z0-9-]+\.amazonaws\.com/prod/' "cdk_output.txt" | head -n 2 | tail -n 1) \

INVOKEPRIMING_URL=$(grep -oE 'https://[a-zA-Z0-9.-]+\.execute-api\.[a-zA-Z0-9-]+\.amazonaws\.com/prod/' "cdk_output.txt" | head -n 3 | tail -n 1) \

CLASSPRIMING_URL=$(grep -oE 'https://[a-zA-Z0-9.-]+\.execute-api\.[a-zA-Z0-9-]+\.amazonaws\.com/prod/' "cdk_output.txt" | head -n 4 | tail -n 1) \

SETUP_URL=$(grep -oE 'https://[a-zA-Z0-9.-]+\.execute-api\.[a-zA-Z0-9-]+\.amazonaws\.com/prod/' "cdk_output.txt" | head -n 5 | tail -n 1)

Etapa 5: Carregue o banco de dados e execute testes de desempenho usando artillery:

  1. Inicialize o banco de dados com dados de exemplo.
    curl -X GET "$SETUP_URL"
    
    #Expected output: {"message":"Database schema initialized and data loaded"}
  1. Execute testes de desempenho para todos os endpoints
    artillery run -t "$ONDEMAND_URL" -v '{ "url": "/unicorn" }' ./loadtest.yaml && \
    artillery run -t "$NOPRIMING_URL" -v '{ "url": "/unicorn" }' ./loadtest.yaml && \
    artillery run -t "$INVOKEPRIMING_URL" -v '{ "url": "/unicorn" }' ./loadtest.yaml && \
    artillery run -t "$CLASSPRIMING_URL" -v '{ "url": "/unicorn" }' ./loadtest.yaml

Etapa 6: comparar os resultados do teste de carga para preparação sob demanda (sem SnapStart), SnapStart, Invoke Priming e Class Priming

Os resultados do teste de desempenho na tabela abaixo são classificados da latência de inicialização mais lenta para a mais rápida. A função sem o SnapStart tem o desempenho mais lento devido à inicialização da JVM, ao carregamento da classe e à compilação do JIT que ocorrem quando a função é invocada. Observe uma melhoria de 4,3 vezes com o SnapStart, que retoma as invocações de um snapshot pré-inicializado, evitando assim a inicialização da JVM e a compilação inicial do JIT. O SnapStart com preparação de classes atinge uma velocidade de 1,4 vezes em relação ao SnapStart, carregando/inicializando proativamente as classes durante o INIT para que elas sejam incluídas no snapshot da sua função. Por fim, o SnapStart com Invoke Priming alcança o desempenho mais rápido — com uma latência de inicialização a frio p99,9 de 781,68 ms que é 1,8 vezes mais rápida que a do SnapStart. Isso ocorre porque, além de inicializar classes, ele também executa métodos nas instâncias dessas classes, resultando na inclusão de ainda mais componentes no snapshot da função.

Observe que, com o Invoke Priming, qualquer código de aplicativo executado deve ser idempotente ou modificar somente dados de stub. Por exemplo, considere o código de uma aplicação que aciona uma transação financeira. Se esse código for executado durante a preparação de invocação com dados reais do usuário, ele poderá gerar efeitos não intencionais com consequências potencialmente graves. A preparação de classes evita isso, pois as classes de aplicativos são inicializadas em vez de serem instanciadas e seus métodos executados. Isso pressupõe que o código do aplicativo não execute a lógica de modificação de estado durante a inicialização da classe. Recomendamos que você tenha essas considerações em mente ao usar invoke e/ou class priming e escolha a abordagem apropriada para seu caso de uso.

Method Cold Start Invocations p50 P90 P99 p99.9
PrimingLogGroup-1_ON_DEMAND 128 5047.94 ms 5386.78 ms 6158.80 ms 6195.84 ms
PrimingLogGroup-2_SnapStart_NO_PRIMING 111 1177.87 ms 1288.73 ms 1419.94 ms 1425.63 ms
PrimingLogGroup-4_SnapStart_CLASS_PRIMING 82 857.81 ms 997.49 ms 1085.94 ms 1085.94 ms
PrimingLogGroup-3_SnapStart_INVOKE_PRIMING 66 608.42 ms 688.88 ms 781.68 ms 781.68 ms

Conclusão

Esta postagem mostrou como o AWS Lambda SnapStart, aprimorado pelos hooks de runtime do CrAC, libera o controle granular sobre a otimização de inicialização a frio para aplicativos Java por meio de duas estratégias de preparação distintas:

  • Invoke Priming: melhora o desempenho executando endpoints críticos durante a criação de snapshots, ideal para fluxos de trabalho idempotentes.
  • Class Priming: pré-carrega as classes sem acionar a lógica de negócios, mitigando os riscos de efeitos colaterais.

Para implementar essas técnicas de otimização em seus aplicativos, avalie seu caso de uso e opte pela abordagem de preparação ideal. Acompanhe as reduções de latência e a utilização de recursos do seu aplicativo por meio das métricas do Amazon CloudWatch para quantificar as melhorias de desempenho. Ao integrar essas estratégias, os desenvolvedores podem obter inícios a frio em menos de um segundo e, ao mesmo tempo, manter a escalabilidade e a economia da arquitetura sem servidor usando Java.

Para se aprofundar, confira o repositório do GitHub com o código de exemplo completo, incluindo instruções de configuração e padrões reutilizáveis que você pode adaptar aos seus próprios projetos. Para obter mais exemplos de aplicativos Java em execução no AWS Lambda, visite serverlessland.com e explore uma ampla variedade de recursos, tutoriais e casos de uso reais.

Este conteúdo foi traduzido da postagem original do blog, que pode ser encontrada aqui.

Biografia do autor

Julian Wood é Developer Advocate Sênior na Amazon Web Services (AWS) e ajuda desenvolvedores e criadores a adotar Serverless como a tecnologias que podem transformar a maneira como criam e executam aplicativos.


Biografia do tradutor

Nicolas Tarzia é Senior Technical Account Manager na AWS, com mais de 13 anos de experiencia, com ampla experiencia em arquitetura cloud, engenharia e design de software. Atualmente está habilitando empresas do ramo de ISV (Independent Software Vendors) simplificando a operação na nuvem e otimizando os custos em cloud. Sua area de interesse são tecnologias serverless.

https://www.linkedin.com/in/nicolastarzia