O blog da AWS

Inicie aplicativos Spring Boot mais rapidamente no AWS Fargate usando o SOCI

Por Sascha Moellering, arquiteto  e gerente de arquitetura de soluções, e Steffen Grunwald, arquiteto de soluções
Há cerca de um ano, publicamos um post sobre como otimizar seu aplicativo Spring Boot para o AWS Fargate, onde abordamos diferentes técnicas de otimização para acelerar o tempo de inicialização dos aplicativos Spring Boot para o AWS Fargate. Começamos a postagem com “ Tempos rápidos de inicialização são fundamentais para reagir rapidamente às interrupções e aos picos de demanda, e podem aumentar a eficiência dos recursos”. O Seekable OCI (SOCI) é uma maneira nova e simples de reduzir os tempos de inicialização de cargas de trabalho Java executadas no AWS Fargate. Ele pode ser combinado com as otimizações anteriores, ou você pode simplesmente usar o SOCI para uma vitória rápida . Os clientes que executam aplicativos no Amazon Elastic Container Service ( Amazon ECS) com o AWS Fargate agora podem usar o SOCI para começar lentamente ou, em outras palavras, começar sem esperar que toda a imagem do contêiner seja baixada. O SOCI inicia seu aplicativo imediatamente e baixa os dados do registro do contêiner quando solicitado pelo aplicativo, melhorando o seu tempo geral de inicialização. Uma excelente análise aprofundada sobre o SOCI e o AWS Fargate pode ser encontrada aqui.Neste post, abordaremos técnicas para otimizar seus aplicativos Java usando SOCI que não exigem que você altere uma única linha de código Java. Em nosso aplicativo de exemplo do Spring Boot, isso melhora o tempo de inicialização do aplicativo em cerca de 25%, e tal melhoria deve aumentar à medida em que o tamanho da imagem do contêiner aumenta. Além disso, também examinaremos mais de perto os benchmarks para diferentes abordagens e frameworks. Você não precisa reconstruir suas imagens para usar o SOCI. No entanto, durante nossos testes, também identificamos que as otimizações para aplicativos Java exigem apenas pequenas modificações no processo de criação e no Dockerfile (ou seja, o aplicativo real não precisa de ajustes) e reduzem ainda mais o tempo de inicialização de uma tarefa do AWS Fargate. Embora atualmente estejamos focados em aplicativos Java, esperamos que o SOCI seja útil em qualquer caso cujo os clientes implantem grandes imagens de contêineres. Esse teste foi realizado com um aplicativo de amostra e a abordagem jar em camadas com SOCI pode não melhorar os tempos de inicialização de todos os aplicativos Spring Boot. Recomendamos testar essa abordagem com seu aplicativo e medir o impacto em seu ambiente.

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).

Infrastructure for the example application

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:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
     <configuration>
         <layers>
             <enabled>true</enabled>
         </layers>
    </configuration> 
</plugin>

Com o comando a seguir, você pode listar camadas do JAR que podem ser extraídas:

$ java -Djarmode=layertools -jar CustomerService-0.0.1.jar list

dependencies
spring-boot-loader
snapshot-dependencies
application

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:

$ unzip -p CustomerService-0.0.1.jar BOOT-INF/layers.idx

- "dependencies":
    - "BOOT-INF/lib/"
- "spring-boot-loader":
    - "org/"
- "snapshot-dependencies":
- "application":
    - "BOOT-INF/classes/"
    - "BOOT-INF/classpath.idx"
    - "BOOT-INF/layers.idx"
    - "META-INF/"

Na próxima etapa da construção, temos que extrair as camadas, isso também pode ser automatizado no Dockerfile usando:

$ java -Djarmode=layertools -jar CustomerService-0.0.1.jar extract

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:

FROM maven:3-amazoncorretto-17 as builder

COPY ./pom.xml ./pom.xml
COPY src ./src/
RUN mvn -Dmaven.test.skip=true clean package && \ 
cd target && \
java -Djarmode=layertools -jar CustomerService-0.0.1.jar extract

FROM public.ecr.aws/amazoncorretto/amazoncorretto:17
RUN yum install -y shadow-utils

WORKDIR application

RUN groupadd --system spring 
RUN adduser spring -g spring 

USER spring:spring

COPY --from=builder target/dependencies/ ./
COPY --from=builder target/spring-boot-loader/ ./
COPY --from=builder target/snapshot-dependencies/ ./
COPY --from=builder target/application/ ./

EXPOSE 8080

ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

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.

Different states of our AWS Fargate tasks

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.

Spring Boot results

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:

FROM registry.access.redhat.com/ubi8/openjdk-17:1.16

ENV LANGUAGE='en_US:en'

# We make four distinct layers so if there are application changes the library layers can be re-used
COPY --chown=185 target/quarkus-app/lib/ /deployments/lib/
COPY --chown=185 target/quarkus-app/*.jar /deployments/
COPY --chown=185 target/quarkus-app/app/ /deployments/app/
COPY --chown=185 target/quarkus-app/quarkus/ /deployments/quarkus/

EXPOSE 8080
USER 185
ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"

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.

Quarkus results

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.

 


Sobre o autor

Sascha Moellering  trabalha há mais de seis anos como arquiteto e gerente de arquitetura de soluções na Amazon Web Services EMEA na filial alemã. Ele compartilha sua experiência com foco em automação, infraestrutura como código, computação distribuída, contêineres e JVM em contribuições regulares para várias revistas e blogs de TI. Ele pode ser contatado em smoell@amazon.de.

 

 

 

 

Steffen Grunwald é arquiteto de soluções principal na Amazon Web Services. Ele ajuda os clientes a resolver seus desafios de sustentabilidade por meio da nuvem. Com uma longa experiência em engenharia de software, ele adora se aprofundar nas arquiteturas de aplicativos e nos processos de desenvolvimento para impulsionar a sustentabilidade, o desempenho, o custo, a eficiência operacional e aumentar a velocidade da inovação.

 

 

 

 

Tradutor

Norberto Hideaki Enomoto atua como arquiteto de soluções corporativo na Amazon Web Services. Ele está à frente de iniciativas de Transformação Digital, utilizando tecnologias avançadas, incluindo arquitetura de microsserviços, APIs, soluções nativas em nuvem, DevSecOps e Internet das Coisas (IoT). Norberto desempenha um papel crucial no suporte a clientes corporativos, orientando-os eficientemente em suas transições e estratégias para a computação em nuvem.