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 exporter | da lanciare per prima |
| Triage | pause lunghe e overhead |
| Pause GC · Frequenza e cause | |
| Allocation e promotion | |
| Heap · Non-heap · Thread | |
| Dimensionamento | -Xmx vs limit |
| Leak | |
| Senza exporter | il 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 $nscount by (__name__) ({__name__=~"jvm_.*", namespace="$ns"})| Metrica che vedi | Strumentazione | Pausa 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_bucket | OpenTelemetry Java agent | Sì — via histogram_quantile |
| nessuna | non strumentata | Senza exporter |
Verifica anche che i label Kubernetes ci siano:
# numero di seriecount 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_exportero# 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, $podincrease(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
# seconditopk(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
# secondimax by (namespace, pod, container) ( max_over_time( jvm_gc_pause_seconds_max{namespace!~"kube-.*|openshift-.*|default"}[1h] )) > 1T3. Overhead GC
# secondi di GC per secondo di wall time: frazione di tempo in cui la JVM è fermatopk(20, sum by (namespace, pod, container) ( rate(jvm_gc_pause_seconds_sum{namespace!~"kube-.*|openshift-.*|default"}[15m]) ))T4. -Xmx fuori norma
# percentuale 0-100100 * ( 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"} )) > 80Ogni riga è un OOMKill che aspetta un picco di traffico.
T5. Candidati leak
# percentuale 0-100100 * ( jvm_gc_live_data_size_bytes{namespace!~"kube-.*|openshift-.*|default"} / jvm_gc_max_data_size_bytes{namespace!~"kube-.*|openshift-.*|default"}) > 85Pause GC
Pausa massima per JVM
# seconditopk(20, max by (namespace, pod, container) ( max_over_time( jvm_gc_pause_seconds_max{namespace!~"kube-.*|openshift-.*|default"}[1h] ) ))| Valore | Lettura |
|---|---|
< 0.05 | GC sano |
0.05 – 0.20 | accettabile per servizi non latency-critical |
0.20 – 1.0 | degrado percepibile dall’utente |
1.0 – 5.0 | timeout applicativi, probe a rischio |
> 5.0 | l’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.
oc get pod -n $ns $pod -o json | jq '.spec.containers[] | {name, livenessProbe}'Pausa massima nel tempo
# secondi · sostituisci $ns, $podjvm_gc_pause_seconds_max{namespace="$ns", pod=~"$pod.*"}Da graficare direttamente: ogni picco è un evento di stop-the-world reale.
Percentile con OpenTelemetry
# OpenTelemetrytopk(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, $podrate(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
# seconditopk(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 $nsmax_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, $podrate(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.
| Valore | Lettura |
|---|---|
< 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.98 | precede 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, $pod100 * jvm_gc_overhead{namespace="$ns", pod=~"$pod.*"}# jmx_exporter · sostituisci $nssum by (namespace, pod) ( rate(jvm_gc_collection_seconds_sum{namespace=~"$ns"}[15m]))Numero di GC al minuto
# eventi al minuto · sostituisci $ns, $podsum by (action) ( rate(jvm_gc_pause_seconds_count{namespace="$ns", pod=~"$pod.*"}[5m])) * 60Young GC ogni pochi secondi è normale. Full GC più di una volta all’ora, no.
Full GC nell’ultima ora
# seconditopk(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, $podmax by (cause, action) ( jvm_gc_pause_seconds_max{namespace="$ns", pod=~"$pod.*"})Il label cause è diagnostico:
cause | Significato | Azione |
|---|---|---|
G1 Evacuation Pause | young GC normale | nessuna, se breve |
Allocation Failure | non c’era spazio per allocare | allocation rate alto o heap piccola |
G1 Humongous Allocation | oggetto > metà di una region G1 | array/buffer enormi: aumentare -XX:G1HeapRegionSize o rivedere il codice |
Metadata GC Threshold | metaspace pieno, non la heap | Non-heap |
GCLocker Initiated GC | JNI critical section | raro, spesso driver nativi |
System.gc() | qualcuno lo chiama esplicitamente | quasi 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, $podcount 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, $podrate(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, $pod100 * ( 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, $podrate(jvm_gc_memory_allocated_bytes_total{namespace="$ns", pod=~"$pod.*"}[5m]) / 1024^2MiB 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, $podrate(jvm_gc_memory_promoted_bytes_total{namespace="$ns", pod=~"$pod.*"}[5m]) / 1024^2MiB 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, $podrate(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.
| Valore | Lettura | Fix |
|---|---|---|
< 2% | ottimo | — |
2 – 10% | normale | — |
> 10% | premature promotion: i full GC arriveranno | survivor 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
# MiBtopk(20, sum by (namespace, pod) ( rate(jvm_gc_memory_allocated_bytes_total{ namespace!~"kube-.*|openshift-.*|default" }[5m]) )) / 1024^2Heap
Occupazione
# MiB · sostituisci $ns, $podsum by (pod) ( jvm_memory_used_bytes{area="heap", namespace="$ns", pod=~"$pod.*"}) / 1024^2# percentuale 0-100 · sostituisci $ns, $pod100 * ( 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, $podjvm_memory_bytes_used{area="heap", namespace="$ns", pod=~"$pod.*"}/jvm_memory_bytes_max{area="heap", namespace="$ns", pod=~"$pod.*"}Per generazione
# MiB · sostituisci $ns, $podjvm_memory_used_bytes{area="heap", namespace="$ns", pod=~"$pod.*"} / 1024^2Il 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, $podjvm_memory_used_bytes{area="heap", id=~".*Old Gen", namespace="$ns", pod=~"$pod.*"} / 1024^2Committed vs used
# MiB · sostituisci $ns, $podjvm_memory_committed_bytes{area="heap", namespace="$ns", pod=~"$pod.*"} / 1024^2La 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
# GiBtopk(20, sum by (namespace, pod) ( jvm_memory_used_bytes{area="heap", namespace!~"kube-.*|openshift-.*|default"} )) / 1024^3Quante JVM ci sono
# numero di seriecount 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, $podjvm_memory_used_bytes{area="nonheap", namespace="$ns", pod=~"$pod.*"} / 1024^2Il label id separa: Metaspace, Compressed Class Space, CodeHeap 'profiled nmethods', CodeHeap 'non-nmethods', CodeHeap 'non-profiled nmethods'.
Metaspace
# MiB · sostituisci $ns, $podjvm_memory_used_bytes{area="nonheap", id="Metaspace", namespace="$ns", pod=~"$pod.*"} / 1024^2Cresce 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, $podsum by (pod) ( jvm_memory_used_bytes{area="nonheap", id=~"CodeHeap.*", namespace="$ns", pod=~"$pod.*"}) / 1024^2Il 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, $podjvm_buffer_memory_used_bytes{id="direct", namespace="$ns", pod=~"$pod.*"} / 1024^2# percentuale 0-100 · sostituisci $ns, $pod100 * ( 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, $podjvm_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=256mMapped buffer
# MiB · sostituisci $ns, $podjvm_buffer_memory_used_bytes{id="mapped", namespace="$ns", pod=~"$pod.*"} / 1024^2Scomposizione completa del working set
# MiB · sostituisci $container, $ns, $podsum by (pod) (container_memory_working_set_bytes{namespace="$ns", pod=~"$pod.*", container="$container"}) / 1024^2# MiB · sostituisci $ns, $podsum by (pod) (jvm_memory_used_bytes{area="heap", namespace="$ns", pod=~"$pod.*"}) / 1024^2# MiB · sostituisci $ns, $podsum by (pod) (jvm_memory_used_bytes{area="nonheap", namespace="$ns", pod=~"$pod.*"}) / 1024^2# MiB · sostituisci $ns, $podsum by (pod) (jvm_buffer_memory_used_bytes{id="direct", namespace="$ns", pod=~"$pod.*"}) / 1024^2Le 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, $podsum 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, $podjvm_threads_live_threads{namespace="$ns", pod=~"$pod.*"}# conteggio · sostituisci $ns, $podjvm_threads_peak_threads{namespace="$ns", pod=~"$pod.*"}# conteggio · sostituisci $ns, $podjvm_threads_daemon_threads{namespace="$ns", pod=~"$pod.*"}# conteggio · sostituisci $ns, $podjvm_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, $podjvm_classes_loaded_classes{namespace="$ns", pod=~"$pod.*"}# eventi al secondo · sostituisci $ns, $podrate(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, $pod100 * ( 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, $pod100 * process_cpu_usage{namespace="$ns", pod=~"$pod.*"}# percentuale 0-100 della CPU del sistema vista dalla JVM · sostituisci $ns, $pod100 * system_cpu_usage{namespace="$ns", pod=~"$pod.*"}# numero di core che la JVM vede (confrontare con limits.cpu) · sostituisci $ns, $podsystem_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, $podjvm_uptime_seconds{namespace="$ns", pod=~"$pod.*"} / 3600Ore 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-100100 * ( 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"} ))| Valore | Cosa 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-100100 * ( 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"} )) > 80Da girare una volta al mese.
JVM senza -Xmx esplicito
# GiB · sostituisci $nsjvm_memory_max_bytes{area="heap", namespace=~"$ns"} / 1024^3# percentuale 0-100 · sostituisci $ns100 * ( 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.0Preferibile 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, $podjvm_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:
| Andamento | Diagnosi |
|---|---|
| Sale, poi si assesta su un plateau | normale. Il plateau è il working set reale |
| Sale a gradini e resta su ogni gradino | cache che si riempie: normale se ha un tetto, leak se non ce l’ha |
| Sale monotona, non torna mai giù | leak confermato |
| Oscilla senza tendenza | nessun 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, $pod100 * ( jvm_gc_live_data_size_bytes{namespace="$ns", pod=~"$pod.*"} / jvm_gc_max_data_size_bytes{namespace="$ns", pod=~"$pod.*"})| Valore | Lettura |
|---|---|
< 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, $podderiv( jvm_gc_live_data_size_bytes{namespace="$ns", pod=~"$pod.*"}[6h:5m]) * 3600 / 1024^2MiB/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])/ 3600Ore 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'oratopk(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, $podsum 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, $podkube_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, $podsum 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, $podrate(container_cpu_usage_seconds_total{namespace="$ns", pod=~"$pod.*", container="$container"}[1m])# 3. throttling alto nonostante l'usage medio basso · sostituisci $container, $ns, $podrate(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, $podincrease(kube_pod_container_status_restarts_total{namespace="$ns", pod=~"$pod.*"}[6h])# elenco: ogni serie restituita soddisfa la condizione · sostituisci $ns, $podkube_pod_container_status_last_terminated_reason{namespace="$ns", pod=~"$pod.*"} == 1reason="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.
oc get events -n $ns --field-selector=involvedObject.name=$pod --sort-by=.lastTimestamp | grep -i probeLiveness 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.
# PID della JVMoc rsh -n $ns $pod jcmd -l
# Heap corrente per pooloc rsh -n $ns $pod jcmd 1 GC.heap_info
# Tutti i flag effettivi, inclusi quelli ergonomicioc rsh -n $ns $pod jcmd 1 VM.flags
# Solo quelli che riguardano heap, GC e CPUoc 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 dumpoc rsh -n $ns $pod jcmd 1 Thread.print > /tmp/threaddump-$(date +%s).txt
# Istogramma delle classi per numero di istanzeoc rsh -n $ns $pod jcmd 1 GC.class_histogram | head -35VM.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
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/v1kind: ServiceMonitormetadata: name: <app>-jvm namespace: <ns>spec: selector: matchLabels: app: <app> endpoints: - port: http path: /actuator/prometheus interval: 30sVerifiche:
# User workload monitoring attivooc -n openshift-monitoring get cm cluster-monitoring-config -o jsonpath='{.data.config\.yaml}'oc -n openshift-user-workload-monitoring get pods
# ServiceMonitor preso in caricooc -n $ns get servicemonitoroc -n openshift-user-workload-monitoring logs -l app.kubernetes.io/name=prometheus-operator --tail=50 | grep -i $ns
# Target attivioc -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
# Quale JVM e quale versioneoc rsh -n $ns $pod java -version
# Flag effettivioc rsh -n $ns $pod jcmd 1 VM.flags
# Memoriaoc rsh -n $ns $pod jcmd 1 GC.heap_infooc rsh -n $ns $pod jcmd 1 VM.native_memory summary
# Confronto: cosa vede il container vs cosa vede la JVMoc 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_bytesoc rsh -n $ns $pod nprococ 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 10donegrep -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.hprofoc cp -n $ns $pod:/tmp/heap.hprof ./heap.hprofTre 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/v1kind: PrometheusRulemetadata: name: jvm-health namespace: <ns> labels: role: alert-rulesspec: 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
| Metrica | Sano | Attenzione | Critico |
|---|---|---|---|
jvm_gc_pause_seconds_max | < 0.05 s | 0.2 – 1 s | > 1 s |
| Overhead GC | < 2% | 5 – 10% | > 10% |
| Full GC / ora | 0 | 1 – 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.memory | 50 – 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