Spring Native — Vale a pena construir uma native image?
Em 11 de Março de 2021 Spring anunciou o lançamento da versão beta do Spring Native após 1 ano e meio de trabalho. Com esta nova versão, além do suporte tradicional para executar uma aplicação Spring na Java Virtual Machine (JVM), é possível compilar o código para native image que roda na GraalVM [1].
E porque esse anúncio é importante e tão esperado pelos adeptos do Spring Framework? Com esse anúncio, Spring se junta a outros frameworks cloud native (por exemplo, Micronaut e Quarkus) que têm como características principais baixo memory footprint (quantidade de memória que uma aplicação usa durante sua execução) e velocidade no startup da aplicação. Além destes pontos que são importantes para aplicações cloud native, a pessoa desenvolvedora pode criar suas aplicações a partir do tradicional ecossistema Spring (Spring Boot, Spring Cloud, Spring Data, etc).
Antes de apresentar um exemplo de Spring Native, é importante saber o que é GraalVM, Native Image, e saber quando optar por Native Image ou uma Hotspot Image (forma tradicional de executar a aplicação — rodando uma JVM).
GraalVM
GraalVM é uma runtime de alto desempenho que fornece melhorias significativas na performance e eficiência do aplicativo, ideal para microsserviços. Ela pode ser executada no contexto do OpenJDK para fazer com que os aplicativos Java sejam executados mais rapidamente com sua nova tecnologia de compilação just-in-time (JIT) [2].
O compilador JIT de alto desempenho gera código de máquina nativo otimizado que roda mais rápido, produz menos lixo — devido à sua capacidade de remover alocações de objetos onerosos — e usa menos CPU graças a uma bateria de otimizações de compilador avançadas e técnicas inlining agressivas e sofisticadas. Os resultados finais são programas complexos e de longa duração que são executados com mais rapidez e menos recursos.
O gráfico anterior mostra as transações por segundo do serviço de telemetria da infraestrutura da Oracle Cloud. Um aumento de 10% na taxa de processamento de transações com uma redução de 25% nos tempos de garbage collector (GC), uma redução de 17% nos tempos de pausa do GC e uma redução de 5% na utilização da CPU [3].
Native Image
Native image é uma tecnologia que compila ahead-of-time (AOT) os bytecodes do Java para um executável standalone em uma imagem nativa. Essa tecnologia é distinta do JIT que compila os bytecodes em tempo de execução.
O processo de build é realizado pelo Native Image Builder. De forma resumida, ele realiza uma análise estática do código descobrindo quais são as classes e métodos que serão realmente utilizados — pode inicializar-los se necessário— compilando esse código e dados acessíveis em um executável nativo para um sistema operacional e arquitetura específica.
A imagem gerada, além de ter as classes dos programas e suas dependências, inclui componentes necessários como gerenciamento de memória, programação de thread e outros componentes de um sistema de runtime diferente, chamado “Substrate VM” [4].
O programa gerado tem um tempo de startup menor e consome menos memória comparado com a JVM. Esses benefícios de performance vem da compilação antes do tempo (AOT) e de não ter que lidar com outras atividades de infraestrutura que a JVM necessita como carregamento de bytecode, interpretação, entre outros [5].
Como escolher entre uma imagem nativa ou uma aplicação que rode em uma imagem hotspot?
Não existe almoço grátis — Milton Friedman
Todos os benefícios citados anteriormente — tempo de startup e baixo consumo de memória — têm o seu preço. A seguir, estão algumas desvantagens de uma native image.
- O tempo de build é muito longo e o consumo de memória é alto
Isso se deve a análise estática para descobrir quais códigos serão executados, quais dependências incluir e quais componentes da JVM adicionar a Substrate VM. Isso pode ser um problema nos ambientes de Continuous Integration (CI), onde a quantidade de memória seja um recurso escasso. - Throughput menor e uma maior latência
Durante a execução da aplicação, a JVM implementa melhorias no código gerado através de análises dinâmicas. Essas melhorias são realizadas pelo JIT compiler. Como todo o código da native image é gerado AOT, não existe essa oportunidade de melhoria dinâmica o que acarreta numa menor performance da native image. Com uma menor performance, o tempo de resposta das chamadas serão maiores (maior latência) e como consequência, a quantidade de transações por segundo (TPS) será menor (baixo throughput). - Requer configuração explícita para recursos dinâmicos como a inicialização, reflection e proxies
O Native Image Builder não pode descobrir estaticamente todos os caminhos de execução se as aplicações utilizarem, por exemplo, Java Reflection API, Proxies, Java Native Interface (JNI) e Dynamic Class Loading. Caso a aplicação utilize algum desses recursos, é necessário uma configuração adicional [6].
Uma tomada de decisão que parece simples como escolher ou não um Native Image pode ser complexa e a resposta vai depender do contexto e da realidade de cada equipe / organização. Portanto, antes de escolher é preciso levar em consideração alguns fatores importantes.
Custos dos recursos na cloud
Quando o deploy da aplicação é feito nos servidores da cloud de terceiros (AWS, GCP, Azure) o custo do deploy está relacionado com a quantidade de recursos que é utilizada (memória, CPU, etc).
Se a aplicação consumir menos memória, o custo operacional da infraestrutura vai ser menor. Com um custo menor, impactará positivamente na margem de lucro da empresa. E, dependendo da quantidade de recursos que uma empresa use e do seu orçamento, pode haver uma restrição no tamanho da infraestrutura na cloud.
Perfil da aplicação
Uma das vantagens da aplicação rodar numa runtime hotspot é a otimização em tempo de execução pelo JIT compiler. No entanto, para que a melhoria seja realizada e tenha o ganho de performance esperado, é necessário uma análise dinâmica que leva tempo.
Portanto, se a aplicação possui um ciclo de vida longo, e, alto throughput e baixa latência são atributos desejados, fazer o deploy da aplicação de forma tradicional pode ser a melhor saída.
No entanto, se o container ou máquina virtual, onde a aplicação é executada, tiver um ciclo de vida curto, as otimizações do JIT não serão vantajosas. Portanto, construir uma native image pode ser uma melhor escolha. As funções lambda (arquitetura serverless), por natureza, também possuem um ciclo de vida curto.
Outra opção de onde usar uma native image é se o tempo de startup da aplicação for um fator crítico. A tabela a seguir mostra uma comparação dos recursos e dos tempos para criar e inicializar uma aplicação de exemplo que está em meu Github com Native Image e com o Hotspot Image.
O tamanho da imagem do Docker é quase 40% menor, o tempo de startup é 30x mais rápido e o consumo de memória é 87% menor. No entanto, o build da imagem é 4x mais lento.
Apesar destes números interessantes, no que diz respeito a performance da aplicação, há um aumento no tempo de resposta e no throughput. Fiz um teste de performance utilizando o JMeter onde a configuração do teste era a seguinte:
- 50 número de threads (usuários)
- 30 segundos de tempo de ramp-up
- 10.000 iterações (Loop Count)
- Mesmo usuário para cada iteração (Same user on each iteration)
Além dessa configuração, executei esse teste 4 vezes e descartei os três primeiros resultados para o compilador JIT otimizar o código. O resultado é apresentado na tabela a seguir.
A média do tempo das requisições da Hotspot Image foi 43% menor e quantidade de requisições por segundo foi 49% maior que a Native Image. Portanto, esse ganho de economia de recursos é em detrimento de um performance mais baixa.
Spring Native
Impacto no desenvolvimento
Conforme visto anteriormente, uma das desvantagens de compilar a aplicação para native image é a necessidade de fazer configurações adicionais para utilizar os recursos dinâmicos da linguagem Java.
No entanto, com o objetivo de não ter uma curva de aprendizagem alta e um custo elevado de mudança para usar native image, plugins Maven e Gradle foram criados para auxiliar nas configurações desses arquivos. Porém, nem todas as configurações nativas podem ser inferidas. Nesses casos, o Spring criou a anotação @NativeHint
[7].
Um exemplo que encontrei usando a versão 0.9.2
do plugin Gradle org.springframework.experimental.aot
onde a configuração não é feita de forma automática é na criação de um ResponseEntityExceptionHandler retornando um objeto customizado. Neste caso, tive que usar a anotação do NativeHint
conforme o código a seguir.
O projeto de exemplo completo pode ser encontrado em meu Github e caso queira saber mais informações do Spring Native, é possível acessar a documentação oficial.
Conclusão
No final das contas, o conhecimento técnico está presente para apoiar a operação e o negócio da empresa. As decisões técnicas devem ser baseadas nos requisitos e restrições da equipe de negócio.
O native image não é uma bala de prata. Nem sempre ela é a melhor escolha para todos os cenários. Logo, é importante saber qual perfil da aplicação, quais são os atributos mais importantes para o software e também se existe alguma limitação relacionada ao custo ou recurso computacional (memória e CPU).
Depois de ter levantado todas essas informações, é hora de escolher qual a melhor opção: ir com o hotspot image ou fazer o deploy de uma native image.
Referências
[1] https://spring.io/blog/2021/03/11/announcing-spring-native-beta
[2] https://www.graalvm.org/why-graalvm/
[3] https://www.graalvm.org/java/advantages/#accelerating-java-performance
[4] https://www.graalvm.org/reference-manual/native-image/
[5] https://medium.com/graalvm/making-sense-of-native-image-contents-741a688dab4d
[6] https://www.graalvm.org/reference-manual/native-image/Limitations/
[7] https://docs.spring.io/spring-native/docs/current/reference/htmlsingle/#native-hints