Skip to content

Nodo

Il filtro sui namespace applicativi non si applica qui: a livello nodo interessa il totale, piattaforma inclusa.

I tre numeri da non confondere

Cosa misuraChi lo usa
UsageCPU e memoria realmente consumatenessuno per decidere: è osservazione
Requestsrisorse prenotate dai podlo scheduler: decide se un pod ci sta
Limitstetto massimo consentitoil kernel, a runtime

Un cluster può essere pieno per lo scheduler (requests al 95%) e vuoto nella realtà (usage al 25%). In quel caso non servono nodi nuovi: servono request corrette. È la situazione più comune e la più costosa.

Triagecondizioni e pressione
CPU · Memoria · Disco
Pressure stallil segnale migliore di saturazione
Requests, limits, overcommit
Kubelet e CRI-O
Capacity planningtrend e proiezioni
RCA — Nodo NotReadyil runbook completo
Comandi oc · Alert

Triage

T1. Nodi non Ready

# elenco: ogni serie restituita soddisfa la condizione
kube_node_status_condition{condition="Ready", status="true"} == 0

Precedenza assoluta. Un nodo NotReady spiega da solo restart, Pending e throttling su decine di pod: chiuderlo prima di indagare qualunque sintomo applicativo.

T2. Tutte le condizioni anomale

# elenco: ogni serie restituita soddisfa la condizione
kube_node_status_condition{
condition=~"MemoryPressure|DiskPressure|PIDPressure|NetworkUnavailable",
status="true"
} == 1

T3. Nodi cordonati o non schedulabili

# elenco: ogni serie restituita soddisfa la condizione
kube_node_spec_unschedulable == 1

Un nodo cordonato e dimenticato dopo una manutenzione riduce silenziosamente la capacity del cluster.

T4. Memoria per nodo

# percentuale (0-100)
topk(20,
100 * (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes))
)

T5. Filesystem per nodo

# percentuale (0-100)
100 * (
1 - (
node_filesystem_avail_bytes{fstype!~"tmpfs|devtmpfs|overlay|squashfs"}
/
node_filesystem_size_bytes{fstype!~"tmpfs|devtmpfs|overlay|squashfs"}
)
) > 80

L’output porta mountpoint: si vede subito quale si è riempito.


CPU

Utilizzo del cluster

# percentuale 0-100 della CPU del cluster in uso
100 * cluster:node_cpu:ratio_rate5m

Recording rule preinstallata: quanto sta lavorando davvero il cluster, indipendentemente da requests e limits.

Utilizzo per nodo (%)

# percentuale (0-100)
100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])))

Solo worker, se il naming è convenzionale:

# percentuale (0-100)
100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle", instance=~".*worker.*"}[5m])))

Versione robusta al naming, basata sul ruolo dichiarato:

# percentuale (0-100)
100 *
max by (nodename) (
(1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])))
* on(instance) group_left(nodename) node_uname_info
* on(nodename) group_left()
label_replace(kube_node_role{role="worker"}, "nodename", "$1", "node", "(.*)")
)

Il passaggio per node_uname_info serve perché node_cpu_seconds_total porta il label instance (host:porta) mentre kube_node_role porta node (nome Kubernetes): i due non coincidono e il join diretto restituisce vuoto.

Versione economica con la recording rule:

# percentuale (0-100)
instance:node_cpu_utilisation:rate1m * 100

Utilizzo per nodo in core

# core CPU
max by (nodename) (
sum by (instance) (rate(node_cpu_seconds_total{mode!~"idle|iowait|steal"}[5m]))
* on(instance) group_left(nodename) node_uname_info
* on(nodename) group_left()
label_replace(kube_node_role{role="worker"}, "nodename", "$1", "node", "(.*)")
)

L’esclusione di iowait e steal è deliberata: iowait è attesa su disco, non lavoro; steal è tempo rubato dall’hypervisor.

Ripartizione per modo

# core CPU · sostituisci $node
sum by (instance, mode) (rate(node_cpu_seconds_total{instance=~"$node.*"}[5m]))
mode altoSignificato
userlavoro applicativo: normale
systemtempo in kernel: molte syscall, I/O intenso, molti container
iowaitil nodo aspetta il disco → Disco
steall’hypervisor toglie CPU alla VM → problema di farm
softirqgestione pacchetti di rete: traffico alto o offload disabilitato

Steal time — contesa sull’hypervisor

# percentuale (0-100)
topk(10, avg by (instance) (rate(node_cpu_seconds_total{mode="steal"}[5m])) * 100)

Sopra 2% sostenuto l’host ESXi è in contesa e le VM stanno aspettando CPU fisica. Nessuna ottimizzazione lato Kubernetes può compensarlo: è una conversazione con chi gestisce la farm, e questa query è l’evidenza da allegare.

iowait

# percentuale (0-100)
topk(10, avg by (instance) (rate(node_cpu_seconds_total{mode="iowait"}[5m])) * 100)

Sopra 10% il nodo passa un decimo del tempo ad aspettare il disco.

CPU disponibile

# percentuale (0-100)
100 * (
sum by (instance) (rate(node_cpu_seconds_total{mode="idle", instance=~".*worker.*"}[5m]))
/
sum by (instance) (rate(node_cpu_seconds_total{instance=~".*worker.*"}[5m]))
)

Load average

# carico medio: numero di task runnable (confrontare con i core del nodo)
node_load1
# carico medio: numero di task runnable (confrontare con i core del nodo)
node_load5 / count by (instance) (node_cpu_seconds_total{mode="idle"})

Load normalizzato sul numero di core. Sopra 1 ci sono più task runnable dei core disponibili; sopra 2 il nodo è in coda seria.

Context switch e interrupt

# context switch al secondo · sostituisci $node
rate(node_context_switches_total{instance=~"$node.*"}[5m])
# interrupt al secondo · sostituisci $node
rate(node_intr_total{instance=~"$node.*"}[5m])

Un aumento sproporzionato di context switch rispetto al carico indica troppi thread in contesa, o thrashing dello scheduler.


Memoria

Utilizzo per nodo (%)

# percentuale (0-100)
100 * (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes))

Sopra 85 è zona di rischio; sopra 92 il kubelet sta già evictando.

Utilizzo per nodo (GiB)

# GiB
(node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / 1024^3

Memoria disponibile del cluster

# percentuale 0-100
100 * (
sum(:node_memory_MemAvailable_bytes:sum)
/
sum(node_memory_MemTotal_bytes{job="node-exporter"})
)

Sotto 0.15 il kubelet valuta l’eviction (soglie di default su OCP: memory.available<100Mi hard).

Scomposizione

# GiB · sostituisci $node
node_memory_MemTotal_bytes{instance=~"$node.*"} / 1024^3
# GiB · sostituisci $node
node_memory_MemFree_bytes{instance=~"$node.*"} / 1024^3
# GiB · sostituisci $node
node_memory_Cached_bytes{instance=~"$node.*"} / 1024^3
# GiB · sostituisci $node
node_memory_Buffers_bytes{instance=~"$node.*"} / 1024^3
# GiB · sostituisci $node
node_memory_SReclaimable_bytes{instance=~"$node.*"} / 1024^3

MemAvailable è la stima del kernel di quanto è realmente allocabile, e tiene già conto della cache liberabile: è il numero giusto. MemFree da solo sottostima sempre e spaventa senza motivo.

OOM del kernel

# conteggio nell'ultima ora · sostituisci $node
increase(node_vmstat_oom_kill{instance=~"$node.*"}[1h])

Contatore del kernel, indipendente da cAdvisor. Se questo è > 0 ma container_oom_events_total sullo stesso nodo è ~0, il kernel ha killato un processo fuori dai cgroup dei pod: un componente di sistema, o il kubelet stesso.

Swap

# byte · sostituisci $node
node_memory_SwapTotal_bytes{instance=~"$node.*"}

Su OCP lo swap è disabilitato di default e questo deve valere zero. Se non vale zero, qualcuno l’ha abilitato e il comportamento dell’OOM killer cambia in modi difficili da diagnosticare.

Chi consuma su un nodo

# MiB · sostituisci $node
topk(20,
sum by (namespace, pod) (
container_memory_working_set_bytes{container!="", container!="POD"}
* on(namespace, pod) group_left(node) max by (namespace,pod,node) (kube_pod_info{node="$node"})
)
) / 1024^2

Da lanciare subito dopo aver identificato un nodo in pressione.

Stessa cosa per la CPU:

# core CPU · sostituisci $node
topk(20,
sum by (namespace, pod) (
rate(container_cpu_usage_seconds_total{container!="", container!="POD"}[5m])
* on(namespace, pod) group_left(node) max by (namespace,pod,node) (kube_pod_info{node="$node"})
)
)

Disco e filesystem

Riempimento

# percentuale (0-100)
100 * (
1 - (
node_filesystem_avail_bytes{fstype!~"tmpfs|devtmpfs|overlay|squashfs"}
/
node_filesystem_size_bytes{fstype!~"tmpfs|devtmpfs|overlay|squashfs"}
)
)

Percorsi critici e cosa succede quando si riempiono:

PercorsoSe pieno
/il nodo va in DiskPressure e inizia a evictare
/var/lib/containersi pull falliscono, nessun pod nuovo parte, i rollout si bloccano
/var/lib/kubeleti mount falliscono, i pod restano in ContainerCreating
/var/lib/etcd (master)etcd va read-only e con lui l’intero cluster
/sysrootostree non riesce a fare rollback

/var/lib/containers

# percentuale (0-100)
100 * (
1 - (
node_filesystem_avail_bytes{mountpoint="/var/lib/containers"}
/
node_filesystem_size_bytes{mountpoint="/var/lib/containers"}
)
) > 80

Il kubelet ha una garbage collection automatica delle immagini (soglie imageGCHighThresholdPercent 85%, LowThreshold 80%), ma non basta su nodi con molte immagini grandi o con imagePullPolicy: Always diffuso.

Inode

# percentuale (0-100)
100 * (
1 - (
node_filesystem_files_free{fstype!~"tmpfs|devtmpfs|overlay|squashfs"}
/
node_filesystem_files{fstype!~"tmpfs|devtmpfs|overlay|squashfs"}
)
) > 80

Trascurato: un nodo con molti container che scrivono file di lock, pid e sessioni esaurisce gli inode molto prima dello spazio. df -h mostra spazio libero e tutto continua a fallire.

Proiezione al riempimento

# byte previsti a fine finestra: negativo = esaurimento prima della scadenza
predict_linear(
node_filesystem_avail_bytes{mountpoint="/var/lib/containers"}[6h],
4 * 24 * 3600
) < 0

Nodi il cui /var/lib/containers si riempirà entro 4 giorni al ritmo attuale. predict_linear è il costrutto pensato esattamente per questo, ed è la forma corretta da usare anche nell’alert.

Saturazione del device

# percentuale 0-100 di tempo con I/O in coda
100 * rate(node_disk_io_time_seconds_total[5m])

Frazione di tempo in cui il device ha almeno una richiesta in coda. Vicino a 1 = disco saturo.

# percentuale 0-100 di tempo con I/O in coda
100 * rate(node_disk_io_time_weighted_seconds_total[5m])

Coda media ponderata: sopra 1 ci sono richieste che aspettano.

Latenza

# secondi per secondo (frazione di tempo)
rate(node_disk_read_time_seconds_total[5m])
/
rate(node_disk_reads_completed_total[5m])
# secondi per secondo (frazione di tempo)
rate(node_disk_write_time_seconds_total[5m])
/
rate(node_disk_writes_completed_total[5m])

Secondi per operazione. Su storage enterprise ci si aspetta sotto 0.005. Sopra 0.020 sostenuto il backend è in affanno e tutto ne risente — inclusi etcd e il kubelet.

IOPS e throughput

# IOPS
topk(10, sum by (instance, device) (rate(node_disk_reads_completed_total[5m])))
# IOPS
topk(10, sum by (instance, device) (rate(node_disk_writes_completed_total[5m])))
# MiB
topk(10, sum by (instance, device) (
rate(node_disk_read_bytes_total[5m]) + rate(node_disk_written_bytes_total[5m])
)) / 1024^2

Pressure stall (PSI)

Se il collector PSI è attivo, sono i segnali migliori di saturazione: misurano il tempo in cui i processi aspettano una risorsa, non quanto la risorsa è usata.

# secondi per secondo (frazione di tempo) · sostituisci $node
rate(node_pressure_cpu_waiting_seconds_total{instance=~"$node.*"}[5m])
# secondi per secondo (frazione di tempo) · sostituisci $node
rate(node_pressure_memory_waiting_seconds_total{instance=~"$node.*"}[5m])
# secondi per secondo (frazione di tempo) · sostituisci $node
rate(node_pressure_memory_stalled_seconds_total{instance=~"$node.*"}[5m])
# secondi per secondo (frazione di tempo) · sostituisci $node
rate(node_pressure_io_waiting_seconds_total{instance=~"$node.*"}[5m])

Il valore è una frazione: 0.20 significa che i processi passano il 20% del tempo in attesa di quella risorsa.

stalled è più grave di waiting: significa che tutti i task erano bloccati contemporaneamente, quindi il nodo era di fatto fermo.

Verifica che il collector sia attivo:

# numero di serie
count(node_pressure_cpu_waiting_seconds_total)

Requests, limits, overcommit

Requests per nodo

# percentuale (0-100)
100 *
sum by (node) (
kube_pod_container_resource_requests{resource="cpu"}
* on(namespace, pod) group_left(node) max by (namespace,pod,node) (kube_pod_info)
)
/
sum by (node) (kube_node_status_allocatable{resource="cpu"})
# percentuale (0-100)
100 *
sum by (node) (
kube_pod_container_resource_requests{resource="memory"}
* on(namespace, pod) group_left(node) max by (namespace,pod,node) (kube_pod_info)
)
/
sum by (node) (kube_node_status_allocatable{resource="memory"})

Il join con kube_pod_info è necessario: kube_pod_container_resource_requests non ha il label node. Aggregare by (node) senza il join restituisce una serie vuota.

Limits per nodo — il rischio reale

# percentuale 0-100
100 * (
sum by (node) (
kube_pod_container_resource_limits{resource="memory"}
* on(namespace, pod) group_left(node) max by (namespace,pod,node) (kube_pod_info)
)
/
sum by (node) (kube_node_status_allocatable{resource="memory"})
)

È questa che predice l’OOM di nodo, non le requests. Le request garantiscono lo spazio; i limit dicono quanto i pod potrebbero arrivare a chiedere. Valore 2.0 = i pod di quel nodo potrebbero collettivamente chiedere il doppio della RAM fisica.

ValoreLettura
< 1.5conservativo
1.5 – 2.5normale su carichi disomogenei
2.5 – 4attenzione
> 4stai scommettendo

Commitment del cluster

# percentuale 0-100
100 * (
sum(namespace_cpu:kube_pod_container_resource_requests:sum)
/ sum(kube_node_status_allocatable{job="kube-state-metrics", resource="cpu"})
)
# percentuale 0-100
100 * (
sum(namespace_memory:kube_pod_container_resource_requests:sum)
/ sum(kube_node_status_allocatable{job="kube-state-metrics", resource="memory"})
)
# moltiplicatore: quanto i limit dichiarati superano la capacità reale del cluster
sum(namespace_memory:kube_pod_container_resource_limits:sum)
/ sum(kube_node_status_allocatable{job="kube-state-metrics", resource="memory"})
MetricaSanoAttenzioneCritico
CPU requests< 0.700.70 – 0.85> 0.85
CPU limitsqualsiasi—l’overcommit CPU è normale: degrada, non uccide
Memory requests< 0.700.70 – 0.85> 0.85
Memory limits< 1.51.5 – 2.5> 2.5

Efficienza del cluster

# percentuale 0-100 della CPU del cluster in uso
100 * (
cluster:node_cpu:ratio_rate5m
/
(
sum(namespace_cpu:kube_pod_container_resource_requests:sum)
/ sum(kube_node_status_allocatable{resource="cpu"})
)
)

Frazione delle risorse prenotate realmente usata. Sotto 0.30 stai pagando nodi per risorse prenotate e mai utilizzate — aggiungere nodi è la risposta sbagliata a un problema di richieste gonfiate.

Spazio residuo per lo scheduling

# GiB
(
sum by (node) (kube_node_status_allocatable{resource="memory", node=~".*worker.*"})
-
sum by (node) (
kube_pod_container_resource_requests{resource="memory"}
* on(namespace, pod) group_left(node) max by (namespace,pod,node) (kube_pod_info)
)
) / 1024^3
# core CPU ancora prenotabili
sum by (node) (kube_node_status_allocatable{resource="cpu", node=~".*worker.*"})
-
sum by (node) (
kube_pod_container_resource_requests{resource="cpu"}
* on(namespace, pod) group_left(node) max by (namespace,pod,node) (kube_pod_info)
)

GiB e core ancora prenotabili. Se un pod da 4 GiB resta Pending, questa query dice se esiste un nodo che potrebbe ospitarlo, e se no di quanto si è lontani.

Sbilanciamento tra nodi

# differenza fra il nodo più carico e il meno carico, in frazione 0-1
max(
sum by (node) (
kube_pod_container_resource_requests{resource="memory"}
* on(namespace, pod) group_left(node) max by (namespace,pod,node) (kube_pod_info)
)
/ on(node) group_left()
sum by (node) (kube_node_status_allocatable{resource="memory"})
)
-
min(
sum by (node) (
kube_pod_container_resource_requests{resource="memory"}
* on(namespace, pod) group_left(node) max by (namespace,pod,node) (kube_pod_info)
)
/ on(node) group_left()
sum by (node) (kube_node_status_allocatable{resource="memory"})
)

Differenza tra il nodo più carico e il meno carico. Sopra 0.30 c’è uno sbilanciamento che vale la pena correggere: il descheduler, o un drain mirato, liberano capacity senza aggiungere hardware.

Densità di pod

# numero di serie
count by (node) (kube_pod_info)
# numero di serie
count by (node) (kube_pod_info)
/
sum by (node) (kube_node_status_allocatable{resource="pods"})

Il limite di default è 250 pod per nodo su OCP. Ci si arriva raramente, ma su cluster con molti pod piccoli è un vincolo reale che si manifesta come Pending inspiegabili. Oltre i 200 pod il kubelet fatica anche a fare il relist dei container e PLEG inizia a degradare.

Riserva di sistema

# byte riservati a sistema, kubelet e CRI-O
sum(kube_node_status_capacity{resource="memory"})
-
sum(kube_node_status_allocatable{resource="memory"})

Memoria riservata a kubelet, CRI-O e sistema operativo, non disponibile per i pod. Tipicamente 1–2 GiB per nodo. Se il valore è molto più alto, systemReserved o kubeReserved sono configurati in modo aggressivo e stai perdendo capacity.

Totali worker

# core CPU
sum(
kube_pod_container_resource_requests{resource="cpu"}
* on(namespace, pod) group_left(node) max by (namespace,pod,node) (kube_pod_info)
* on(node) group_left() max by (node) (kube_node_role{role="worker"})
)
# GiB
sum(
container_memory_working_set_bytes{container!="", container!="POD", pod!=""}
* on(namespace, pod) group_left(node) max by (namespace, pod, node) (kube_pod_info)
* on(node) group_left() max by (node) (kube_node_role{role="worker"})
) / 1024^3

Il max by (...) intorno a kube_pod_info deduplica: con più repliche di kube-state-metrics la stessa serie è esposta due volte e il join la conterebbe doppia.

# core CPU
sum(kube_node_status_allocatable{resource="cpu"} * on(node) group_left() max by (node) (kube_node_role{role="worker"}))
# GiB
sum(kube_node_status_allocatable{resource="memory"} * on(node) group_left() max by (node) (kube_node_role{role="worker"})) / 1024^3

Kubelet e CRI-O

Restart del kubelet

# numero di variazioni nella finestra
changes(process_start_time_seconds{job="kubelet"}[6h])

Ogni cambio è un restart. Valori > 0 fuori da una finestra di MachineConfig indicano crash.

Node exporter e kubelet raggiungibili

# elenco: ogni serie restituita soddisfa la condizione
up{job="kubelet"} == 0
# elenco: ogni serie restituita soddisfa la condizione
up{job="node-exporter"} == 0

Se entrambi sono a zero nello stesso istante, il nodo era completamente irraggiungibile e non c’è telemetria da analizzare: bisogna passare ai log del boot precedente.

Latenza delle operazioni del runtime

# secondi
histogram_quantile(0.99,
sum by (le, operation_type, instance) (
rate(kubelet_runtime_operations_duration_seconds_bucket[5m])
)
)

Le operazioni verso CRI-O. Se list_containers o container_status superano i secondi, PLEG sta per degradare e il nodo sta per andare NotReady.

Errori del runtime

# eventi al secondo
sum by (instance, operation_type) (
rate(kubelet_runtime_operations_errors_total[5m])
) > 0

Latenza del PLEG

# secondi
histogram_quantile(0.99,
sum by (le, instance) (rate(kubelet_pleg_relist_duration_seconds_bucket[5m]))
)

Il relist deve stare sotto il secondo. Il kubelet dichiara PLEG is not healthy quando supera i 3 minuti, e a quel punto il nodo va NotReady.

# secondi
histogram_quantile(0.99,
sum by (le, instance) (rate(kubelet_pleg_relist_interval_seconds_bucket[5m]))
)

Pod in avvio

# secondi
histogram_quantile(0.99,
sum by (le, instance) (rate(kubelet_pod_start_duration_seconds_bucket[5m]))
)

Tempo dall’accettazione del pod al primo container avviato. Un aumento indica storage lento (mount dei volumi) o registry lento (pull).

Container in esecuzione per nodo

# numero di serie
count by (node) (
container_last_seen{container!="", container!="POD"}
* on(namespace, pod) group_left(node) max by (namespace,pod,node) (kube_pod_info)
)

Capacity planning

Media e picco a 30 giorni

# percentuale (0-100)
avg_over_time(instance:node_cpu_utilisation:rate1m{job="node-exporter"}[30d]) * 100
# percentuale (0-100)
max_over_time(instance:node_cpu_utilisation:rate1m{job="node-exporter"}[30d]) * 100
# percentuale (0-100)
avg_over_time(instance:node_memory_utilisation:ratio{job="node-exporter"}[30d]) * 100
# percentuale (0-100)
max_over_time(instance:node_memory_utilisation:ratio{job="node-exporter"}[30d]) * 100

Le recording rule instance:node_* sono preinstallate e molto più economiche del calcolo grezzo: senza di esse queste query mandano Prometheus in timeout su cluster di taglia media.

La coppia media/picco è ciò che serve. Media 30% e picco 95% descrivono un cluster da livellare, non da ampliare. Media 75% e picco 85% descrivono un cluster da ampliare adesso.

Crescita mensile delle richieste

# delta rispetto alla finestra di confronto
sum(namespace_memory:kube_pod_container_resource_requests:sum)
-
sum(namespace_memory:kube_pod_container_resource_requests:sum offset 30d)

Proiezione a 90 giorni

# byte previsti a fine finestra: negativo = esaurimento prima della scadenza
predict_linear(
sum(namespace_memory:kube_pod_container_resource_requests:sum)[30d:1h],
90 * 24 * 3600
)
/
sum(kube_node_status_allocatable{resource="memory"})

Frazione di allocatable che sarà prenotata tra 90 giorni al ritmo attuale. Sopra 1 il cluster sarà pieno prima di allora.

Query molto costosa: subquery a 30 giorni. Da lanciare a mano per il report, mai in dashboard.

Chi è cresciuto

# GiB
topk(20,
sum by (namespace) (namespace_memory:kube_pod_container_resource_requests:sum)
-
sum by (namespace) (namespace_memory:kube_pod_container_resource_requests:sum offset 30d)
) / 1024^3

Overhead di piattaforma

# GiB
sum(
container_memory_working_set_bytes{
namespace=~"openshift-.*|kube-.*", container!="", container!="POD"
}
) / 1024^3
# GiB
topk(15,
sum by (namespace) (
container_memory_working_set_bytes{
namespace=~"openshift-.*|kube-.*", container!="", container!="POD"
}
)
) / 1024^3

openshift-monitoring è quasi sempre il primo, seguito da openshift-logging e openshift-etcd. Se un altro namespace scala la classifica dopo un upgrade, vale la pena capire perché. Su un cluster ben dimensionato la piattaforma sta sotto il 20–25% del totale.


RCA — Nodo NotReady

Setup

Terminal window
NODE=<node-name>
oc get node $NODE -o wide

Fase 1 — Condizioni ed eventi

Terminal window
oc describe node $NODE | sed -n '/Conditions:/,/Addresses:/p'
oc describe node $NODE | sed -n '/Events:/,$p'
CondizioneSignificatoProsegui con
Ready=False con KubeletNotReadyil kubelet gira e si dichiara non prontoFase 3.5
Ready=Unknownil kubelet non comunica più con l’API serverFase 2
MemoryPressure=Truememoria esauritaFase 3.2
DiskPressure=Truedisco o inode esauritiFase 3.3
PIDPressure=TruePID esauritiFase 3.6
NetworkUnavailable=TrueCNI non configurataFase 6

La distinzione False / Unknown è la prima biforcazione: False = il kubelet è vivo e sta dicendo che qualcosa non va; Unknown = non risponde più e il Node Controller ha smesso di ricevere heartbeat.

Istante esatto della transizione:

# elenco: ogni serie restituita soddisfa la condizione · sostituisci $node
kube_node_status_condition{node="$node", condition="Ready", status="true"} == 0

È il dato da mettere in cima alla timeline dell’RCA — non l’ora della segnalazione.

Fase 2 — Salute del kubelet

Terminal window
oc get --raw /api/v1/nodes/$NODE/proxy/healthz ; echo
oc get --raw /api/v1/nodes/$NODE/proxy/stats/summary | head -40
RisultatoInterpretazione
Timeoutkubelet down o nodo congelato
okkubelet vivo: il problema è più in basso
Errore sulle statsil runtime o lo storage non rispondono

Fase 3 — Ispezione

Terminal window
oc debug node/$NODE -- chroot /host bash

Se oc debug non parte (il nodo non riesce a schedulare il pod di debug), resta SSH dal bastion.

3.1 Reboot

Terminal window
uptime; who -b; last reboot | head
journalctl --list-boots | tail -5
journalctl -b -1 -n 300 --no-pager | tail -100

Uptime basso spiega tutto: il nodo è ripartito. Resta da capire se è stato MCO (Fase 5), l’hypervisor, o un kernel panic — il boot precedente lo dice.

3.2 Memoria

Terminal window
free -m; vmstat 1 5
journalctl -k --since "2 hours ago" | grep -iE "out of memory|oom.?kill|killed process"
cat /proc/pressure/memory

3.3 Disco e inode

Terminal window
df -hT; df -hi
du -xh --max-depth=1 /var/lib/containers 2>/dev/null | sort -rh | head
du -xh --max-depth=1 /var/log 2>/dev/null | sort -rh | head
journalctl --disk-usage

3.4 Storage e I/O

Terminal window
journalctl -k --since "4 hours ago" | grep -iE "I/O error|blocked for more than|hung task|reset|xfs|ext4|multipath|scsi"
cat /proc/pressure/io
iostat -x 2 5 2>/dev/null || vmstat -d

blocked for more than 120 seconds è il segnale inequivocabile di uno storage backend che non risponde. Su VMware significa datastore in sofferenza: escalation immediata, non c’è nulla da fare lato OpenShift.

3.5 Kubelet

Terminal window
journalctl -u kubelet --since "2 hours ago" | grep -iE "error|failed|PLEG|eviction" | tail -40
MessaggioSignificato
PLEG is not healthyil kubelet non interroga CRI-O entro il timeout. Precede quasi sempre il NotReady
Container runtime is downCRI-O non risponde → 3.7
context deadline exceededtimeout verso runtime o API server
Eviction manager: attempting to reclaimsta evictando per pressione
failed to garbage collect required amount of imagesdisco pieno e la GC non basta
Failed to update node leaseproblema di rete verso l’API server

3.6 PID e processi

Terminal window
cat /proc/sys/kernel/pid_max; ps -eLf | wc -l
cat /proc/pressure/cpu
top -b -n1 | head -25

3.7 CRI-O

Terminal window
systemctl status crio
journalctl -u crio --since "2 hours ago" | grep -iE "error|failed|timeout" | tail -40
crictl info | head -30
crictl ps -a | head -30
crictl stats | head -20
crictl imagefsinfo

3.8 Rete

Terminal window
ip -br addr; ip -br link; ss -s
cat /proc/net/nf_conntrack | wc -l 2>/dev/null
sysctl net.netfilter.nf_conntrack_max
timeout 5 bash -c "</dev/tcp/api.<cluster-domain>/6443" && echo "API OK" || echo "API UNREACHABLE"

Se il nodo non raggiunge l’API server, Ready diventa Unknown indipendentemente dallo stato reale.

Fase 4 — Pressione dal cluster

Terminal window
oc adm top nodes
oc get pods -A -o wide --field-selector spec.nodeName=$NODE | head -40
oc get pods -A --field-selector spec.nodeName=$NODE --no-headers | wc -l

Fase 5 — MachineConfigPool

Terminal window
oc get mcp
oc describe mcp worker | sed -n '/Conditions:/,/Configuration:/p'
oc get node $NODE -o jsonpath='{.metadata.annotations}' | jq
AnnotationSignificato
machineconfiguration.openshift.io/state: WorkingMCO sta applicando una config: il reboot è atteso
machineconfiguration.openshift.io/state: Degradedapplicazione fallita: nodo in stato inconsistente
currentConfig ≠ desiredConfigaggiornamento in corso o bloccato

Se il MCP è Updating=True, il NotReady è un reboot pianificato e la procedura si ferma qui.

Terminal window
oc logs -n openshift-machine-config-operator -l k8s-app=machine-config-daemon --tail=100 | grep -i $NODE

Fase 6 — Rete e OVN

Terminal window
oc get pods -n openshift-ovn-kubernetes -o wide --field-selector spec.nodeName=$NODE
oc logs -n openshift-ovn-kubernetes <ovnkube-node-pod> -c ovnkube-controller --tail=100

Se ovnkube-node non è Ready sul nodo, tutti i pod di quel nodo hanno rete parziale o assente anche se il nodo risulta Ready.

Fase 7 — Query forensi

Da lanciare guardando all’indietro, per ricostruire la timeline.

# elenco: ogni serie restituita soddisfa la condizione · sostituisci $node
kube_node_status_condition{node="$node", status="true"} == 1

L’output porta condition: si vede quale è cambiata per prima. Quella è la causa, le altre sono conseguenze.

# percentuale (0-100) · sostituisci $node
100 * (1 - (node_memory_MemAvailable_bytes{instance=~"$node.*"} / node_memory_MemTotal_bytes{instance=~"$node.*"}))
# conteggio nell'ultima ora · sostituisci $node
increase(node_vmstat_oom_kill{instance=~"$node.*"}[1h])
# percentuale (0-100) · sostituisci $node
100 * (1 - (node_filesystem_avail_bytes{instance=~"$node.*", fstype!~"tmpfs|devtmpfs|overlay|squashfs"} / node_filesystem_size_bytes{instance=~"$node.*", fstype!~"tmpfs|devtmpfs|overlay|squashfs"}))
# percentuale 0-100 di tempo con I/O in coda · sostituisci $node
100 * rate(node_disk_io_time_seconds_total{instance=~"$node.*"}[5m])
# numero di variazioni nella finestra · sostituisci $node
changes(process_start_time_seconds{job="kubelet", instance=~"$node.*"}[6h])
# percentuale (0-100) · sostituisci $node
avg(rate(node_cpu_seconds_total{mode="steal", instance=~"$node.*"}[5m])) * 100
# 1 = target raggiungibile, 0 = irraggiungibile · sostituisci $node
up{job="node-exporter", instance=~"$node.*"}

Cause ricorrenti

Sintomo osservatoCausa più probabilePerimetro
healthz in timeout, node exporter giùkubelet crashato o nodo congelatoInfrastruttura
node_vmstat_oom_kill > 0, MemoryPressurememoria esaurita: overcommit dei limitApplicativo + Infra
DiskPressure, /var/lib/containers pienoimage cache non ripulitaInfrastruttura
blocked for more than 120 seconds, io_time ~1latenza del backend storageInfra / farm
PLEG is not healthyCRI-O non risponde: storage lento o troppi containerInfrastruttura
Restart del kubelet ricorrenticrash del runtime o config errataInfrastruttura
MCP Updating=Truereboot pianificato da MCONessuna azione
steal elevato prima dell’eventocontesa sull’hypervisorFarm VMware
Ready=Unknown, tutto il resto sanorete verso l’API serverNetworking

Azioni immediate

Terminal window
oc adm cordon $NODE
oc adm drain $NODE \
--ignore-daemonsets \
--delete-emptydir-data \
--force \
--grace-period=60 \
--timeout=15m

Se il drain si blocca su un PodDisruptionBudget:

Terminal window
oc get pdb -A
oc get events -A --field-selector reason=EvictionBlocked --sort-by=.lastTimestamp | tail

Non forzare oltre il PDB senza aver verificato l’impatto: esiste perché qualcuno ha dichiarato quante repliche servono. Vedi Workload.

Dopo il ripristino:

Terminal window
oc adm uncordon $NODE
oc get node $NODE

Se il nodo non torna Ready dopo un reboot, verificare nell’ordine: MachineConfig applicata, CSR pendenti, raggiungibilità dell’API server.

Terminal window
oc get csr | grep -i pending
oc adm certificate approve <csr-name>

Su cluster air-gapped con nodi spenti a lungo, i certificati del kubelet scadono e le CSR di rinnovo restano pendenti: è la causa più comune di un nodo che non torna Ready dopo una manutenzione.

Escalation

EvidenzaA chi
Errori I/O, latenza storage, reboot della VMInfrastruttura / farm VMware
steal elevato, contesa CPUInfrastruttura / farm VMware
Memory leak applicativi, pod senza limitApplication Management
MCO degradato, kubelet in crash loopPlatform team
Rete verso API server, OVNNetworking

Comandi oc

Terminal window
# Stato di tutti i nodi
oc get nodes -o wide
oc get nodes -L node-role.kubernetes.io/worker,topology.kubernetes.io/zone
# Nodi non Ready o cordonati
oc get nodes --no-headers | grep -vE "\sReady\s"
# Consumo
oc adm top nodes
oc adm top nodes --sort-by=memory
# Allocazione di un nodo
oc describe node $NODE | sed -n '/Allocated resources:/,/Events:/p'
# Pod su un nodo, per consumo
oc adm top pods -A --sort-by=memory | head -30
oc get pods -A -o wide --field-selector spec.nodeName=$NODE
# Debug
oc debug node/$NODE -- chroot /host df -hT
oc debug node/$NODE -- chroot /host df -hi
oc debug node/$NODE -- chroot /host crictl images --digests | head -30
oc debug node/$NODE -- chroot /host journalctl -u kubelet --since "1 hour ago" | tail -50
# MachineConfig
oc get mcp
oc get machineconfig | tail -10
# CSR pendenti
oc get csr | grep -i pending
oc get csr -o name | xargs oc adm certificate approve
# Manutenzione
oc adm cordon $NODE
oc adm drain $NODE --ignore-daemonsets --delete-emptydir-data --force --timeout=15m
oc adm uncordon $NODE

Alert

Da applicare in openshift-monitoring, richiede cluster-admin.

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: node-health-custom
namespace: openshift-monitoring
labels:
role: alert-rules
spec:
groups:
- name: node.resources
rules:
- alert: NodeMemoryOvercommitCritical
expr: |
sum by (node) (
kube_pod_container_resource_limits{resource="memory"}
* on(namespace, pod) group_left(node) max by (namespace,pod,node) (kube_pod_info)
)
/
sum by (node) (kube_node_status_allocatable{resource="memory"})
> 3
for: 1h
labels:
severity: warning
annotations:
summary: "{{ $labels.node }}: memory limit a {{ $value | printf \"%.1f\" }}x l'allocatable"
description: "Se i pod convergono verso i propri limit il kernel inizierà a killare. Ribilanciare o correggere i limit."
- alert: NodeRequestsNearlyFull
expr: |
sum by (node) (
kube_pod_container_resource_requests{resource="memory"}
* on(namespace, pod) group_left(node) max by (namespace,pod,node) (kube_pod_info)
)
/
sum by (node) (kube_node_status_allocatable{resource="memory"})
> 0.92
for: 1h
labels:
severity: warning
annotations:
summary: "{{ $labels.node }}: {{ $value | humanizePercentage }} della memoria prenotata"
description: "Nodo pieno dal punto di vista dello scheduler, indipendentemente dall'uso reale."
- alert: NodeCPUSteal
expr: |
avg by (instance) (rate(node_cpu_seconds_total{mode="steal"}[5m])) > 0.02
for: 30m
labels:
severity: warning
annotations:
summary: "{{ $labels.instance }}: steal time al {{ $value | humanizePercentage }}"
description: "L'hypervisor sta togliendo CPU alla VM. Nessuna azione possibile lato OpenShift: escalation alla farm."
- name: node.disk
rules:
- alert: NodeFilesystemWillFillIn4Days
expr: |
predict_linear(
node_filesystem_avail_bytes{fstype!~"tmpfs|devtmpfs|overlay|squashfs"}[6h],
4 * 24 * 3600
) < 0
and
node_filesystem_avail_bytes{fstype!~"tmpfs|devtmpfs|overlay|squashfs"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|devtmpfs|overlay|squashfs"} < 0.30
for: 1h
labels:
severity: warning
annotations:
summary: "{{ $labels.instance }}:{{ $labels.mountpoint }} pieno entro 4 giorni"
description: "Proiezione lineare sulle ultime 6 ore. Pianificare la pulizia prima che diventi un incident notturno."
- alert: NodeInodesNearlyExhausted
expr: |
node_filesystem_files_free{fstype!~"tmpfs|devtmpfs|overlay|squashfs"}
/ node_filesystem_files{fstype!~"tmpfs|devtmpfs|overlay|squashfs"} < 0.15
for: 30m
labels:
severity: warning
annotations:
summary: "{{ $labels.instance }}:{{ $labels.mountpoint }}: inode al {{ $value | humanizePercentage }} liberi"
description: "Il filesystem si comporterà come pieno pur mostrando spazio disponibile."
- alert: NodeDiskLatencyHigh
expr: |
rate(node_disk_write_time_seconds_total[5m])
/ rate(node_disk_writes_completed_total[5m]) > 0.05
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $labels.instance }} {{ $labels.device }}: {{ $value | printf \"%.3f\" }}s per scrittura"
description: "Backend storage in affanno. Impatta kubelet, etcd e tutti i pod del nodo."
- name: node.kubelet
rules:
- alert: KubeletPLEGDegraded
expr: |
histogram_quantile(0.99,
sum by (le, instance) (rate(kubelet_pleg_relist_duration_seconds_bucket[5m]))
) > 10
for: 15m
labels:
severity: critical
annotations:
summary: "{{ $labels.instance }}: PLEG relist p99 a {{ $value | printf \"%.1f\" }}s"
description: "Il kubelet fatica a interrogare CRI-O. Precede il passaggio a NotReady: intervenire prima."
- alert: KubeletRestarting
expr: changes(process_start_time_seconds{job="kubelet"}[1h]) > 2
labels:
severity: warning
annotations:
summary: "{{ $labels.instance }}: kubelet riavviato {{ $value }} volte in un'ora"
- alert: NodeCordonedTooLong
expr: kube_node_spec_unschedulable == 1
for: 24h
labels:
severity: info
annotations:
summary: "{{ $labels.node }} cordonato da oltre 24 ore"
description: "Capacity ridotta silenziosamente. Verificare se la manutenzione è conclusa."

KubeNodeNotReady, KubeNodeUnreachable e NodeFilesystemSpaceFillingUp esistono già nativamente. Verifica prima di duplicare:

Terminal window
oc -n openshift-monitoring get prometheusrule -o yaml | grep -E "alert: (KubeNode|NodeFilesystem)"

Soglie di riferimento

MetricaSanoAttenzioneCritico
Memoria usata< 70%70 – 85%> 85%
CPU usata< 60%60 – 80%> 80%
Steal time< 0.5%0.5 – 2%> 2%
iowait< 3%3 – 10%> 10%
Filesystem< 70%70 – 85%> 85%
Inode< 70%70 – 85%> 85%
Latenza disco (write)< 5 ms5 – 20 ms> 20 ms
PLEG relist p99< 1 s1 – 10 s> 10 s
Requests memoria / allocatable< 70%70 – 90%> 90%
Limits memoria / allocatable< 1.51.5 – 3> 3
Pod per nodo< 150150 – 220> 220

Vedi anche