Rodando aplicações de JVM no Kubernetes: Além do java -jar
Descubra algumas dicas importantes sobre a execução de aplicações de JVM em ambientes conteinerizados e orquestrados pelo Kubernetes

Desde já, peço desculpas por essa bela arte acima :D
Você que é mais novo, talvez não saiba o trabalho que era cuidar de um Tamagotchi, se não déssemos a devida atenção, ele acabava morrendo.
A JVM em um container é como um Tamagotchi moderno. Você precisa cuidar dela para que ela funcione corretamente, alimentando-a com os recursos necessários, como CPU e memória, para que ela possa executar suas tarefas da melhor maneira possível.
Público-Alvo
Está rodando aplicações de JVM em ambientes conteinerizados no Kubernetes e sente que poderia estar otimizando melhor a performance e o custo?
Está pensando em migrar suas aplicações de JVM para esse tipo de ambiente, mas tem dúvidas sobre como fazer isso de forma eficiente?
Sofre com problemas de latência e throughput em suas aplicações de JVM rodando em Kubernetes?
Ou apenas quer aprender mais sobre otimização de aplicações de JVM em ambientes Kubernetes?
Então, este post é para você!
Antes de começar, é importante deixar claro que em alguns cenários muito específicos, nem todas as dicas passadas a seguir podem fazer sentido. No entanto, acredito que para a maioria dos casos de uso em que temos serviços rodando dentro de uma JVM em ambiente containerizado e orquestrado com Kubernetes, as informações a seguir poderão ser bastante úteis.
Para um melhor entendimento das dicas que apresentaremos a seguir, é importante alinhar alguns conceitos básicos:
- Java (Linguagem de Programação) VS Plataforma Java/JVM: não confunda a linguagem de programação Java com a plataforma Java. Hoje em dia, a JVM é capaz de executar e suportar várias linguagens de programação além do Java, como Kotlin, Scala, Groovy, Clojure, entre outras. Durante este post, falaremos principalmente sobre a JVM em relação ao ambiente de execução (runtime), em vez das linguagens propriamente ditas.

Com isso em mente, vamos às dicas.
1) Ergonomics: O herói e ao mesmo tempo vilão
Hoje quando você executa sua aplicação de JVM, simplesmente rodando o comando java -jar, existe um recurso da JVM chamado Ergonomics, que tenta encontrar uma configuração adequada com base no ambiente onde a JVM está sendo executada.
Em um primeiro momento, você pode pensar: Nossa, mas isso não é bom?
E a resposta é: depende. Por um lado, esse recurso pode ajudar você a construir e rodar sua aplicação de forma mais rápida, sem se preocupar com ajustes finos, porém quando estamos falando de ambientes de larga escala, os resultados podem não ser os melhores.
Alguns dos ajustes automáticos de configurações feitos pelo Ergonomics na JVM, que podem impactar diretamente em performance e consumo de recursos, dizem respeito à escolha do Garbage Collector e ao tamanho do Heap. Vamos falar um pouco mais sobre eles.
Escolha do Garbage Collector
A escolha do GC é feita com base em duas condições: quantidade de memória e cpus disponíveis para JVM.
A regra funciona da seguinte forma:
- Até o Java 8: Se a quantidade de cpus for igual ou maior a 2 e a quantidade e memória for maior de 1792MB, o GC escolhido será o ParallelGC. Se qualquer uma destas duas condições estiverem abaixo dos valores citados, o GC escolhido será o SerialGC.
- Do java 9 em diante: As condições são praticamente as mesmas, porém em cenários onde a quantidade de cpus for igual ou maior a 2 e a quantidade e memória for maior de 1792MB, ao invés do ParallelGC, teremos o G1GC como GC escolhido.

Para os mais curiosos, segue um trecho da implementação de seleção do GC através do Ergonomics na JDK 11.
Tamanho do Heap
Salvo algumas exceções, na grande maioria dos casos, quando não informamos o tamanho do Heap desejado, seja utilizando o parâmetro -xmx ou a flag -XX:MaxRAMPercentage, o Ergonomics acaba configurando o valor máximo do heap como ¼ da memória disponível. Exemplo, se seu container tem um limite de memória configurado em 2GB, o Ergonomics irá configurar o tamanho máximo do Heap como 512MB.
Exceções citadas:
- Se o container tiver até 256MB de memória disponível, o valor máximo do heap será de 50%.
- Se o container tiver entre 256MB e 512MB de memória disponível, o valor máximo do heap será de aproximadamente 127MB.
Mas quais são os impactos de tudo isso?
Sobre a escolha automática do GC, precisamos ter em mente que o SerialGC não irá performar bem ambientes server side de alta concorrência, devido aos longos tempos de pausa gerados pelo mesmo.
Mas aí pode surgir a pergunta, em um cenário onde meu container tem o limite de 1000m e consequentemente a JVM tem somente 1 CPU disponível, qual seria a vantagem de usar um GC que usufrua de recursos de multi-threading?
Para responder isso, não vou entrar em detalhes específicos, mas resumidamente, do ponto de vista do container, existe uma confusão, muitas pessoas confundem 1000m (1000 milicores) com 1 cpu, porém ao invés disso, millicores representam tempo de capacidade computacional, algo que pode ser distribuído entre todos os cpus disponíveis do node.
Para um melhor entendimento sobre millicores no Kubernetes, acessar a página: Resource Management for Pods and Containers | Kubernetes
A confusão é gerada, uma vez que a JVM acaba interpretando 1000m como 1 cpu disponível, 1001m é entendido como 2 cpus, 2001 como 3, assim por diante.
Sabendo disso, é possível que a JVM consiga usufruir de GCs multi-threading mesmo rodando em containers limitados em 1000m de cpu. Para que isso aconteça, podemos forçar um número de cpus disponíveis para JVM através da flag XX:ActiveProcessorCount, passando valores maiores do que 1.
É importante ter em mente que mesmo que possamos utilizar outros GCs além do SerialGC com somente 1000m, dependendo do caso de uso, isso pode ter um resultado ruim, pois pela baixa disponibilidade de CPU, podemos sofrer com throttling da aplicação pelo breve atingimento de quota (CFS Quota) disponível.
Para um melhor entendimento sobre as quotas de CPU no Kubernetes, acessar a seguite página: Control CPU Management Policies on the Node | Kubernetes
Para saber qual implementação de GC sua JVM está utilizando, acesse o container através do kubectl exec e execute o seguinte comando:
java -XX:+PrintFlagsFinal -version | grep “Use*.*GC “
Você terá uma saída conforme exemplo abaixo, informando qual a implementação utilizada através de um valor booleano:
No exemplo do print, é possível ver que a implementação em uso é a do SerialGC e a mesma foi configurada através do Ergonomics.
Observação importante: Durante alguns testes que realizarei neste post, como este que acabamos de executar, estarei acessando o container e executando uma instância de JVM à parte. Porém, em cenários cotidianos, sua JVM provavelmente será instanciada na inicialização do container. Nesse caso, para saber os parâmetros corretos dessa instância, utilize a flag -XX:+PrintFlagsFinal e acompanhe a saída por meio dos logs.
É importante deixar isso claro, pois se a instância principal da JVM inicializada com o container receber parâmetros diferentes de inicialização, a saída da flag -XX:+PrintFlagsFinal pode apresentar resultados diferentes.
Sobre o tamanho do Heap, ao usar somente ¼ da memória disponível, podemos ter um desperdício do recurso, sendo que o container é algo construído isoladamente para executar sua aplicação e não deveríamos ter outros processos paralelos para consumir o restante da memória disponível, porém, é sempre importante ter em mente, que a JVM não é composta somente pelo heap, mas também por outros componentes que chamamos de non-heap. Além do heap e non-heap, ainda temos alguns processos relacionados ao “sistema operacional” que também consomem memória, mesmo que em menores quantidades. Falaremos melhor sobre isso na próxima dica.
Coloquei “sistema operacional” entre aspas, pois quando falo de container, não é de fato um sistema operacional, e sim algo emulado que utiliza o sistema do hospedeiro através de recursos de isolamento, mas acredito que a analogia seja válida para o entendimento.

Para saber o tamanho máximo do Heap configurado para sua JVM, acesse o container através do kubectl exec e execute o seguinte comando:
java -XX:+PrintFlagsFinal -version | grep “ MaxHeapSize”
Você terá uma saída conforme exemplo abaixo:
No exemplo do print, é possível ver o MaxHeapSize representado em bytes e que o mesmo foi configurado através do Ergonomics.
Resumo da dica
- Evite o uso do SerialGC em ambientes server side de alta concorrência, para isso não deixe a JVM somente com 1 cpu disponível. Temos algumas formas de fazer isso, seja alterando o limite de CPU do container, deixando ele acima de 1001m, ou através da flag XX:ActiveProcessorCount, passando valores maiores do que 1.
- Fique atento, pois se seu container estiver com menos de 1792MB de memória disponível e você não forçar uma versão especifica de GC, o Ergonomics também irá selecionar o SerialGC.
- Você também pode informar a implementação desejada de GC, através de argumento de JVM. Como recomendação, utilize o ParallelGC para heaps até 4GB e G1 acima de 4GB. Apesar de existirem mais implementações de GC disponíveis, isso deve atender grande parte dos casos de uso. Seguem alguns exemplos de argumentos para forçar o uso de um GC específico:
# Serial GC
java -XX:+UseSerialGC -jar meuapp.jar
# Parallel GC
java -XX:+UseParallelGC -jar meuapp.jar
# G1 GC
java -XX:+UseG1GC -jar meuapp.jar
# Shenandoah GC
java -XX:+UseShenandoahGC -jar meuapp.jar
# Z GC
java -XX:+UseZGC -jar meuapp.jar
- Para uma melhor performance e não sofrer com throttling da aplicação, evite containers com menos de 2000m de limite de cpu. Em muitas situações, vale mais uma única JVM em um container com 2000m de limite de cpu, do que duas JVMs separadas em dois containers com 1000m de limite de cpu, nessas horas menos é mais, pense nisso.
- Para evitar desperdício de recursos, configure adequadamente o tamanho do heap da JVM, utilizando o parâmetro -xmx ou a flag -XX:MaxRAMPercentage. Falaremos mais sobre tamanhos adequados para heap na próxima dica.
# Exemplos
# Usando o MaxRAMPercentage
java -XX:MaxRAMPercentage=50.0 -jar meuapp.jar
# Usando o xmx
java -Xmx1g -jar meuapp.jar
2) Dimensionamento adequado de memória: Nem só de Heap vive a JVM
Lendo a primeira dica, pode surgir a seguinte dúvida: Pensando na otimização de recursos, por que não configurar o tamanho do heap para 100% da memória disponível no container?
Vou deixar para responder essa pergunta ao final desta dica, antes disso, gostaria de explicar de forma resumida, sobre as áreas de memória que compõem a JVM.
Conforme o spoiler dado na dica 1, como parte da composição das áreas de memória da JVM, além do Heap, temos o non-heap, também conhecido como Native Memory ou Off-heap memory. Dentro do non-heap, temos alguns componentes importantes, entre eles: Metaspace, Code Cache, Stack Memory, GC Data, entre outros.
Não vou detalhar cada um destes componentes, não é meu objetivo com este post, meu intuito é apenas esclarecer que além do heap, temos outras áreas da JVM que também consomem memória do host hospedeiro, neste caso, do container onde a aplicação está rodando.
Agora você deve estar pensando: Hummm, então basta eu dividir a memória disponível no container entre heap e non-heap?
A resposta é: não. Precisamos considerar que a imagem base utilizada por este container, onde temos a JVM executando, também possui o seu sistema operacional emulado, e este acaba consumindo uma certa quantidade de memória para se manter vivo.
Sendo assim, agora respondendo a pergunta inicial: Por que não configurar o tamanho do heap para 100% da memória disponível no container?
Se configurarmos o tamanho do heap como 100% da memória disponível, nosso container será morto por OOM (Out Of Memory), pois precisamos considerar que além do heap, o non-heap e o sistema operacional também utilizam a memória do container.

Mas aí, uma nova pergunta pode surgir, como ficaria essa divisão de memória entre heap, non-heap e sistema operacional?
Já vi casos na literatura citando que 75% de memória disponível é um valor seguro para ser configurado para o heap, porém na prática, já tive problemas ao utilizar valores acima de 60%. Particularmente, minhas experiências profissionais, mostraram que 50% reservado para o heap é um valor seguro, mesmo que inicialmente pareça conservador demais. De qualquer forma, não descarto que possam ser utilizados valores acima de 50%, porém é importante que isso seja validado. Para validação, podemos utilizar testes de carga.

Outro ponto importante quando falamos de tamanho de heap, é saber que heaps muito pequenos podem causar um trabalho excessivo por parte do garbage collector, o que pode resultar em maior utilização de cpu e comprometimento da performance da aplicação. Por outro lado, heaps muito grandes podem impactar consideravelmente o tempo de subida da aplicação e gerar tempos elevados para coleta de lixo.
Resumo da dica
- Visando otimizar a utilização de memória nos seus containers, configure o heap size da sua JVM entre 50% e 75%, reservando o valor de sobra para non-heap e sistema operacional.
- Utilize testes de carga para validar que o valor configurado para o heap está adequado para sua aplicação, checando se não está sofrendo com ocorrências de OOM (Out Of Memory) durante a execução dos testes. Você pode utilizar ferramentas de monitoração para acompanhar o consumo de memória da JVM e também acompanhar as métricas das pods através do Kubernetes Metrics Server utilizando a API do K8s.
- Evite heaps muito pequenos para não causar comprometimento da performance da aplicação.
- Evite heaps muito grandes para não impactar o tempo de subida da aplicação e não gerar tempos elevados para coleta de lixo.
3) Xms igual a Xmx: Me diga logo de quanto você precisa
Antes de falarmos sobre essa dica, um resumo básico sobre xms e xmx.
São dois parâmetros utilizados para informar para JVM o tamanho mínimo (xms) e máximo (xmx) do heap.
Funciona basicamente assim: o xms representa o quanto de memória inicial a JVM irá alocar para o heap, e o xmx representa o valor máximo que poderá ser alocado. A JVM aloca inicialmente o valor definido no xms, e durante a execução do programa, conforme a necessidade, esse valor pode aumentar até o valor definido no xmx. Em casos nos quais a JVM tenta alocar valores superiores ao xmx, temos ocorrências de OutOfMemoryError.
Antes do uso de containers se tornar tão difundido como hoje, as aplicações que rodavam em JVMs, muitas vezes compartilhavam o mesmo servidor. Nesses cenários, era comum definir um valor menor para o parâmetro xms e um valor maior para o xmx, visando um melhor aproveitamento e compartilhamento de recursos entre os processos em execução. Dessa forma, a JVM alocava apenas a memória necessária, devolvendo-a para o host quando não estava mais em uso.
Quando estamos falando de containers, as coisas mudam de figura, na maioria dos casos, não temos outros processos paralelos relevantes rodando dentro do mesmo container, desta forma, não existe a necessidade de ficar alocando e devolvendo memória para o host durante a execução do programa de forma dinâmica, sendo assim, para evitar que a JVM precise ficar lidando com tarefas de alocação de memória, podemos configurar o valor do xms igual ao valor do xmx.
Resumo da dica
- Para evitar que a JVM precise ficar lidando com tarefas de alocação e devolução de memória para o host, utilize o valor do xms igual ao valor do xmx, para isso recorra aos parâmetros -xms e -xmx na JVM.
# Exemplo
java -Xms2g -Xmx2g -jar meuapp.jar
4) Overbooking de CPU em containers com JVM: Uma prática arriscada, mas aceitável em alguns casos
Antes de prosseguirmos com essa dica, é importante explicar dois conceitos do Kubernetes: “Gerenciamento de Recursos de Pods e Containers” e “QoS (Quality of Service)”. Esses conceitos ajudarão a compreender melhor o tema que será abordado a seguir.
Gerenciamento de Recursos de Pods e Containers
Neste ponto, gostaria de abordar o conceito de requests e limits para CPU e memória. Basicamente, os requests representam a quantidade mínima de recursos que um container precisa para executar. Já os limits representam a quantidade máxima de recursos que um container pode consumir dentro do cluster.
Os requests e limits de memória podem ser configurados no arquivo YAML usado para criar a Pod no Kubernetes, na seção “resources”.
Um exemplo simples seria:
apiVersion: v1
kind: Pod
metadata:
name: exemplo
spec:
containers:
- name: nome-container
image: nome-imagem:latest
resources:
requests:
memory: "1Gi"
cpu: "1"
limits:
memory: "2Gi"
cpu: "2"
Neste ponto, gostaria de fazer uma pergunta relacionada ao tema abordado na dica 1, onde falamos do Ergonomics e das configurações default que ele configura para a JVM de acordo com os recursos disponíveis no hospedeiro. A pergunta é a seguinte:
Caso executemos uma JVM dentro de um container, sem qualquer parametrização da mesma, deixando o Ergonomics definir as configurações, com os requests e limits do container iguais aos citados no exemplo do yaml acima, quais seriam as configurações de GC e tamanho máximo de heap adotadas pela JVM? Ela consideraria os valores de request ou de limit definidos para o container?
Para responder a essa pergunta, vamos executar um container com uma JVM na versão 17 e com as configurações do YAML acima. Depois, iremos acessar o container através do kubectl exec, verificar qual foi o GC escolhido e qual o tamanho máximo do heap.
Se a JVM respeitar o request de 1GB de memória e 1 CPU, teremos o GC escolhido como SerialGC e o tamanho máximo do heap de 256MB (¼ de 1GB).
No entanto, se a JVM respeitar o limit de 2GB de memória e 2 CPUs, teremos o GC escolhido como G1GC e o tamanho máximo do heap de 512MB (¼ de 2GB).
Abaixo, seguem as configurações da pod e do container após a execução:

O resultado das configurações da JVM executadas pelo Ergonomics é o seguinte:

Foi possível notar que a JVM considerou o valor do limit do container. Sendo assim, o GC escolhido foi o G1GC e o tamanho máximo do heap foi de 512MB, correspondente a ¼ de 2GB.
Agora vamos fazer uma pausa no assunto referente a gerenciamento de recursos e falar sobre o segundo conceito…
QoS (Quality of Service)
O QoS (Quality of Service) do Kubernetes é uma forma de classificar os pods em três categorias: Guaranteed, Burstable e BestEffort, com base nos recursos que eles solicitam e utilizam. Essa classificação é usada pelo Kubernetes para determinar a prioridade de cada pod em relação a outros pods em caso de escassez de recursos.
As regras para classificação de uma pod em cada categoria de QoS são definidas pelas seguintes condições:
- Guaranteed: Neste nível, para CPU e memória, os valores de request e limit precisam ser especificados e iguais. Exemplo:
resources:
requests:
memory: "3Gi"
cpu: "2"
limits:
memory: "3Gi"
cpu: "2"
- Burstable: Esse nível é para as pods que não atendem às regras para serem classificadas como Guaranteed. Pelo menos um contêiner na pod precisa ter request ou limit de memória ou CPU. Exemplo:
resources:
requests:
memory: "3Gi"
# Outro exemplo de Burstable
resources:
requests:
memory: "3Gi"
cpu: "1"
limits:
memory: "3Gi"
cpu: "2"
# Neste exemplo, mesmo que o request e
# limit de memória sejam iguais, o
# request de cpu é inferior ao limit,
# isso classifica a pod como Burstable.
- BestEffort: Um pod é classificado como BestEffort quando nenhum dos containers dentro da pod possui configurações de request ou limit para CPU ou memória. Se pelo menos um container tiver alguma configuração de request ou limit, a classificação muda para Burstable.
Mas afinal, qual a importância do QoS e suas categorias de níveis?
Quando um node do cluster está sobrecarregado ou com escassez de recursos, o scheduler do Kubernetes pode selecionar uma pod para ser removida com base na sua prioridade de QoS, que é definida da seguinte forma:
- Best-Effort: essas pods possuem a menor prioridade e são as primeiras a serem removidas. Como não possuem request ou limits de recursos definidos, são as mais fáceis de serem removidas sem causar impacto no cluster.
- Burstable: essas pods possuem prioridade média e são removidas após as pods Best-Effort, já que possuem pelo menos uma configuração mínima de request de recursos.
- Guaranteed: essas pods possuem a maior prioridade e são as últimas a serem removidas, já que possuem tanto os valores de request quanto de limit iguais para CPU e memória, garantindo que terão os recursos necessários para executar adequadamente.
Legal, agora que falamos dos dois conceitos acima, “Gerenciamento de Recursos de Pods e Containers” e “QoS (Quality of Service)”, você pode estar se perguntando se configurar todas as pods como Guaranteed seria a melhor opção, já que esse nível oferece maior garantia e estabilidade para as pods, certo?
Então, configurar todas as pods como Guaranteed, sempre mantendo os requests e limits iguais para cpu e memória, pode tornar o seu ambiente muito oneroso, uma vez que os nodes comportariam menos pods, exigindo mais nodes no seu cluster. Além disso, essa configuração pode levar a um subaproveitamento, deixando recursos ociosos, que poderiam estar sendo compartilhados com outras pods, resultando em uma utilização ineficiente de recursos.
Por outro lado, configurar todas as pods como Guaranteed garante que elas sempre terão os recursos necessários para executar sem falhas e atrasos, o que garante a estabilidade da aplicação. Além disso, essa configuração pode ser uma boa opção para aplicações críticas que exigem alta disponibilidade e desempenho, uma vez que garante a prioridade máxima para as pods.
Agora, antes de avançarmos para a dica propriamente dita, gostaria de discutir somente mais um conceito adicional para que você realmente entenda o que vou te sugerir.
Vamos falar sobre o conceito de Overbooking em clusters de Kubernetes.
Em geral, o overbooking é uma técnica que permite alocar mais recursos do que o disponível, na expectativa de que nem todos os recursos alocados serão usados simultaneamente. Por exemplo, em uma companhia aérea, o overbooking é usado para vender mais assentos do que o número total disponível em um voo, assumindo que nem todos os passageiros comparecerão ao voo.
No contexto do Kubernetes, o overbooking pode ser aplicado aos recursos de CPU e memória que são alocados para contêineres em um pod. Isso significa que é possível alocar mais recursos do que o total disponível no cluster, com base requests e limits definidos para os contêineres.
Como exemplo, imagine que você tem um node com 4GB de memória e 4 CPUs. Neste node, você tem duas pods, cada uma com um container com as seguintes configurações de recursos:
resources:
requests:
memory: "1Gi"
cpu: "1"
limits:
memory: "1Gi"
cpu: "3"
Note que o limite de CPU está configurado como 3. O ponto é que, considerando as duas pods em execução, cada uma com um container com as configurações acima, se multiplicarmos a quantidade de pods pelo limite de CPU configurado, teremos o resultado de 6 CPUs, o que ultrapassa o valor máximo de CPUs disponíveis no node.
Ao trabalhar com Overbooking em Kubernetes, é importante ter em mente que o CPU é considerado um recurso “compressível”, enquanto a memória não. Isso significa que o Kubernetes irá garantir que seus contêineres recebam a quantidade de CPU solicitada e limitará o restante. No entanto, se um contêiner começar a ultrapassar os limites de CPU, o Kubernetes iniciará o throttling no contêiner, ou seja, limitará o uso de CPU, o que pode resultar em uma queda de desempenho da aplicação, porém vale ressaltar que o contêiner não será encerrado ou removido.
Diferentemente do CPU, a memória não é um recurso compressível. Isso significa que, se o Node ficar sem memória disponível, o Kubernetes precisará tomar decisões sobre quais contêineres encerrar para liberar espaço na memória.
Levando em consideração que aplicações de JVM, na maioria dos casos de uso, demandam mais memória, exceto em situações que envolvam processamento de dados intensivo ou cálculos complexos, aqui vai uma dica para reduzir o custo do seu ambiente, especialmente ambientes de desenvolvimento.
Para garantir o uso eficiente do cluster, no caso de memória, utilize os valores de requests iguais aos limits, mas para CPUs, faça overbooking e use valores de request inferiores aos limits, mantendo as pods com nível Burstable. Isso permitirá que o recurso de CPU seja compartilhado entre as pods, resultando em maior eficiência do cluster.
Se por acaso todas as pods precisarem utilizar os limites de CPU simultaneamente (o que é algo difícil), use o consumo excessivo de CPU como gatilho para o autoscale dos nodes, a fim de equilibrar a carga do ambiente. Nesse cenário, o único impacto negativo seria o throttling temporário dos containers até que os nodes sejam escalados.
Essa estratégia pode parecer controversa em um primeiro momento, mas pode otimizar drasticamente o custo do seu ambiente, pois o preço de CPU é proporcionalmente mais alto em comparação com o da memória.
Resumo da dica
- Para reduzir os custos do seu ambiente Kubernetes, especialmente em ambientes de teste, considere configurar as pods que executam aplicativos JVM no nível Burstable, com memória contendo os request e limits iguais. No entanto, para CPU, faça o overbooking, definindo requests inferiores aos limites.
5) JVM & HPA: Se tiver que usar o padrão, priorize CPU em vez de memória
Antes de avançarmos com essa dica, gostaria de falar brevemente sobre o HPA (Horizontal Pod Autoscaler).
O HPA é um recurso do Kubernetes que ajuda a ajustar automaticamente o número de réplicas da sua aplicação com base no uso de CPU ou memória das pods. Ele aumenta ou diminui o número de réplicas para atender a uma demanda específica. Se houver uma maior necessidade de capacidade de processamento ou memória, o HPA aumenta o número de réplicas. Quando a demanda diminui, o HPA reduz o número de réplicas. Dessa forma, o HPA mantém a disponibilidade do serviço, mesmo em momentos de alta demanda.
Ao lidar com aplicações de JVM, é comum que elas consumam mais memória do que CPU. Por isso, pode ser tentador configurar o HPA para escalar com base na memória. No entanto, é importante lembrar que a JVM passa por flutuações no consumo de memória devido aos processos de coleta de lixo e alocação de memória. Isso pode tornar o uso da memória como gatilho para o HPA instável, podendo prejudicar o processo de escalonamento horizontal da sua aplicação.
Sabendo disso, como de forma default, o Kubernetes nos permite escalar considerando métricas de memória e cpu, só nos resta considerar cpu como alternativa para configurar os gatilhos de hpa.
É importante lembrar que o Kubernetes permite o uso de outras métricas personalizadas para o HPA, mas neste post vamos nos concentrar nas métricas padrão.
Mas não pense que estamos falando da CPU como alternativa para o HPA porque é a única opção disponível. Na verdade, existe uma estratégia interessante para escalar aplicações de JVM horizontalmente no Kubernetes, que envolve o uso de CPU como métrica. Vamos falar mais sobre isso.
A ideia é basicamente a seguinte: Em cenários de alta demanda, a JVM começa a alocar memória de forma intensa, o que leva a um aumento no trabalho do Garbage Collector (GC) da aplicação, incluindo ciclos completos de coleta de lixo (Full GC), que é um processo intensivo de consumo de CPU. É aí que as coisas começam a fazer sentido para a atuação do HPA. Nesse sentido, o HPA pode ser utilizado para atuar no escalonamento horizontal da aplicação com base na métrica de CPU, já que ela pode ser um indicativo da demanda da aplicação.
Segue um exemplo de como ficaria a configuração deste HPA:
apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata:
name: nome-do-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: nome-do-deployment
minReplicas: 1
maxReplicas: 5
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50
Neste exemplo, o HPA está configurado para monitorar a utilização de CPU do deployment e agir automaticamente no número de réplicas do pod para manter a utilização de CPU em 50%. O número mínimo de réplicas é 1 e o número máximo é 5, permitindo que a aplicação escale horizontalmente de acordo com a demanda.
O HPA monitora periodicamente a utilização de CPU do deployment e, se a utilização permanecer abaixo do limite definido por um determinado período de tempo, o HPA começará a reduzir gradualmente o número de réplicas até atingir o valor mínimo definido. Por padrão, o Kubernetes espera 5 minutos antes de iniciar o processo de scale down.
Resumo da dica
- Para melhorar o desempenho de suas aplicações Java Virtual Machine (JVM) em situações de alta demanda, use a métrica de CPU como configuração padrão do Horizontal Pod Autoscaler (HPA) no Kubernetes. Isso ocorre porque a alocação de memória intensa da JVM durante picos de tráfego pode aumentar significativamente o trabalho do coletor de lixo, levando a ciclos completos de coleta (Full GC) e uso excessivo de CPU.
É isso!
Espero que as dicas compartilhadas tenham sido úteis para você.
Gostaria de lembrar que esse é apenas o começo, e que pretendo compartilhar mais dicas como essas em uma possível parte 2.
Caso você tenha alguma crítica, sugestão ou simplesmente gostou do conteúdo, deixe um comentário para que eu possa saber.
Referências
- Microsoft para desenvolvedores Java | Microsoft Learn
- Kubernetes Documentation | Kubernetes
- (178) Secrets of Performance Tuning Java on Kubernetes by Bruno Borges — YouTube (Melhor conteúdo que já vi sobre o tema)
- Kubernetes requests vs limits: Why adding them to your Pods and Namespaces matters | Google Cloud Blog
- Java no docker — O que você precisa saber para não FALHAR — Rafael Benevides’ Blog (rafabene.com)
- Como Escalar Automaticamente suas Cargas de Trabalho no Kubernetes da DigitalOcean | DigitalOcean