PerfectScalePerfectScale

PerfectScale

Tu servicio JVM necesita 2 CPUs durante 30 segundos y 0.3 CPUs el resto de su vida

La JVM exige más CPU en sus primeros segundos. Descubre cómo los límites de CPU en Kubernetes frenan su arranque, cómo medirlo y cómo dimensionar tus pods.

Esta página también está disponible en English, Deutsch, Français, Italiano, 日本語 y Português.

Tania Duggal
By Tania Duggal
Oct 14, 202618 min read

Un servicio Java necesita su máximo de CPU en los primeros segundos. La JVM carga clases, compila código y construye la aplicación. Después de eso, el servicio necesita mucha menos CPU. En la mayoría de los clústeres, un pod mantiene el mismo request de CPU y el mismo límite de CPU durante toda su vida. Por lo tanto, el pod no puede seguir este patrón.

Este desajuste provoca CPU throttling en Kubernetes durante el arranque de la JVM. Si el límite es bajo, la JVM no puede terminar rápido su trabajo de arranque, y el pod pasa a Ready tarde. Si el request es alto, el nodo reserva CPU que el servicio no usa después del arranque. Ninguna de las dos configuraciones encaja en ambas fases.

Los números del título son un ejemplo. Tu propio servicio tiene sus propios números, y este artículo te muestra cómo comprobar qué hace el tuyo. El artículo explica por qué el arranque necesita más CPU, cómo Kubernetes aplica la configuración de CPU y qué decide la JVM a partir de esos valores. Al final encontrarás las opciones que tienes disponibles hoy.

¿Por qué el arranque de Java necesita más CPU que el estado estable?

Antes de que una JVM atienda su primera solicitud, debe completar una gran cantidad de trabajo. Este trabajo tiene cuatro partes principales:

Carga y enlace de clases

La JVM carga una clase cuando el código la necesita por primera vez. Lee el archivo de la clase, lo verifica y lo enlaza. Una aplicación Spring necesita una gran cantidad de clases en los primeros segundos, porque el framework escanea la aplicación y crea sus beans.

Compilación JIT con C1 y C2

La JVM comienza interpretando bytecode, lo cual es lento. Cuenta cuántas veces se ejecuta cada método. Luego, HotSpot compila los métodos más activos a código máquina con dos compiladores. C1 compila rápido y produce código simple. C2 compila más lento y produce código más rápido para los métodos más usados. Este diseño se llama compilación por niveles (tiered compilation). Los compiladores corren en sus propios hilos, y la JVM elige el número predeterminado de estos hilos a partir del conteo de CPUs.

Hilos de recolección de basura y crecimiento del heap

El arranque asigna muchos objetos. El heap crece y el recolector se ejecuta para liberar memoria. La JVM elige el recolector y su número de hilos a partir del conteo de CPUs y el tamaño de la memoria.

Inicialización del framework

El framework construye beans, crea proxies, lee la configuración y abre conexiones. Spring Boot Actuator puede listar cada uno de estos pasos en su endpoint de arranque cuando usas BufferingApplicationStartup, lo que te ayuda a ver en qué se va el tiempo.

Cada parte tiene un final. Una vez que las clases están cargadas, los métodos activos están compilados y los beans existen, la demanda de CPU cae. La caída no es instantánea, porque los compiladores JIT siguen compilando métodos a medida que llega tráfico real. Aun así, el estado estable necesita mucha menos CPU que el arranque. La brecha entre las dos fases es la razón por la que una sola configuración de CPU no puede servir para ambas.

¿Cómo aplica Kubernetes los requests y límites de CPU?

Kubernetes controla la CPU de un contenedor con dos configuraciones:

Requests de CPU y scheduling

El request de CPU es la cantidad de CPU que Kubernetes reserva para un contenedor. El scheduler suma los requests de los pods en cada nodo y coloca un pod nuevo solo en un nodo con suficiente CPU sin reservar. El request también define la proporción de CPU en un nodo saturado. Cuando los contenedores compiten por CPU, el kernel da más tiempo de CPU al contenedor con un request mayor. En cgroup v2, el runtime del contenedor escribe el request como cpu.weight. En cgroup v1, usa cpu.shares.

Si defines un límite y ningún request, Kubernetes usa el límite como request. El request y el límite también determinan la clase QoS del pod. Un pod es Guaranteed cuando cada contenedor tiene el mismo valor para su request de CPU y su límite de CPU, y el mismo valor para su request de memoria y su límite de memoria. Es Burstable cuando tiene algunos requests o límites pero no cumple la regla de Guaranteed. Es BestEffort cuando no tiene ninguno.

Límites de CPU y throttling

El límite de CPU es un tope estricto, y el kernel lo aplica mediante throttling. El kernel divide el tiempo en períodos, y el kubelet usa un período de 100 ms de forma predeterminada. En cada período, el contenedor puede usar una cuota de tiempo de CPU. El límite define el tamaño de la cuota. El runtime escribe el límite como cpu.max en cgroup v2. Un límite de 500m se convierte en 50000 100000, es decir, una cuota de 50,000 microsegundos en cada período de 100,000 microsegundos.

resources:
requests:
cpu: 300m # reserva CPU y define la proporción de CPU en un nodo saturado
memory: 2Gi
limits:
cpu: "1" # tope estricto: se escribe como 100000 100000 en cpu.max
memory: 2Gi

¿Qué es el CPU throttling en Kubernetes?

El throttling es la forma en que el kernel aplica un límite de CPU. Cuando los hilos de un contenedor consumen toda la cuota de un período, el kernel los pausa a todos hasta que comienza el siguiente período.

Los hilos comparten una sola cuota, y este detalle importa para una JVM. Considera un límite de 500m, que da 50 ms de tiempo de CPU en cada período. Un hilo ocupado necesita 50 ms para consumir la cuota, así que corre durante la mitad del período. Ocho hilos ocupados consumen la misma cuota en unos 6 ms, y entonces el kernel pausa a los ocho durante el resto del período. Una JVM en arranque ejecuta muchos hilos ocupados al mismo tiempo, así que alcanza su cuota temprano en cada período.

Kubernetes no detiene un contenedor por usar demasiada CPU. Lo ralentiza. Kubernetes no reporta el throttling como un evento, y los logs del contenedor no muestran ningún error. El daño se manifiesta como retraso, y solo lo encuentras cuando lo mides.

CPU throttling durante el arranque de la JVM

Gran parte del arranque de la JVM es trabajo limitado por CPU. La JVM tiene una cantidad fija de trabajo que terminar antes de que la aplicación pueda atender tráfico, y el límite define qué tan rápido puede hacer ese trabajo el contenedor. Un límite más alto termina el trabajo antes. Un límite más bajo estira el mismo trabajo a lo largo de más períodos, y el contenedor espera en cada período después de consumir su cuota. El throttling durante el arranque causa dos problemas:

Arranque lento y readiness tardía

Una JVM con throttling tarda más en arrancar, así que el pod pasa a Ready más tarde. Este retraso tiene costos fuera del pod. Una actualización continua (rolling update) espera a que cada pod nuevo pase a Ready, así que cada despliegue toma más tiempo. Las réplicas nuevas ayudan solo después de pasar a Ready, así que el escalado horizontal es más lento cuando el tráfico crece. Durante una falla de nodo, los pods de reemplazo también necesitan el mismo tiempo extra.

Probes fallidos y reinicios

Un startup probe le indica al kubelet cuándo arrancó la aplicación. Mientras corre el startup probe, Kubernetes no ejecuta los liveness y readiness probes. Si el startup probe no tiene éxito dentro de failureThreshold × periodSeconds, el kubelet reinicia el contenedor. Deberías usar un startup probe para un contenedor que necesita mucho tiempo para arrancar, y apuntarlo al mismo endpoint que el liveness probe.

Sin un startup probe, un arranque lento puede causar un ciclo de reinicios. El liveness probe falla mientras la JVM todavía está arrancando, el kubelet reinicia el contenedor, y el contenedor nuevo repite el mismo trabajo de CPU. Una JVM con throttling hace este ciclo más probable, porque su arranque tarda más.

Un startup probe con una ventana amplia evita el ciclo:

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

Una ventana amplia detiene los reinicios, pero no hace el arranque más rápido. El pod sigue pasando a Ready tarde.

¿Cómo medir el throttling?

Dos contadores de cAdvisor muestran el throttling. El contador container_cpu_cfs_periods_total cuenta todos los períodos. El contador container_cpu_cfs_throttled_periods_total cuenta los períodos en los que el contenedor usó toda su cuota. Divide el segundo contador entre el primero para obtener la proporción de períodos con throttling.

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

Una tasa de cinco minutos oculta un arranque que dura solo unos segundos. Para el arranque, lee los contadores sin procesar cuando el pod pase a Ready y haz la división tú mismo. En cgroup v2, el contenedor también expone los mismos datos en cpu.stat. Los campos nr_periods, nr_throttled y throttled_usec muestran el número de períodos, el número de períodos con throttling y el tiempo total con throttling.

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

También puedes leer los valores de cAdvisor desde el kubelet:

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

Recuerda el intervalo de muestreo. El sistema de métricas actualiza sus valores solo a un intervalo fijo. Un pico de arranque más corto que este intervalo se ve más pequeño de lo que es, o directamente no aparece. Para picos cortos, muestrea los contadores sin procesar con más frecuencia.

¿Cómo cambia el límite de CPU el comportamiento de la JVM?

El límite de CPU hace más que ralentizar la JVM. En Linux, la JVM soporta contenedores de forma predeterminada (-XX:+UseContainerSupport). Lee el límite cuando arranca. Estos tres puntos muestran cómo ese valor cambia lo que hace la JVM:

Conteo de procesadores activos a partir de los límites de CPU

La JVM divide la cuota de CPU entre el período y redondea el resultado hacia arriba al siguiente número entero. Por lo tanto, un límite de 1001m da 2 CPUs. JDK 19 y posteriores, y JDK 17.0.5 y posteriores, no usan el request de CPU (cpu.shares) para contar CPUs. Muchas configuraciones de la JVM dependen de este conteo, incluido el número de hilos del compilador JIT y el número de hilos del GC.

Selección del recolector de basura (Serial vs. G1)

La JVM trata una máquina como de clase servidor cuando tiene al menos 2 procesadores y al menos 1792 MB de memoria. En una máquina de clase servidor, la JVM selecciona G1. De lo contrario, selecciona el recolector Serial. Un cambio pequeño en el límite puede, por lo tanto, cambiar el recolector. Un límite de 1 CPU da un procesador, así que la JVM selecciona Serial. Un límite de 1100m da dos procesadores, así que la JVM selecciona G1 cuando la memoria es de al menos 1792 MB. Los dos recolectores se comportan diferente en el arranque. G1 arranca varios hilos de trabajo que corren en paralelo, así que puede usar más CPU a la vez. Serial recolecta con un solo hilo.

Pods sin límites de CPU

Sin un límite, la JVM cuenta las CPUs del nodo. En un nodo grande, ve muchas CPUs y arranca muchos hilos de compilador y de GC. El pod entonces no sufre throttling, pero puede usar una gran cantidad de CPU en total, y compite con sus vecinos en proporción a su request. Un request pequeño le da a este pod una proporción pequeña cuando el nodo está saturado.

Puedes verificar las decisiones de tu propia JVM. Habilita el log de contenedor y lee los flags de la JVM en ejecución:

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'

Nota: el comando necesita jcmd en la imagen y espera que la JVM corra como proceso 1. Si un shell arranca la JVM, usa el ID de proceso real de la JVM. Una imagen solo con JRE no incluye jcmd.

El flag -XX:ActiveProcessorCount=<n> sobrescribe el conteo de CPUs detectado. Úsalo cuando quieras el mismo comportamiento de la JVM en todos los entornos.

¿Por qué ningún request de CPU es seguro por sí solo?

Cada forma de definir el request tiene un costo. Estas son las cuatro opciones:

Dimensionar para el arranque

Un request y un límite altos le dan a la JVM suficiente CPU para arrancar rápido. Pero el nodo reserva el request alto durante toda la vida del pod, y otros pods no pueden usar esa CPU. Después del arranque, el servicio usa solo una pequeña parte de la reserva. Multiplicada por muchas réplicas y muchos nodos, esta CPU sin usar se convierte en un costo considerable.

Dimensionar para el estado estable

Un request y un límite pequeños se ajustan al servicio después del arranque. Pero la JVM ahora tiene muy poca CPU para su trabajo de arranque. El kernel le aplica throttling, el pod pasa a Ready tarde, los probes pueden fallar y los despliegues toman más tiempo. El servicio es barato pero frágil.

Eliminar el límite de CPU

Sin un límite, el kernel no aplica throttling al contenedor, y el arranque puede usar cualquier CPU libre del nodo. Esta opción tiene sus pros y sus contras. La JVM dimensiona sus hilos para todas las CPUs del nodo. En un nodo saturado, el contenedor obtiene tiempo de CPU solo en proporción a su request, así que un request pequeño aún puede ralentizar el arranque. Además, sin un límite, un pod puede tomar CPU que sus vecinos necesitan.

HPA y el período de inicialización de CPU

El Horizontal Pod Autoscaler (HPA) mide el uso de CPU como porcentaje del request. Un request pequeño hace que el pico de arranque parezca una carga muy alta, y el HPA puede agregar réplicas que el servicio no necesita. Kubernetes reduce este riesgo, porque el HPA puede omitir las muestras de CPU de un pod nuevo. De forma predeterminada, el retraso de readiness es de 30 segundos (--horizontal-pod-autoscaler-initial-readiness-delay), y el período de inicialización de CPU es de 5 minutos (--horizontal-pod-autoscaler-cpu-initialization-period). Ambos flags pertenecen a kube-controller-manager, así que en un clúster gestionado quizá no puedas cambiarlos.

Formas de manejar la CPU del arranque hoy

Estas son las maneras en que puedes manejar la CPU del arranque:

Reducir el trabajo de arranque

Menos trabajo significa un arranque más corto con cualquier límite. Spring Boot puede crear beans en el primer uso con spring.main.lazy-initialization=true. Class Data Sharing y la caché AOT pueden eliminar parte del trabajo de carga de clases. La caché AOT llegó en JDK 24 (JEP 483). Spring Framework soporta tanto CDS como la caché AOT. El profiling muestra en qué se va tu propio tiempo de arranque.

Fijar el límite de CPU por encima de la necesidad del arranque

Mantén el request cerca del nivel de estado estable y define el límite lo suficientemente alto para el arranque. El pod entonces tiene la clase QoS Burstable. El scheduler reserva solo el request, y el pod puede usar CPU hasta el límite cuando el nodo tiene CPU libre. En el arranque, el nodo podría no tener CPU libre, así que esta opción ayuda sobre todo en nodos con capacidad de sobra. Un límite más alto también cambia el conteo de CPUs que ve la JVM.

Resize de pods in-place

El resize de pods in-place cambia la CPU y la memoria de un pod en ejecución sin reiniciarlo. La funcionalidad llegó a beta en Kubernetes 1.33 y se volvió estable en Kubernetes 1.35. Con esta funcionalidad, un pod puede arrancar con un límite de CPU alto y pasar a uno más bajo después del arranque. Un contenedor controla el comportamiento con una política de resize. La política predeterminada para CPU es NotRequired, y puedes declararla en el manifiesto:

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

Los recursos se cambian a través del subrecurso resize. Este comando necesita kubectl 1.32 o posterior:

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

El campo status.containerStatuses[].resources muestra los recursos que el contenedor usa en este momento. Algunos pods no pueden usar el resize in-place. Los pods de Windows y los pods bajo la política estática del CPU o Memory manager son ejemplos. Un resize tampoco puede cambiar la clase QoS del pod. Alguien también debe activar el resize. Una persona puede ejecutar el patch a mano, pero para muchos pods un controlador debe hacerlo.

Un resize cambia el límite del cgroup, pero la JVM elige algunas configuraciones solo una vez. Selecciona el recolector en el arranque y lo mantiene. Las configuraciones que dependen del conteo de CPUs, como el número máximo de hilos de GC, también se fijan en el arranque. Runtime.availableProcessors() puede cambiar mientras la JVM corre, así que el código que lee el conteo de CPUs en tiempo de ejecución puede ver el valor nuevo. Prueba un resize en tu propio JDK antes de confiar en él.

Advertencia sobre la memoria de la JVM

No bajes el límite de memoria de un contenedor JVM. La JVM fija su heap máximo en el arranque. De forma predeterminada, el heap máximo es el 25% de la memoria del contenedor (-XX:MaxRAMPercentage). La JVM también usa memoria fuera del heap, como el metaspace, las pilas de hilos y la caché de código. Un límite de memoria por debajo del uso real del proceso causa un OOM kill. Por esta razón, redimensiona la CPU después del arranque y sube el límite de memoria solo cuando sea necesario.

VPA y opciones gestionadas de boost de arranque

El Vertical Pod Autoscaler (VPA) tiene un modo de actualización InPlaceOrRecreate. Este modo intenta primero un resize in-place y recrea el pod cuando un resize in-place no es posible. En GKE, el boost de CPU de arranque funciona con el VPA. Sube el request de CPU cuando se crea el pod y mantiene el request más alto durante durationSeconds después de que el pod pasa a Ready. Luego GKE regresa el request a su valor base in-place. El boost se aplica solo cuando se crea el pod, así que un reinicio de contenedor dentro del mismo pod no recibe boost.

La funcionalidad está en Preview al momento de escribir esto, y necesita GKE 1.36.0-gke.4447000 o 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

En este ejemplo, el boost duplica el request de CPU (factor: 2) y lo mantiene durante 10 segundos después de que el pod pasa a Ready. Con updateMode en Off, el VPA aplica solo el boost y no hace otros cambios. Si usas un HPA con el boost, define un readiness probe y pon durationSeconds en 0. Esto evita que el HPA agregue réplicas por el pico de CPU en el arranque.

Automatización de doble fase de PerfectScale

Un servicio JVM tiene dos necesidades de recursos. En el arranque, la JVM carga clases, compila código y construye la aplicación, así que el servicio necesita mucha CPU. Después, necesita mucha menos. Una recomendación promedio única no funciona para ambas fases. PerfectScale proporciona dos recomendaciones para cada workload: una para el arranque y otra para el estado estable en runtime. PerfectScale dimensiona la recomendación de arranque según la necesidad más alta de las dos fases, para que el nodo tenga suficiente capacidad durante el arranque.

PerfectScale aplica la recomendación de arranque cuando se crea el pod. Una vez que el pod corre de forma estable, PerfectScale aplica la recomendación de runtime in-place, sin reinicio. PerfectScale sigue la fase de cada pod y agrega verificaciones de seguridad cuando un workload no puede pasar a la fase de runtime de forma limpia.

El resize in-place necesita Kubernetes 1.33 o posterior y la política de resize del contenedor restartPolicy: NotRequired. Soporta Deployments, StatefulSets, DaemonSets y Argo Rollouts, y no soporta pods de Windows. Cuando el clúster no soporta el resize in-place, PerfectScale recurre a su enfoque seguro estándar.

PerfectScale protege el heap de la JVM. Cuando defines un tamaño de heap máximo o mínimo, PerfectScale no aplica una recomendación que salga de ese rango. Para el resize in-place, el límite de memoria solo puede aumentar. Para los contenedores Java, PerfectScale también recopila datos de heap, non-heap y recolección de basura. Muestra el tiempo de GC, el conteo de procesadores activos y el tipo de GC. Puedes habilitar la recopilación de métricas de la JVM al instalar el agente de PerfectScale, y solo soporta nodos Linux. PerfectScale muestra los recursos de arranque y de estado estable por separado, así que los números de costo y pérdida reflejan lo que el workload necesitó en cada fase.

Únete al workshop. Vikram Seshadri de DoiT muestra cómo dimensionar un pod JVM para su arranque y cómo redimensionarlo al estado estable sin reiniciarlo, en el workshop práctico "Java on K8s: The Startup vs. Steady-State Sizing Dilemma" el 28 de octubre de 2026 a las 11 AM ET. Reserva tu lugar.

¿Quieres ver cómo PerfectScale aplica right-sizing a tus workloads de Java? Comienza gratis o agenda una demo.