Nota del traduttore.: questa storia istruttiva di Omio — l’aggregatore europeo di viaggi — guida i lettori dalla teoria di base alle affascinanti complessità pratiche nella configurazione di Kubernetes. Affrontare casi come questi non solo amplia le proprie prospettive ma aiuta anche a prevenire problemi non banali.

Ti è mai capitato di notare che un’applicazione ‘si bloccava’, smettendo di rispondere alle richieste di controllo dello stato (health check) e non riuscivi a capire il motivo di tale comportamento? Una delle possibili spiegazioni è legata al limite delle quote sulle risorse CPU. Di questo si parlerà in questo articolo.
TL;DR:
Raccomandiamo vivamente di rinunciare ai limiti della CPU in Kubernetes (o disabilitare le quote CFS in Kubelet), se si utilizza una versione del kernel Linux con un bug nei CFS-quota. Nel kernel ben noto.
che porta a un throttling e a ritardi eccessivi. In Omiotutta l'infrastruttura è gestita da Kubernetes.
Tutti i nostri carichi di lavoro stateful e stateless funzionano esclusivamente su Kubernetes (utilizziamo Google Kubernetes Engine). Negli ultimi sei mesi, abbiamo iniziato a notare rallentamenti casuali. Le applicazioni si bloccano o smettono di rispondere agli health check, perdono connessione alla rete e così via. Questo comportamento ci ha messo in difficoltà per un po', e alla fine abbiamo deciso di affrontare il problema in modo diretto.
- Sommario dell’articolo:
- Alcune parole sui container e Kubernetes;
- Come sono implementati i CPU request e limit;
- Come funziona il CPU limit in ambienti con più core;
- Come monitorare il throttling della CPU;
Soluzioni al problema e le peculiarità.
Alcune parole sui container e Kubernetes
Container
Kubernetes è, di fatto, lo standard moderno nel mondo dell'infrastruttura. La sua funzione principale è l'orchestrazione dei container.
In passato dovevamo creare artefatti come JAR/WAR di Java, Egg di Python o eseguibili da avviare successivamente sui server. Tuttavia, per farli funzionare, era necessario svolgere ulteriori attività: installare un ambiente di runtime (Java/Python), posizionare i file richiesti nei luoghi giusti, garantire la compatibilità con una specifica versione del sistema operativo, ecc. In altre parole, dovevamo prestare particolare attenzione alla gestione delle configurazioni (che spesso era motivo di conflitto tra sviluppatori e amministratori di sistema). I container hanno cambiato tutto.
Ora l'artefatto è un'immagine del container. Può essere considerata come un file eseguibile esteso, contenente non solo il programma, ma anche un ambiente di runtime completo (Java/Python/…) e i file/pacchetti necessari, preinstallati e pronti per essere eseguiti. I container possono essere distribuiti e avviati su server diversi senza ulteriori azioni.
Kubernetes
Inoltre, i container operano nel proprio ambiente sandbox. Hanno il proprio adattatore di rete virtuale, il proprio file system con accesso limitato, la propria gerarchia di processi, i propri limiti su CPU e memoria, e così via. Tutto ciò è realizzato grazie a una particolare sottosistema del kernel Linux — namespaces.

Come già detto, Kubernetes è un orchestratore di container. Funziona in questo modo: fornisci un pool di macchine e poi dici: ‘Ehi, Kubernetes, avvia dieci istanze del mio container con 2 processori e 3 GB di memoria ciascuna, e mantienile in funzione!’. Kubernetes si occuperà di tutto il resto. Troverà le risorse disponibili, avvierà i container e li riavvierà se necessario, eseguirà l'aggiornamento in caso di cambio di versioni, e così via. In sostanza, Kubernetes consente di astrarsi dall'hardware e rende la varietà dei sistemi idonea all'implementazione e al funzionamento delle applicazioni.
Kubernetes dal punto di vista di un semplice cittadino
Che cosa sono request e limit in Kubernetes
Ok, abbiamo compreso i container e Kubernetes. Sappiamo anche che più container possono risiedere su una singola macchina.
Si può fare un’analogia con un appartamento condiviso. Si prende uno spazio ampio (macchine/nodi) e lo si affitta a diversi inquilini (container). Kubernetes funge da agente immobiliare. E ora ci si chiede: come mantenere gli inquilini lontani dai conflitti tra loro? Cosa succede se uno di loro dovesse decidere di monopolizzare il bagno per mezza giornata? Richiesta È qui che entrano in gioco request e limit. Limite è necessaria esclusivamente per la pianificazione. È una sorta di ‘lista dei desideri’ del container, utilizzata per selezionare il nodo più adatto. Allo stesso tempo, la CPU non sarà in grado di può essere paragonata a un contratto d'affitto: una volta scelto un nodo per il container, questo
non avrà
la possibilità di oltrepassare i limiti stabiliti. E qui nasce il problema…
Richiesta CPU

Come è implementata una richiesta CPU
Per semplicità, consideriamo il processo su una macchina con CPU a 4 core.
K8s utilizza il meccanismo dei gruppi di controllo (cgroups) per gestire la distribuzione delle risorse (memoria e CPU). È disponibile un modello gerarchico: un discendente eredita i limit della gruppo genitore. I dettagli della distribuzione sono memorizzati in un filesystem virtuale (/sys/fs/cgroup). Nel caso della CPU questo è /sys/fs/cgroup/cpu,cpuacct/*.
K8s utilizza il file cpu.share per la distribuzione delle risorse della CPU. Nel nostro caso, il gruppo di controllo principale riceve 4096 quote di risorse della CPU — il 100% della potenza disponibile della CPU (1 core = 1024; questo è un valore fisso). Il gruppo principale distribuisce le risorse in modo proporzionale in base alle quote dei discendenti definite in cpu.share, e questi, a loro volta, agiscono in modo simile con i loro discendenti, e così via. In un tipico nodo Kubernetes, il gruppo di controllo principale ha tre discendenti: system.slice, user.slice e kubepods. Le prime due sottogruppi vengono utilizzati per distribuire risorse tra carichi di sistema critici e applicazioni utente al di fuori di K8s. L'ultimo — kubepods — è creato da Kubernetes per distribuire risorse tra i pod.
Nello schema sopra, si vede che le prime due sottogruppi hanno ricevuto ciascuno 1024 quote, con a kubepod assegnato 4096 quote. Com'è possibile: poiché il gruppo principale ha a disposizione solo 4096 quote, e la somma delle quote dei suoi discendenti supera di gran lunga questo numero (6144)? Il valore ha un significato logico, pertanto il pianificatore Linux (CFS) lo utilizza per la distribuzione proporzionale delle risorse della CPU. Nel nostro caso, i primi due gruppi ricevono ciascuno 680 quote reali (16,6% di 4096), e kubepod riceve le restanti 2736 quote. In caso di inattività, i primi due gruppi non utilizzeranno le risorse assegnate.
Fortunatamente, il pianificatore ha un meccanismo che consente di evitare la perdita di risorse CPU inutilizzate. Trasferisce le potenze 'inattive' in un pool globale, da cui vengono distribuite ai gruppi che necessitano di potenza aggiuntiva della CPU (il trasferimento avviene a blocchi per evitare perdite dovute all'arrotondamento). Un metodo simile viene applicato anche a tutti i discendenti dei discendenti.
Questo meccanismo garantisce una distribuzione equa delle potenze della CPU e si assicura che nessun processo 'ruba' risorse da altri.
Limite CPU
Anche se le configurazioni dei limiti e delle richieste in K8s sembrano simili, la loro implementazione è radicalmente diversa: è la parte più fuorviante e meno documentata.
K8s impiega per implementare i limiti. Le loro impostazioni sono definiti nei file cfs_period_us e cfs_quota_us nella directory cgroup (lì si trova anche il file cpu.share).
A differenza di cpu.share, la quota si basa sul periodo di tempo, e non sulla potenza disponibile della CPU. cfs_period_us definisce la durata del periodo (epoca) — è sempre 100000 microsecondi (100 ms). In K8s è possibile modificare questo valore, ma al momento è disponibile solo in versione alpha. Il pianificatore utilizza l'epoca per riavviare le quote utilizzate. Il secondo file, cfs_quota_us, definisce il tempo disponibile (quota) in ogni epoca. Si noti che è anch'essa espressa in microsecondi. La quota può superare la durata dell'epoca; in altre parole, può essere superiore a 100 ms.
Consideriamo due scenari su macchine a 16 core (il tipo più comune di computer da noi in Omio):

Scenario 1: 2 thread e limite di 200 ms. Senza throttling

Scenario 2: 10 thread e limite di 200 ms. Il throttling inizia dopo 20 ms, l'accesso alle risorse della CPU riprende dopo ulteriori 80 ms.
Supponiamo che tu abbia impostato un limite CPU su 2 core; Kubernetes tradurrà questo valore in 200 ms. Questo significa che il contenitore può utilizzare al massimo 200 ms di tempo di CPU senza throttling.
Ed è qui che inizia la parte interessante. Come detto sopra, la quota disponibile è di 200 ms. Se hai in esecuzione contemporaneamente dieci thread su una macchina a 12 core (vedi l'illustrazione dello scenario 2), mentre tutti gli altri pod sono inattivi, la quota verrà esaurita dopo solo 20 ms (poiché 10 * 20 ms = 200 ms), e tutti i thread di questo pod 'si bloccheranno' (throttle) per i successivi 80 ms. La situazione è già complicata dal , che causa un throttling eccessivo e il contenitore non riesce nemmeno a produrre la quota prevista.
Come valutare il throttling nei pod?
Basta accedere al pod ed eseguire cat /sys/fs/cgroup/cpu/cpu.stat.
-
nr_periods— il numero totale di periodi dello scheduler; -
nr_throttled— il numero di periodi throttled;nr_periods; -
throttled_time— il tempo totale di throttling in nanosecondi.

Cosa sta succedendo realmente?
Di conseguenza, otteniamo un alto livello di throttling in tutte le applicazioni. A volte è una volta e mezzo più forte di quanto previsto!
Questo porta a vari errori: fallimenti nei controlli di prontezza, blocchi nei contenitori, interruzioni nelle connessioni di rete, timeout all'interno delle chiamate ai servizi. Alla fine, si traduce in un aumento della latenza e in un incremento degli errori.
Soluzione e conseguenze
La soluzione è semplice. Abbiamo abbandonato i limiti della CPU e aggiornato il kernel del sistema operativo nei cluster all'ultima versione, in cui il bug è stato corretto. Il numero di errori (HTTP 5xx) nei nostri servizi è diminuito immediatamente e significativamente:
Errori HTTP 5xx

Errori HTTP 5xx di un servizio critico
Tempo di risposta p95

Latenza delle richieste di un servizio critico, 95° percentile
Costi operativi

Numero di ore macchina spese
Qual è il problema?
Come detto all'inizio dell'articolo:
Si può fare un paragone con un appartamento in comune... Kubernetes agisce come un agente immobiliare. Ma come mantenere i coinquilini in buona armonia? Cosa succede se uno di loro decidesse di occupare il bagno per mezza giornata?
Ecco il problema. Un contenitore problematico può assorbire tutte le risorse CPU disponibili sulla macchina. Se hai uno stack applicativo ben progettato (ad esempio, JVM, Go, Node VM opportunamente configurati), questo non rappresenta un problema: è possibile lavorare in tali condizioni per lungo tempo. Ma se le applicazioni sono malottimizzate o non ottimizzate affatto (FROM java:latest), la situazione può sfuggire di mano. Noi di Omio abbiamo file Docker base automatizzati con impostazioni ragionevoli per default per i principali linguaggi, quindi tale problema non si è mai verificato.
Raccomandiamo di monitorare le metriche (utilizzo, saturazione ed errori), le latenze API e la frequenza degli errori. Assicurati che i risultati siano conformi alle aspettative.
Link
Questa è la nostra storia. I seguenti materiali ci hanno molto aiutato a capire cosa sta succedendo:
- ;
- ;
- ;
- ;
- — cerca «cpu throttling».
Segnalazioni di errori Kubernetes:
- ;
- ;
- .
Hai mai affrontato problemi simili nella tua esperienza o hai un'esperienza in merito al throttling in ambienti di produzione containerizzati? Condividi la tua storia nei commenti!
P.S. dal traduttore
Leggete anche nel nostro blog:
- «»;
- «»;
- «».
Fonte: habr.com
