Kubernetes, CPU Limits e Requests per i Pod, spiegazione e confronto: massimo controllo o massima efficienza?

0

Il progetto Kubernetes si appresta a pubblicare la versione 1.29 e come sempre, nel consueto articolo pre-release, sono stati presentati i principali cambiamenti, le deprecazioni e le rimozioni previste. Tutte informazioni che, in virtù dei cicli frenetici di sviluppo da tre le release all’anno, vale sempre la pena confrontare con i propri workload, per evitare spiacevoli sorprese in fase di aggiornamento.

Non sono molte, e ci torneremo quando la release sarà ufficialmente pubblicata, ma in certi ambiti queste potrebbero rivestire importanza capitale per le proprie applicazioni e, come recita l’articolo, esiste una lista di tutte le deprecazioni, release per release, da tenere sempre a portata di mano.

L’articolo sulle deprecazioni però potrebbe aver fatto passare in secondo piano un altro post, apparso lo stesso giorno sempre sul blog della piattaforma di container orchestration più famosa del mondo, dal titolo interessantissimo: The Case for Kubernetes Resource Limits: Predictability vs. Efficiency.

Il problema affrontato nell’articolo è piuttosto semplice: in Kubernetes possono essere dichiarati per ciascun Pod (quindi per ciascuna applicazione) due tipi di impostazione per le CPU:

  • Limits, vincoli imposti sulle risorse come CPU e memoria per evitare che un Pod consumi eccessivamente le risorse del sistema, assicurando una gestione equa e prevenendo il sovraccarico del cluster.
  • Requests, la quantità minima di risorse (CPU, memoria) che un Pod richiede al cluster per funzionare correttamente. Essenzialmente, indicano al sistema di gestione del cluster di Kubernetes di allocare almeno questa quantità di risorse per il corretto funzionamento del Pod.

Come funzionano CPU Limits & Requests

Per affrontare con cognizione di causa le questioni emerse nell’articolo è necessario cercare di comprendere fino in fondo come funzionino le impostazioni di Limits e Requests all’interno dei Pod di Kubernetes e per farlo verrà utilizzato un cluster di test composto da tre nodi, che svolgono contemporaneamente il ruolo di control-plane e quello di worker, un setup che solitamente viene chiamato hyperconverged.

Il cluster di test ha questa disponibilità di CPU, elemento su cui verranno applicate le impostazioni:

[kirater@training-kfs-minikube ~]$ kubectl get nodes -o=jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.capacity.cpu}{"\n"}{end}'
training-kfs-kubernetes-1       4
training-kfs-kubernetes-2       4
training-kfs-kubernetes-3       4

Il cluster dovrà obbligatoriamente avere la componente di metriche abilitata, poiché senza il monitoraggio delle risorse nessun dato relativo alla CPU o alla memoria potrà essere elaborato.

Il tutto è installabile con facilità in qualsiasi cluster:

[kirater@training-kfs-minikube ~]$ kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
serviceaccount/metrics-server created
clusterrole.rbac.authorization.k8s.io/system:aggregated-metrics-reader created
clusterrole.rbac.authorization.k8s.io/system:metrics-server created
rolebinding.rbac.authorization.k8s.io/metrics-server-auth-reader created
clusterrolebinding.rbac.authorization.k8s.io/metrics-server:system:auth-delegator created
clusterrolebinding.rbac.authorization.k8s.io/system:metrics-server created
service/metrics-server created
deployment.apps/metrics-server created
apiservice.apiregistration.k8s.io/v1beta1.metrics.k8s.io created

Dopo l’abilitazione delle metriche sarà possibile consultare l’utilizzo delle risorse per ciascun nodo, utilizzando il comando kubectl top nodes:

[kirater@training-kfs-minikube ~]$ kubectl top nodes
NAME                        CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%   
training-kfs-kubernetes-1   253m         6%     2228Mi          58%       
training-kfs-kubernetes-2   406m         10%    2001Mi          52%       
training-kfs-kubernetes-3   269m         6%     2394Mi          62%  

Il test prevede la creazione di un namespace comprensivo di deployment dell’applicazione nginx:

[kirater@training-kfs-minikube ~]$ kubectl create namespace myns
namespace/myns created

[kirater@training-kfs-minikube ~]$ kubectl config set-context --current --namespace=myns                                                                 
Context "kubernetes-admin@training-kfs-kubernetes" modified.

[kirater@training-kfs-minikube ~]$ kubectl create deployment nginx --image nginx:latest --port 80                                                        
deployment.apps/nginx created

[kirater@training-kfs-minikube ~]$ kubectl get all
NAME                         READY   STATUS    RESTARTS   AGE
pod/nginx-7c79c4bf97-2jvhc   1/1     Running   0          49s

NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/nginx   1/1     1            1           49s

NAME                               DESIRED   CURRENT   READY   AGE
replicaset.apps/nginx-7c79c4bf97   1         1         1       49s

Il deployment userà le risorse in questo modo: Limits a 2 core e Requests a 1 core:

[kirater@training-kfs-minikube ~]$ kubectl patch deployment nginx -p '{"spec":{"template":{"spec":{"containers":[{"name":"nginx","resources":{"limits":{"c
pu":"2"},"requests":{"cpu":"1"}}}]}}}}'                                                                                                               
deployment.apps/nginx patched

Test delle CPU Requests

Ogni singolo nodo del cluster potrà erogare al massimo 4 istanze (1 core * 4 istanze = 4 cpu ossia il totale delle CPU di ciascun nodo).

Per testare il tutto basterà incrementare il numero di repliche del deployment a 13, aspettandosi di avere, nella migliore delle ipotesi, 4 istanze per ciascun nodo ed una pending, poiché ciascun nodo avrà saturato le Requests disponibili:

[kirater@training-kfs-minikube ~]$ kubectl scale deployment nginx --replicas 13
deployment.apps/nginx scaled

In realtà la situazione sarà qualcosa di molto simile a quanto segue:

[kirater@training-kfs-minikube ~]$ kubectl get pods -o wide
NAME                     READY   STATUS    RESTARTS   AGE     IP             NODE                        NOMINATED NODE   READINESS GATES
nginx-5bf45bd587-2mpfg   0/1     Pending   0          2m2s    <none>         <none>                      <none>           <none>
nginx-5bf45bd587-6ln7m   1/1     Running   0          2m2s    172.16.0.41    training-kfs-kubernetes-1   <none>           <none>
nginx-5bf45bd587-6ndqp   0/1     Pending   0          2m2s    <none>         <none>                      <none>           <none>
nginx-5bf45bd587-7pd5g   1/1     Running   0          2m2s    172.16.1.75    training-kfs-kubernetes-2   <none>           <none>
nginx-5bf45bd587-984qx   1/1     Running   0          5m50s   172.16.0.39    training-kfs-kubernetes-1   <none>           <none>
nginx-5bf45bd587-b7z8f   0/1     Pending   0          2m2s    <none>         <none>                      <none>           <none>
nginx-5bf45bd587-dqptm   1/1     Running   0          2m2s    172.16.0.40    training-kfs-kubernetes-1   <none>           <none>
nginx-5bf45bd587-m6lzt   0/1     Pending   0          2m2s    <none>         <none>                      <none>           <none>
nginx-5bf45bd587-pvbtq   1/1     Running   0          2m2s    172.16.2.250   training-kfs-kubernetes-3   <none>           <none>
nginx-5bf45bd587-qhvhq   0/1     Pending   0          2m2s    <none>         <none>                      <none>           <none>
nginx-5bf45bd587-rh99w   1/1     Running   0          2m2s    172.16.2.252   training-kfs-kubernetes-3   <none>           <none>
nginx-5bf45bd587-rjr2n   1/1     Running   0          2m2s    172.16.1.76    training-kfs-kubernetes-2   <none>           <none>
nginx-5bf45bd587-vj4s9   1/1     Running   0          2m2s    172.16.2.251   training-kfs-kubernetes-3   <none>           <none>

Poiché non ci sono solo questi Pod nell’intero cluster, visto che i nodi sono allo stesso tempo control-plane e worker altri Pod sono in azione sui nodi, pertanto il livello di saturazione verrà raggiunto molto presto.

Test dei CPU Limits

Le impostazioni relative ai Limits possono essere testate monitorando il consumo di CPU del singolo pod:

[kirater@training-kfs-minikube ~]$ kubectl top pod 
NAME                     CPU(cores)   MEMORY(bytes)   
nginx-5ddbbd88db-65584   0m           22Mi    

Per forzare l’incremento di consumo CPU è possibile installare nel Pod il programma stress, il cui compito è appunto quello di sollecitare la CPU andando a saturare il numero di core indicati dal parametro --cpu:

[kirater@training-kfs-minikube ~]$ kubectl exec -it nginx-5ddbbd88db-65584 -- /bin/bash                                                                  
root@nginx-5ddbbd88db-65584:/# apt update
Get:1 http://deb.debian.org/debian bookworm InRelease [151 kB]
Get:2 http://deb.debian.org/debian bookworm-updates InRelease [52.1 kB]                                                                                  
Get:3 http://deb.debian.org/debian-security bookworm-security InRelease [48.0 kB]                                                                        
Get:4 http://deb.debian.org/debian bookworm/main amd64 Packages [8780 kB]
Get:5 http://deb.debian.org/debian bookworm-updates/main amd64 Packages [6668 B]                                                                         
Get:6 http://deb.debian.org/debian-security bookworm-security/main amd64 Packages [103 kB] 
...
...

root@nginx-5ddbbd88db-65584:/# apt install stress
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
The following NEW packages will be installed:
  stress
0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded.
Need to get 21.9 kB of archives.
After this operation, 57.3 kB of additional disk space will be used.
Get:1 http://deb.debian.org/debian bookworm/main amd64 stress amd64 1.0.7-1 [21.9 kB]
Fetched 21.9 kB in 0s (849 kB/s)
debconf: delaying package configuration, since apt-utils is not installed
Selecting previously unselected package stress.
(Reading database ... 7590 files and directories currently installed.)
Preparing to unpack .../stress_1.0.7-1_amd64.deb ...
Unpacking stress (1.0.7-1) ...
Setting up stress (1.0.7-1) ...

root@nginx-5ddbbd88db-65584:/# stress --cpu 3
stress: info: [157] dispatching hogs: 3 cpu, 0 io, 0 vm, 0 hdd

Nonostante il tentativo sia quello di portare il Pod a consumare 3 interi core di CPU, il massimo ottenibile è 1 solo core, corrispondente a 1000 millicore:

[kirater@training-kfs-minikube ~]$ kubectl top pod                                                                                                       
NAME                     CPU(cores)   MEMORY(bytes)
nginx-5ddbbd88db-65584   1003m        24Mi 

Stoppato il comando stress nel Pod, il consumo torna quasi immediatamente a 0m, che vale la pena notare non è il valore impostato per le Requests, ed il motivo per cui appare così è perché kubectl top pod mostra solo le metriche di utilizzo effettivo delle risorse (cioè quanto effettivamente utilizzato) e non le richieste di risorse dichiarate nel manifesto del Pod. Il comando quindi mostra il consumo effettivo di CPU e memoria, ma non le richieste dichiarate nel manifesto del Pod.

Il valore “0m” indica semplicemente che la specifica risorsa non sta utilizzando CPU da quel Pod o che il valore di richiesta di CPU dichiarato è molto basso e quindi non è rilevabile nel contesto delle metriche di utilizzo effettivo.

Le richieste Requests specificate nel manifest del Pod sono usate da Kubernetes per la pianificazione delle risorse e la distribuzione dei Pod sui nodi del cluster in base alle richieste e ai limiti specificati.

Come scegliere CPU Limits & Requests

Ora che i presupposti di funzionamento di Limits e Requests dovrebbero essere chiari è possibile ritornare all’articolo citato in apertura, che si concentra in particolare sull’impostazione dei Limits la quale, sebbene viene indicata come best-practice piuttosto frequentemente, espone il fianco a problemi di inefficienza, poiché l’insuperabilità degli stessi che può portare alle applicazioni un funzionamento poco performante.

L’articolo del blog di Kubernetes è piuttosto breve, ma fornisce un paio di link di approfondimento che aiutano, ribaltando alcuni concetti dati per certi, a comprendere fino in fondo la questione presentando casi di studio effettivi.

In particolare For the Love of God, Stop Using CPU Limits on Kubernetes oltre a fornire alcuni esempi coloriti di come il dualismo Limits/Requests possa essere interpretato, fornisce uno schema riassuntivo estremamente chiaro in merito al perché l’autore suggerisca di impostare unicamente delle Requests, tralasciando i Limits:

Il quadrante verde rappresenta nell’articolo la soluzione ottimale: le risorse vengono preventivamente allocate (e quindi le applicazioni sulla carta avranno tutti i presupposti per funzionare correttamente), ma nel momento in cui dovessero richiedere risorse aggiuntive queste, se saranno disponibili, potranno essere utilizzate.

Per spiegarlo ancora più nel dettaglio, ecco i tre esempi coloriti che partono dal presupposto che due persone, A e B, siano nel deserto ed abbiano bisogno per sopravvivere di 1 litro d’acqua, su tre totali a disposizione:

  • Caso 1 – senza Limits, senza Requests: A beve tutta l’acqua prima che B possa berne. B muore di sete.
  • Caso 2 – con Limits, con o senza Requests: B ha bisogno di un po’ d’acqua in più. A beve il suo litro e ne restano due litri. B beve un litro e ora ne rimane un litro. B non riesce ad accedere all’ulteriore litro di cui avrebbe bisogno, nonostante ci sia, perché il suo limite è di 1 litro al giorno, quindi muore di sete.
  • Caso 3 – senza Limits, con Requests: A un giorno ha bisogno di più acqua. Cerca di bere tutta l’acqua, ma si ferma quando nella bottiglia rimane solo 1 litro. Questo viene risparmiato per B perché ha bisogno di 1 litro al giorno. Beve il suo 1 litro. Non rimane nulla. Entrambi vivono.

Espresse in questo modo le casistiche sono molto chiare, e sebbene le conclusioni portino a pensare che la soluzione “senza Limits, con Requests” sia la migliore la verità, come sempre, dipende dalla situazione a cui la “regola” viene applicata.

Conclusioni

La conclusione che l’articolo citato tira è che, per quanto riguarda i Limits è bene “lasciar fare a Kubernetes”, poiché sarà l’orchestrator a preoccuparsi di “scodare” le richieste nella forma più efficiente possibile, ed essendo quello un software, sicuramente lo farà meglio che un essere umano.

Allo stesso tempo, definire esatte Requests per le proprie applicazioni è un lavoro certosino, che oseremmo definire l’essenza del FinOps, ormai imprescindibile per ambienti di produzione di classe enterprise.

Il che porta alla definizione della regola finale, promossa anche da Tim Hockin, uno dei maintainer di Kubernetes in Google:

  1. Utilizzare le CPU Requests per tutto.
  2. Assicurarsi che queste siano accurate (e qui è racchiusa tutta la complessità dell’argomento).
  3. Non usare i CPU Limits.

È quindi pensabile di poter vivere senza Limits? In realtà questo approccio non sarà mai al 100% tutelativo, poiché la coperta è corta: se un’applicazione ha fame di CPU (o è banalmente scritta male) potrebbe comunque saturare i nodi del cluster, quindi potrebbe essere pensabile impostare CPU Limits decisamente alti in modo da avere comunque un tetto massimo. Ma a quel punto però perché non lasciar fare a Kubernetes?

Nota finale importante: il ragionamento di questo articolo si riferisce solo alle CPU. La memoria, che può essere liberata solo in seguito all’uccisione del processo associato, fa storia a sé, tanto che tutti (in questo caso proprio tutti) consigliano di impostare i Memory Limits.

Di questo magari, ne parleremo in futuro.

Da sempre appassionato del mondo open-source e di Linux nel 2009 ho fondato il portale Mia Mamma Usa Linux! per condividere articoli, notizie ed in generale tutto quello che riguarda il mondo del pinguino, con particolare attenzione alle tematiche di interoperabilità, HA e cloud.
E, sì, mia mamma usa Linux dal 2009.