O blog da AWS
Inicie aplicativos Spring Boot mais rapidamente no AWS Fargate usando o SOCI
Visão geral da solução
Nesta seção, apresentaremos o aplicativo de amostra e a arquitetura da AWS usados no benchmarking. Se você quiser ver o código do aplicativo e do AWS Cloud Development Kit (AWS CDK) para implantar a arquitetura, visite este repositório do GitHub.
Nosso aplicativo de exemplo é um serviço simples de Create Read Update Delete (CRUD) baseado em REST que implementa funcionalidades básicas de gerenciamento de clientes. Todos os dados são mantidos em uma tabela do Amazon DynamoDB acessada usando o AWS SDK para Java V2.
A funcionalidade REST está localizada na classe CustomerController
, que usa a anotação Spring Boot RestController
. Esta classe invoca o CustomerService
, a qual utiliza a implementação do repositório de dados Spring CustomerRepository
. Esse repositório implementa as funcionalidades para acessar uma tabela do Amazon DynamoDB com o AWS SDK. Todas as informações relacionadas ao usuário são armazenadas em um Plain Old Java Object (POJO) chamado Customer.
O aplicativo tem várias dependências, incluindo o AWS Software Development Kit (AWS SDK), o cliente aprimorado do DynamoDB e o Lombok. O Projeto Lombok é uma biblioteca de geração de código para Java para reduzir o código boilerplate. O cliente aprimorado do Amazon DynamoDB é uma biblioteca de alto nível que faz parte do AWS SDK para Java V2 e oferece uma maneira simples de mapear classes do lado do cliente para tabelas do Amazon DynamoDB. Além disso, usamos o Tomcat como contêiner da web.
Em nosso Dockerfile, usamos uma abordagem de construção em vários estágios com uma imagem de destino baseada no Amazon Corretto 17 (public.ecr.aws/amazoncorretto/amazoncorretto:17), que é uma distribuição gratuita, multiplataforma e pronta para produção do Open Java Development Kit (OpenJDK).
Figura 1: Infraestrutura para o aplicativo de exemplo
O aplicativo Java é executado como um serviço Amazon Elastic Container Service (Amazon ECS) em um cluster do Amazon ECS com o AWS Fargate. Usamos o serviço Amazon ECS para executar e manter um número específico de instâncias de uma definição de tarefa simultaneamente em nosso cluster Amazon ECS. Um grupo de segurança é definido na definição de tarefas do Amazon ECS, e isso controla o tráfego de rede permitido para os recursos em sua nuvem privada virtual (VPC). A imagem do contêiner contendo o aplicativo é armazenada no Amazon Elastic Container Registry (Amazon ECR). Conforme indicado anteriormente, o estado do aplicativo é armazenado em uma tabela do Amazon DynamoDB.
Passo a passo
Considerações sobre desempenho
Comparamos o efeito do SOCI com duas abordagens diferentes para empacotar o aplicativo: Uber JARs e JARs em camadas. Essas abordagens de empacotamento são relevantes, pois afetam a quantidade de arquivos no sistema de arquivos que representam nosso aplicativo.
Em containerd, o componente que gerencia o sistema de arquivos do contêiner é chamado de snapshotter. O snapshotter padrão é overlayfs. Ele puxa e descompacta toda a imagem do contêiner antes que um contêiner possa ser iniciado. Com snapshotters de carregamento lento (como stargz ou snapshotter SOCI), o contêiner inicia sem baixar a imagem inteira do contêiner. Os aplicativos Java geralmente têm dependências empacotadas que nem são usadas. De acordo com este estudo, “apenas 6,4% dos dados transferidos por um pull são realmente necessários antes que um contêiner possa começar um trabalho útil”. É assim que o carregamento lento ajuda a melhorar o tempo de inicialização do contêiner, pois baixa arquivos que são realmente necessários para iniciar o contêiner e evita o download de arquivos redundantes.
A abordagem Uber JAR arquiva o aplicativo e as dependências em um único arquivo
Os JARs do Uber contêm não apenas o aplicativo Java, mas também suas dependências, em um único arquivo. Se a classe com o método principal estiver definida no arquivo MANIFEST, um comando Java conveniente, como java -jar myApplication.jar
, será suficiente para iniciar o aplicativo. Com a abordagem baseada no Uber JAR, o carregamento lento sempre carrega e extrai o arquivo completo. Nossa imagem de contêiner baseada no Uber JAR tem um tamanho compactado de 875 MB. O tamanho da imagem afeta o tempo de extração da imagem do registro, bem como o tempo de inicialização do aplicativo.
A abordagem JAR em camadas separa o aplicativo e as dependências em camadas.
As imagens de contêiner são criadas usando camadas. Os arquivos JAR em camadas separam o aplicativo e suas dependências para que cada parte possa ser armazenada em uma camada de imagem de contêiner dedicada. Isso tem a vantagem de que as camadas em cache possam ser reutilizadas durante a criação do aplicativo, o que acelera significativamente a reconstrução da imagem do contêiner.
Com a versão 2.3, o Spring Boot introduziu suporte para arquivos JAR em camadas. Um dos requisitos essenciais para isso é o arquivo layers.idx. Esse arquivo contém uma lista de camadas e as partes do JAR que devem estar contidas lá. A ordem das camadas é certamente importante, pois isso tem uma influência significativa na forma como os caches são usados durante o processo de criação da imagem do contêiner. A ordem padrão é dependencies, spring-boot-loader, snapshot-dependencies e application. Para nosso benchmarking, alteramos o Dockerfile e a compilação do Maven. A imagem do contêiner tem um tamanho compactado de 790 MB.
O seguinte trecho do arquivo pom.xml
mostra como as camadas foram ativadas:
Com o comando a seguir, você pode listar camadas do JAR que podem ser extraídas:
O arquivo de índice de camadas layers.idx
contém uma lista de diferentes camadas e partes correspondentes do JAR que devem ser incluídas:
Na próxima etapa da construção, temos que extrair as camadas, isso também pode ser automatizado no Dockerfile usando:
Todas as etapas descritas fazem parte da primeira etapa do nosso processo de construção em vários estágios. Na próxima etapa da construção, copiamos o arquivo criado da imagem do contêiner de construção para a imagem de destino nas camadas correspondentes. O Dockerfile completo pode ser visto abaixo:
Medição e resultados
Queremos descobrir o impacto do carregamento lento no tempo de inicialização das tarefas, por isso medimos a duração da prontidão da tarefa mostrada abaixo para a tarefa do AWS Fargate. Isso pode ser calculado usando o timestamp da chamada RunTask-API no AWS CloudTrail e o timestamp do ApplicationReadyEvent em nosso aplicativo Spring Boot.
Para medir o tempo de inicialização, usamos uma combinação de dados do endpoint de metadados da tarefa e das chamadas de API para o plano de controle do Amazon ECS. Entre outras coisas, esse endpoint retorna o ARN da tarefa e o nome do cluster. Precisamos desses dados para chamadas DescribeTasks para o plano de controle do Amazon ECS para receber as seguintes métricas:
- pullStartedAt: A data e hora do Unix para a hora em que a extração da imagem do contêiner começou.
- pullStoppedAt: A data e hora do Unix para a hora em que a extração da imagem do contêiner foi concluída.
- createdAt: O carimbo de data/hora do Unix para a hora em que a tarefa foi criada. Esse parâmetro será omitido se o contêiner ainda não tiver sido criado.
- StartedAt: O carimbo de data/hora do Unix para a hora em que a tarefa foi iniciada. Esse parâmetro será omitido se o contêiner ainda não tiver sido iniciado.
- SpringDuration: o período entre o início da JVM e o
ApplicationReaderEvent
(o aplicativo está pronto para atender às solicitações)
A lógica para extrair as métricas necessárias é implementada na classe ECSMetadataService
.
Os diferentes estados e durações medidas de nossas tarefas do AWS Fargate são mostrados no diagrama a seguir.
Figura 2: Diferentes estados de nossas tarefas do AWS Fargate
A Figura 3 mostra os resultados do nosso aplicativo Spring Boot com uma configuração de tarefa de 1 vCPU e 2 GB de memória na região eu-west-1 da AWS. O gráfico azul superior mostra as durações absolutas de 500 startups de contêineres como um gráfico de caixa. Cada caixa representa o intervalo interquartil (IQR) entre os percentis 25 e 75 das durações medidas. A linha branca no meio é a mediana. As linhas pretas verticais representam as durações medidas dentro do 1,5 IQR. As durações medidas externas são representadas graficamente como círculos. O gráfico laranja inferior mostra a alteração mediana em relação ao desempenho básico da abordagem Uber JAR sem o SOCI.
Figura 3: Resultados do Spring Boot
Quando examinamos mais de perto o horário de início completo, começando com a chamada Runtask-API e terminando com o ApplicationReadyEvent
, podemos ver uma melhoria consistente no desempenho de 25,6% do tempo de inicialização do aplicativo quando comparamos a versão Uber-JAR não SOCI com a versão em camadas do SOCI.
O efeito em aplicativos baseados em Quarkus
Também implementamos uma versão do aplicativo baseada no Quarkus para testar o impacto do carregamento lento em uma plataforma que prioriza contêineres, oferece tempos de inicialização otimizados e baixo consumo de memória por padrão. O tamanho compactado da nossa imagem de contêiner para o aplicativo Quarkus no Amazon ECR é de cerca de 175 MB. Imagens de contêineres grandes (> 250 MB) geralmente apresentam os maiores benefícios do SOCI. No entanto, queríamos medir se um aplicativo Quarkus também poderia se beneficiar do SOCI. Quando um aplicativo é configurado com o Quarkus, diferentes Dockerfiles já são criados em src/main/docker
. O arquivo Dockerfile.legacy-jar
cria um Uber JAR clássico que mostrou melhorias de 13% no tempo total de inicialização ao usar o SOCI.
O Dockerfile para a abordagem em camadas (Dockerfile.jvm
) tem o seguinte conteúdo:
Isso é muito semelhante ao conteúdo do Spring Boot-Dockerfile acima. Aqui, também, os arquivos são organizados em quatro camadas diferentes, significando que a reconstrução de uma imagem de contêiner é rápida devido aos caches e que os índices correspondentes podem ser usados ao extrair do Amazon ECR.
Medimos os resultados de 500 tarefas com a mesma configuração do nosso aplicativo baseado em Spring Boot. Para o Quarkus, usamos o StartupEvent, que não é completamente o mesmo que o ApplicationReadyEvent
do Spring Boot. Como estamos apenas comparando diferentes abordagens de empacotamento baseadas em Quarkus, isso é suficiente para nosso propósito.
Figura 4: Resultados do Quarkus
Os ganhos de desempenho com o aplicativo Quarkus não são tão altos quanto com o Spring Boot, o que é compreensível. Isso ocorre porque, para imagens de contêiner com menos de 250 MB, a sobrecarga inicial de carregamento lento pode ser maior do que o tempo necessário para extrair a imagem completa do contêiner usando métodos tradicionais. No entanto, podemos ver uma melhoria consistente de desempenho de 14% no tempo de inicialização do aplicativo quando comparamos a versão não SOC do Uber-JAR com a versão SOCI do JAR em camadas.
Compensações
O SOCI reduz o tempo de extração com a desvantagem de um maior tempo de inicialização do aplicativo, pois os arquivos são carregados lentamente. Algumas partes não precisam ser lidas ou transferidas até que o aplicativo precise delas, se é que precisam. No entanto, eventualmente, o SOCI coloca tudo em segundo plano. Portanto, você ainda deve manter o tamanho dos artefatos de construção e o número de arquivos baixos para reduzir o custo de armazenamento, o custo de transferência de dados e os tempos de inicialização. Isso é especialmente verdadeiro porque os benefícios da otimização se somam ao número de versões que você cria de seus aplicativos ao longo do tempo.
Atualmente, o SOCI não funciona com imagens compactadas zstd. Todas as imagens de contêiner na definição da tarefa devem ter índices SOCI no mesmo registro de contêiner de cada imagem. Se uma única imagem na definição da tarefa não tiver um índice SOCI, a tarefa será iniciada sem o SOCI.
Conclusão
Neste post, mostramos o impacto do SOCI no tempo de inicialização de um aplicativo Spring Boot e Quarkus em execução no Amazon ECS com o AWS Fargate. Nós os testamos no AWS Fargate, mas esperamos que os JARs em camadas geralmente sejam uma abordagem útil para melhorar o desempenho do tempo de lançamento de grandes aplicativos Java implantados por meio de contêineres.
Inicialmente, começamos com uma abordagem baseada em Uber-JAR para nossos aplicativos e o SOCI melhorou o tempo de inicialização do aplicativo em quase 17%. Quando mudamos o layout do aplicativo e do Dockerfile para um JAR em camadas, verificamos uma redução de 25% no tempo de inicialização do Spring Boot. Para nosso aplicativo Quarkus, poderíamos obter um tempo de inicialização 14% melhor usando uma abordagem em camadas, em comparação com uma melhoria de 13% usando um Uber JAR com SOCI. Isso é gratuito, você só precisa alterar sua compilação Maven ou Gradle e seu Dockerfile para seu aplicativo Spring Boot, caso ainda use um Uber-jar. Para um aplicativo Quarkus padrão, não precisa alterar nada.
Esperamos ter lhe dado algumas ideias sobre como você pode otimizar seu aplicativo Java existente para reduzir o tempo de inicialização. Sinta-se à vontade para enviar aprimoramentos para o aplicativo de exemplo no repositório de origem.
Este artigo foi traduzido do Blog da AWS em Inglês.