JVM em Kubernetes: performance começa onde o container aperta

Rodar Java em Kubernetes virou o caminho padrão para sistemas distribuídos — e também virou uma fonte silenciosa de degradação de performance que raramente aparece no code review. O problema quase nunca está no código: está no encontro entre duas máquinas de gerenciamento de recursos que foram projetadas em épocas diferentes, com premissas diferentes. De um lado, a JVM, construída para assumir que é dona da máquina. Do outro, o Kubernetes, herdeiro direto do Borg do Google [1][2], construído para assumir que ninguém é dono de nada e que todo processo vive dentro de um orçamento de CPU e memória imposto por cgroups.
Este post é sobre essa fronteira: o que acontece quando a JVM encontra os limites do container, por que isso derruba latência e throughput de formas não óbvias, e o que a literatura e as JEPs recentes oferecem para fechar o gap. A tese central: em Kubernetes, a maior parte dos ganhos de performance de um sistema Java não vem de otimizar código — vem de alinhar a ergonomia da JVM com o modelo de recursos do cluster.
1. A JVM precisou aprender que vive num container
Até o JDK 8u191, a JVM simplesmente não sabia que estava num container. Ela lia /proc do host para descobrir quantas CPUs e quanta memória existiam — e configurava sua ergonomia inteira (tamanho do heap, threads de GC, threads do compilador JIT, paralelismo do ForkJoinPool) com base na máquina física, não no limite do cgroup [3].
O resultado clássico: um pod com limite de 2 GiB de memória rodando num nó de 64 GiB. A ergonomia padrão define heap máximo como 1/4 da RAM "visível" — 16 GiB, oito vezes o limite do container. O kernel não negocia: quando o consumo total do processo cruza o limite do cgroup, vem o OOMKill, e o pod morre com exit code 137 sem nenhum stack trace de OutOfMemoryError, porque do ponto de vista da JVM havia memória de sobra.
O suporte a containers entrou de forma consistente no JDK 10 (-XX:+UseContainerSupport, ativo por padrão) e foi retroportado para o 8u191 [3]; o suporte a cgroups v2 — hoje o padrão nas distribuições e no Kubernetes moderno — chegou no JDK 15 [4]. Com isso, Runtime.availableProcessors() e a ergonomia de heap passaram a respeitar os limites do cgroup. Mas "respeitar o limite" resolve só o primeiro problema. Os mais interessantes vêm depois.
2. CPU limits, CFS e o throttling que não aparece no gráfico de CPU
Quando você define limits.cpu num pod, o Kubernetes traduz isso em CFS bandwidth control: uma cota de tempo de CPU (cfs_quota_us) dentro de um período fixo, por padrão 100 ms (cfs_period_us) [5]. Um limite de 500m significa: este cgroup pode consumir 50 ms de CPU a cada janela de 100 ms. Esgotou a cota aos 30 ms da janela? Todas as threads do processo ficam congeladas pelos 70 ms restantes.
Para a JVM, isso é veneno em dose específica. Uma aplicação Java não é um processo de consumo constante — ela tem picos estruturais de paralelismo: threads do compilador C1/C2 durante o warmup, threads de GC em ciclos concorrentes, ForkJoinPool comum, pools de I/O. Cada pico consome a cota em rajada e empurra as threads de aplicação para a geladeira até a próxima janela. O sintoma é um padrão de latência serrilhado — p99 com espinhos de dezenas de milissegundos — que não aparece como CPU alta em dashboards de utilização média, porque a média está confortável. A métrica que denuncia é outra: container_cpu_cfs_throttled_periods_total.
O quadro já foi pior: um bug no CFS fazia o kernel expirar fatias de cota não usadas e provocar throttling mesmo com cota sobrando; foi corrigido no kernel 5.4 após investigação detalhada da engenharia do Indeed [6]. Mas mesmo com o kernel corrigido, o mecanismo de cota continua estruturalmente hostil a workloads em rajada — que é exatamente o perfil da JVM.
O que fazer, em ordem de custo-benefício:
- Defina requests.cpu com base em medição e questione a necessidade de limits.cpu. Requests governam o scheduling e a partilha proporcional via CFS shares; limits impõem a cota dura. Para workloads latency-sensitive, rodar sem CPU limit (mantendo requests corretos) elimina o throttling e deixa o pod usar ociosidade do nó — posição defendida publicamente por mantenedores do próprio Kubernetes [7]. O trade-off é isolamento: sem limit, um vizinho pode sentir seus picos, e ambientes multi-tenant rígidos podem exigir a cota mesmo assim.
- Se o limit for obrigatório, alinhe a JVM a ele. -XX:ActiveProcessorCount=N fixa quantas CPUs a JVM considera, dimensionando GC e JIT de forma coerente com a cota real em vez do arredondamento do cgroup.
- Monitore throttling como métrica de primeira classe, ao lado de p99 — não como curiosidade de infra.
3. Memória: o heap é só um dos inquilinos
O erro simétrico ao do CPU é dimensionar limits.memory igual ao -Xmx. O processo JVM carrega muito mais que heap: Metaspace, code cache do JIT, stacks de thread (~1 MiB cada; 400 threads são ~400 MiB), buffers diretos de NIO, memória nativa de bibliotecas e do próprio GC. Em serviços com Netty ou gRPC, os direct buffers sozinhos podem rivalizar com o Metaspace.
Duas práticas resolvem a maior parte dos incidentes:
- Dimensione por proporção, não por valor absoluto: -XX:MaxRAMPercentage=60-75 deixa a JVM derivar o heap do limite do container, mantendo folga para o resto do processo. O percentual certo depende do perfil — meça com NativeMemoryTracking antes de apertar.
- Trate OOMKill (exit 137) e OutOfMemoryError como incidentes diferentes. O primeiro é o processo inteiro estourando o cgroup (quase sempre memória fora do heap); o segundo é o heap interno esgotado. As correções são opostas: o primeiro pede mais folga entre heap e limite; o segundo pede heap maior ou vazamento corrigido.
4. GC sob cota: a escolha do coletor muda de natureza
Fora de containers, escolher GC é um trade-off clássico entre throughput e pausa. Dentro de um cgroup com cota de CPU, entra uma terceira dimensão: quanto CPU concorrente o coletor rouba da cota.
O G1, padrão desde o JDK 9, equilibra bem pausas e throughput para heaps médios e é a escolha certa na maioria dos serviços. O ZGC — cujo design de pausas sub-milissegundo independentes do tamanho do heap está formalizado no paper de Yang e Wrigstad na ACM TOPLAS [8] — tornou-se generacional por padrão (JEP 474, JDK 23; o modo não-generacional foi removido no JDK 24 pela JEP 490 [9]) e é transformador para heaps grandes com requisitos duros de latência.
Mas há uma pegadinha específica de Kubernetes: coletores concorrentes trocam pausa por trabalho concorrente — e trabalho concorrente consome cota de CFS. Num pod de 1 CPU de limite, as threads concorrentes do ZGC competem com as threads de aplicação pela mesma cota de 100 ms por janela, e o resultado pode ser throttling induzido pelo GC, com latência pior do que o G1 entregaria. A regra prática que uso: ZGC brilha onde há CPU folgada e heap grande; sob cota apertada, G1 com heap bem dimensionado costuma vencer. Como sempre, a resposta final vem de teste de carga com a cota real de produção, não do benchmark do laptop.
5. Warmup, probes e o paradoxo do autoscaling
A JVM entrega performance de pico só depois do warmup: o JIT precisa observar o código executando para compilar os hot paths em tier final (C2). Nos primeiros segundos — às vezes minutos — o serviço roda código interpretado ou compilado em tier baixo, com latência várias vezes pior que o regime estável.
Em Kubernetes isso cria dois problemas acoplados:
Probes mal calibradas matam pods saudáveis. Liveness probe agressiva durante o warmup gera restart, que reinicia o warmup, que falha a probe de novo — crash loop de um serviço perfeitamente funcional. O desenho correto: startupProbe generosa cobrindo o warmup completo, readinessProbe sinalizando aptidão para receber tráfego, e livenessProbe reservada para deadlock real, nunca para lentidão [10].
HPA baseado em CPU escala na direção errada. O pico de CPU do warmup (JIT + GC + carga inicial de classes) engana o Horizontal Pod Autoscaler: pods novos nascem "quentes" em CPU e frios em performance, o HPA lê CPU alta e cria mais pods frios, degradando o p99 exatamente durante o pico de tráfego que motivou a escalada. Para JVM, métricas de negócio — lag de fila, RPS por pod, p99 — via custom metrics ou KEDA descrevem capacidade real muito melhor que CPU.
A resposta estrutural para o custo do warmup está vindo do próprio OpenJDK, no Projeto Leyden: o AOT cache introduzido pela JEP 483 (JDK 24) permite carregar e linkar classes ahead-of-time a partir de uma execução de treino, e as JEPs 514 e 515 (JDK 25) simplificam a ergonomia e adicionam perfis de método ao cache — reduzindo tanto o startup quanto o tempo até a performance de pico [11]. Para casos extremos, o GraalVM Native Image elimina o warmup por completo via compilação closed-world com inicialização em build time, abordagem formalizada por Wimmer et al. no OOPSLA [12] — ao custo de perder otimização especulativa do JIT no regime estável e de restrições sobre reflection e classloading dinâmico. Entre os dois extremos, o CRaC (Coordinated Restore at Checkpoint) restaura um processo já aquecido a partir de snapshot. A escolha depende do perfil: serviços de vida longa amortizam o warmup e raramente justificam native image; funções e workloads de scale-to-zero, sim.
6. O checklist que eu aplico
Condensando em prática, na ordem em que investigo qualquer serviço Java com performance ruim em Kubernetes:
kubectl exec no pod e confirmar o que a JVM enxerga: java -XX:+PrintFlagsFinal -version | grep -E "MaxHeapSize|ActiveProcessorCount".
Checar container_cpu_cfs_throttled_periods_total antes de olhar qualquer flame graph.
Validar que limits.memory tem folga real sobre o heap (MaxRAMPercentage ≤ 75%, medido com NMT).
Revisar probes: startup cobre o warmup? Liveness só detecta deadlock?
Conferir a métrica do HPA: se for CPU pura numa JVM, há um incidente esperando data.
Só então perfilar código — com JFR ou async-profiler, dentro do container, sob a cota real.
A ordem importa porque os itens 1–5 são baratos e explicam a maioria dos casos. Perfilamento de código é o passo mais caro e frequentemente aponta para um culpado que não existe: o código estava certo; o orçamento de recursos, não.
Conclusão
Kubernetes herdou do Borg a premissa de que eficiência de cluster vem de empacotar workloads sob orçamentos estritos de recursos [1]. A JVM passou vinte anos assumindo o oposto. A convergência entre os dois — container awareness, cgroups v2, ZGC generacional, AOT do Leyden — é um dos movimentos mais interessantes da plataforma Java na década, mas ela não elimina o trabalho de engenharia na fronteira: entender o que o CFS faz com as suas threads, o que o cgroup conta como memória e o que o warmup faz com o seu autoscaling continua sendo responsabilidade de quem opera o sistema.
Performance de Java em Kubernetes não é uma propriedade do código. É uma propriedade do contrato entre a JVM e o cluster — e contratos ruins cobram juros em p99.
Referências
[1] Verma, A. et al. Large-scale cluster management at Google with Borg. EuroSys 2015. https://research.google/pubs/pub43438/
[2] Burns, B., Grant, B., Oppenheimer, D., Brewer, E., Wilkes, J. Borg, Omega, and Kubernetes. ACM Queue, vol. 14, 2016. https://queue.acm.org/detail.cfm?id=2898444
[3] OpenJDK. JDK-8146115: Improve docker container detection and resource configuration usage. https://bugs.openjdk.org/browse/JDK-8146115
[4] OpenJDK. JDK-8230305: Cgroups v2 support. https://bugs.openjdk.org/browse/JDK-8230305
[5] Linux Kernel Documentation. CFS Bandwidth Control. https://docs.kernel.org/scheduler/sched-bwc.html
[6] Indeed Engineering. Unthrottled: Fixing CPU Limits in the Cloud. 2019. (Correção no kernel 5.4, commit de53fd7ae.)
[7] Kubernetes Documentation. Resource Management for Pods and Containers. https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
[8] Yang, A. M., Wrigstad, T. Deep Dive into ZGC: A Modern Garbage Collector in OpenJDK. ACM Transactions on Programming Languages and Systems (TOPLAS), 2022. https://dl.acm.org/doi/10.1145/3538532
[9] OpenJDK. JEP 474: ZGC — Generational Mode by Default; JEP 490: ZGC — Remove the Non-Generational Mode. https://openjdk.org/jeps/474 · https://openjdk.org/jeps/490
[10] Kubernetes Documentation. Configure Liveness, Readiness and Startup Probes. https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
[11] OpenJDK, Projeto Leyden. JEP 483: Ahead-of-Time Class Loading & Linking (JDK 24); JEP 514: Ahead-of-Time Command-Line Ergonomics; JEP 515: Ahead-of-Time Method Profiling (JDK 25). https://openjdk.org/jeps/483
[12] Wimmer, C. et al. Initialize Once, Start Fast: Application Initialization at Build Time. Proc. ACM Program. Lang. (OOPSLA), 2019. https://dl.acm.org/doi/10.1145/3360610