(PT) Reduzindo o Uso de RAM em 50% com GraalVM
📉

(PT) Reduzindo o Uso de RAM em 50% com GraalVM

Consegui melhorar drasticamente o desempenho da minha API REST Spring Boot gerando uma imagem nativa com o toolkit GraalVM. O adjetivo “drasticamente” se traduz nos seguintes números:
Tempo de Inicialização
Uso de Memória
Uso Máximo de CPU
Com GraalVM JVM
3.310 ms
280,9 MB
15%
Imagem Nativa
0,157 ms
147,2 MB
0,13%
Comparação
95% a menos
50% a menos
98% a menos
Se você quiser pular direto para o How-To, clique aqui.

História

Meu Caso de Uso

Desenvolvi uma API REST para backend, atuando como BFF (Backend for Frontend) para o meu portfolio-website. Tudo o que ela fazia era realizar requisições autenticadas à API do GitHub e armazenar essas respostas em cache. No meu trabalho, estava acostumado a fazer deploy de aplicações sem me preocupar muito com recursos, então você pode imaginar minha surpresa quando essa pequena API ficou sem memória e travou.
A primeira coisa que pensei foi que o plano gratuito do Fly.io oferecia uma quantidade de memória irrealisticamente pequena. Mas, ao verificar, o limite de memória era de 228 MB. Para uma API tão simples, 228 MB deveria ser mais do que suficiente. Foi então que percebi por que algumas pessoas criticam o Java e a JVM... Minha aplicação estava usando até 280 MB para um fluxo tão simples. Então, eu teria que aumentar a memória da máquina ou reduzir seu consumo.
O Fly.io oferece mais memória por 5 dólares, mas eu não queria desistir tão facilmente. Tentei um pouco mais e lembrei do GraalVM. Eu sabia que ele era ótimo para acelerar o tempo de inicialização da aplicação e melhorar o desempenho geral. Mas eu não esperava essas melhorias.
Como mostrado na tabela no início deste artigo, o uso de memória foi reduzido em ~50%. Além disso, o tempo de inicialização e o uso médio de CPU também melhoraram muito. Com a redução do uso de memória, posso ficar tranquilo de que minha API não ficará sem memória.
Essa é a aplicação rodando com a JVM. A última coluna representa a memória reservada.
Essa é a aplicação rodando com a JVM. A última coluna representa a memória reservada.
Essa é a aplicação rodando com o GraalVM.
Essa é a aplicação rodando com o GraalVM.

How-To

1. O que é GraalVM Native Image?

GraalVM é um conjunto de ferramentas que inclui o construtor de imagens nativas. O construtor de imagens nativas lê o bytecode gerado pelo compilador JDK e realiza uma compilação antecipada (ahead-of-time). A diferença entre as duas compilações é que o compilador JDK gera bytecode, enquanto o construtor de imagens nativas gera binários. O bytecode é interpretado pela JVM e pode rodar em qualquer lugar onde haja uma JVM. Já os binários rodam diretamente no sistema operacional e são específicos para um SO e arquitetura de processador.
Representação visual dos atores no processo de construção de uma imagem nativa.
Representação visual dos atores no processo de construção de uma imagem nativa.

2. Preparando o Ambiente

2.1. Requisitos de Hardware

O processo de build requer muita RAM. Meu ambiente possui um Intel Core i5-1035 com 8 GiB de RAM e, quando eu tinha menos de 4 GiB livres, recebi uma OutOfMemoryException do builder de imagem nativa. Então, certifique-se de ter pelo menos 4 GiB de RAM disponíveis antes de construir sua imagem.

2.2. Usando Buildpacks

Se você planeja implantar sua aplicação com uma imagem Docker, e tem um daemon Docker rodando em seu ambiente, a maneira mais fácil de construir sua imagem é usar Buildpacks. O plugin do Spring Boot configura tudo para você. Basta ir para 3.2 Configurando o Gradle e continuar de lá.
Usar um container Docker para construir sua imagem provavelmente é uma boa ideia. Isso pode funcionar como uma maneira de pular 2.2 Instalar bibliotecas para o Build. Mas eu ainda não tentei.

2.3. Instalar o JDK GraalVM

Recomendo usar o SDKMAN, pois ele oferece uma interface de linha de comando fácil para baixar e alterar o JDK no seu sistema. (Se você não puder usar o SDKMAN por algum motivo, pode baixar diretamente do site do GraalVM.)
Basta executar:
bash Copiar código sdk install java 17.0.8-graal // pressione Y ou simplesmente Enter para definir esta JVM como padrão
Como você pode ver, eu estarei usando o Java 17 para este tutorial.

2.4. Instalar Bibliotecas para o Build

O compilador GraalVM precisa de algumas bibliotecas para construir a imagem nativa para o seu sistema operacional. Quais bibliotecas você precisa baixar dependem do seu ambiente. Portanto, verifique a página de pré-requisitos do GraalVM.
Como uso Ubuntu, isto é o que eu executei:
bash Copiar código sudo apt-get install build-essential libz-dev zlib1g-dev

3. Configurando o Agente de Rastreamento

3.1. O que é o Agente de Rastreamento?

Primeiro, você precisa entender por que (provavelmente) precisa dele. Os executáveis de imagem nativa rodam sem a JVM e com a suposição de mundo fechado. Isso significa que apenas o código que é acessível durante o tempo de build é adicionado ao binário final. Portanto, algumas coisas extremamente importantes que consideramos garantidas em uma aplicação Spring Boot não estão disponíveis. Por exemplo: Reflexão, Proxies Dinâmicos, Serialização e mais não são suportados ou são suportados com limitações.
Isso significa que não seríamos capazes de usar nenhum dos seguintes:
@Controller public class MyController{} @Cacheable public List<Data> getMyData() {} @Value("${application.cache.data-ttl}") private Duration dataCacheTtl;
Não poder usar esses recursos em tempo de execução nos obriga a trazê-los para o tempo de build. Para isso, o GraalVM usa um conjunto de arquivos de configuração que fornecem ao builder de imagem nativa os metadados de acessibilidade. Escrever esses arquivos manualmente seria muito trabalhoso. Então entra em cena o agente de rastreamento.
O agente de rastreamento observa enquanto sua aplicação roda com a JVM e escreve os metadados de acessibilidade para o builder de imagem nativa. Você pode usar o agente enquanto percorre manualmente os fluxos da sua aplicação ou executa testes automatizados.
📖
Apenas lembre-se de que somente o código acessado enquanto o agente de rastreamento estiver ativo será incluído nos metadados de acessibilidade (ou seja, se você ou seus testes não chamarem todos os endpoints da sua API e todos os possíveis resultados, sua imagem nativa falhará). Caso contrário, você precisará adicioná-los manualmente aos arquivos de metadados.

3.2. Configurando o Gradle

Usei Gradle para o meu projeto e para este tutorial, mas há uma documentação para Maven também.
Inclua o plugin GraalVM Native Build Tools:
plugins { id 'org.graalvm.buildtools.native' version '0.9.25' }

3.3. Configurando o Agente de Rastreamento nos Testes

Não quero executar manualmente todos os cenários da minha API toda vez que implementar uma mudança — então implementei um conjunto de testes de integração. Quando executo o test (ou tracingAgentTest), ele observa meus casos de teste e escreve os metadados de acessibilidade.
Tudo que você precisa para executar o agente de rastreamento é adicionar -agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image como um argumento da JVM.
./gradlew test -Dorg.gradle.jvmargs=-agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image
💡
O caminho padrão onde o builder de Imagem Nativa do GraalVM procurará os metadados de acessibilidade é /src/main/resources/META-INF/native-image.
Ou adicione isso a uma tarefa de teste personalizada, caso não queira reescrever o META-INF em cada execução de teste durante o desenvolvimento:
tasks.register("tracingAgentTest", Test) { group "verification" useJUnitPlatform() jvmArgs "-agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image" }

3.4. Configurando o Agente de Rastreamento na Execução Local

É praticamente a mesma configuração dos testes. Se você já tem o JDK do GraalVM instalado, tudo que precisa fazer é:
./gradlew bootRun -Dorg.gradle.jvmargs=-agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image
⚠️
Tive alguns problemas ao tentar executar as tarefas do builder de imagem nativa do Gradle pelo IntelliJ. Portanto, recomendo executar esses comandos diretamente no terminal, caso você encontre algum problema.

4. Gerando o Executável Nativo

Tudo o que você precisa fazer agora é gerar o executável nativo.
./gradlew tracingAgentTest nativeCompile
Demora alguns minutos para compilar e consome muita memória, mas compensa com a melhoria de performance em tempo de execução. A saída deve ser semelhante a esta:
Saída da geração de uma Imagem Nativa.
Saída da geração de uma Imagem Nativa.
A imagem nativa gerada estará no seu projeto, em /build/native/nativeCompile/.

5. Executando a Imagem Nativa

Você tem um executável nativo que funciona para qualquer máquina com a mesma arquitetura e sistema operacional que você usou para construí-lo. Pode haver uma maneira de gerar para um conjunto diferente de arquitetura e sistema operacional, mas ainda não investiguei isso.
Para executar o seu executável:
./build/native/nativeCompile/<nome da sua aplicação>
E é isso. Agora você tem seu executável nativo para usar como quiser. Envolva-o em uma imagem Docker para facilitar a implantação.