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 misura | Chi lo usa | |
|---|---|---|
| Usage | CPU e memoria realmente consumate | nessuno per decidere: è osservazione |
| Requests | risorse prenotate dai pod | lo scheduler: decide se un pod ci sta |
| Limits | tetto massimo consentito | il 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.
| Triage | condizioni e pressione |
| CPU · Memoria · Disco | |
| Pressure stall | il segnale migliore di saturazione |
| Requests, limits, overcommit | |
| Kubelet e CRI-O | |
| Capacity planning | trend e proiezioni |
| RCA — Nodo NotReady | il runbook completo |
Comandi oc · Alert |
Triage
T1. Nodi non Ready
# elenco: ogni serie restituita soddisfa la condizionekube_node_status_condition{condition="Ready", status="true"} == 0Precedenza 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 condizionekube_node_status_condition{ condition=~"MemoryPressure|DiskPressure|PIDPressure|NetworkUnavailable", status="true"} == 1T3. Nodi cordonati o non schedulabili
# elenco: ogni serie restituita soddisfa la condizionekube_node_spec_unschedulable == 1Un 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"} )) > 80L’output porta mountpoint: si vede subito quale si è riempito.
CPU
Utilizzo del cluster
# percentuale 0-100 della CPU del cluster in uso100 * cluster:node_cpu:ratio_rate5mRecording 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 * 100Utilizzo per nodo in core
# core CPUmax 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 $nodesum by (instance, mode) (rate(node_cpu_seconds_total{instance=~"$node.*"}[5m]))mode alto | Significato |
|---|---|
user | lavoro applicativo: normale |
system | tempo in kernel: molte syscall, I/O intenso, molti container |
iowait | il nodo aspetta il disco → Disco |
steal | l’hypervisor toglie CPU alla VM → problema di farm |
softirq | gestione 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 $noderate(node_context_switches_total{instance=~"$node.*"}[5m])# interrupt al secondo · sostituisci $noderate(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^3Memoria disponibile del cluster
# percentuale 0-100100 * ( 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 $nodenode_memory_MemTotal_bytes{instance=~"$node.*"} / 1024^3# GiB · sostituisci $nodenode_memory_MemFree_bytes{instance=~"$node.*"} / 1024^3# GiB · sostituisci $nodenode_memory_Cached_bytes{instance=~"$node.*"} / 1024^3# GiB · sostituisci $nodenode_memory_Buffers_bytes{instance=~"$node.*"} / 1024^3# GiB · sostituisci $nodenode_memory_SReclaimable_bytes{instance=~"$node.*"} / 1024^3MemAvailable è 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 $nodeincrease(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 $nodenode_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 $nodetopk(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^2Da lanciare subito dopo aver identificato un nodo in pressione.
Stessa cosa per la CPU:
# core CPU · sostituisci $nodetopk(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:
| Percorso | Se pieno |
|---|---|
/ | il nodo va in DiskPressure e inizia a evictare |
/var/lib/containers | i pull falliscono, nessun pod nuovo parte, i rollout si bloccano |
/var/lib/kubelet | i mount falliscono, i pod restano in ContainerCreating |
/var/lib/etcd (master) | etcd va read-only e con lui l’intero cluster |
/sysroot | ostree 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"} )) > 80Il 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"} )) > 80Trascurato: 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 scadenzapredict_linear( node_filesystem_avail_bytes{mountpoint="/var/lib/containers"}[6h], 4 * 24 * 3600) < 0Nodi 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 coda100 * 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 coda100 * 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
# IOPStopk(10, sum by (instance, device) (rate(node_disk_reads_completed_total[5m])))# IOPStopk(10, sum by (instance, device) (rate(node_disk_writes_completed_total[5m])))# MiBtopk(10, sum by (instance, device) ( rate(node_disk_read_bytes_total[5m]) + rate(node_disk_written_bytes_total[5m]))) / 1024^2Pressure 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 $noderate(node_pressure_cpu_waiting_seconds_total{instance=~"$node.*"}[5m])# secondi per secondo (frazione di tempo) · sostituisci $noderate(node_pressure_memory_waiting_seconds_total{instance=~"$node.*"}[5m])# secondi per secondo (frazione di tempo) · sostituisci $noderate(node_pressure_memory_stalled_seconds_total{instance=~"$node.*"}[5m])# secondi per secondo (frazione di tempo) · sostituisci $noderate(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 seriecount(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-100100 * ( 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.
| Valore | Lettura |
|---|---|
< 1.5 | conservativo |
1.5 – 2.5 | normale su carichi disomogenei |
2.5 – 4 | attenzione |
> 4 | stai scommettendo |
Commitment del cluster
# percentuale 0-100100 * ( sum(namespace_cpu:kube_pod_container_resource_requests:sum) / sum(kube_node_status_allocatable{job="kube-state-metrics", resource="cpu"}))# percentuale 0-100100 * ( 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 clustersum(namespace_memory:kube_pod_container_resource_limits:sum)/ sum(kube_node_status_allocatable{job="kube-state-metrics", resource="memory"})| Metrica | Sano | Attenzione | Critico |
|---|---|---|---|
| CPU requests | < 0.70 | 0.70 – 0.85 | > 0.85 |
| CPU limits | qualsiasi | — | l’overcommit CPU è normale: degrada, non uccide |
| Memory requests | < 0.70 | 0.70 – 0.85 | > 0.85 |
| Memory limits | < 1.5 | 1.5 – 2.5 | > 2.5 |
Efficienza del cluster
# percentuale 0-100 della CPU del cluster in uso100 * ( 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 prenotabilisum 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-1max( 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 seriecount by (node) (kube_pod_info)# numero di seriecount 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-Osum(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 CPUsum( 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"}))# GiBsum( 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^3Il 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 CPUsum(kube_node_status_allocatable{resource="cpu"} * on(node) group_left() max by (node) (kube_node_role{role="worker"}))# GiBsum(kube_node_status_allocatable{resource="memory"} * on(node) group_left() max by (node) (kube_node_role{role="worker"})) / 1024^3Kubelet e CRI-O
Restart del kubelet
# numero di variazioni nella finestrachanges(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 condizioneup{job="kubelet"} == 0# elenco: ogni serie restituita soddisfa la condizioneup{job="node-exporter"} == 0Se 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
# secondihistogram_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 secondosum by (instance, operation_type) ( rate(kubelet_runtime_operations_errors_total[5m])) > 0Latenza del PLEG
# secondihistogram_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.
# secondihistogram_quantile(0.99, sum by (le, instance) (rate(kubelet_pleg_relist_interval_seconds_bucket[5m])))Pod in avvio
# secondihistogram_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 seriecount 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]) * 100Le 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 confrontosum(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 scadenzapredict_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
# GiBtopk(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^3Overhead di piattaforma
# GiBsum( container_memory_working_set_bytes{ namespace=~"openshift-.*|kube-.*", container!="", container!="POD" }) / 1024^3# GiBtopk(15, sum by (namespace) ( container_memory_working_set_bytes{ namespace=~"openshift-.*|kube-.*", container!="", container!="POD" } )) / 1024^3openshift-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
NODE=<node-name>oc get node $NODE -o wideFase 1 — Condizioni ed eventi
oc describe node $NODE | sed -n '/Conditions:/,/Addresses:/p'oc describe node $NODE | sed -n '/Events:/,$p'| Condizione | Significato | Prosegui con |
|---|---|---|
Ready=False con KubeletNotReady | il kubelet gira e si dichiara non pronto | Fase 3.5 |
Ready=Unknown | il kubelet non comunica più con l’API server | Fase 2 |
MemoryPressure=True | memoria esaurita | Fase 3.2 |
DiskPressure=True | disco o inode esauriti | Fase 3.3 |
PIDPressure=True | PID esauriti | Fase 3.6 |
NetworkUnavailable=True | CNI non configurata | Fase 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 $nodekube_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
oc get --raw /api/v1/nodes/$NODE/proxy/healthz ; echooc get --raw /api/v1/nodes/$NODE/proxy/stats/summary | head -40| Risultato | Interpretazione |
|---|---|
| Timeout | kubelet down o nodo congelato |
ok | kubelet vivo: il problema è più in basso |
| Errore sulle stats | il runtime o lo storage non rispondono |
Fase 3 — Ispezione
oc debug node/$NODE -- chroot /host bashSe oc debug non parte (il nodo non riesce a schedulare il pod di debug), resta SSH dal bastion.
3.1 Reboot
uptime; who -b; last reboot | headjournalctl --list-boots | tail -5journalctl -b -1 -n 300 --no-pager | tail -100Uptime 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
free -m; vmstat 1 5journalctl -k --since "2 hours ago" | grep -iE "out of memory|oom.?kill|killed process"cat /proc/pressure/memory3.3 Disco e inode
df -hT; df -hidu -xh --max-depth=1 /var/lib/containers 2>/dev/null | sort -rh | headdu -xh --max-depth=1 /var/log 2>/dev/null | sort -rh | headjournalctl --disk-usage3.4 Storage e I/O
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/ioiostat -x 2 5 2>/dev/null || vmstat -dblocked 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
journalctl -u kubelet --since "2 hours ago" | grep -iE "error|failed|PLEG|eviction" | tail -40| Messaggio | Significato |
|---|---|
PLEG is not healthy | il kubelet non interroga CRI-O entro il timeout. Precede quasi sempre il NotReady |
Container runtime is down | CRI-O non risponde → 3.7 |
context deadline exceeded | timeout verso runtime o API server |
Eviction manager: attempting to reclaim | sta evictando per pressione |
failed to garbage collect required amount of images | disco pieno e la GC non basta |
Failed to update node lease | problema di rete verso l’API server |
3.6 PID e processi
cat /proc/sys/kernel/pid_max; ps -eLf | wc -lcat /proc/pressure/cputop -b -n1 | head -253.7 CRI-O
systemctl status criojournalctl -u crio --since "2 hours ago" | grep -iE "error|failed|timeout" | tail -40crictl info | head -30crictl ps -a | head -30crictl stats | head -20crictl imagefsinfo3.8 Rete
ip -br addr; ip -br link; ss -scat /proc/net/nf_conntrack | wc -l 2>/dev/nullsysctl net.netfilter.nf_conntrack_maxtimeout 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
oc adm top nodesoc get pods -A -o wide --field-selector spec.nodeName=$NODE | head -40oc get pods -A --field-selector spec.nodeName=$NODE --no-headers | wc -lFase 5 — MachineConfigPool
oc get mcpoc describe mcp worker | sed -n '/Conditions:/,/Configuration:/p'oc get node $NODE -o jsonpath='{.metadata.annotations}' | jq| Annotation | Significato |
|---|---|
machineconfiguration.openshift.io/state: Working | MCO sta applicando una config: il reboot è atteso |
machineconfiguration.openshift.io/state: Degraded | applicazione fallita: nodo in stato inconsistente |
currentConfig ≠ desiredConfig | aggiornamento in corso o bloccato |
Se il MCP è Updating=True, il NotReady è un reboot pianificato e la procedura si ferma qui.
oc logs -n openshift-machine-config-operator -l k8s-app=machine-config-daemon --tail=100 | grep -i $NODEFase 6 — Rete e OVN
oc get pods -n openshift-ovn-kubernetes -o wide --field-selector spec.nodeName=$NODEoc logs -n openshift-ovn-kubernetes <ovnkube-node-pod> -c ovnkube-controller --tail=100Se 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 $nodekube_node_status_condition{node="$node", status="true"} == 1L’output porta condition: si vede quale è cambiata per prima. Quella è la causa, le altre sono conseguenze.
# percentuale (0-100) · sostituisci $node100 * (1 - (node_memory_MemAvailable_bytes{instance=~"$node.*"} / node_memory_MemTotal_bytes{instance=~"$node.*"}))# conteggio nell'ultima ora · sostituisci $nodeincrease(node_vmstat_oom_kill{instance=~"$node.*"}[1h])# percentuale (0-100) · sostituisci $node100 * (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 $node100 * rate(node_disk_io_time_seconds_total{instance=~"$node.*"}[5m])# numero di variazioni nella finestra · sostituisci $nodechanges(process_start_time_seconds{job="kubelet", instance=~"$node.*"}[6h])# percentuale (0-100) · sostituisci $nodeavg(rate(node_cpu_seconds_total{mode="steal", instance=~"$node.*"}[5m])) * 100# 1 = target raggiungibile, 0 = irraggiungibile · sostituisci $nodeup{job="node-exporter", instance=~"$node.*"}Cause ricorrenti
| Sintomo osservato | Causa più probabile | Perimetro |
|---|---|---|
healthz in timeout, node exporter giù | kubelet crashato o nodo congelato | Infrastruttura |
node_vmstat_oom_kill > 0, MemoryPressure | memoria esaurita: overcommit dei limit | Applicativo + Infra |
DiskPressure, /var/lib/containers pieno | image cache non ripulita | Infrastruttura |
blocked for more than 120 seconds, io_time ~1 | latenza del backend storage | Infra / farm |
PLEG is not healthy | CRI-O non risponde: storage lento o troppi container | Infrastruttura |
| Restart del kubelet ricorrenti | crash del runtime o config errata | Infrastruttura |
MCP Updating=True | reboot pianificato da MCO | Nessuna azione |
steal elevato prima dell’evento | contesa sull’hypervisor | Farm VMware |
| Ready=Unknown, tutto il resto sano | rete verso l’API server | Networking |
Azioni immediate
oc adm cordon $NODE
oc adm drain $NODE \ --ignore-daemonsets \ --delete-emptydir-data \ --force \ --grace-period=60 \ --timeout=15mSe il drain si blocca su un PodDisruptionBudget:
oc get pdb -Aoc get events -A --field-selector reason=EvictionBlocked --sort-by=.lastTimestamp | tailNon forzare oltre il PDB senza aver verificato l’impatto: esiste perché qualcuno ha dichiarato quante repliche servono. Vedi Workload.
Dopo il ripristino:
oc adm uncordon $NODEoc get node $NODESe il nodo non torna Ready dopo un reboot, verificare nell’ordine: MachineConfig applicata, CSR pendenti, raggiungibilità dell’API server.
oc get csr | grep -i pendingoc 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
| Evidenza | A chi |
|---|---|
| Errori I/O, latenza storage, reboot della VM | Infrastruttura / farm VMware |
steal elevato, contesa CPU | Infrastruttura / farm VMware |
| Memory leak applicativi, pod senza limit | Application Management |
| MCO degradato, kubelet in crash loop | Platform team |
| Rete verso API server, OVN | Networking |
Comandi oc
# Stato di tutti i nodioc get nodes -o wideoc get nodes -L node-role.kubernetes.io/worker,topology.kubernetes.io/zone
# Nodi non Ready o cordonatioc get nodes --no-headers | grep -vE "\sReady\s"
# Consumooc adm top nodesoc adm top nodes --sort-by=memory
# Allocazione di un nodooc describe node $NODE | sed -n '/Allocated resources:/,/Events:/p'
# Pod su un nodo, per consumooc adm top pods -A --sort-by=memory | head -30oc get pods -A -o wide --field-selector spec.nodeName=$NODE
# Debugoc debug node/$NODE -- chroot /host df -hToc debug node/$NODE -- chroot /host df -hioc debug node/$NODE -- chroot /host crictl images --digests | head -30oc debug node/$NODE -- chroot /host journalctl -u kubelet --since "1 hour ago" | tail -50
# MachineConfigoc get mcpoc get machineconfig | tail -10
# CSR pendentioc get csr | grep -i pendingoc get csr -o name | xargs oc adm certificate approve
# Manutenzioneoc adm cordon $NODEoc adm drain $NODE --ignore-daemonsets --delete-emptydir-data --force --timeout=15moc adm uncordon $NODEAlert
Da applicare in openshift-monitoring, richiede cluster-admin.
apiVersion: monitoring.coreos.com/v1kind: PrometheusRulemetadata: name: node-health-custom namespace: openshift-monitoring labels: role: alert-rulesspec: 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:
oc -n openshift-monitoring get prometheusrule -o yaml | grep -E "alert: (KubeNode|NodeFilesystem)"Soglie di riferimento
| Metrica | Sano | Attenzione | Critico |
|---|---|---|---|
| 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 ms | 5 – 20 ms | > 20 ms |
| PLEG relist p99 | < 1 s | 1 – 10 s | > 10 s |
| Requests memoria / allocatable | < 70% | 70 – 90% | > 90% |
| Limits memoria / allocatable | < 1.5 | 1.5 – 3 | > 3 |
| Pod per nodo | < 150 | 150 – 220 | > 220 |
Vedi anche
- Pod / Container — chi consuma sul nodo
- Volumi — storage persistente
- Rete — OVN a livello nodo
- Control plane — master ed etcd