Skip to content

JVM

Le pause GC lunghe sono la causa più frequente di degrado intermittente quando CPU e memoria del container non mostrano anomalie.

Durante una stop-the-world tutti i thread applicativi sono fermi: le richieste in corso restano appese, le liveness probe scadono e il pod viene riavviato da Kubernetes senza che nei log applicativi ci sia traccia di un errore.

Da qui la firma tipica: restart senza stacktrace, con Liveness probe failed negli events.

Quale exporterda lanciare per prima
Triagepause lunghe e overhead
Pause GC · Frequenza e cause
Allocation e promotion
Heap · Non-heap · Thread
Dimensionamento-Xmx vs limit
Leak
Senza exporteril caso più frequente sul campo
Comandi · Alert

Quale exporter

I nomi delle metriche cambiano completamente in base a come la JVM è strumentata. Prima query da lanciare sempre, prima di copiare qualunque altra cosa da questa pagina:

# numero di serie · sostituisci $ns
count by (__name__) ({__name__=~"jvm_.*", namespace="$ns"})
Metrica che vediStrumentazionePausa massima disponibile
jvm_gc_pause_seconds_*Micrometer (Spring Boot Actuator, Quarkus)Sì — jvm_gc_pause_seconds_max
jvm_gc_collection_seconds_*jmx_exporter (agent o sidecar)No — solo count e sum
jvm_gc_duration_seconds_bucketOpenTelemetry Java agentSì — via histogram_quantile
nessunanon strumentataSenza exporter

Verifica anche che i label Kubernetes ci siano:

# numero di serie
count by (namespace, pod, container) ({__name__=~"jvm_gc.*"})

Se pod non compare, il ServiceMonitor non fa il relabeling standard e nessuna query per pod di questa pagina funzionerà. Il label alternativo è spesso kubernetes_pod_name o instance.

Nel resto della pagina, dove la differenza conta, trovi il blocco marcato # jmx_exporter o # OpenTelemetry. Dove non è marcato, la query vale per Micrometer.

La limitazione di jmx_exporter

jmx_exporter espone un Summary senza quantili: hai il numero di GC e il tempo totale, non la durata del singolo evento. Se in 30 secondi ci sono 40 GC da 10 ms e uno da 4 secondi, la media dice 110 ms e la pausa che ha causato l’incidente è invisibile.

Mitigazione parziale — finestra stretta, così un singolo evento non viene mediato con troppi altri:

# jmx_exporter — pausa media su finestra di 1 minuto · sostituisci $ns, $pod
increase(jvm_gc_collection_seconds_sum{namespace="$ns", pod=~"$pod.*"}[1m])
/
increase(jvm_gc_collection_seconds_count{namespace="$ns", pod=~"$pod.*"}[1m])

Funziona solo se il GC è poco frequente. Per un rilevamento affidabile delle pause lunghe con jmx_exporter servono i GC log — vedi Senza exporter, la tecnica è la stessa.


Triage

T1. Pause GC più lunghe del cluster

# secondi
topk(20,
max by (namespace, pod, container) (
max_over_time(
jvm_gc_pause_seconds_max{
namespace!~"kube-.*|openshift-.*|default"
}[1h]
)
)
)

È la query di riferimento per le pause lunghe. Per ogni JVM del cluster restituisce la singola pausa più lunga dell’ultima ora, in secondi.

T2. Solo oltre soglia

# secondi
max by (namespace, pod, container) (
max_over_time(
jvm_gc_pause_seconds_max{namespace!~"kube-.*|openshift-.*|default"}[1h]
)
) > 1

T3. Overhead GC

# secondi di GC per secondo di wall time: frazione di tempo in cui la JVM è ferma
topk(20,
sum by (namespace, pod, container) (
rate(jvm_gc_pause_seconds_sum{namespace!~"kube-.*|openshift-.*|default"}[15m])
)
)

T4. -Xmx fuori norma

# percentuale 0-100
100 * (
sum by (namespace, pod, container) (
jvm_memory_max_bytes{area="heap", namespace!~"kube-.*|openshift-.*|default"}
)
/
sum by (namespace, pod, container) (
kube_pod_container_resource_limits{resource="memory"}
)
) > 80

Ogni riga è un OOMKill che aspetta un picco di traffico.

T5. Candidati leak

# percentuale 0-100
100 * (
jvm_gc_live_data_size_bytes{namespace!~"kube-.*|openshift-.*|default"}
/
jvm_gc_max_data_size_bytes{namespace!~"kube-.*|openshift-.*|default"}
) > 85

Pause GC

Pausa massima per JVM

# secondi
topk(20,
max by (namespace, pod, container) (
max_over_time(
jvm_gc_pause_seconds_max{namespace!~"kube-.*|openshift-.*|default"}[1h]
)
)
)
ValoreLettura
< 0.05GC sano
0.05 – 0.20accettabile per servizi non latency-critical
0.20 – 1.0degrado percepibile dall’utente
1.0 – 5.0timeout applicativi, probe a rischio
> 5.0l’applicazione è ferma; restart quasi certo

Confronta sempre con il timeoutSeconds della liveness probe. Se la pausa massima supera timeoutSeconds × failureThreshold, il pod viene ucciso da Kubernetes durante il GC. È lo scenario in cui l’AM giura che l’applicazione non ha errori, e ha ragione.

Terminal window
oc get pod -n $ns $pod -o json | jq '.spec.containers[] | {name, livenessProbe}'

Pausa massima nel tempo

# secondi · sostituisci $ns, $pod
jvm_gc_pause_seconds_max{namespace="$ns", pod=~"$pod.*"}

Da graficare direttamente: ogni picco è un evento di stop-the-world reale.

Percentile con OpenTelemetry

# OpenTelemetry
topk(20,
histogram_quantile(0.99,
sum by (namespace, pod, container, le) (
rate(jvm_gc_duration_seconds_bucket{
namespace!~"kube-.*|openshift-.*|default"
}[1h])
)
)
)

Pausa media, per confronto col massimo

# secondi: durata media della singola pausa GC · sostituisci $ns, $pod
rate(jvm_gc_pause_seconds_sum{namespace="$ns", pod=~"$pod.*"}[5m])
/
rate(jvm_gc_pause_seconds_count{namespace="$ns", pod=~"$pod.*"}[5m])

Il confronto media vs massimo è la vera informazione. Media 15 ms e massimo 4 s significa che il problema è un evento raro e catastrofico (probabilmente un full GC), non un degrado continuo. Sono due problemi con due fix diversi.

Per namespace

# secondi
topk(20,
max by (namespace) (
max_over_time(jvm_gc_pause_seconds_max{namespace!~"kube-.*|openshift-.*|default"}[1h])
)
)

max by (namespace) è corretto: la pausa peggiore del namespace è quella della sua JVM peggiore, non la somma.

Picco su 7 giorni

# secondi · sostituisci $ns
max_over_time(
max by (namespace, pod) (jvm_gc_pause_seconds_max{namespace=~"$ns"})[7d:5m]
)

Per capire se la pausa vista oggi è un’anomalia o la normalità settimanale. Subquery costosa: una alla volta.


Frequenza e cause

Overhead — la metrica di sintesi

# secondi di GC per secondo di wall time: frazione di tempo in cui la JVM è ferma · sostituisci $ns, $pod
rate(jvm_gc_pause_seconds_sum{namespace="$ns", pod=~"$pod.*"}[5m])

rate() di una somma di secondi restituisce secondi di GC per secondo di wall time: è direttamente la frazione di tempo in cui la JVM è ferma.

ValoreLettura
< 2%sano
2 – 5%accettabile
5 – 10%throughput applicativo già eroso
> 10%la JVM è ferma piu di un decimo del tempo: heap sotto-dimensionata o leak
→ 0.98precede OutOfMemoryError: GC overhead limit exceeded

Con collector paralleli il valore può superare 1, perché somma il tempo di più thread GC.

Su Micrometer 1.9+ esiste una metrica diretta, più stabile del rate():

# percentuale 0-100 · sostituisci $ns, $pod
100 * jvm_gc_overhead{namespace="$ns", pod=~"$pod.*"}
# jmx_exporter · sostituisci $ns
sum by (namespace, pod) (
rate(jvm_gc_collection_seconds_sum{namespace=~"$ns"}[15m])
)

Numero di GC al minuto

# eventi al minuto · sostituisci $ns, $pod
sum by (action) (
rate(jvm_gc_pause_seconds_count{namespace="$ns", pod=~"$pod.*"}[5m])
) * 60

Young GC ogni pochi secondi è normale. Full GC più di una volta all’ora, no.

Full GC nell’ultima ora

# secondi
topk(20,
sum by (namespace, pod, container) (
increase(jvm_gc_pause_seconds_count{
namespace!~"kube-.*|openshift-.*|default",
action="end of major GC"
}[1h])
)
)

Il label action di Micrometer distingue end of minor GC (young, frequente e normale) da end of major GC (full, raro e costoso). Su un servizio latency-critical con G1 in salute i full GC devono essere zero. Anche uno solo all’ora merita indagine.

Pause per causa

# secondi · sostituisci $ns, $pod
max by (cause, action) (
jvm_gc_pause_seconds_max{namespace="$ns", pod=~"$pod.*"}
)

Il label cause è diagnostico:

causeSignificatoAzione
G1 Evacuation Pauseyoung GC normalenessuna, se breve
Allocation Failurenon c’era spazio per allocareallocation rate alto o heap piccola
G1 Humongous Allocationoggetto > metà di una region G1array/buffer enormi: aumentare -XX:G1HeapRegionSize o rivedere il codice
Metadata GC Thresholdmetaspace pieno, non la heapNon-heap
GCLocker Initiated GCJNI critical sectionraro, spesso driver nativi
System.gc()qualcuno lo chiama esplicitamentequasi sempre un bug o una libreria vecchia: -XX:+DisableExplicitGC

Metadata GC Threshold e System.gc() cambiano completamente la diagnosi: in entrambi i casi alzare la heap non serve a nulla.

Collector in uso

# numero di serie · sostituisci $ns, $pod
count by (gc) (jvm_gc_pause_seconds_count{namespace="$ns", pod=~"$pod.*"})

Il label gc porta il nome: G1 Young Generation, G1 Old Generation, PS Scavenge, ZGC Cycles. Utile per sapere con cosa hai a che fare prima di suggerire tuning.

Correlazione con il throttling CPU

# secondi di GC per secondo di wall time: frazione di tempo in cui la JVM è ferma · sostituisci $ns, $pod
rate(jvm_gc_pause_seconds_sum{namespace="$ns", pod=~"$pod.*"}[5m])
# percentuale 0-100 dei periodi CFS in cui il container è stato bloccato · sostituisci $container, $ns, $pod
100 * (
rate(container_cpu_cfs_throttled_periods_total{namespace="$ns", pod=~"$pod.*", container="$container"}[5m])
/
rate(container_cpu_cfs_periods_total{namespace="$ns", pod=~"$pod.*", container="$container"}[5m])
)

Sovrapponi le due curve. È una delle correlazioni più utili di tutta la sezione.

I thread del GC consumano CPU. Se il container ha un limits.cpu basso, il GC viene throttled e una pausa che sarebbe durata 50 ms ne dura 800. Il risultato è un circolo vizioso: GC lento → più oggetti accumulati → GC più frequente → più CPU → più throttling.

Il numero di thread GC di default è calcolato sui core del nodo, non sul limit del container: una JVM con limits.cpu: 1 su un worker da 32 core può partire con 8+ thread GC che si contendono un solo core.

Fix, in ordine di efficacia:

-XX:ActiveProcessorCount=<pari al limits.cpu>
-XX:ParallelGCThreads=<pari al limits.cpu, minimo 2>
-XX:ConcGCThreads=<circa 1/4 di ParallelGCThreads>

-XX:ActiveProcessorCount è il più pulito: allinea in un colpo solo tutte le euristiche della JVM (thread GC, thread JIT, pool ForkJoin) al limit del container. Su JDK 10+ con UseContainerSupport attivo (default) la JVM legge già i cgroup, ma arrotonda per eccesso: con limits.cpu: 500m vede 1 core, con 1500m ne vede 2.

Vedi anche Pod — throttling con usage basso.


Allocation e promotion rate

Allocation rate

# MiB · sostituisci $ns, $pod
rate(jvm_gc_memory_allocated_bytes_total{namespace="$ns", pod=~"$pod.*"}[5m]) / 1024^2

MiB allocati al secondo. Non esiste una soglia assoluta: conta il confronto con se stesso nel tempo e con le altre repliche. Un allocation rate che raddoppia dopo un rilascio senza che raddoppi il traffico è una regressione applicativa.

Promotion rate — il predittore dei full GC

# MiB · sostituisci $ns, $pod
rate(jvm_gc_memory_promoted_bytes_total{namespace="$ns", pod=~"$pod.*"}[5m]) / 1024^2

MiB al secondo promossi da young a old generation. È la metrica che predice i full GC: tutto ciò che viene promosso può essere liberato solo da un GC della old gen, che è quello caro.

In un’applicazione sana la stragrande maggioranza degli oggetti muore giovane e il promotion rate è vicino a zero.

Rapporto promotion / allocation

# byte al secondo · sostituisci $ns, $pod
rate(jvm_gc_memory_promoted_bytes_total{namespace="$ns", pod=~"$pod.*"}[5m])
/
rate(jvm_gc_memory_allocated_bytes_total{namespace="$ns", pod=~"$pod.*"}[5m])

Frazione di oggetti allocati che sopravvive alla young gen.

ValoreLetturaFix
< 2%ottimo—
2 – 10%normale—
> 10%premature promotion: i full GC arriverannosurvivor space o young gen troppo piccole

Cause della premature promotion:

  • Survivor space troppo piccolo — gli oggetti vengono promossi prima di avere il tempo di morire. Alzare -XX:MaxTenuringThreshold.
  • Young gen troppo piccola — stessa conseguenza. Con G1, -XX:G1NewSizePercent.
  • L’applicazione crea davvero oggetti long-lived — cache che cresce, sessioni. Allora è leak.

Top allocation del cluster

# MiB
topk(20,
sum by (namespace, pod) (
rate(jvm_gc_memory_allocated_bytes_total{
namespace!~"kube-.*|openshift-.*|default"
}[5m])
)
) / 1024^2

Heap

Occupazione

# MiB · sostituisci $ns, $pod
sum by (pod) (
jvm_memory_used_bytes{area="heap", namespace="$ns", pod=~"$pod.*"}
) / 1024^2
# percentuale 0-100 · sostituisci $ns, $pod
100 * (
sum by (pod) (jvm_memory_used_bytes{area="heap", namespace="$ns", pod=~"$pod.*"})
/
sum by (pod) (jvm_memory_max_bytes{area="heap", namespace="$ns", pod=~"$pod.*"})
)
# jmx_exporter · sostituisci $ns, $pod
jvm_memory_bytes_used{area="heap", namespace="$ns", pod=~"$pod.*"}
/
jvm_memory_bytes_max{area="heap", namespace="$ns", pod=~"$pod.*"}

Per generazione

# MiB · sostituisci $ns, $pod
jvm_memory_used_bytes{area="heap", namespace="$ns", pod=~"$pod.*"} / 1024^2

Il label id separa i pool: G1 Eden Space, G1 Survivor Space, G1 Old Gen. Il pool Old Gen è quello che conta: se cresce e non torna giù dopo i full GC, è un leak.

# MiB · sostituisci $ns, $pod
jvm_memory_used_bytes{area="heap", id=~".*Old Gen", namespace="$ns", pod=~"$pod.*"} / 1024^2

Committed vs used

# MiB · sostituisci $ns, $pod
jvm_memory_committed_bytes{area="heap", namespace="$ns", pod=~"$pod.*"} / 1024^2

La JVM può restituire memoria al sistema operativo (con G1 e -XX:+ShrinkHeapInSteps), ma raramente lo fa. committed è ciò che il container ha davvero occupato: è quello che conta per il cgroup, non used.

Top heap del cluster

# GiB
topk(20,
sum by (namespace, pod) (
jvm_memory_used_bytes{area="heap", namespace!~"kube-.*|openshift-.*|default"}
)
) / 1024^3

Quante JVM ci sono

# numero di serie
count by (namespace) (
count by (namespace, pod, container) (jvm_memory_used_bytes{area="heap"})
)

Serve per dare peso al dato: tre JVM problematiche su quaranta sono un caso isolato, tre su tre sono un problema di configurazione comune.


Memoria non-heap

Un container OOMKilled con la heap tranquilla al 40% è quasi sempre non-heap.

Tutte le aree

# MiB · sostituisci $ns, $pod
jvm_memory_used_bytes{area="nonheap", namespace="$ns", pod=~"$pod.*"} / 1024^2

Il label id separa: Metaspace, Compressed Class Space, CodeHeap 'profiled nmethods', CodeHeap 'non-nmethods', CodeHeap 'non-profiled nmethods'.

Metaspace

# MiB · sostituisci $ns, $pod
jvm_memory_used_bytes{area="nonheap", id="Metaspace", namespace="$ns", pod=~"$pod.*"} / 1024^2

Cresce con il numero di classi caricate. Una metaspace che sale monotona senza stabilizzarsi è un classloader leak: tipico di applicazioni che fanno hot-redeploy, generano proxy dinamici (Spring AOP, Hibernate, CGLIB) o caricano script.

Se i GC hanno cause="Metadata GC Threshold", la JVM sta facendo full GC solo per liberare metaspace: alzare la heap non produce nessun effetto, serve -XX:MaxMetaspaceSize più alto o risolvere il leak.

Code cache

# MiB · sostituisci $ns, $pod
sum by (pod) (
jvm_memory_used_bytes{area="nonheap", id=~"CodeHeap.*", namespace="$ns", pod=~"$pod.*"}
) / 1024^2

Il JIT compilato. Se satura, la JVM smette di compilare e le prestazioni degradano progressivamente senza nessun errore. Si controlla con -XX:ReservedCodeCacheSize.

Direct buffer

# MiB · sostituisci $ns, $pod
jvm_buffer_memory_used_bytes{id="direct", namespace="$ns", pod=~"$pod.*"} / 1024^2
# percentuale 0-100 · sostituisci $ns, $pod
100 * (
jvm_buffer_memory_used_bytes{id="direct", namespace="$ns", pod=~"$pod.*"}
/
jvm_buffer_total_capacity_bytes{id="direct", namespace="$ns", pod=~"$pod.*"}
)
# numero di buffer allocati · sostituisci $ns, $pod
jvm_buffer_count_buffers{id="direct", namespace="$ns", pod=~"$pod.*"}

I DirectByteBuffer vivono fuori dalla heap ma dentro il cgroup: sono il modo più comune di farsi OOMKillare con la heap semi-vuota. Li usano Netty, i client HTTP reattivi, i driver NIO, Kafka.

Non hanno un limite di default sensato: -XX:MaxDirectMemorySize, se non specificato, vale quanto -Xmx. Con -Xmx al 75% del limit, la direct memory può teoricamente raddoppiare il consumo. Su applicazioni Netty-based va impostato esplicitamente:

-XX:MaxDirectMemorySize=256m

Mapped buffer

# MiB · sostituisci $ns, $pod
jvm_buffer_memory_used_bytes{id="mapped", namespace="$ns", pod=~"$pod.*"} / 1024^2

Scomposizione completa del working set

# MiB · sostituisci $container, $ns, $pod
sum by (pod) (container_memory_working_set_bytes{namespace="$ns", pod=~"$pod.*", container="$container"}) / 1024^2
# MiB · sostituisci $ns, $pod
sum by (pod) (jvm_memory_used_bytes{area="heap", namespace="$ns", pod=~"$pod.*"}) / 1024^2
# MiB · sostituisci $ns, $pod
sum by (pod) (jvm_memory_used_bytes{area="nonheap", namespace="$ns", pod=~"$pod.*"}) / 1024^2
# MiB · sostituisci $ns, $pod
sum by (pod) (jvm_buffer_memory_used_bytes{id="direct", namespace="$ns", pod=~"$pod.*"}) / 1024^2

Le quattro nello stesso grafico. Il residuo — working set meno le altre tre — è thread stack, GC metadata, JIT e allocazioni native delle librerie. Se il residuo è la voce dominante e cresce, sospetta una libreria nativa (JNI, driver Oracle OCI, compressione).

Query diretta del residuo:

# byte · sostituisci $container, $ns, $pod
sum by (pod) (container_memory_working_set_bytes{namespace="$ns", pod=~"$pod.*", container="$container"})
-
sum by (pod) (jvm_memory_used_bytes{namespace="$ns", pod=~"$pod.*"})

Valore tipico: 150–400 MiB. Se supera il gigabyte, c’è qualcosa da indagare.


Thread e classi

# conteggio · sostituisci $ns, $pod
jvm_threads_live_threads{namespace="$ns", pod=~"$pod.*"}
# conteggio · sostituisci $ns, $pod
jvm_threads_peak_threads{namespace="$ns", pod=~"$pod.*"}
# conteggio · sostituisci $ns, $pod
jvm_threads_daemon_threads{namespace="$ns", pod=~"$pod.*"}
# conteggio · sostituisci $ns, $pod
jvm_threads_states_threads{namespace="$ns", pod=~"$pod.*"}

Ogni thread costa uno stack (default 1 MiB su x86_64, -Xss). 2000 thread sono 2 GiB fuori dalla heap. Una crescita monotona è un thread leak: pool creati e mai chiusi, tipico di client HTTP istanziati per richiesta.

Il label state di jvm_threads_states_threads mostra runnable, blocked, waiting, timed-waiting. Molti thread in blocked indicano contesa su lock: l’applicazione è viva ma non avanza.

Classi caricate

# conteggio · sostituisci $ns, $pod
jvm_classes_loaded_classes{namespace="$ns", pod=~"$pod.*"}
# eventi al secondo · sostituisci $ns, $pod
rate(jvm_classes_unloaded_classes_total{namespace="$ns", pod=~"$pod.*"}[1h])

In un’applicazione stabile il numero di classi caricate si assesta dopo il warm-up. Se continua a crescere ore dopo l’avvio, hai un classloader leak confermato — e la metaspace lo seguirà.

File descriptor

# percentuale 0-100 · sostituisci $ns, $pod
100 * (
process_open_fds{namespace="$ns", pod=~"$pod.*"}
/
process_max_fds{namespace="$ns", pod=~"$pod.*"}
)

Sopra 0.80 sei vicino a Too many open files. Causa tipica: socket in CLOSE_WAIT mai chiusi.

CPU della JVM

# percentuale 0-100 della CPU usata dal processo JVM · sostituisci $ns, $pod
100 * process_cpu_usage{namespace="$ns", pod=~"$pod.*"}
# percentuale 0-100 della CPU del sistema vista dalla JVM · sostituisci $ns, $pod
100 * system_cpu_usage{namespace="$ns", pod=~"$pod.*"}
# numero di core che la JVM vede (confrontare con limits.cpu) · sostituisci $ns, $pod
system_cpu_count{namespace="$ns", pod=~"$pod.*"}

system_cpu_count è la query da lanciare per verificare cosa vede davvero la JVM. Se il container ha limits.cpu: 1 e questa restituisce 32, la JVM sta dimensionando i suoi pool sui core del nodo: è la causa del throttling descritto sopra.

Uptime

# secondi dall'avvio della JVM · sostituisci $ns, $pod
jvm_uptime_seconds{namespace="$ns", pod=~"$pod.*"} / 3600

Ore dall’avvio. Da correlare con la crescita della heap: un leak si misura in MiB per ora di uptime, non in valore assoluto.


Dimensionamento heap vs limit

Il disallineamento più costoso e più frequente sulle JVM in container.

Heap massima vs memory limit

# percentuale 0-100
100 * (
sum by (namespace, pod, container) (
jvm_memory_max_bytes{area="heap", namespace!~"kube-.*|openshift-.*|default"}
)
/
sum by (namespace, pod, container) (
kube_pod_container_resource_limits{resource="memory"}
)
)
ValoreCosa succede
> 90%OOMKill certo. La heap può da sola riempire il container e resta fuori tutto il non-heap
75 – 90%rischioso: basta un picco di direct buffer o thread
50 – 75%zona corretta
< 50%spreco: heap piccola, il GC lavora troppo, e il container prenota RAM che non usa

La regola: -Xmx ≈ 70–75% del limits.memory. Il restante 25–30% serve a metaspace, code cache, thread stack, direct buffer e all’overhead del runtime — che il kernel conta comunque nel cgroup.

Un container con limits.memory: 2Gi e -Xmx2g verrà OOMKilled, ed è la configurazione che si trova più spesso in produzione.

Audit del cluster

# percentuale 0-100
100 * (
sum by (namespace, pod, container) (
jvm_memory_max_bytes{area="heap", namespace!~"kube-.*|openshift-.*|default"}
)
/
sum by (namespace, pod, container) (
kube_pod_container_resource_limits{resource="memory"}
)
) > 80

Da girare una volta al mese.

JVM senza -Xmx esplicito

# GiB · sostituisci $ns
jvm_memory_max_bytes{area="heap", namespace=~"$ns"} / 1024^3
# percentuale 0-100 · sostituisci $ns
100 * (
jvm_memory_max_bytes{area="heap", namespace=~"$ns"}
/
kube_pod_container_resource_limits{resource="memory", namespace=~"$ns"}
)

Senza -Xmx, una JVM moderna con UseContainerSupport prende il 25% del limit (MaxRAMPercentage di default). Con limits.memory: 4Gi significa 1 GiB di heap e 3 GiB sprecati: il GC lavora continuamente mentre il container ha memoria libera in abbondanza.

Se questa query restituisce circa un quarto del limit, è quasi certamente il caso. Fix moderno, che si adatta se qualcuno cambia il limit:

-XX:MaxRAMPercentage=75.0

Preferibile a -Xmx fisso, che va aggiornato a mano ogni volta che cambia il manifest.


Leak vs sotto-dimensionamento

Distinguere le due cose cambia completamente la risposta all’AM: nel primo caso alzare il limit sposta il problema di qualche giorno.

Live data size — la query decisiva

# MiB · sostituisci $ns, $pod
jvm_gc_live_data_size_bytes{namespace="$ns", pod=~"$pod.*"} / 1024^2

È la metrica più importante per diagnosticare un memory leak in una JVM.

jvm_gc_live_data_size_bytes è l’occupazione della old generation subito dopo un full GC: ciò che è sopravvissuto alla raccolta, quindi memoria realmente raggiungibile e non liberabile.

Lettura su 24–48 ore:

AndamentoDiagnosi
Sale, poi si assesta su un plateaunormale. Il plateau è il working set reale
Sale a gradini e resta su ogni gradinocache che si riempie: normale se ha un tetto, leak se non ce l’ha
Sale monotona, non torna mai giùleak confermato
Oscilla senza tendenzanessun leak. Se il GC è pesante, è sotto-dimensionamento

Il vantaggio su container_memory_working_set_bytes: il working set include cache e memoria liberabile, quindi cresce anche senza leak. live_data_size no.

Percentuale di old gen occupata

# percentuale 0-100 · sostituisci $ns, $pod
100 * (
jvm_gc_live_data_size_bytes{namespace="$ns", pod=~"$pod.*"}
/
jvm_gc_max_data_size_bytes{namespace="$ns", pod=~"$pod.*"}
)
ValoreLettura
< 50%c’è margine, il GC lavora comodo
50 – 70%normale a regime
70 – 85%il GC gira spesso per recuperare poco
> 85%affanno: full GC continui con guadagno minimo. Precede OutOfMemoryError

Sopra 0.85 con andamento piatto = sotto-dimensionamento, alza la heap. Sopra 0.85 con andamento crescente = leak, alzare la heap sposta il problema.

Tasso di crescita dei dati vivi

# MiB all'ora · sostituisci $ns, $pod
deriv(
jvm_gc_live_data_size_bytes{namespace="$ns", pod=~"$pod.*"}[6h:5m]
) * 3600 / 1024^2

MiB/h di crescita dei dati vivi. Costantemente positivo su più finestre consecutive = leak.

Tempo stimato al prossimo OOM

# ore · sostituisci $ns, $pod
(
jvm_gc_max_data_size_bytes{namespace="$ns", pod=~"$pod.*"}
-
jvm_gc_live_data_size_bytes{namespace="$ns", pod=~"$pod.*"}
)
/
deriv(jvm_gc_live_data_size_bytes{namespace="$ns", pod=~"$pod.*"}[6h:5m])
/ 3600

Ore rimanenti prima che i dati vivi saturino la old generation, al ritmo attuale. Valori negativi o enormi = nessun leak in corso.

È il numero da mettere in una comunicazione all’AM: una stima oraria del prossimo OOM è più azionabile della segnalazione generica di un leak.

Top candidati leak del cluster

# MiB all'ora
topk(20,
deriv(
jvm_gc_live_data_size_bytes{namespace!~"kube-.*|openshift-.*|default"}[6h:5m]
) * 3600 / 1024^2
)

Query costosa: subquery su tutte le JVM. Fuori dagli orari di picco, mai in dashboard con auto-refresh.

Il pattern a dente di sega

Stesso profilo visto sul working set del container, ma misurato sulla heap: salita lineare fino al massimo, caduta verticale al restart, risalita con pendenza identica. La pendenza costante è la prova: un leak accumula al ritmo del traffico, che di giorno in giorno è simile.

# MiB · sostituisci $ns, $pod
sum by (pod) (jvm_memory_used_bytes{area="heap", namespace="$ns", pod=~"$pod.*"}) / 1024^2
# contatore cumulativo di restart, azzerato alla ricreazione del pod · sostituisci $container, $ns, $pod
kube_pod_container_status_restarts_total{namespace="$ns", pod=~"$pod.*", container="$container"}

Sovrapposte su 48 ore. Heap che sale linearmente fino al massimo, restart, ripartenza da zero con la stessa pendenza: leak confermato senza bisogno d’altro. L’intervallo tra i restart è proporzionale alla heap — raddoppiarla raddoppia solo l’attesa.


Senza exporter JVM

Nessuna metrica jvm_* e l’AM non può o non vuole aggiungere Actuator. È il caso più frequente sul campo, e si può comunque dire molto dall’esterno del processo.

La firma a livello container

Tre query da leggere insieme:

# 1. working set a plateau vicino al limit, che non scende mai · sostituisci $container, $ns, $pod
sum by (pod) (
container_memory_working_set_bytes{namespace="$ns", pod=~"$pod.*", container="$container"}
)
/
sum by (pod) (
kube_pod_container_resource_limits{resource="memory", namespace="$ns", pod=~"$pod.*", container="$container"}
)
# 2. CPU a picchi regolari con media bassa · sostituisci $container, $ns, $pod
rate(container_cpu_usage_seconds_total{namespace="$ns", pod=~"$pod.*", container="$container"}[1m])
# 3. throttling alto nonostante l'usage medio basso · sostituisci $container, $ns, $pod
rate(container_cpu_cfs_throttled_periods_total{namespace="$ns", pod=~"$pod.*", container="$container"}[5m])
/
rate(container_cpu_cfs_periods_total{namespace="$ns", pod=~"$pod.*", container="$container"}[5m])

Tutte e tre insieme — working set a plateau alto, CPU a dente di sega regolare, throttling con media bassa — sono la firma di una JVM che passa il tempo a fare GC.

Restart senza errori applicativi

# conteggio nelle ultime 6 ore · sostituisci $ns, $pod
increase(kube_pod_container_status_restarts_total{namespace="$ns", pod=~"$pod.*"}[6h])
# elenco: ogni serie restituita soddisfa la condizione · sostituisci $ns, $pod
kube_pod_container_status_last_terminated_reason{namespace="$ns", pod=~"$pod.*"} == 1

reason="Error" con exit code 137 (SIGKILL da liveness fallita) e log applicativi vuoti nei secondi precedenti = compatibile con una pausa GC oltre il timeout della probe.

Terminal window
oc get events -n $ns --field-selector=involvedObject.name=$pod --sort-by=.lastTimestamp | grep -i probe

Liveness probe failed: context deadline exceeded ripetuto e senza corrispondenza nei log = l’applicazione era ferma, non rotta.

Verifica diretta dal pod

jcmd è presente in tutte le immagini basate su JDK, non in quelle JRE-only.

Terminal window
# PID della JVM
oc rsh -n $ns $pod jcmd -l
# Heap corrente per pool
oc rsh -n $ns $pod jcmd 1 GC.heap_info
# Tutti i flag effettivi, inclusi quelli ergonomici
oc rsh -n $ns $pod jcmd 1 VM.flags
# Solo quelli che riguardano heap, GC e CPU
oc rsh -n $ns $pod jcmd 1 VM.flags | tr ' ' '\n' \
| grep -iE "MaxHeapSize|InitialHeapSize|UseG1|UseParallel|ParallelGCThreads|ConcGCThreads|ActiveProcessor|MaxRAMPercentage|MaxMetaspaceSize|MaxDirectMemory"
# Riepilogo nativo della memoria (richiede -XX:NativeMemoryTracking=summary all'avvio)
oc rsh -n $ns $pod jcmd 1 VM.native_memory summary
# Thread dump
oc rsh -n $ns $pod jcmd 1 Thread.print > /tmp/threaddump-$(date +%s).txt
# Istogramma delle classi per numero di istanze
oc rsh -n $ns $pod jcmd 1 GC.class_histogram | head -35

VM.flags mostra l’-Xmx effettivo, che è quasi sempre diverso da quello dichiarato nel Deployment: JAVA_OPTS sovrascritte, entrypoint che aggiunge flag, ergonomia della JVM.

GC.class_histogram forza un full GC prima di stampare: su un pod in produzione provoca una pausa. Da usare solo su un pod già isolato dal traffico.

VM.native_memory summary è il modo definitivo per capire dove va la memoria non-heap, ma richiede che -XX:NativeMemoryTracking=summary fosse presente all’avvio.

Attivare i GC log senza ricostruire l’immagine

Terminal window
oc set env -n $ns deployment/$workload \
JAVA_TOOL_OPTIONS="-Xlog:gc*:file=/proc/1/fd/1:time,uptime,level,tags"

JAVA_TOOL_OPTIONS viene letta da qualunque JVM senza modificare l’entrypoint, e /proc/1/fd/1 manda l’output su stdout, quindi finisce direttamente in Loki. Serve comunque un restart del pod.

In LogQL:

{namespace="$ns", pod=~"$pod.*"} |= "Pause Full"
{namespace="$ns", pod=~"$pod.*"} |~ "Pause.*[0-9]{4,}\\.[0-9]+ms"

La seconda cattura le pause a quattro cifre di millisecondi, cioè oltre il secondo.

Da concordare con l’AM: i GC log su un’applicazione con GC frequente producono volume di log non trascurabile. Attivare, misurare per qualche ora, disattivare.

Chiedere l’esposizione delle metriche

Se l’applicazione è Spring Boot, l’attivazione è a costo quasi zero e vale la pena insistere:

management:
endpoints:
web:
exposure:
include: health,prometheus
metrics:
tags:
application: ${spring.application.name}

Lato piattaforma serve solo il ServiceMonitor:

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: <app>-jvm
namespace: <ns>
spec:
selector:
matchLabels:
app: <app>
endpoints:
- port: http
path: /actuator/prometheus
interval: 30s

Verifiche:

Terminal window
# User workload monitoring attivo
oc -n openshift-monitoring get cm cluster-monitoring-config -o jsonpath='{.data.config\.yaml}'
oc -n openshift-user-workload-monitoring get pods
# ServiceMonitor preso in carico
oc -n $ns get servicemonitor
oc -n openshift-user-workload-monitoring logs -l app.kubernetes.io/name=prometheus-operator --tail=50 | grep -i $ns
# Target attivi
oc -n openshift-user-workload-monitoring exec -c prometheus prometheus-user-workload-0 -- \
promtool query instant http://localhost:9090 'up{namespace="'$ns'"}'

Senza user workload monitoring il ServiceMonitor viene ignorato senza segnalare errori: è la causa più comune di metriche assenti a fronte di una configurazione apparentemente corretta.


Comandi

Terminal window
# Quale JVM e quale versione
oc rsh -n $ns $pod java -version
# Flag effettivi
oc rsh -n $ns $pod jcmd 1 VM.flags
# Memoria
oc rsh -n $ns $pod jcmd 1 GC.heap_info
oc rsh -n $ns $pod jcmd 1 VM.native_memory summary
# Confronto: cosa vede il container vs cosa vede la JVM
oc rsh -n $ns $pod cat /sys/fs/cgroup/memory.max 2>/dev/null || oc rsh -n $ns $pod cat /sys/fs/cgroup/memory/memory.limit_in_bytes
oc rsh -n $ns $pod nproc
oc get pod -n $ns $pod -o json | jq '.spec.containers[] | {name, resources}'
# Thread dump (tre a distanza di 10s, per confronto)
for i in 1 2 3; do
oc rsh -n $ns $pod jcmd 1 Thread.print > /tmp/td-$i.txt
sleep 10
done
grep -c "java.lang.Thread.State: BLOCKED" /tmp/td-*.txt
# Heap dump (attenzione: pausa lunga e file grande quanto la heap)
oc rsh -n $ns $pod jcmd 1 GC.heap_dump /tmp/heap.hprof
oc cp -n $ns $pod:/tmp/heap.hprof ./heap.hprof

Tre thread dump a 10 secondi di distanza sono lo strumento migliore per diagnosticare un deadlock o una contesa: se gli stessi thread sono BLOCKED sullo stesso lock in tutti e tre, non è un caso.

Un heap dump di una JVM da 4 GiB produce un file da 4 GiB e blocca l’applicazione per la durata: va concordato, fatto su un pod isolato dal traffico, e serve verificare che ci sia spazio su /tmp.


Alert

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: jvm-health
namespace: <ns>
labels:
role: alert-rules
spec:
groups:
- name: jvm.gc
rules:
- alert: JVMLongGCPause
expr: |
max by (namespace, pod, container) (
max_over_time(jvm_gc_pause_seconds_max[10m])
) > 1
for: 5m
labels:
severity: warning
annotations:
summary: "{{ $labels.pod }}: pausa GC di {{ $value | printf \"%.2f\" }}s"
description: "Stop-the-world oltre 1 secondo. Confrontare con timeoutSeconds x failureThreshold della liveness probe: se superiore, Kubernetes riavvierà il pod durante il GC."
- alert: JVMGCOverheadHigh
expr: |
sum by (namespace, pod, container) (
rate(jvm_gc_pause_seconds_sum[15m])
) > 0.10
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $labels.pod }}: GC overhead al {{ $value | humanizePercentage }}"
description: "Oltre il 10% del tempo in garbage collection. Heap sotto-dimensionata, leak, o thread GC throttled dal limits.cpu."
- alert: JVMFullGCFrequent
expr: |
sum by (namespace, pod, container) (
increase(jvm_gc_pause_seconds_count{action="end of major GC"}[1h])
) > 3
for: 10m
labels:
severity: warning
annotations:
summary: "{{ $labels.pod }}: {{ $value }} full GC nell'ultima ora"
description: "I full GC sono stop-the-world lunghi. Su servizio latency-critical dovrebbero essere zero. Verificare promotion rate e dimensione della young generation."
- alert: JVMPrematurePromotion
expr: |
rate(jvm_gc_memory_promoted_bytes_total[15m])
/ rate(jvm_gc_memory_allocated_bytes_total[15m]) > 0.15
for: 30m
labels:
severity: info
annotations:
summary: "{{ $labels.pod }}: {{ $value | humanizePercentage }} degli oggetti promossi a old gen"
description: "Premature promotion: i full GC arriveranno. Young generation o survivor space troppo piccoli."
- name: jvm.memory
rules:
- alert: JVMHeapLeakSuspected
expr: |
(jvm_gc_live_data_size_bytes / jvm_gc_max_data_size_bytes > 0.85)
and
(deriv(jvm_gc_live_data_size_bytes[6h:5m]) > 0)
for: 2h
labels:
severity: critical
annotations:
summary: "{{ $labels.pod }}: old gen all'{{ $value | humanizePercentage }} e in crescita"
description: "Dati vivi oltre l'85% dopo full GC con trend crescente. Leak confermato: escalation all'AM, alzare la heap sposta solo la data dell'OOM."
- alert: JVMXmxTooCloseToLimit
expr: |
(
sum by (namespace, pod, container) (jvm_memory_max_bytes{area="heap"})
/
sum by (namespace, pod, container) (kube_pod_container_resource_limits{resource="memory"})
) > 0.85
for: 30m
labels:
severity: warning
annotations:
summary: "{{ $labels.pod }}: -Xmx all'{{ $value | humanizePercentage }} del memory limit"
description: "Non resta spazio per metaspace, code cache, thread stack e direct buffer. OOMKill certo. Portare -Xmx al 70-75% del limit (o -XX:MaxRAMPercentage=75)."
- alert: JVMMetaspaceGrowing
expr: |
deriv(jvm_memory_used_bytes{area="nonheap", id="Metaspace"}[6h:5m]) * 3600 / 1024^2 > 5
for: 6h
labels:
severity: warning
annotations:
summary: "{{ $labels.pod }}: metaspace cresce di {{ $value | printf \"%.1f\" }} MiB/h"
description: "Classloader leak: proxy dinamici, hot-redeploy, script. Alzare la heap non serve."
- alert: JVMThreadLeak
expr: |
deriv(jvm_threads_live_threads[6h:5m]) * 3600 > 20
for: 6h
labels:
severity: warning
annotations:
summary: "{{ $labels.pod }}: {{ $value | printf \"%.0f\" }} thread in più all'ora"
description: "Ogni thread costa ~1 MiB di stack fuori dalla heap. Pool creati e mai chiusi."
- alert: JVMFileDescriptorsHigh
expr: process_open_fds / process_max_fds > 0.80
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $labels.pod }}: file descriptor all'{{ $value | humanizePercentage }}"
description: "Vicino a Too many open files. Verificare socket in CLOSE_WAIT."

Soglie di riferimento

MetricaSanoAttenzioneCritico
jvm_gc_pause_seconds_max< 0.05 s0.2 – 1 s> 1 s
Overhead GC< 2%5 – 10%> 10%
Full GC / ora01 – 3> 3
live_data_size / max_data_size< 70%70 – 85%> 85%
deriv(live_data_size)~ 0> 0 su 1 finestra> 0 su 3+ finestre
-Xmx / limits.memory50 – 75%75 – 90%> 90%
promotion / allocation< 2%2 – 10%> 10%
heap used / max< 70%70 – 85%> 85%
file descriptor usati< 50%50 – 80%> 80%

Vedi anche

  • Pod / Container — memoria e throttling visti dal cgroup
  • Namespace — quante JVM per namespace
  • Nodo — core reali vs core visti dalla JVM