PerfectScale
Como identificar o que torna seu serviço caro para iniciar?
Descubra por que um serviço Java demora ou consome muita CPU ao iniciar usando logs, JFR e async-profiler, e transforme as descobertas em decisões de dimensionamento no Kubernetes.
Esta página também está disponível em English, Deutsch, Español, Français, Italiano e 日本語.
Um serviço Java pode ser caro para iniciar por muitos motivos. Ele pode carregar muitas classes, compilar código, construir um grande conjunto de beans, esperar por um banco de dados ou preencher um cache. O custo aparece como um tempo até Ready longo, alto uso de CPU, ou ambos.
Cada motivo pede uma correção diferente. Se você chutar, vai ajustar a coisa errada. Mais CPU não ajuda um serviço que espera por um banco de dados. Um limite de CPU mais baixo torna um serviço ocupado mais lento enquanto ele compila código.
Você pode encontrar a causa real com medições e um perfil. Logs, o Java Flight Recorder (JFR) e o async-profiler mostram, cada um, uma parte diferente da inicialização. Quando você conhece a causa, pode escolher configurações de CPU e memória que sejam seguras para o serviço.
O que torna um serviço caro para iniciar?
Um serviço é caro para iniciar quando precisa de muito tempo, muita CPU, ou ambos, antes de poder atender tráfego. Três medidas descrevem esse custo:
a. Tempo até Ready: O tempo até Ready é o tempo entre o início do contêiner e o momento em que o pod fica Ready. Um tempo até Ready longo atrasa os rollouts, deixa o scale-out mais lento e aumenta o risco de falha nas probes. Este comando imprime os dois timestamps para o contêiner chamado app:
kubectl get pod <pod-name> -o jsonpath='{.status.containerStatuses[?(@.name=="app")].state.running.startedAt}{"\n"}{.status.conditions[?(@.type=="Ready")].lastTransitionTime}{"\n"}'A diferença entre os dois valores é o tempo até Ready. A linha de log do Spring Boot "Started ... in N seconds" mostra um número menor. Ela começa a contar quando a aplicação inicia e não inclui o tempo que a JVM e o contêiner precisam para iniciar.
b. CPU-segundos até o Ready: Um CPU-segundo é uma CPU trabalhando por um segundo. Os CPU-segundos até o Ready representam o trabalho total de CPU da inicialização. Um contêiner não pode usar mais CPU do que o seu limite, então os CPU-segundos divididos pelo limite são o menor tempo possível até Ready. No cgroup v2, leia o campo usage_usec assim que o pod ficar Ready:
kubectl exec <pod-name> -c app -- cat /sys/fs/cgroup/cpu.statO campo usage_usec é o tempo de CPU que o contêiner usou desde que iniciou, em microssegundos. Divida-o por 1.000.000 para obter os CPU-segundos. Os campos nr_throttled e throttled_usec no mesmo arquivo mostram quanto o kernel aplicou throttling ao contêiner. Se você ler o arquivo tarde demais, o valor inclui o trabalho feito depois do Ready.
c. Warmup depois do Ready: Ready não significa rápido. Quando o pod fica Ready, a JVM ainda executa muitos métodos no interpretador, que é lento, e os compiladores JIT continuam compilando em segundo plano. Durante esse warmup, o serviço usa mais CPU do que no estado estável, e as primeiras requisições podem ser lentas. Se você reduzir os recursos no momento do Ready, pode deixar o warmup mais lento. Meça o uso de CPU por vários minutos depois do Ready, e não apenas até o Ready.

Ferramentas de profiling de inicialização
Existem três ferramentas para encontrar a causa de uma inicialização cara. Comece pela mais barata e use a próxima só quando precisar de mais detalhes:
a. Logs e o endpoint de startup do Spring Boot: A linha de log do Spring Boot "Started ... in N seconds" dá um primeiro número. Para mais detalhes, o endpoint startup do Spring Boot Actuator lista cada etapa da inicialização com sua duração. Ele exige um BufferingApplicationStartup na aplicação:
SpringApplication application = new SpringApplication(MyApplication.class);application.setApplicationStartup(new BufferingApplicationStartup(4096));application.run(args);O número 4096 é a capacidade do buffer, ou seja, o número máximo de etapas que ele armazena. Depois, exponha o endpoint. Mantenha health na lista para que as suas probes continuem funcionando:
management.endpoints.web.exposure.include=health,startupUma requisição GET para /actuator/startup retorna um snapshot das etapas. Uma requisição POST retorna as etapas e limpa o buffer. Cada etapa tem um nome, uma duração e tags, como o nome do bean. Verifique primeiro as etapas mais longas. Nomes como spring.beans.instantiate, spring.context.config-classes.parse e spring.context.refresh mostram para onde o tempo está indo. Para spring.beans.instantiate, a tag beanName identifica o bean, então você consegue encontrar o bean mais lento. Os nomes das etapas podem mudar entre versões do Spring.
Duas opções de log da JVM acrescentam mais dados. A opção -Xlog:gc* grava os eventos de coleta de lixo, e a opção -Xlog:class+load grava cada carregamento de classe. Você pode passar as duas em JAVA_TOOL_OPTIONS.
b. Java Flight Recorder (JFR): O JFR vem embutido no JDK. Ele registra eventos da JVM, como coletas de lixo, compilações JIT e amostras de execução. As configurações padrão adicionam pouquíssimo overhead, e as configurações profile registram mais eventos. O JFR inicia junto com a JVM quando você usa a opção -XX:StartFlightRecording, então ele captura toda a inicialização. Depois de copiar o arquivo para fora do pod, leia-o com a ferramenta jfr do JDK ou com o JDK Mission Control:
jfr summary startup.jfrjfr print --events jdk.GarbageCollection,jdk.Compilation startup.jfrjfr print --events jdk.ExecutionSample --stack-depth 8 startup.jfrO primeiro comando lista os tipos de evento e suas contagens. O segundo imprime os eventos de coleta de lixo e as compilações JIT com suas durações. O terceiro imprime amostras do código que as threads executam, com uma profundidade de pilha de 8 frames. O evento jdk.Compilation registra apenas compilações que levam mais tempo do que o limite das suas configurações.
c. async-profiler: O async-profiler é um profiler por amostragem para Java que gera flame graphs. Dois dos seus modos são relevantes para a inicialização. O modo cpu amostra apenas as threads que usam CPU, mostrando o que consome CPU. O modo wall amostra todas as threads em um intervalo fixo, estejam elas executando, dormindo ou esperando, mostrando para onde o tempo vai, incluindo as esperas. O async-profiler pode iniciar junto com a JVM como agente, capturando o primeiro segundo.
Capturando um perfil de inicialização no Kubernetes
A gravação precisa iniciar junto com a JVM e rodar sob as condições reais do serviço. Dois pontos importam:
a. Faça o profiling com os limites de CPU de produção: Use a mesma imagem, as mesmas flags de JVM e as mesmas configurações de CPU e memória da produção. Um limite diferente muda a própria inicialização. Ele altera o throttling, a contagem de CPUs que a JVM enxerga e o coletor que a JVM seleciona. Um perfil capturado sem o limite de produção mostra uma inicialização diferente. Para se aprofundar, leia CPU throttling during JVM startup.
Este template de pod inicia o JFR junto com a JVM e grava o arquivo em um volume:
spec: template: spec: containers: - name: app image: registry.example.com/orders-service:1.8.0 env: - name: JAVA_TOOL_OPTIONS value: >- -XX:StartFlightRecording=filename=/profiles/startup-%p.jfr,settings=profile,duration=120s,dumponexit=true resources: requests: cpu: 300m memory: 2Gi limits: cpu: "2" memory: 2Gi volumeMounts: - name: profiles mountPath: /profiles volumes: - name: profiles emptyDir: sizeLimit: 500MiCada configuração tem uma função:
filename=/profiles/startup-%p.jfrgrava o arquivo no volume. O texto%pvira o ID do processo.settings=profileregistra mais eventos do que as configurações padrão.duration=120sencerra a gravação após 120 segundos. Escolha um tempo que cubra a inicialização e vários minutos de warmup.dumponexit=truegrava o arquivo mesmo se a JVM encerrar antes, por exemplo, quando uma probe mata o contêiner.emptyDiroferece uma pasta gravável mesmo quando o sistema de arquivos raiz é somente leitura. OsizeLimitevita que um arquivo grande encha o disco do node.- Os valores de CPU e memória são exemplos. Use os seus valores de produção.
Em um contêiner, a JVM geralmente é o processo 1, então o texto %p vira 1 e o arquivo fica com o nome startup-1.jfr. Se você quiser manter os arquivos de várias execuções em um único volume, adicione %t ao nome do arquivo. Ele adiciona um timestamp. Tanto o JFR quanto o async-profiler aceitam %t.
Para usar o async-profiler, coloque o libasyncProfiler.so na imagem ou em um volume que um init container preenche com o arquivo de release do async-profiler. Depois, mude a variável de ambiente:
env: - name: JAVA_TOOL_OPTIONS value: >- -agentpath:/profiler/lib/libasyncProfiler.so=start,event=cpu,threads,timeout=120,file=/profiles/startup-cpu-%p.htmlAs opções do agente funcionam assim: start inicia o profiling quando a JVM inicia, event=cpu seleciona o modo de CPU, threads faz o profiling de cada thread separadamente, timeout=120 encerra após 120 segundos e file=...html grava um flame graph. Para um perfil de wall-clock, substitua event=cpu por event=wall,interval=10ms. Contêineres costumam bloquear eventos perf. Se o profiler não iniciar, use event=ctimer, que funciona sem eventos perf.
Use apenas um profiler em cada execução e repita cada execução três vezes. Uma única execução pode enganar você.
b. Copiando os perfis para fora do pod: Use kubectl cp para copiar a pasta para o seu computador:
kubectl cp <namespace>/<pod-name>:/profiles ./profiles -c appO comando precisa do programa tar na imagem do contêiner. Se a imagem não tiver tar, o comando falha. Nesse caso, monte o mesmo volume em um pequeno segundo contêiner que tenha tar e use o nome dele com -c. Copie os arquivos antes que o pod seja removido, porque o Kubernetes apaga um volume emptyDir junto com o pod. Abra um arquivo HTML no navegador.
Lendo um flame graph de inicialização
Um flame graph mostra os stack traces de todas as amostras. Cada caixa é um método, e a sua largura é a fração das amostras que o incluem. Em um gráfico do async-profiler, os frames mais externos, como main, ficam na base, e os frames folha, que fazem o trabalho, ficam no topo. Caixas largas mostram para onde o tempo vai. Por causa da opção threads, cada thread tem a sua própria torre, e a torre começa com uma caixa que traz o nome da thread. O Linux corta os nomes das threads em 15 caracteres, então nomes longos aparecem encurtados no gráfico. Três grupos de threads importam:
a. Threads da aplicação: A thread que executa main roda o código de inicialização da aplicação. A sua caixa raiz pode se chamar main ou DestroyJavaVM, então encontre-a pelo frame SpringApplication.run, e não pelo nome. Uma torre larga de SpringApplication.run, ou uma torre larga de refresh, significa que a inicialização do framework consome a maior parte do tempo. Caixas largas com defineClass ou loadClass indicam carregamento de classes. Caixas largas com os nomes dos seus próprios pacotes indicam código de inicialização seu, como o carregamento de um cache ou uma migração de banco de dados.
b. Threads do compilador JIT: Raízes que começam com C1 CompilerThre e C2 CompilerThre são os compiladores JIT. Os nomes completos das threads são C1 CompilerThread0, C2 CompilerThread0 e assim por diante. Se essas torres forem tão largas quanto a torre da thread de inicialização, o trabalho do JIT é uma parte grande da CPU da inicialização. Para ver quais frames rodam no interpretador, grave o perfil em um arquivo com a extensão .collapsed e adicione a opção ann. Nesse arquivo, frames interpretados terminam com _[0], frames do C1 com _[1], outros frames compilados com _[j] e frames inlined com _[i]. Muitos frames _[0] na thread de inicialização significam que o código ainda não foi compilado. O gráfico HTML não adiciona esses sufixos. Ele mostra os tipos de frame com cores.
c. Threads de GC: Com o G1, raízes como GC Thread#0, G1 Conc#0 e G1 Refine#0 fazem o trabalho de coleta de lixo. Com o coletor Serial, a VM Thread faz esse trabalho. Um limite de CPU de 1 CPU ou menos, ou um limite de memória abaixo de 1.792 MB, faz a JVM selecionar o coletor Serial, então um gráfico de um pod assim não tem worker threads de GC. Uma torre larga de coleta de lixo nos primeiros segundos significa que o heap é pequeno demais para a alocação da inicialização, ou que ele cresce muitas vezes. Confirme isso com os eventos jdk.GarbageCollection do JFR, em que o nome do evento mostra o coletor (por exemplo, DefNew e SerialOld pertencem ao coletor Serial), ou com a opção de log -Xlog:gc*.
Compare um gráfico cpu com um gráfico wall da mesma inicialização. Um gráfico wall também mostra muitas threads ociosas, como as threads do servidor web. Ignore-as e foque na thread de inicialização.

Categorias comuns de custo de inicialização
A maioria das inicializações caras se encaixa em uma ou mais de cinco categorias. Em cada categoria, o perfil tem um sinal claro, e a decisão de dimensionamento é diferente:
a. Aplicações com framework pesado: O sinal é uma torre larga sob SpringApplication.run na thread de inicialização, muitas etapas spring.beans.instantiate e muito carregamento de classes. O trabalho é limitado por CPU, então um limite de CPU baixo o desacelera diretamente. O Spring Boot pode criar beans no primeiro uso com spring.main.lazy-initialization=true. O Class Data Sharing e o cache AOT removem parte do trabalho de carregamento de classes. O cache AOT chegou no JDK 24 (JEP 483).
b. Workloads com JIT pesado: O sinal são torres largas de C1 CompilerThre e C2 CompilerThre e um uso de CPU que continua alto depois que o pod fica Ready. O cache AOT do JDK 25 pode armazenar perfis de métodos de uma execução de treinamento (JEP 515). A JVM passa então a precisar de menos tempo para o profiling, e o JIT pode começar o seu trabalho mais cedo. Os compiladores JIT continuam rodando, então o warmup não desaparece.
c. Pressão de GC e de heap: O sinal são muitos eventos de coleta de lixo nos primeiros segundos e torres largas de GC. Por padrão, a JVM começa com um heap pequeno e o aumenta conforme a aplicação aloca objetos, e cada etapa de crescimento adiciona trabalho de CPU. Você pode definir um heap inicial maior com -XX:InitialRAMPercentage. Um heap inicial maior usa mais memória desde o começo, então verifique o limite de memória primeiro. Mantenha o heap inicial abaixo do heap máximo, que por padrão é 25% da memória do contêiner (-XX:MaxRAMPercentage).
d. Esperas de I/O e de dependências: O sinal é um uso de CPU baixo e um tempo até Ready longo. O gráfico wall mostra a thread de inicialização em leituras de socket, sleeps ou chamadas park. Causas típicas são conexões com banco de dados, migrações de schema, configuração remota, consultas DNS e handshakes TLS.
e. Aquecimento antecipado de cache: O sinal são threads da aplicação no seu próprio código de carregamento de cache e um uso de CPU e memória que sobe enquanto o cache carrega, às vezes depois que o pod fica Ready. Decida se o aquecimento precisa terminar antes do Ready. Se sim, inclua a CPU e a memória dele no dimensionamento da inicialização. Se não, mova-o para depois do Ready e acompanhe o uso de CPU.
Transformando descobertas em decisões de dimensionamento
Uma descoberta vira uma decisão de dimensionamento quando você responde a duas perguntas. O trabalho é limitado por CPU ou fica esperando? O trabalho para depois da inicialização? Estas são as decisões:
a. Limitado por CPU e curto (framework pesado e JIT pesado): Dê CPU suficiente para a inicialização. Defina o limite de CPU acima da necessidade da inicialização, ou use um boost de startup ou um resize in-place, e mantenha o request perto do estado estável. Se o limite for baixo demais, o pod fica Ready tarde, e a startup probe pode falhar.
b. Esperando por dependências: Não aumente a CPU. Corrija ou mova a espera, por exemplo, executando a migração como um job separado. Defina a janela da startup probe, que é failureThreshold × periodSeconds, para pelo menos o tempo até Ready mais lento que você mediu, mais uma margem. Neste exemplo, a janela é 24 × 5 = 120 segundos:
startupProbe: httpGet: path: /actuator/health/liveness port: 8080 periodSeconds: 5 failureThreshold: 24Se a janela for curta demais, o kubelet reinicia o contêiner, e o novo contêiner repete a mesma espera.
c. Pressão de GC e de heap: Aumente o heap inicial ou o limite de memória. Para contêineres com JVM, mantenha o request de memória igual ao limite de memória. Não reduza o limite de memória depois da inicialização, porque a JVM define o seu heap máximo na inicialização.
d. Warmup depois do Ready: Mantenha os recursos de inicialização até o uso de CPU cair para o estado estável.
e. Verifique depois de cada mudança: Mude uma coisa de cada vez, faça o profiling de novo e compare o tempo até Ready e os CPU-segundos até o Ready. Uma mudança só ajuda se as duas medidas melhorarem.

Profiling de inicialização em muitos serviços
Você pode fazer o profiling de um serviço manualmente, mas não dá para fazer isso com todos os serviços. Use métricas do cluster para encontrar os serviços que custam mais para iniciar e faça o profiling só desses. Três passos ajudam:
a. Classifique os pods pelo tempo até Ready: Esta query usa duas métricas do kube-state-metrics. Substitua app pelo nome do seu contêiner:
topk(20, max by (namespace, pod) ( kube_pod_status_ready_time - on (namespace, pod, uid) kube_pod_container_state_started{container="app"} ))A query retorna apenas pods que ainda têm métricas. Pods que o Kubernetes removeu há muito tempo não aparecem.
b. Classifique os contêineres pela CPU nos primeiros 10 minutos: O contador de CPU começa em zero quando um contêiner inicia, então o seu valor após 10 minutos corresponde aos CPU-segundos dos primeiros 10 minutos:
topk(20, max by (namespace, container) ( max_over_time( ( container_cpu_usage_seconds_total{container!~"POD|"} and on (namespace, pod) (time() - kube_pod_start_time < 600) )[7d:1m] ) ))Troque 600 pelo tempo de inicialização do seu serviço. Execute esta query quando os pods tiverem mais de 10 minutos, porque pods mais novos mostram só parte da sua CPU. O resultado é o maior valor entre os pods de cada contêiner, então a query classifica contêineres, e não pods individuais.
c. Faça o profiling dos principais serviços: Pegue os serviços bem posicionados nas duas queries. Capture um perfil de inicialização de cada um sob os seus limites de produção e atribua cada serviço a uma das cinco categorias.
A PerfectScale mostra as principais métricas de JVM em todos os seus clusters. Para contêineres Java, ela coleta o uso de memória heap e não heap, junto com métricas de coleta de lixo (GC), incluindo tempo de GC, contagem de processadores ativos e tipo de GC. A coleta de métricas de JVM pode ser habilitada durante a instalação do agente da PerfectScale, sem nenhuma configuração adicional. Esse recurso é compatível apenas com nodes Linux.
Com a coleta de dados de JVM habilitada, a PerfectScale oferece visibilidade granular do comportamento da aplicação, ajudando você a identificar serviços com altos requisitos de recursos na inicialização. A PerfectScale detecta esses serviços automaticamente e fornece duas recomendações para cada workload: uma para a inicialização e outra para o runtime em estado estável.
A PerfectScale aplica a recomendação de inicialização quando um pod é criado e depois ajusta os recursos in-place para corresponder à recomendação de runtime assim que o pod atinge o estado estável, ajudando você a otimizar a alocação de recursos nas duas fases sem comprometer o desempenho da aplicação. A PerfectScale também respeita os seus limites de tamanho de heap da JVM, então as recomendações nunca ultrapassam o heap máximo configurado nem ficam abaixo do mínimo configurado. E não é necessário reiniciar o pod nem intervir manualmente.
O resize in-place requer o Kubernetes 1.33 ou posterior e a política de resize de contêiner restartPolicy: NotRequired. Ele suporta Deployments, StatefulSets, DaemonSets e Argo Rollouts, e não suporta pods Windows.
Acertar o dimensionamento dos pods Java é fundamental para equilibrar o desempenho da aplicação e a eficiência de recursos. Este guia cobre o essencial, mas se você quiser ver uma abordagem mais avançada na prática, participe com Vikram Seshadri, da DoiT, do workshop prático "Java on K8s: The Startup vs. Steady-State Sizing Dilemma", em 28 de outubro de 2026, às 11h (ET). Ele mostra como dimensionar um pod JVM para a inicialização e como redimensioná-lo para o estado estável sem reiniciar. Garanta sua vaga.
Quer ver como a PerfectScale faz o right-sizing dos seus workloads Java? Comece grátis ou agende uma sessão técnica.
