PerfectScalePerfectScale

PerfectScale

¿Cómo identificar qué encarece el arranque de tu servicio?

Descubre por qué un servicio Java arranca lento o consume mucha CPU usando logs, JFR y async-profiler, y convierte los hallazgos en decisiones de dimensionamiento en Kubernetes.

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

Tania Duggal
By Tania Duggal
Oct 26, 202615 min read

Arrancar un servicio Java puede resultar costoso por muchas razones. Puede cargar muchas clases, compilar código, construir un gran conjunto de beans, esperar a una base de datos o llenar una caché. El costo se manifiesta como un tiempo hasta Ready prolongado, un uso alto de CPU, o ambos.

Cada causa requiere una solución distinta. Si lo haces a ciegas, terminas ajustando lo que no corresponde. Más CPU no ayuda a un servicio que espera a una base de datos. Un límite de CPU más bajo hace más lento a un servicio ocupado mientras compila código.

Puedes encontrar la causa real con mediciones y un perfil. Los logs, Java Flight Recorder (JFR) y async-profiler muestran, cada uno, una parte distinta del arranque. Cuando conoces la causa, puedes elegir configuraciones de CPU y memoria seguras para el servicio.

¿Qué encarece el arranque de un servicio?

Arrancar un servicio resulta costoso cuando necesita mucho tiempo, mucha CPU, o ambos, antes de poder atender tráfico. Tres medidas describen el costo:

a. Tiempo hasta Ready: El tiempo hasta Ready es el tiempo desde el inicio del contenedor hasta el momento en que el pod pasa a Ready. Un tiempo hasta Ready largo retrasa los despliegues, ralentiza el escalado horizontal y aumenta el riesgo de que fallen las probes. Este comando imprime las dos marcas de tiempo del contenedor llamado app:

kubectl get pod <pod-name> -o jsonpath='{.status.containerStatuses[?(@.name=="app")].state.running.startedAt}{"\n"}{.status.conditions[?(@.type=="Ready")].lastTransitionTime}{"\n"}'

La diferencia entre los dos valores es el tiempo hasta Ready. La línea de log de Spring Boot "Started ... in N seconds" da un número menor. Comienza cuando arranca la aplicación y no incluye el tiempo que necesitan la JVM y el contenedor para iniciar.

b. Segundos de CPU hasta Ready: Un segundo de CPU es una CPU trabajando durante un segundo. Los segundos de CPU hasta Ready son el trabajo total de CPU del arranque. Un contenedor no puede usar más CPU que su límite, así que los segundos de CPU divididos entre el límite son el tiempo mínimo posible hasta Ready. En cgroup v2, lee el campo usage_usec en cuanto el pod pase a Ready:

kubectl exec <pod-name> -c app -- cat /sys/fs/cgroup/cpu.stat

El campo usage_usec es el tiempo de CPU que el contenedor ha usado desde que inició, en microsegundos. Divídelo entre 1,000,000 para obtener los segundos de CPU. Los campos nr_throttled y throttled_usec del mismo archivo muestran cuánto limitó el kernel al contenedor. Si lees el archivo tarde, el valor incluye trabajo posterior al estado Ready.

c. Calentamiento después de Ready: Ready no significa rápido. Cuando el pod pasa a Ready, la JVM todavía ejecuta muchos métodos en el intérprete, que es lento, y los compiladores JIT siguen compilando en segundo plano. Durante este calentamiento, el servicio usa más CPU que en estado estable y las primeras solicitudes pueden ser lentas. Si reduces los recursos en el momento de Ready, puedes ralentizar el calentamiento. Conviene medir el uso de CPU durante varios minutos después de Ready, y no solo hasta Ready.

media

Herramientas de perfilado del arranque

Existen tres herramientas para encontrar la causa de un arranque costoso. Puedes empezar con la más económica y pasar a la siguiente solo cuando necesites más detalle:

a. Logs y el endpoint de arranque de Spring Boot: La línea de log de Spring Boot "Started ... in N seconds" da un primer número. Para más detalle, el endpoint startup de Spring Boot Actuator lista cada paso del arranque con su duración. Necesita un BufferingApplicationStartup en la aplicación:

SpringApplication application = new SpringApplication(MyApplication.class);
application.setApplicationStartup(new BufferingApplicationStartup(4096));
application.run(args);

El número 4096 es la capacidad del búfer, es decir, el número máximo de pasos que almacena. Después expón el endpoint. Mantén health en la lista para que tus probes sigan funcionando:

management.endpoints.web.exposure.include=health,startup

Una solicitud GET a /actuator/startup devuelve una instantánea de los pasos. Una solicitud POST devuelve los pasos y vacía el búfer. Cada paso tiene un nombre, una duración y etiquetas como el nombre del bean. Revisa primero los pasos más largos. Nombres como spring.beans.instantiate, spring.context.config-classes.parse y spring.context.refresh muestran a dónde se va el tiempo. En spring.beans.instantiate, la etiqueta beanName indica el bean, así que puedes encontrar el bean más lento. Los nombres de los pasos pueden cambiar entre versiones de Spring.

Dos opciones de log de la JVM agregan más datos. La opción -Xlog:gc* escribe los eventos de recolección de basura y la opción -Xlog:class+load escribe cada carga de clase. Puedes pasar ambas en JAVA_TOOL_OPTIONS.

b. Java Flight Recorder (JFR): JFR viene integrado en el JDK. Registra eventos de la JVM, como recolecciones de basura, compilaciones JIT y muestras de ejecución. La configuración por defecto agrega muy poca sobrecarga, y la configuración profile registra más eventos. JFR arranca junto con la JVM cuando usas la opción -XX:StartFlightRecording, así que captura todo el arranque. Después de copiar el archivo fuera del pod, léelo con la herramienta jfr del JDK o con JDK Mission Control:

jfr summary startup.jfr
jfr print --events jdk.GarbageCollection,jdk.Compilation startup.jfr
jfr print --events jdk.ExecutionSample --stack-depth 8 startup.jfr

El primer comando lista los tipos de evento y sus conteos. El segundo imprime los eventos de recolección de basura y las compilaciones JIT con sus duraciones. El tercero imprime muestras del código que ejecutan los hilos, con una profundidad de pila de 8 frames. El evento jdk.Compilation registra solo las compilaciones que superan el umbral de tu configuración.

c. async-profiler: async-profiler es un perfilador por muestreo para Java que genera flame graphs. Dos de sus modos importan para el arranque. El modo cpu muestrea solo los hilos que usan CPU, así que muestra qué consume CPU. El modo wall muestrea todos los hilos a un intervalo fijo, ya sea que se ejecuten, duerman o esperen, así que muestra a dónde se va el tiempo, incluidas las esperas. async-profiler puede arrancar con la JVM como agente, por lo que captura el primer segundo.

Java on K8s: The Startup vs. Steady-State Sizing Dilemma. Workshop práctico en vivo el 28 de octubre de 2026 a las 11 AM ET

Capturar un perfil de arranque en Kubernetes

La grabación debe iniciar con la JVM y debe ejecutarse bajo las condiciones reales del servicio. Dos puntos importan:

a. Perfila con los límites de CPU de producción: Usa la misma imagen, las mismas flags de la JVM y la misma configuración de CPU y memoria que en producción. Un límite distinto cambia el arranque en sí. Cambia el throttling, el número de CPUs que ve la JVM y el recolector que selecciona la JVM. Un perfil tomado sin el límite de producción muestra un arranque diferente. Para profundizar, lee CPU throttling durante el arranque de la JVM.

Esta plantilla de pod inicia JFR con la JVM y escribe el archivo en un volumen:

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: 500Mi

Cada parámetro tiene una función:

  • filename=/profiles/startup-%p.jfr escribe el archivo en el volumen. El texto %p se convierte en el ID del proceso.
  • settings=profile registra más eventos que la configuración por defecto.
  • duration=120s detiene la grabación después de 120 segundos. Elige un tiempo que cubra el arranque y varios minutos de calentamiento.
  • dumponexit=true escribe el archivo incluso si la JVM termina antes de tiempo, por ejemplo cuando una probe mata el contenedor.
  • emptyDir ofrece una carpeta con permisos de escritura incluso cuando el sistema de archivos raíz es de solo lectura. El sizeLimit evita que un archivo grande llene el disco del nodo.
  • Los valores de CPU y memoria son ejemplos. Usa tus valores de producción.

En un contenedor, la JVM suele ser el proceso 1, así que el texto %p se convierte en 1 y el archivo se llama startup-1.jfr. Si quieres conservar los archivos de varias ejecuciones en un mismo volumen, agrega %t al nombre del archivo. Esto añade una marca de tiempo. Tanto JFR como async-profiler aceptan %t.

Para usar async-profiler, incluye libasyncProfiler.so en la imagen, o en un volumen que un init container llene con el archivo de la release de async-profiler. Luego cambia la variable de entorno:

env:
- name: JAVA_TOOL_OPTIONS
value: >-
-agentpath:/profiler/lib/libasyncProfiler.so=start,event=cpu,threads,timeout=120,file=/profiles/startup-cpu-%p.html

Las opciones del agente funcionan así: start comienza el perfilado cuando arranca la JVM, event=cpu selecciona el modo CPU, threads perfila cada hilo por separado, timeout=120 se detiene a los 120 segundos y file=...html escribe un flame graph. Para un perfil de tiempo de reloj (wall-clock), reemplaza event=cpu por event=wall,interval=10ms. Los contenedores suelen bloquear los eventos de perf. Si el perfilador no logra iniciar, usa event=ctimer, que funciona sin eventos de perf.

Ejecuta un solo perfilador por ejecución y repite cada ejecución tres veces. Una sola ejecución puede llevarte a conclusiones equivocadas.

b. Copiar los perfiles fuera del pod: Usa kubectl cp para copiar la carpeta a tu computadora:

kubectl cp <namespace>/<pod-name>:/profiles ./profiles -c app

El comando necesita el programa tar en la imagen del contenedor. Si la imagen no tiene tar, el comando falla. En ese caso, monta el mismo volumen en un segundo contenedor pequeño que tenga tar y usa su nombre con -c. Copia los archivos antes de que el pod se elimine, porque Kubernetes borra un volumen emptyDir junto con el pod. Abre un archivo HTML en un navegador.

Cómo leer un flame graph de arranque

Un flame graph muestra las trazas de pila de todas las muestras. Cada caja es un método y su ancho es la proporción de las muestras que lo incluyen. En un gráfico de async-profiler, los frames más externos, como main, están abajo, y los frames hoja, que hacen el trabajo, están arriba. Las cajas anchas indican a dónde se va el tiempo. Gracias a la opción threads, cada hilo tiene su propia torre, y la torre comienza con una caja que lleva el nombre del hilo. Linux recorta los nombres de hilo a 15 caracteres, así que los nombres largos se ven más cortos en el gráfico. Tres grupos de hilos importan:

a. Hilos de la aplicación: El hilo que ejecuta main corre el código de arranque de la aplicación. Su caja raíz puede llamarse main o DestroyJavaVM, así que búscalo por el frame SpringApplication.run y no por su nombre. Una torre ancha de SpringApplication.run, o una torre ancha de refresh, significa que el arranque del framework consume la mayor parte del tiempo. Cajas anchas con defineClass o loadClass significan carga de clases. Cajas anchas con los nombres de tus propios paquetes significan tu propio código de arranque, como la carga de una caché o una migración de base de datos.

b. Hilos del compilador JIT: Las raíces que empiezan con C1 CompilerThre y C2 CompilerThre son los compiladores JIT. Los nombres completos de los hilos son C1 CompilerThread0, C2 CompilerThread0, y así sucesivamente. Si estas torres son tan anchas como la torre del hilo de arranque, el trabajo del JIT es una parte importante de la CPU del arranque. Para ver qué frames corren en el intérprete, escribe el perfil en un archivo con la extensión .collapsed y agrega la opción ann. En ese archivo, los frames interpretados terminan en _[0], los frames C1 en _[1], otros frames compilados en _[j] y los frames inlined en _[i]. Muchos frames _[0] en el hilo de arranque significan que el código aún no está compilado. El gráfico HTML no agrega estas terminaciones. Muestra los tipos de frame con colores.

c. Hilos de GC: Con G1, raíces como GC Thread#0, G1 Conc#0 y G1 Refine#0 hacen el trabajo de recolección de basura. Con el recolector Serial, lo hace el VM Thread. Un límite de CPU de 1 CPU o menos, o un límite de memoria por debajo de 1792 MB, hace que la JVM seleccione el recolector Serial, así que un gráfico de un pod en esas condiciones no tiene hilos de trabajo de GC. Una torre ancha de recolección de basura en los primeros segundos significa que el heap es demasiado pequeño para la asignación del arranque, o que crece muchas veces. Confírmalo con los eventos jdk.GarbageCollection de JFR, donde el nombre del evento indica el recolector (por ejemplo, DefNew y SerialOld pertenecen al recolector Serial), o con la opción de log -Xlog:gc*.

Compara un gráfico cpu con un gráfico wall del mismo arranque. Un gráfico wall también muestra muchos hilos inactivos, como los hilos del servidor web. Ignóralos y concéntrate en el hilo de arranque.

media

Categorías comunes de costo de arranque

La mayoría de los arranques costosos encajan en una o más de estas cinco categorías. En cada categoría, el perfil tiene una señal clara y la decisión de dimensionamiento es distinta:

a. Aplicaciones con mucho framework: La señal es una torre ancha bajo SpringApplication.run en el hilo de arranque, muchos pasos spring.beans.instantiate y mucha carga de clases. El trabajo está limitado por CPU, así que un límite de CPU bajo lo ralentiza directamente. Spring Boot puede crear los beans en el primer uso con spring.main.lazy-initialization=true. Class Data Sharing y la caché AOT eliminan parte del trabajo de carga de clases. La caché AOT llegó en JDK 24 (JEP 483).

b. Workloads intensivos en JIT: La señal son torres anchas de C1 CompilerThre y C2 CompilerThre, y un uso de CPU que sigue alto después de que el pod pasa a Ready. La caché AOT de JDK 25 puede guardar perfiles de métodos de una ejecución de entrenamiento (JEP 515). La JVM entonces necesita menos tiempo para perfilar y el JIT puede comenzar su trabajo antes. Los compiladores JIT siguen corriendo, así que el calentamiento no desaparece.

c. Presión de GC y de heap: La señal son muchos eventos de recolección de basura en los primeros segundos y torres anchas de GC. Por defecto, la JVM inicia con un heap pequeño y lo hace crecer a medida que la aplicación asigna objetos, y cada paso de crecimiento agrega trabajo de CPU. Puedes definir un heap inicial más grande con -XX:InitialRAMPercentage. Un heap inicial más grande usa más memoria desde el principio, así que revisa primero el límite de memoria. Mantén el heap inicial por debajo del heap máximo, que por defecto es el 25% de la memoria del contenedor (-XX:MaxRAMPercentage).

d. Esperas de I/O y dependencias: La señal es un uso bajo de CPU y un tiempo hasta Ready largo. El gráfico wall muestra al hilo de arranque en lecturas de socket, sleeps o llamadas a park. Las causas típicas son conexiones a bases de datos, migraciones de esquema, configuración remota, búsquedas DNS y handshakes TLS.

e. Calentamiento anticipado de caché: La señal son hilos de la aplicación en tu propio código de carga de caché, y un uso de CPU y memoria que sube mientras la caché se carga, a veces después de que el pod está Ready. Decide si el calentamiento debe terminar antes de Ready. Si la respuesta es sí, incluye su CPU y memoria en el tamaño de arranque. Si es no, muévelo después de Ready y vigila el uso de CPU.

Convertir los hallazgos en decisiones de dimensionamiento

Un hallazgo se convierte en una decisión de dimensionamiento cuando respondes dos preguntas. ¿El trabajo está limitado por CPU o está esperando? ¿El trabajo se detiene después del arranque? Estas son las decisiones:

a. Limitado por CPU y corto (mucho framework y mucho JIT): Dale al arranque suficiente CPU. Define el límite de CPU por encima de lo que necesita el arranque, o usa un boost de arranque o un redimensionamiento in situ, y mantén el request cerca del estado estable. Si el límite es demasiado bajo, el pod pasa a Ready tarde y la startup probe puede fallar.

b. Espera de dependencias: No subas la CPU. Corrige o mueve la espera, por ejemplo ejecutando la migración como un job separado. Define la ventana de la startup probe, que es failureThreshold × periodSeconds, en al menos el tiempo hasta Ready más lento que mediste, más un margen. En este ejemplo, la ventana es 24 × 5 = 120 segundos:

startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
periodSeconds: 5
failureThreshold: 24

Si la ventana es demasiado corta, el kubelet reinicia el contenedor y el nuevo contenedor repite la misma espera.

c. Presión de GC y de heap: Sube el heap inicial o el límite de memoria. Para contenedores JVM, mantén el request de memoria igual al límite de memoria. No bajes el límite de memoria después del arranque, porque la JVM fija su heap máximo al iniciar.

d. Calentamiento después de Ready: Mantén los recursos del arranque hasta que el uso de CPU baje al estado estable.

e. Verifica después de cada cambio: Cambia una sola cosa, perfila de nuevo y compara el tiempo hasta Ready y los segundos de CPU hasta Ready. Un cambio ayuda solo si ambas medidas mejoran.

media

Perfilar el arranque en muchos servicios

Puedes perfilar un servicio a mano, pero no puedes perfilar cada servicio a mano. Usa las métricas del clúster para encontrar los servicios cuyo arranque cuesta más y perfila solo esos. Tres pasos te ayudarán:

a. Ordena los pods por tiempo hasta Ready: Esta consulta usa dos métricas de kube-state-metrics. Reemplaza app con el nombre de tu contenedor:

topk(20,
max by (namespace, pod) (
kube_pod_status_ready_time
- on (namespace, pod, uid)
kube_pod_container_state_started{container="app"}
))

La consulta devuelve solo pods que todavía tienen métricas. Los pods que Kubernetes eliminó hace tiempo no aparecen.

b. Ordena los contenedores por CPU en los primeros 10 minutos: El contador de CPU empieza en cero cuando un contenedor inicia, así que su valor después de 10 minutos equivale a los segundos de CPU de los primeros 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]
)
)
)

Cambia 600 por el tiempo de arranque de tu servicio. Ejecuta esta consulta cuando los pods tengan más de 10 minutos, porque los pods más jóvenes muestran solo una parte de su CPU. El resultado es el valor más alto entre los pods de cada contenedor, así que la consulta ordena contenedores y no pods individuales.

c. Perfila los servicios principales: Toma los servicios que aparecen arriba en ambas consultas. Captura un perfil de arranque de cada uno bajo sus límites de producción y asigna cada servicio a una de las cinco categorías.

PerfectScale muestra las métricas clave de la JVM en todos tus clústeres. Para los contenedores Java, recopila el uso de memoria heap y no heap, junto con métricas de recolección de basura (GC), incluidos el tiempo de GC, el número de procesadores activos y el tipo de GC. La recopilación de métricas de la JVM se puede habilitar durante la instalación del agente de PerfectScale, sin necesidad de configuración adicional. Esta funcionalidad solo es compatible con nodos Linux.

Una vez habilitada la recopilación de datos de la JVM, PerfectScale te da una visibilidad granular del comportamiento de la aplicación y te ayuda a identificar los servicios que requieren muchos recursos en el arranque. PerfectScale detecta estos servicios automáticamente y propone dos recomendaciones para cada workload: una para el arranque y otra para el estado estable en tiempo de ejecución.

PerfectScale aplica la recomendación de arranque cuando se crea un pod y luego ajusta los recursos in situ para que coincidan con la recomendación de tiempo de ejecución una vez que el pod alcanza el estado estable, lo que te ayuda a optimizar la asignación de recursos en ambas fases sin comprometer el rendimiento de la aplicación. PerfectScale también respeta tus límites de tamaño de heap de la JVM, así que las recomendaciones nunca superan el heap máximo que configuraste ni bajan del mínimo configurado. Y no se necesita reiniciar el pod ni intervenir manualmente.

El redimensionamiento in situ requiere Kubernetes 1.33 o posterior, y la política de redimensionamiento del contenedor restartPolicy: NotRequired. Es compatible con Deployments, StatefulSets, DaemonSets y Argo Rollouts, y no es compatible con pods de Windows.

Dimensionar bien los pods de Java es clave para equilibrar el rendimiento de la aplicación y la eficiencia de los recursos. Esta guía cubre los fundamentos imprescindibles, pero si quieres ver un enfoque más avanzado en acción, únete a Vikram Seshadri de DoiT 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. Ahí muestra cómo dimensionar un pod de JVM para su arranque y cómo redimensionarlo al estado estable sin reiniciarlo. Reserva tu lugar.

Java on K8s: The Startup vs. Steady-State Sizing Dilemma. Workshop práctico en vivo el 28 de octubre de 2026 a las 11 AM ET

¿Quieres ver cómo PerfectScale aplica right-sizing a tus workloads de Java? Comienza gratis o agenda una sesión técnica.