PerfectScalePerfectScale

PerfectScale

Seu serviço JVM precisa de 2 CPUs por 30 segundos e de 0,3 CPU para sempre

A JVM exige mais CPU nos primeiros segundos. Veja como os limites de CPU do Kubernetes causam throttling na inicialização da JVM, como medir isso e como dimensionar seus pods.

Esta página também está disponível em English, Deutsch, Español, Français, Italiano e 日本語.

Tania Duggal
By Tania Duggal
Oct 14, 202618 min read

Um serviço Java precisa do máximo de CPU nos primeiros segundos. A JVM carrega classes, compila código e constrói a aplicação. Depois disso, o serviço precisa de muito menos CPU. Na maioria dos clusters, um pod mantém o mesmo request e o mesmo limite de CPU durante toda a vida. O pod, portanto, não consegue acompanhar esse padrão.

Esse descompasso causa throttling de CPU no Kubernetes durante a inicialização da JVM. Se o limite é baixo, a JVM não consegue concluir o trabalho de inicialização rapidamente, e o pod demora para ficar Ready. Se o request é alto, o nó reserva CPU que o serviço não usa depois da inicialização. Nenhuma das configurações serve para as duas fases.

Os números do título são um exemplo. Seu serviço tem os próprios números, e este artigo mostra como verificar o que o seu serviço faz. O artigo explica por que a inicialização precisa de mais CPU, como o Kubernetes aplica as configurações de CPU e o que a JVM decide a partir dessas configurações. No final, você encontra as opções disponíveis hoje.

Por que a inicialização do Java precisa de mais CPU do que o estado estável?

Antes de atender à primeira requisição, a JVM precisa concluir uma grande quantidade de trabalho. Esse trabalho tem quatro partes principais:

Carregamento e vinculação de classes

A JVM carrega uma classe quando o código precisa dela pela primeira vez. Ela lê o arquivo da classe, verifica e vincula. Uma aplicação Spring precisa de um grande número de classes nos primeiros segundos, porque o framework escaneia a aplicação e cria seus beans.

Compilação JIT com C1 e C2

A JVM começa interpretando bytecode, o que é lento. Ela conta a frequência com que cada método é executado. O HotSpot então compila os métodos mais usados para código de máquina com dois compiladores. O C1 compila rápido e produz código simples. O C2 compila mais devagar e produz código mais rápido para os métodos mais usados. Esse design é chamado de compilação em camadas (tiered compilation). Os compiladores rodam em threads próprias, e a JVM escolhe o número padrão dessas threads a partir da contagem de CPUs.

Threads de garbage collection e crescimento do heap

A inicialização aloca muitos objetos. O heap cresce, e o coletor roda para liberar memória. A JVM escolhe o coletor e o número de threads dele a partir da contagem de CPUs e do tamanho da memória.

Inicialização do framework

O framework constrói beans, cria proxies, lê configurações e abre conexões. O Spring Boot Actuator pode listar cada uma dessas etapas no endpoint de startup quando você usa BufferingApplicationStartup, o que ajuda a ver onde o tempo é gasto.

Cada parte tem um fim. Depois que as classes foram carregadas, os métodos mais usados foram compilados e os beans existem, a demanda por CPU cai. A queda não é instantânea, porque os compiladores JIT continuam compilando métodos à medida que o tráfego real chega. Ainda assim, o estado estável precisa de muito menos CPU do que a inicialização. A diferença entre as duas fases é o motivo pelo qual uma única configuração de CPU não serve para ambas.

Como o Kubernetes aplica requests e limits de CPU?

O Kubernetes controla a CPU de um contêiner com duas configurações:

Requests de CPU e agendamento

O request de CPU é a quantidade de CPU que o Kubernetes reserva para um contêiner. O scheduler soma os requests dos pods em cada nó e coloca um novo pod apenas em um nó com CPU não reservada suficiente. O request também define a fatia de CPU em um nó ocupado. Quando contêineres competem por CPU, o kernel dá mais tempo de CPU ao contêiner com request maior. No cgroup v2, o runtime do contêiner grava o request como cpu.weight. No cgroup v1, usa cpu.shares.

Se você define um limite e nenhum request, o Kubernetes usa o limite como request. O request e o limite também determinam a classe de QoS do pod. Um pod é Guaranteed quando todos os contêineres têm o mesmo valor de request e limite de CPU, e o mesmo valor de request e limite de memória. É Burstable quando tem alguns requests ou limits, mas não atende à regra do Guaranteed. É BestEffort quando não tem nenhum.

Limits de CPU e throttling

O limite de CPU é um teto rígido, e o kernel o aplica com throttling. O kernel divide o tempo em períodos, e o kubelet usa um período de 100 ms por padrão. Em cada período, o contêiner pode usar uma cota de tempo de CPU. O limite define o tamanho da cota. O runtime grava o limite como cpu.max no cgroup v2. Um limite de 500m vira 50000 100000, que é uma cota de 50.000 microssegundos em cada período de 100.000 microssegundos.

resources:
requests:
cpu: 300m # reserva CPU e define a fatia de CPU em um nó ocupado
memory: 2Gi
limits:
cpu: "1" # teto rígido: gravado como 100000 100000 em cpu.max
memory: 2Gi

O que é throttling de CPU no Kubernetes?

O throttling é a forma como o kernel aplica um limite de CPU. Quando as threads de um contêiner usam toda a cota de um período, o kernel pausa todas elas até o próximo período começar.

As threads compartilham uma única cota, e esse detalhe importa para uma JVM. Considere um limite de 500m, que dá 50 ms de tempo de CPU em cada período. Uma thread ocupada precisa de 50 ms para usar a cota, então ela roda por metade do período. Oito threads ocupadas usam a mesma cota em cerca de 6 ms, e então o kernel pausa as oito pelo resto do período. Uma JVM na inicialização roda muitas threads ocupadas ao mesmo tempo, então atinge a cota logo no começo de cada período.

O Kubernetes não interrompe um contêiner por usar CPU demais. Ele desacelera o contêiner. O Kubernetes não reporta o throttling como um evento, e os logs do contêiner não mostram erro. O dano aparece na forma de atraso, e você só o encontra quando mede.

Throttling de CPU durante a inicialização da JVM

Boa parte da inicialização da JVM é trabalho limitado por CPU. A JVM tem uma quantidade fixa de trabalho a concluir antes que a aplicação possa atender tráfego, e o limite define a velocidade com que o contêiner pode fazer esse trabalho. Um limite mais alto termina o trabalho mais cedo. Um limite mais baixo estica o mesmo trabalho por mais períodos, e o contêiner espera em cada período depois de usar a cota. O throttling durante a inicialização causa dois problemas:

Inicialização lenta e demora para ficar Ready

Uma JVM sob throttling demora mais para iniciar, então o pod fica Ready mais tarde. Esse atraso tem custos fora do pod. Um rolling update espera cada novo pod ficar Ready, então todo rollout demora mais. Novas réplicas só ajudam depois de ficarem Ready, então o scale-out fica mais lento quando o tráfego cresce. Durante uma falha de nó, os pods substitutos também precisam do mesmo tempo extra.

Probes com falha e reinicializações

Uma startup probe informa ao kubelet quando a aplicação iniciou. Enquanto a startup probe roda, o Kubernetes não executa as probes de liveness e readiness. Se a startup probe não tiver sucesso dentro de failureThreshold × periodSeconds, o kubelet reinicia o contêiner. Use uma startup probe para um contêiner que precisa de muito tempo para iniciar e aponte-a para o mesmo endpoint da liveness probe.

Sem uma startup probe, um início lento pode causar um loop de reinicializações. A liveness probe falha enquanto a JVM ainda está iniciando, o kubelet reinicia o contêiner, e o novo contêiner repete o mesmo trabalho de CPU. Uma JVM sob throttling torna esse loop mais provável, porque sua inicialização demora mais.

Uma startup probe com uma janela ampla evita o loop:

startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 5
failureThreshold: 24 # espera até 24 x 5 = 120 segundos

Uma janela ampla impede as reinicializações, mas não acelera a inicialização. O pod ainda fica Ready tarde.

Como medir o throttling?

Dois contadores do cAdvisor mostram o throttling. O contador container_cpu_cfs_periods_total conta todos os períodos. O contador container_cpu_cfs_throttled_periods_total conta os períodos em que o contêiner usou toda a cota. Divida o segundo contador pelo primeiro para obter a proporção de períodos com throttling.

sum(rate(container_cpu_cfs_throttled_periods_total{container="app"}[5m]))
/
sum(rate(container_cpu_cfs_periods_total{container="app"}[5m]))

Uma taxa de cinco minutos esconde uma inicialização que dura só alguns segundos. Para a inicialização, leia os contadores brutos quando o pod ficar Ready e faça a divisão você mesmo. No cgroup v2, o contêiner também expõe os mesmos dados em cpu.stat. Os campos nr_periods, nr_throttled e throttled_usec mostram o número de períodos, o número de períodos com throttling e o tempo total sob throttling.

Terminal window
kubectl exec <pod-name> -- cat /sys/fs/cgroup/cpu.stat

Você também pode ler os valores do cAdvisor a partir do kubelet:

Terminal window
kubectl get --raw /api/v1/nodes/<node-name>/proxy/metrics/cadvisor | grep container_cpu_cfs

Lembre-se do intervalo de amostragem. O sistema de métricas atualiza seus valores apenas em um intervalo fixo. Um pico de inicialização mais curto que esse intervalo parece menor do que é, ou nem aparece. Para picos curtos, amostre os contadores brutos com mais frequência.

Como o limite de CPU muda o comportamento da JVM?

O limite de CPU faz mais do que desacelerar a JVM. No Linux, a JVM oferece suporte a contêineres por padrão (-XX:+UseContainerSupport). Ela lê o limite quando inicia. Estes três pontos mostram como esse valor muda o que a JVM faz:

Contagem de processadores ativos a partir dos limits de CPU

A JVM divide a cota de CPU pelo período e arredonda o resultado para cima, para o próximo número inteiro. Um limite de 1001m, portanto, resulta em 2 CPUs. O JDK 19 e posteriores, e o JDK 17.0.5 e posteriores, não usam o request de CPU (cpu.shares) para contar CPUs. Muitas configurações da JVM dependem dessa contagem, incluindo o número de threads do compilador JIT e o número de threads de GC.

Seleção do garbage collector (Serial vs. G1)

A JVM trata uma máquina como server-class quando ela tem pelo menos 2 processadores e pelo menos 1.792 MB de memória. Em uma máquina server-class, a JVM seleciona o G1. Caso contrário, seleciona o coletor Serial. Uma pequena mudança no limite pode, portanto, mudar o coletor. Um limite de 1 CPU resulta em um processador, então a JVM seleciona o Serial. Um limite de 1100m resulta em dois processadores, então a JVM seleciona o G1 quando a memória é de pelo menos 1.792 MB. Os dois coletores se comportam de forma diferente na inicialização. O G1 inicia várias worker threads que rodam em paralelo, então pode usar mais CPU de uma vez. O Serial coleta com uma única thread.

Pods sem limits de CPU

Sem um limite, a JVM conta as CPUs do nó. Em um nó grande, ela vê muitas CPUs e inicia muitas threads de compilador e de GC. O pod então não sofre throttling, mas pode usar uma grande quantidade total de CPU e compete com os vizinhos proporcionalmente ao seu request. Um request pequeno dá a esse pod uma fatia pequena quando o nó está ocupado.

Você pode conferir as escolhas da sua própria JVM. Habilite o log de contêiner e leia as flags da JVM em execução:

env:
- name: JAVA_TOOL_OPTIONS
value: "-Xlog:os+container=trace -Xlog:gc*"
Terminal window
kubectl exec <pod-name> -- jcmd 1 VM.flags -all \
| grep -E 'UseSerialGC|UseG1GC|ParallelGCThreads|CICompilerCount'

Observação: o comando precisa do jcmd na imagem e espera que a JVM rode como processo 1. Se um shell inicia a JVM, use o ID de processo real da JVM. Uma imagem somente com JRE não inclui o jcmd.

A flag -XX:ActiveProcessorCount=<n> sobrescreve a contagem de CPUs detectada. Use-a quando quiser o mesmo comportamento da JVM em todos os ambientes.

Por que nenhum valor único de request de CPU é seguro?

Cada forma de definir o request tem um custo. Estas são as quatro opções:

Dimensionar para a inicialização

Um request e um limite altos dão à JVM CPU suficiente para iniciar rápido. Mas o nó reserva o request alto por toda a vida do pod, e outros pods não podem usar essa CPU. Depois da inicialização, o serviço usa apenas uma pequena parte da reserva. Somando muitas réplicas e muitos nós, essa CPU sem uso vira um custo grande.

Dimensionar para o estado estável

Um request e um limite pequenos servem ao serviço depois da inicialização. Mas a JVM agora tem CPU insuficiente para o trabalho de inicialização. O kernel aplica throttling, o pod fica Ready tarde, probes podem falhar e rollouts demoram mais. O serviço fica barato, mas frágil.

Remover o limite de CPU

Sem um limite, o kernel não aplica throttling ao contêiner, e a inicialização pode usar qualquer CPU livre do nó. Essa escolha tem seus trade-offs. A JVM dimensiona suas threads para todas as CPUs do nó. Em um nó ocupado, o contêiner recebe tempo de CPU apenas proporcional ao request, então um request pequeno ainda pode atrasar a inicialização. Além disso, sem um limite, um pod pode tomar CPU de que os vizinhos precisam.

HPA e o período de inicialização de CPU

O Horizontal Pod Autoscaler (HPA) mede o uso de CPU como porcentagem do request. Um request pequeno faz o pico de inicialização parecer uma carga muito alta, e o HPA pode adicionar réplicas de que o serviço não precisa. O Kubernetes reduz esse risco, porque o HPA pode ignorar amostras de CPU de um pod novo. Por padrão, o atraso de readiness é de 30 segundos (--horizontal-pod-autoscaler-initial-readiness-delay), e o período de inicialização de CPU é de 5 minutos (--horizontal-pod-autoscaler-cpu-initialization-period). As duas flags pertencem ao kube-controller-manager, então em um cluster gerenciado talvez você não consiga alterá-las.

Como lidar com a CPU de inicialização hoje

Estas são as formas de lidar com a CPU de inicialização:

Reduza o trabalho de inicialização

Menos trabalho significa uma inicialização mais curta com qualquer limite. O Spring Boot pode criar beans no primeiro uso com spring.main.lazy-initialization=true. O Class Data Sharing e o cache AOT podem remover parte do trabalho de carregamento de classes. O cache AOT chegou no JDK 24 (JEP 483). O Spring Framework oferece suporte tanto ao CDS quanto ao cache AOT. O profiling mostra para onde vai o seu próprio tempo de inicialização.

Defina o limite de CPU acima da necessidade de inicialização

Mantenha o request perto do nível do estado estável e defina o limite alto o suficiente para a inicialização. O pod então fica na classe de QoS Burstable. O scheduler reserva apenas o request, e o pod pode usar CPU até o limite quando o nó tem CPU livre. Na inicialização, o nó pode não ter CPU livre, então essa opção ajuda mais em nós com capacidade sobrando. Um limite mais alto também muda a contagem de CPUs que a JVM vê.

Redimensionamento de pod in-place

O redimensionamento de pod in-place muda a CPU e a memória de um pod em execução sem reinicialização. O recurso chegou ao beta no Kubernetes 1.33 e ficou estável no Kubernetes 1.35. Com esse recurso, um pod pode começar com um limite de CPU alto e passar para um mais baixo depois da inicialização. Um contêiner controla o comportamento com uma resize policy. A política padrão para CPU é NotRequired, e você pode declará-la no manifesto:

containers:
- name: app
resizePolicy:
- resourceName: cpu
restartPolicy: NotRequired

Você altera os recursos pelo subresource resize. Este comando precisa do kubectl 1.32 ou posterior:

Terminal window
kubectl patch pod <pod-name> --subresource resize --patch \
'{"spec":{"containers":[{"name":"app","resources":{"requests":{"cpu":"300m"},"limits":{"cpu":"500m"}}}]}}'

O campo status.containerStatuses[].resources mostra os recursos que o contêiner usa agora. Alguns pods não podem usar o redimensionamento in-place. Pods Windows e pods sob a política estática do CPU Manager ou do Memory Manager são exemplos. Um redimensionamento também não pode mudar a classe de QoS do pod. Além disso, alguém precisa acionar o redimensionamento. Uma pessoa pode rodar o patch manualmente, mas, para muitos pods, um controlador precisa executá-lo.

Um redimensionamento muda o limite do cgroup, mas a JVM escolhe algumas configurações apenas uma vez. Ela seleciona o coletor na inicialização e o mantém. Configurações que dependem da contagem de CPUs, como o número máximo de threads de GC, também são definidas na inicialização. Runtime.availableProcessors() pode mudar enquanto a JVM roda, então código que lê a contagem de CPUs em tempo de execução pode ver o novo valor. Teste um redimensionamento no seu próprio JDK antes de depender dele.

Cuidado com a memória da JVM

Não reduza o limite de memória de um contêiner JVM. A JVM define o heap máximo na inicialização. Por padrão, o heap máximo é 25% da memória do contêiner (-XX:MaxRAMPercentage). A JVM também usa memória fora do heap, como metaspace, stacks de threads e o code cache. Um limite de memória abaixo do uso real do processo causa um OOM kill. Por isso, redimensione a CPU depois da inicialização e aumente o limite de memória apenas quando necessário.

VPA e opções gerenciadas de boost de inicialização

O Vertical Pod Autoscaler (VPA) tem um modo de atualização InPlaceOrRecreate. Esse modo tenta primeiro um redimensionamento in-place e recria o pod quando o redimensionamento in-place não é possível. No GKE, o CPU startup boost funciona com o VPA. Ele aumenta o request de CPU quando o pod é criado e mantém o request mais alto por durationSeconds depois de o pod ficar Ready. Em seguida, o GKE retorna o request ao valor de base, in-place. O boost se aplica apenas quando o pod é criado, então uma reinicialização de contêiner dentro do mesmo pod não recebe boost.

O recurso está em Preview no momento em que este artigo foi escrito e precisa do GKE 1.36.0-gke.4447000 ou posterior.

apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: example-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: example
updatePolicy:
updateMode: "Off"
startupBoost:
cpu:
type: "Factor"
factor: 2
durationSeconds: 10

Neste exemplo, o boost dobra o request de CPU (factor: 2) e o mantém por 10 segundos depois de o pod ficar Ready. Com updateMode em Off, o VPA aplica apenas o boost e não faz nenhuma outra mudança. Se você usa um HPA com o boost, defina uma readiness probe e configure durationSeconds como 0. Isso impede que o HPA adicione réplicas por causa do pico de CPU na inicialização.

Automação em duas fases do PerfectScale

Um serviço JVM tem duas necessidades de recursos. Na inicialização, a JVM carrega classes, compila código e constrói a aplicação, então o serviço precisa de muita CPU. Depois disso, precisa de muito menos. Uma única recomendação média não funciona para as duas fases. O PerfectScale fornece duas recomendações para cada workload: uma para a inicialização e outra para o runtime em estado estável. O PerfectScale dimensiona a recomendação de inicialização para a maior necessidade entre as duas fases, para que o nó tenha capacidade suficiente durante a inicialização.

O PerfectScale aplica a recomendação de inicialização quando o pod é criado. Depois que o pod roda de forma estável, o PerfectScale aplica a recomendação de runtime in-place, sem reinicialização. O PerfectScale acompanha a fase de cada pod e adiciona verificações de segurança quando um workload não consegue passar para a fase de runtime de forma limpa.

O redimensionamento in-place precisa do Kubernetes 1.33 ou posterior e da resize policy de contêiner restartPolicy: NotRequired. Ele oferece suporte a Deployments, StatefulSets, DaemonSets e Argo Rollouts, e não oferece suporte a pods Windows. Quando o cluster não oferece suporte ao redimensionamento in-place, o PerfectScale recorre à sua abordagem segura padrão.

O PerfectScale protege o heap da JVM. Quando você define um tamanho máximo ou mínimo de heap, o PerfectScale não aplica uma recomendação que saia desses limites. No redimensionamento in-place, o limite de memória só pode aumentar. Para contêineres Java, o PerfectScale também coleta dados de heap, non-heap e garbage collection. Ele mostra o tempo de GC, a contagem de processadores ativos e o tipo de GC. Você pode habilitar a coleta de métricas da JVM ao instalar o agente do PerfectScale, com suporte apenas a nós Linux. O PerfectScale mostra os recursos de inicialização e de estado estável separadamente, para que os números de custo e desperdício reflitam o que o workload precisou em cada fase.

Participe do workshop. Vikram Seshadri, da DoiT, mostra como dimensionar um pod JVM para a inicialização e como redimensioná-lo para o estado estável sem reinicialização no workshop prático "Java on K8s: The Startup vs. Steady-State Sizing Dilemma", em 28/10/2026, às 11h (horário do leste dos EUA, ET). Garanta sua vaga.

Quer ver como o PerfectScale faz o right-sizing dos seus workloads Java? Comece grátis ou agende uma demo.