PerfectScale
Votre service JVM a besoin de 2 CPU pendant 30 secondes, puis de 0,3 CPU à vie
C'est dans ses premières secondes qu'une JVM a le plus besoin de CPU. Découvrez comment les limites CPU de Kubernetes brident le démarrage de la JVM, comment le mesurer et comment dimensionner vos pods.
Cette page est également disponible en English, Deutsch, Español, Italiano, 日本語 et Português.
C'est dans ses premières secondes qu'un service Java a le plus besoin de CPU. La JVM charge les classes, compile le code et construit l'application. Ensuite, le service a besoin de beaucoup moins de CPU. Dans la plupart des clusters, un pod conserve la même request CPU et la même limite CPU pendant toute sa durée de vie. Le pod ne peut donc pas suivre ce schéma.
Ce décalage provoque du CPU throttling Kubernetes pendant le démarrage de la JVM. Si la limite est basse, la JVM ne peut pas terminer rapidement son travail de démarrage, et le pod passe à l'état Ready tardivement. Si la request est élevée, le nœud réserve du CPU que le service n'utilise plus après le démarrage. Aucun des deux réglages ne convient aux deux phases.
Les chiffres du titre ne sont qu'un exemple. Votre service a ses propres chiffres, et cet article vous montre comment vérifier ce qu'il en est chez vous. Il explique pourquoi le démarrage exige plus de CPU, comment Kubernetes applique les réglages CPU et ce que la JVM déduit de ces réglages. En fin d'article, vous trouverez les options dont vous disposez aujourd'hui.
Pourquoi le démarrage Java exige-t-il plus de CPU que le régime stable ?
Avant qu'une JVM ne serve sa première requête, elle doit accomplir une grande quantité de travail. Ce travail comporte quatre volets principaux :
Chargement et liaison des classes
La JVM charge une classe au moment où le code en a besoin pour la première fois. Elle lit le fichier de classe, le vérifie et le lie. Une application Spring a besoin d'un grand nombre de classes dans les premières secondes, car le framework analyse l'application et crée ses beans.
Compilation JIT avec C1 et C2
La JVM commence par interpréter le bytecode, ce qui est lent. Elle compte la fréquence d'exécution de chaque méthode. HotSpot compile ensuite les méthodes les plus sollicitées en code machine avec deux compilateurs. C1 compile vite et produit du code simple. C2 compile plus lentement et produit du code plus rapide pour les méthodes les plus utilisées. Ce mécanisme s'appelle la tiered compilation. Les compilateurs s'exécutent sur des threads dédiés, et la JVM détermine le nombre par défaut de ces threads à partir du nombre de CPU.
Threads de garbage collection et croissance du heap
Le démarrage alloue de nombreux objets. Le heap grossit, et le collecteur s'exécute pour libérer de la mémoire. La JVM choisit le collecteur et son nombre de threads à partir du nombre de CPU et de la taille mémoire.
Initialisation du framework
Le framework construit les beans, crée les proxys, lit la configuration et ouvre les connexions. Spring Boot Actuator peut lister chacune de ces étapes dans son endpoint de démarrage lorsque vous utilisez BufferingApplicationStartup, ce qui vous aide à voir où passe le temps.
Chaque volet a une fin. Une fois les classes chargées, les méthodes sollicitées compilées et les beans créés, la demande en CPU retombe. La baisse n'est pas instantanée, car les compilateurs JIT continuent de compiler des méthodes à mesure que le trafic réel arrive. Il n'empêche : le régime stable nécessite bien moins de CPU que le démarrage. L'écart entre ces deux phases explique pourquoi un seul réglage CPU ne peut pas convenir aux deux.
Comment Kubernetes applique-t-il les requests et limits CPU ?
Kubernetes contrôle le CPU d'un conteneur avec deux réglages :
Requests CPU et scheduling
La request CPU est la quantité de CPU que Kubernetes réserve pour un conteneur. Le scheduler additionne les requests des pods de chaque nœud et ne place un nouveau pod que sur un nœud disposant d'assez de CPU non réservé. La request détermine aussi la part de CPU sur un nœud chargé. Lorsque des conteneurs se disputent le CPU, le noyau accorde plus de temps CPU à un conteneur dont la request est plus élevée. Sur cgroup v2, le runtime de conteneurs écrit la request dans cpu.weight. Sur cgroup v1, il utilise cpu.shares.
Si vous définissez une limite sans request, Kubernetes utilise la limite comme request. La request et la limite déterminent aussi la classe QoS du pod. Un pod est Guaranteed lorsque chaque conteneur a la même valeur pour sa request CPU et sa limite CPU, et la même valeur pour sa request mémoire et sa limite mémoire. Il est Burstable lorsqu'il a certaines requests ou limits mais ne respecte pas la règle Guaranteed. Il est BestEffort lorsqu'il n'en a aucune.
Limites CPU et throttling
La limite CPU est un plafond strict, et le noyau la fait respecter par throttling. Le noyau découpe le temps en périodes, et le kubelet utilise par défaut une période de 100 ms. À chaque période, le conteneur peut consommer un quota de temps CPU. La limite fixe la taille du quota. Le runtime écrit la limite dans cpu.max sur cgroup v2. Une limite de 500m devient 50000 100000, soit un quota de 50 000 microsecondes pour chaque période de 100 000 microsecondes.
resources: requests: cpu: 300m # réserve du CPU et fixe la part de CPU sur un nœud chargé memory: 2Gi limits: cpu: "1" # plafond strict : écrit sous la forme 100000 100000 dans cpu.max memory: 2GiQu'est-ce que le CPU throttling Kubernetes ?
Le throttling est le mécanisme par lequel le noyau fait respecter une limite CPU. Lorsque les threads d'un conteneur consomment la totalité du quota d'une période, le noyau les met tous en pause jusqu'au début de la période suivante.
Les threads partagent un seul quota, et ce détail compte pour une JVM. Prenez une limite de 500m, soit 50 ms de temps CPU par période. Un seul thread actif a besoin de 50 ms pour épuiser le quota : il tourne donc pendant la moitié de la période. Huit threads actifs consomment le même quota en 6 ms environ, puis le noyau met les huit en pause pour le reste de la période. Une JVM au démarrage fait tourner de nombreux threads actifs en même temps : elle atteint donc son quota très tôt à chaque période.
Kubernetes n'arrête pas un conteneur qui consomme trop de CPU. Il le ralentit. Kubernetes ne signale pas le throttling sous forme d'événement, et les logs du conteneur n'affichent aucune erreur. Les dégâts se manifestent sous forme de latence, et vous ne les découvrez qu'en les mesurant.
Le CPU throttling pendant le démarrage de la JVM
Une grande partie du démarrage de la JVM est un travail limité par le CPU. La JVM a une quantité fixe de travail à terminer avant que l'application puisse servir du trafic, et la limite détermine la vitesse à laquelle le conteneur peut accomplir ce travail. Une limite plus élevée termine le travail plus tôt. Une limite plus basse étale le même travail sur davantage de périodes, et le conteneur attend à chaque période après avoir épuisé son quota. Le throttling au démarrage pose deux problèmes :
Démarrage lent et readiness tardive
Une JVM throttlée met plus de temps à démarrer, donc le pod passe à l'état Ready plus tard. Ce délai a des coûts au-delà du pod. Un rolling update attend que chaque nouveau pod soit Ready : chaque déploiement prend donc plus de temps. Les nouvelles répliques n'aident qu'une fois Ready : le scale-out est donc plus lent lorsque le trafic augmente. Lors d'une défaillance de nœud, les pods de remplacement ont eux aussi besoin du même temps supplémentaire.
Probes en échec et redémarrages
Une startup probe indique au kubelet que l'application a démarré. Pendant que la startup probe s'exécute, Kubernetes n'exécute pas les probes liveness et readiness. Si la startup probe ne réussit pas dans un délai de failureThreshold × periodSeconds, le kubelet redémarre le conteneur. Utilisez une startup probe pour un conteneur dont le démarrage est long, et pointez-la vers le même endpoint que la liveness probe.
Sans startup probe, un démarrage lent peut provoquer une boucle de redémarrages. La liveness probe échoue pendant que la JVM démarre encore, le kubelet redémarre le conteneur, et le nouveau conteneur refait le même travail CPU. Une JVM throttlée rend cette boucle plus probable, car son démarrage dure plus longtemps.
Une startup probe avec une fenêtre large évite la boucle :
startupProbe: httpGet: path: /actuator/health/liveness port: 8080 periodSeconds: 5 failureThreshold: 24 # attend jusqu'à 24 x 5 = 120 secondesUne fenêtre large stoppe les redémarrages, mais elle n'accélère pas le démarrage. Le pod passe toujours à l'état Ready tardivement.
Comment mesurer le throttling ?
Deux compteurs cAdvisor révèlent le throttling. Le compteur container_cpu_cfs_periods_total compte toutes les périodes. Le compteur container_cpu_cfs_throttled_periods_total compte les périodes pendant lesquelles le conteneur a épuisé la totalité de son quota. Divisez le second compteur par le premier pour obtenir la part de périodes throttlées.
sum(rate(container_cpu_cfs_throttled_periods_total{container="app"}[5m]))/sum(rate(container_cpu_cfs_periods_total{container="app"}[5m]))Un taux sur cinq minutes masque un démarrage qui ne dure que quelques secondes. Pour le démarrage, lisez les compteurs bruts au moment où le pod devient Ready, et faites la division vous-même. Sur cgroup v2, le conteneur expose aussi les mêmes données dans cpu.stat. Les champs nr_periods, nr_throttled et throttled_usec indiquent le nombre de périodes, le nombre de périodes throttlées et le temps total de throttling.
kubectl exec <pod-name> -- cat /sys/fs/cgroup/cpu.statVous pouvez aussi lire les valeurs cAdvisor depuis le kubelet :
kubectl get --raw /api/v1/nodes/<node-name>/proxy/metrics/cadvisor | grep container_cpu_cfsGardez à l'esprit l'intervalle d'échantillonnage. Le système de métriques ne met à jour ses valeurs qu'à intervalle fixe. Un pic de démarrage plus court que cet intervalle paraît plus petit qu'il ne l'est, ou n'apparaît pas du tout. Pour les pics courts, échantillonnez les compteurs bruts plus souvent.
Comment la limite CPU modifie-t-elle le comportement de la JVM ?
La limite CPU ne fait pas que ralentir la JVM. Sous Linux, la JVM prend en charge les conteneurs par défaut (-XX:+UseContainerSupport). Elle lit la limite à son démarrage. Ces trois points montrent comment cette valeur change ce que fait la JVM :
Nombre de processeurs actifs déduit des limites CPU
La JVM divise le quota CPU par la période et arrondit le résultat à l'entier supérieur. Une limite de 1001m donne donc 2 CPU. JDK 19 et versions ultérieures, ainsi que JDK 17.0.5 et versions ultérieures, n'utilisent pas la request CPU (cpu.shares) pour compter les CPU. De nombreux réglages de la JVM dépendent de ce nombre, dont le nombre de threads du compilateur JIT et le nombre de threads GC.
Choix du garbage collector (Serial ou G1)
La JVM considère une machine comme server-class lorsqu'elle dispose d'au moins 2 processeurs et d'au moins 1792 Mo de mémoire. Sur une machine server-class, la JVM sélectionne G1. Sinon, elle sélectionne le collecteur Serial. Un petit changement de limite peut donc changer de collecteur. Une limite de 1 CPU donne un processeur : la JVM sélectionne Serial. Une limite de 1100m donne deux processeurs : la JVM sélectionne G1 si la mémoire atteint au moins 1792 Mo. Les deux collecteurs se comportent différemment au démarrage. G1 lance plusieurs threads de travail qui s'exécutent en parallèle et peut donc utiliser plus de CPU à la fois. Serial collecte avec un seul thread.
Pods sans limite CPU
Sans limite, la JVM compte les CPU du nœud. Sur un gros nœud, elle voit de nombreux CPU et lance de nombreux threads de compilation et de GC. Le pod n'est alors pas throttlé, mais il peut consommer une grande quantité de CPU au total, et il entre en concurrence avec ses voisins au prorata de sa request. Une petite request donne à ce pod une petite part lorsque le nœud est chargé.
Vous pouvez vérifier les choix de votre propre JVM. Activez le log conteneur et lisez les flags de la JVM en cours d'exécution :
env: - name: JAVA_TOOL_OPTIONS value: "-Xlog:os+container=trace -Xlog:gc*"kubectl exec <pod-name> -- jcmd 1 VM.flags -all \ | grep -E 'UseSerialGC|UseG1GC|ParallelGCThreads|CICompilerCount'Remarque : la commande nécessite jcmd dans l'image et suppose que la JVM tourne en tant que processus 1. Si un shell lance la JVM, utilisez le véritable ID de processus de la JVM. Une image contenant uniquement un JRE n'inclut pas jcmd.
Le flag -XX:ActiveProcessorCount=<n> remplace le nombre de CPU détecté. Utilisez-le lorsque vous voulez le même comportement de la JVM dans tous les environnements.
Pourquoi aucune request CPU unique n'est-elle sûre ?
Chaque façon de définir la request a un coût. Voici les quatre choix possibles :
Dimensionner pour le démarrage
Une request et une limite élevées donnent à la JVM assez de CPU pour démarrer vite. Mais le nœud réserve cette request élevée pendant toute la vie du pod, et les autres pods ne peuvent pas utiliser ce CPU. Après le démarrage, le service n'utilise qu'une petite partie de la réservation. Multiplié par de nombreuses répliques et de nombreux nœuds, ce CPU inutilisé devient un coût important.
Dimensionner pour le régime stable
Une request et une limite faibles conviennent au service après le démarrage. Mais la JVM dispose alors de trop peu de CPU pour son travail de démarrage. Le noyau la throttle, le pod devient Ready tardivement, les probes peuvent échouer et les déploiements prennent plus de temps. Le service est peu coûteux mais fragile.
Supprimer la limite CPU
Sans limite, le noyau ne throttle pas le conteneur, et le démarrage peut utiliser tout le CPU libre du nœud. Ce choix a ses contreparties. La JVM dimensionne ses threads pour tous les CPU du nœud. Sur un nœud chargé, le conteneur n'obtient du temps CPU qu'au prorata de sa request : une petite request peut donc encore ralentir le démarrage. De plus, sans limite, un pod peut accaparer du CPU dont ses voisins ont besoin.
HPA et période d'initialisation CPU
Le Horizontal Pod Autoscaler (HPA) mesure l'utilisation du CPU en pourcentage de la request. Une petite request fait passer le pic de démarrage pour une charge très élevée, et le HPA peut ajouter des répliques dont le service n'a pas besoin. Kubernetes réduit ce risque, car le HPA peut ignorer les échantillons CPU d'un nouveau pod. Par défaut, le délai de readiness est de 30 secondes (--horizontal-pod-autoscaler-initial-readiness-delay) et la période d'initialisation CPU est de 5 minutes (--horizontal-pod-autoscaler-cpu-initialization-period). Ces deux flags appartiennent à kube-controller-manager : sur un cluster managé, vous ne pourrez peut-être pas les modifier.
Les moyens de gérer le CPU de démarrage aujourd'hui
Voici les moyens à votre disposition pour gérer le CPU de démarrage :
Réduire le travail de démarrage
Moins de travail signifie un démarrage plus court, quelle que soit la limite. Spring Boot peut créer les beans à la première utilisation avec spring.main.lazy-initialization=true. Le Class Data Sharing et le cache AOT peuvent supprimer une partie du travail de chargement des classes. Le cache AOT est arrivé avec JDK 24 (JEP 483). Spring Framework prend en charge à la fois le CDS et le cache AOT. Le profiling montre où passe votre propre temps de démarrage.
Fixer la limite CPU au-dessus du besoin de démarrage
Gardez la request proche du niveau de régime stable et fixez la limite assez haut pour le démarrage. Le pod relève alors de la classe QoS Burstable. Le scheduler ne réserve que la request, et le pod peut consommer du CPU jusqu'à la limite lorsque le nœud a du CPU libre. Au démarrage, le nœud peut ne pas en avoir : cette option est donc surtout utile sur des nœuds disposant de capacité disponible. Une limite plus élevée change aussi le nombre de CPU que voit la JVM.
Le redimensionnement de pod en place (in-place pod resize)
L'in-place pod resize modifie le CPU et la mémoire d'un pod en cours d'exécution sans redémarrage. La fonctionnalité est passée en bêta dans Kubernetes 1.33 et est devenue stable dans Kubernetes 1.35. Avec elle, un pod peut démarrer avec une limite CPU élevée et passer à une limite plus basse après le démarrage. Un conteneur contrôle ce comportement via une resize policy. La policy par défaut pour le CPU est NotRequired, et vous pouvez l'indiquer dans le manifeste :
containers: - name: app resizePolicy: - resourceName: cpu restartPolicy: NotRequiredVous modifiez les ressources via la sous-ressource resize. Cette commande nécessite kubectl 1.32 ou version ultérieure :
kubectl patch pod <pod-name> --subresource resize --patch \ '{"spec":{"containers":[{"name":"app","resources":{"requests":{"cpu":"300m"},"limits":{"cpu":"500m"}}}]}}'Le champ status.containerStatuses[].resources affiche les ressources que le conteneur utilise actuellement. Certains pods ne peuvent pas utiliser l'in-place resize. Les pods Windows et les pods soumis à la policy statique du CPU manager ou du Memory manager en sont des exemples. Un resize ne peut pas non plus changer la classe QoS du pod. Il faut par ailleurs que quelqu'un déclenche le resize. Une personne peut lancer le patch à la main, mais pour de nombreux pods, c'est un contrôleur qui doit s'en charger.
Un resize modifie la limite du cgroup, mais la JVM ne choisit certains réglages qu'une seule fois. Elle sélectionne le collecteur au démarrage et le conserve. Les réglages qui dépendent du nombre de CPU, comme le nombre maximal de threads GC, sont eux aussi fixés au démarrage. Runtime.availableProcessors() peut changer pendant l'exécution de la JVM : le code qui lit le nombre de CPU à l'exécution peut donc voir la nouvelle valeur. Testez un resize sur votre propre JDK avant de vous y fier.
Mise en garde sur la mémoire de la JVM
N'abaissez pas la limite mémoire d'un conteneur JVM. La JVM fixe son heap maximal au démarrage. Par défaut, le heap maximal représente 25 % de la mémoire du conteneur (-XX:MaxRAMPercentage). La JVM utilise aussi de la mémoire hors heap, comme le metaspace, les piles de threads et le cache de code. Une limite mémoire inférieure à l'utilisation réelle du processus provoque un OOM kill. Pour cette raison, redimensionnez le CPU après le démarrage et n'augmentez la limite mémoire que si nécessaire.
VPA et options managées de boost au démarrage
Le Vertical Pod Autoscaler (VPA) propose un mode de mise à jour InPlaceOrRecreate. Ce mode tente d'abord un resize en place et recrée le pod lorsque ce resize n'est pas possible. Sur GKE, le CPU startup boost fonctionne avec le VPA. Il augmente la request CPU à la création du pod et conserve cette request plus élevée pendant durationSeconds après le passage du pod à l'état Ready. GKE ramène ensuite la request à sa valeur de référence, en place. Le boost ne s'applique qu'à la création du pod : un redémarrage de conteneur au sein du même pod n'en bénéficie pas.
La fonctionnalité est en Preview au moment de la rédaction et nécessite GKE 1.36.0-gke.4447000 ou version ultérieure.
apiVersion: autoscaling.k8s.io/v1kind: VerticalPodAutoscalermetadata: name: example-vpaspec: targetRef: apiVersion: apps/v1 kind: Deployment name: example updatePolicy: updateMode: "Off" startupBoost: cpu: type: "Factor" factor: 2 durationSeconds: 10Dans cet exemple, le boost double la request CPU (factor: 2) et la conserve pendant 10 secondes après le passage du pod à l'état Ready. Avec updateMode réglé sur Off, le VPA n'applique que le boost et ne fait aucune autre modification. Si vous utilisez un HPA avec le boost, définissez une readiness probe et réglez durationSeconds sur 0. Vous empêchez ainsi le HPA d'ajouter des répliques à cause du pic CPU au démarrage.
L'automatisation en deux phases de PerfectScale
Un service JVM a deux besoins en ressources. Au démarrage, la JVM charge les classes, compile le code et construit l'application : le service a donc besoin de beaucoup de CPU. Ensuite, il en a besoin de beaucoup moins. Une recommandation moyenne unique ne peut pas convenir aux deux phases. PerfectScale fournit deux recommandations pour chaque workload : une pour le démarrage et une pour le régime stable. PerfectScale dimensionne la recommandation de démarrage sur le besoin le plus élevé des deux phases, afin que le nœud dispose d'assez de capacité pendant le démarrage.
PerfectScale applique la recommandation de démarrage à la création du pod. Une fois le pod en régime stable, PerfectScale applique la recommandation runtime en place, sans redémarrage. PerfectScale suit la phase de chaque pod et ajoute des contrôles de sécurité lorsqu'un workload ne peut pas passer proprement à la phase runtime.
L'in-place resize nécessite Kubernetes 1.33 ou version ultérieure et la resize policy de conteneur restartPolicy: NotRequired. Il prend en charge les Deployments, StatefulSets, DaemonSets et Argo Rollouts, et ne prend pas en charge les pods Windows. Lorsque le cluster ne prend pas en charge l'in-place resize, PerfectScale revient à son approche standard et sûre.
PerfectScale protège le heap de la JVM. Lorsque vous définissez une taille de heap maximale ou minimale, PerfectScale n'applique pas de recommandation qui en sortirait. Pour l'in-place resize, la limite mémoire ne peut qu'augmenter. Pour les conteneurs Java, PerfectScale collecte aussi les données de heap, hors heap et de garbage collection. Il affiche le temps de GC, le nombre de processeurs actifs et le type de GC. Vous pouvez activer la collecte des métriques JVM lors de l'installation de l'agent PerfectScale ; elle prend en charge les nœuds Linux uniquement. PerfectScale affiche séparément les ressources de démarrage et de régime stable : les chiffres de coût et de gaspillage reflètent ainsi ce dont le workload avait réellement besoin dans chaque phase.
Participez au workshop. Vikram Seshadri, de DoiT, montre comment dimensionner un pod JVM pour son démarrage, puis le redimensionner vers le régime stable sans redémarrage, lors du workshop pratique "Java on K8s: The Startup vs. Steady-State Sizing Dilemma" le 28 octobre 2026 à 11 h (heure de l'Est). Réservez votre place.
Vous voulez voir comment PerfectScale applique le right-sizing à vos workloads Java ? Commencez gratuitement ou réservez une démo.