Otimizando a inicialização da JVM e aumentando a eficácia da aplicação dentro de containers
Rafael Pazini
Posted on March 24, 2022
Hoje em dia, estamos numa hype tremenda com relação a containers, ainda mais com a ascensão de microsserviços e afins já que containers facilitam e muito a vida de prover os serviços, escalar e garantir que os serviços rodam de uma forma uniforme, evitando o tão famigerado "na minha máquina funciona" 😬.
Bom, mas esse não é o ponto que quero tratar hoje. O assunto de hoje é performance de inicialização quando precisamos escalar nossas aplicações de uma forma rápida e eficiente. E minha intenção é mostrar o trabalho de análise da situação e como resolvemos isso 🤓.
O problema
De uns meses pra cá a aplicação que é de responsabilidade minha e da minha equipe vem crescendo exponencialmente. Ela é um serviço onde são consultados os dados fiscais e informações de produtos de nosso catálogo, hoje no modelo de negócio é um dos serviços mais utilizados e que recebe maior throughput. Dessa forma sempre que algum problema acontece, toda a cadeia é afetada, já que é essencial no mundo fiscal do Brasil existir os dados fiscais do produto para que o mesmo seja transportado de uma forma rápida.
E com isso começamos a sofrer algumas avalanches de requests — estava um dia lindo e ensolarado quando saímos de 5k/rpm*(requests per minute) para mais ou menos 45k/rpm, e toda essa carga em menos de 2 minutos de diferença. O resultado? Nossa aplicação não conseguiu escalar a tempo para dar vazão as chamadas e subíamos as novas instâncias, porém estávamos demorando para responder, com isso nosso sistema caiu por uns instantes até as novas instâncias começarem a responder e tudo se normalizar depois de 10 minutos… *(que como todos sabem em um sistema crítico é uma eternidade hehehe).
Se você esta se questionando "nossa com uma fila isso resolve…ahh, mas da pra colocar um circuit breaker e depois tentar reprocessar… " é nós já fazemos isso 🤓, precisamos melhorar? Talves sim, mas vamos deixar este assunto para outro dia hehehe.
Então surgiu a questão,e ainda estavamos um pouco lentos por que estamos demorando tanto para começar a responder os requests em novas instâncias?
O processo de análise
Este é o processo que todos perguntam como foi feito, pois existem várias formas de investigar o que estava acontecendo desde de fazer um profiler completo da aplicação até apenas como nossa ferramenta de deploy se comportava. Então os passos que utilizamos aqui são apenas exemplos do que pode ser feito, vale lembrar que não existe uma receita de bolo para isso, tudo depende do cenário que estamos enfrentando.
Basicamente tentamos seguir os "baby steps" e descobrir o que tinhamos em mão, seguimos mais ou menos estes passos:
Verificar o que estávamos adicionando no build da imagem;
Verificar o tamanho da imagem da aplicação;
Verificar como era o comportamento de inicialização da aplicação dentro da instância;
Colher os dados do cenário atual onde sofríamos com a demora da inicialização;
E por fim, tentar sanar os problemas coletados durante a análise de uma forma eficaz que não gerasse danos para a aplicação;
*Só pra contextualizar a aplicação é uma aplicação SpringBoot, onde utilizamos WebFlux como camada Web e para prover tudo isso o Undertow como embedded server… agora vamos a nossa análise *😊
Primeira coisa foi analisar o tamanho da imagem que era gerada em nosso build e nesse ponto já tivemos a primeira surpresa. A imagem da aplicação estava com 1.2 GB — é… é muito grande… E ai investigamos que a imagem que era utilizada se tratava de um SO completo com a versão do JRE instalada. Rota traçada por onde começar.
Logo depois disso a primeira mudança foi alterar para uma versão menor do JRE e ver se conseguíamos algum ganho, o resultado foi o seguinte:
Só de trocarmos a imagem que nossa aplicação utilizava pela do OpenJDK 8 JRE rodando em cima de um linux alpine, reduzimos o tamanho para 362mb — isso significa que quando nosso sistema for baixar a imagem do registry ele fará o download muito mais rápido do que vinha acontecendo normalmente.
Comparando o tempo de inicialização das imagens após fazermos a troca para o openjdk alpine, o resultado foi um ganho de quase 50% …
Um belo resultado para o primeiro ajuste 🤓. Mas ainda não estávamos satisfeitos pois achamos que daria para ir além…
Trocando a JVM e otimizando a inicialização
A próxima abordagem foi trocar a JVM, não sei se todos sabem, mas existem várias implentações da mesma com focos diferentes, por exemplo a HotSpot que é utilizada pelo OpenJDK, temos a Zulu, e a queridinha do momento a GraalVM.
Há um tempo atrás estudando sobre JVMs eu me deparei com uma que era bem bacana, a OpenJ9. Ela tem como proposta aumentar a performance da sua aplicação desde a inicialização e diminuindo o memory footprint também. Eis que era o problema que estávamos tentando sanar além de ser compatível com a versão do java que estamos usando e ganhariamos algumas funções novas da JVM que poderiam nos ajudar… Então resolvemos apostar na OpenJ9 para ver como a aplicação se comportava. Para facilitar utilizamos a imagem do AdoptOpenJDK que prove versões das imagens do OpenJDK substituindo a HotSpot pela OpenJ9 🤓
Todos que trabalham com Java, sabem que a versão 8 não é otimizada para containers e verificando os gráficos de uso de memória da instância não estavamos usando todo o potencial do container e ainda estavamos um pouco lento na inicialização da aplicação. Com a OpenJ9 temos algumas opções de tunning onde conseguimos otimizar este cenário e também com a versão mais recente do Java 8, tivemos acesso a novas opções que antes eram possíveis apenas a partir do Java 9, que foi a primeira versão que começou a ser pensada para containers. Com isso dito, foi hora de começar a "tunar" a JVM para obter melhores resultados, e para isso usamos as seguintes opções:
**-Xquickstart: **Essa opção faz com que o compilador JIT seja executado com um subconjunto de otimizações, o que pode melhorar o desempenho de aplicativos de execução curta. No caso como estamos sempre subindo e baixando a aplicação essa opção funciona bem, em aplicações que ficam mais tempo no ar podem ficar um pouco mais lentas e guardam muito estado na própria memória, isso pode prejudicar um pouco o desempenho da mesma já que estamos falando pro compilador tentar compilar o máximo de hot-spots possíveis no momento da inicialicação.
**-XX:+UseCGroupMemoryLimitForHeap: **Esta foi "incorporada" do java 9 na versão 8u131. Ela diz para a JVM usar toda a memória do cgroup como limit pro heap. O memory heap é a memória alocada para todas as instâncias de classes e arrays do java.
**-XX:MaxRAMPercentage: **E neste último caso, colocamos como limite o tamanho da porcentagem de memória utilizada dentro do container, uma feature implementada da versão 10 do java na versão 8u191. Limitamos esses tamanhos pois utilizar toda a memória de um container para a JVM pode ser catastrófico, já que o container pode rodar algumas rotinas separadas e caso ele não tenha memória para isso a aplicação cai e nem sabemos o por que…
E depois de todos estes ajustes finos, temos um Docker file parecido com esse…
Agora aquele momento que todo mundo pira… Os gráficos de antes e depois dos ajustes com o OpenJ9:
Resultado foi a redução da inicialização das instâncias de 2.20 min para 0.31 segundos, quase uma média de 85% mais rápido que nosso primeiro cenário… Agora sim é uma reforma legal para irmos testar em produção e ver o que dá.
Resultados em produção
Como dizem que uma imagem vale mais que mil palavras, lá vamos nós para os gráficos de comparação do primeiro cenário onde ainda não existiam as otimizações.
Estas informações foram extraídas da ferramenta que utilizamos para monitorar as informações da aplicação, o NewRelic.
O cenário antigo
+/- 15k/rpm com 52 instâncias da aplicação rodando e o Apdex — que é o Índice de Desempenho da Aplicação — caindo, chegando a baixo de 0.5…
O novo mundo depois do deploy
No novo cenário tivemos um pico de +/- 25k/rpm (dobrando os requests a cada 10 minutos) e esses são os números:
com 25k/rpm nosso apdex se manteve saudável e com um número muito menor de instâncias para responder a quantidade de requests. Conseguimos acompanhar a evolução progressiva da escalabilidade, onde vemos as maquinas sendo criadas de acordo com a necessidade.
Se repararmos nos números vemos que as instâncias se mantém constantes e utilizando de forma saudável o recurso que ela tem disponível, com porcentagens de uso da CPU ok e uso da memória também.
Além de melhorar a performance da aplicação conseguiremos reduzir os custos drásticamente, já que conseguimos atender quase o dobro de requests com 10% das instâncias que existiam antigamente. E quando a nossa avalanche de requests acabam, as instâncias morrem e continuamos a vida normalmente hehehe.
Quase 1 mês depois destes ajustes, nossos problemas com essa aplicação acabaram. Agora vamos continuar a melhoria e revisar os outros serviços, pois quando eles escalam, muitas vezes esquecemos de olhar o alicerce e ver como estão funcionando as coisas, acho que essa é a dica que gostaria de deixar, sempre revisitar a base da aplicação conforme o volume de uso da mesma cresce 😁.
Bom é isso galera, qualquer coisa estamos aqui para ajudar e espero compartilhar mais casos logo menos :D
Posted on March 24, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.