Nota di traduzione.: questa storia istruttiva di Omio — il aggregatore di viaggi europeo — guida i lettori dalla teoria di base alle affascinanti complessità pratiche nella configurazione di Kubernetes. Incontrare casi come questi aiuta non solo a espandere i propri orizzonti, ma anche a prevenire problemi non banali.

Hai mai avuto a che fare con un'applicazione che "si bloccava" sul posto, 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 parleremo in questo articolo.
TL;DR:
Raccomandiamo vivamente di evitare i limiti CPU in Kubernetes (o disabilitare le quote CFS in Kubelet), se si utilizza una versione del kernel Linux con un bug sulle quote CFS. Nel kernel c'è un bug serio e che porta a uno throttling e a ritardi eccessivi.
In Omio tutta 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 app si bloccano o smettono di rispondere agli health check, perdono connessione alla rete, ecc. Questo comportamento ci ha messo in difficoltà a lungo e, infine, abbiamo deciso di affrontare il problema a fondo.
Riepilogo dell'articolo:
- Qualche parola sui container e Kubernetes;
- Come vengono implementati i request e i limit sulle CPU;
- Come funziona il limite CPU in ambienti con più core;
- Come monitorare il throttling della CPU;
- Soluzione del problema e dettagli.
Qualche parola sui container e Kubernetes
Kubernetes, in sostanza, è il standard moderno nel mondo dell'infrastruttura. Il suo compito principale è l'orchestrazione dei container.
Contenitori
In passato dovevamo creare artefatti come JAR/WAR di Java, Egg di Python o file eseguibili per essere eseguiti successivamente sui server. Tuttavia, per farli funzionare, era necessario fare un lavoro aggiuntivo: installare l'ambiente di esecuzione (Java/Python), posizionare i file necessari nei posti giusti, garantire la compatibilità con una specifica versione del sistema operativo, ecc. In altre parole, era necessario prestare molta attenzione alla gestione delle configurazioni (cosa che spesso era causa di attriti tra sviluppatori e amministratori di sistema).
I container hanno cambiato tutto. Ora l'artefatto è un'immagine del contenitore. Può essere visualizzato come un file eseguibile esteso, che contiene non solo il programma, ma anche un ambiente di esecuzione completo (Java/Python/…), oltre ai file/pacchetti necessari, preinstallati e pronti per l'uso. I contenitori possono essere distribuiti e avviati su diversi server senza ulteriori azioni.
Inoltre, i contenitori operano in un proprio ambiente sandbox. Hanno il loro adattatore di rete virtuale, un proprio file system con accesso limitato, una propria gerarchia di processi, i propri limiti su CPU e memoria, ecc. Tutto ciò è reso possibile da un particolare sottosistema del kernel Linux — i namespaces.
Kubernetes
Come già accennato, Kubernetes è un orchestratore di contenitori. Funziona nel seguente modo: fornisci un pool di macchine e poi dici: "Ehi, Kubernetes, avvia dieci istanze del mio contenitore con 2 processori e 3 GB di memoria ciascuno, e mantienili in esecuzione!". Kubernetes si occuperà del resto. Troverà le risorse disponibili, avvierà i contenitori e li riavvierà se necessario, implementerà aggiornamenti al cambio di versione, ecc. In sostanza, Kubernetes consente di astrarsi dall'hardware e rende tutte le diverse configurazioni di sistema adatte per il deployment e l'esecuzione delle applicazioni.

Kubernetes dal punto di vista di un semplice cittadino
Cosa sono i request e i limit in Kubernetes
Ok, abbiamo chiarito le cose sui contenitori e su Kubernetes. Sappiamo anche che più contenitori possono essere ospitati su una sola macchina.
Si può fare un'analogia con un appartamento condiviso. Si prende uno spazio ampio (macchine/nodi) e si affitta a vari inquilini (contenitori). Kubernetes funge da agente immobiliare. Si pone la questione: come mantenere gli inquilini senza conflitti tra di loro? E se uno di loro decidesse di occupare il bagno per mezza giornata?
È qui che entrano in gioco i request e i limit. CPU Request è necessario esclusivamente per la pianificazione. È una sorta di "lista dei desideri" del contenitore e viene utilizzata per trovare il nodo più adatto. Allo stesso tempo, CPU Limit può essere paragonato a un contratto di affitto: una volta trovato il nodo per il contenitore, esso non potrà superare i limiti stabiliti. Ed ecco che sorge un problema…
Come sono implementati i request e i limit in Kubernetes
Kubernetes utilizza un meccanismo di throttling integrato nel kernel per implementare i limiti della CPU. Se un'applicazione supera il limite, viene attivato il throttling (cioè riceve meno cicli della CPU). I request e i limit per la memoria sono organizzati in modo diverso, quindi sono più facili da rilevare. È sufficiente controllare l'ultimo stato di riavvio del pod: non è stato "OOMKilled". Con il throttling della CPU non è così semplice, poiché K8s rende disponibili solo le metriche di utilizzo, e non quelle sui cgroups.
CPU Request

Come è implementato il CPU request
Per semplicità, consideriamo il processo utilizzando un esempio di una macchina con CPU quad-core.
K8s utilizza meccanismi di controllo (cgroups) per gestire la distribuzione delle risorse (memoria e CPU). È disponibile un modello gerarchico: un figlio eredita i limiti del gruppo genitore. I dettagli della distribuzione sono memorizzati nel file system virtuale (/sys/fs/cgroup). Nel caso della CPU, questo /sys/fs/cgroup/cpu,cpuacct/*.
K8s utilizza il file cpu.share per il ripartimento delle risorse della CPU. Nel nostro caso, il gruppo di controllo radice riceve 4096 quote di risorse CPU — 100% della potenza di CPU disponibile (1 core = 1024; questo è un valore fisso). Il gruppo radice distribuisce le risorse in modo proporzionale in base alle quote dei figli, specificate in cpu.share, e questi, a loro volta, agiscono in modo analogo con i loro figli, e così via. In un nodo Kubernetes tipico, il gruppo di controllo radice ha tre figli: system.slice, user.slice e kubepods. Le prime due sottogruppi vengono utilizzati per distribuire le risorse tra i carichi di lavoro di sistema critici e i programmi user fuori K8s. L'ultimo — kubepods — è creato da Kubernetes per distribuire le risorse tra i pod.
Nello schema sopra, si può notare che il primo e il secondo sottogruppo hanno ricevuto entrambi 1024 quote, mentre al sottogruppo kubepod sono state assegnate 4096 quote. Come è possibile: dato che al gruppo radice sono disponibili solo 4096 quote, mentre la somma delle quote dei suoi figli supera di gran lunga questo numero (6144)? Il fatto è che il valore ha senso logico, per cui il pianificatore Linux (CFS) lo utilizza per la distribuzione proporzionale delle risorse CPU. Nel nostro caso, i primi due gruppi ricevono entrambi 680 quote reali (16,6% di 4096), mentre kubepod riceve quelle rimanenti 2736 quote. In caso di inattività, i primi due gruppi non utilizzeranno le risorse dedicate.
Fortunatamente, nel pianificatore è presente un meccanismo che consente di evitare la perdita delle risorse CPU non utilizzate. Esso trasferisce le capacità "inattive" in un pool globale, da cui vengono distribuite ai gruppi che necessitano di capacità aggiuntive di elaborazione (il trasferimento avviene per lotti per evitare perdite dovute all'arrotondamento). Un metodo simile viene applicato a tutti i discendenti di questi discendenti.
Questo meccanismo garantisce una distribuzione equa delle capacità della CPU e si assicura che nessun processo "rubando" risorse ad altri.
Limite CPU
Sebbene le configurazioni dei limiti e delle richieste in K8s possano sembrare simili, la loro implementazione è radicalmente diversa: si tratta di la parte più fuorviante e meno documentata.
K8s utilizza per implementare i limiti. Le loro impostazioni sono definite 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 su un periodo di tempo, piuttosto che sulla potenza di CPU disponibile. cfs_period_us definisce la durata del periodo (era) — questo è sempre 100000 microsecondi (100 ms). In K8s è possibile modificare questo valore, ma attualmente è accessibile solo nella versione alfa. Il pianificatore utilizza l'era per riavviare le quote utilizzate. Il secondo file, cfs_quota_us, definisce il tempo disponibile (quota) in ogni era. Si noti che è anche indicato in microsecondi. La quota può superare la durata dell'era; in altre parole, può essere superiore a 100 ms.
Consideriamo due scenari su macchine a 16 core (il tipo di computer più comune da noi in Omio):

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

Scenario 2: 10 flussi e limite di 200 ms. Il throttling inizia dopo 20 ms, l'accesso alle risorse della CPU riprende dopo ulteriori 80 ms
Supponiamo di aver impostato un limite CPU su 2 core; Kubernetes convertirà questo valore in 200 ms. Ciò significa che il contenitore può utilizzare al massimo 200 ms di tempo di CPU senza throttling.
E qui inizia la parte più interessante. Come detto sopra, la quota disponibile è di 200 ms. Se stai eseguendo in parallelo dieci flussi su una macchina a 12 core (vedi l'illustrazione dello scenario 2), mentre tutti gli altri pod sono inattivi, la quota sarà esaurita dopo soli 20 ms (dato che 10 * 20 ms = 200 ms), e tutti i flussi di questo pod si "bloccheranno". (throttle) per i successivi 80 ms. La situazione è ulteriormente aggravata dal già menzionato , che provoca eccessivo throttling e il contenitore non può produrre nemmeno la quota disponibile.
Come valutare il throttling nei pod?
Basta entrare nel pod e eseguire cat /sys/fs/cgroup/cpu/cpu.stat.
-
nr_periods— numero totale dei periodi di pianificazione; -
nr_throttled— numero di periodi throttled inclusi innr_periods; -
throttled_time— tempo totale throttled in nanosecondi.

Cosa sta realmente accadendo?
Di conseguenza, otteniamo un alto throttling in tutte le applicazioni. A volte è una volta e mezza più forte di quanto calcolato!
Questo porta a vari errori: fallimenti dei controlli di prontezza (readiness), blocchi dei contenitori, disconnessioni di rete, timeout all'interno delle chiamate ai servizi. In ultima analisi, si traduce in un aumento della latenza e del numero di errori.
Soluzione e conseguenze
Qui tutto è semplice. Abbiamo rinunciato ai limiti CPU e ci siamo occupati dell'aggiornamento del kernel del sistema operativo nei cluster all'ultima versione, in cui il bug era stato corretto. Il numero di errori (HTTP 5xx) nei nostri servizi è subito diminuito significativamente:
Errori HTTP 5xx

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

Ritardo delle richieste di un servizio critico, 95° percentile
Costi di gestione

Numero di ore macchina spese
Qual è il problema?
Come detto all'inizio dell'articolo:
Si può fare un'analogia con un appartamento condiviso… Kubernetes funge da agente immobiliare. Ma come mantenere gli inquilini senza conflitti tra loro? Cosa succede se uno di loro, per esempio, decide di occupare il bagno per mezzo giorno?
Ecco qual è il problema. Un contenitore negligente può assorbire tutte le risorse CPU disponibili sulla macchina. Se hai uno stack applicativo competente (ad esempio, JVM, Go, Node VM correttamente configurati), allora non è un problema: è possibile lavorare in tali condizioni per lungo tempo. Ma se le applicazioni sono ottimizzate male o non ottimizzate affatto (FROM java:latest), la situazione potrebbe sfuggire al controllo. In Omio abbiamo Dockerfile di base automatizzati con impostazioni predefinite adeguate per lo stack dei linguaggi principali, quindi non c'è mai stata una tale problema.
Ti consigliamo di monitorare le metriche (utilizzo, saturazione e errori), ritardi delle API e frequenza degli errori. Assicurati che i risultati siano in linea con le aspettative.
Link
Questa è la nostra storia. I seguenti materiali hanno molto aiutato a chiarire ciò che sta accadendo:
- ;
- ;
- ;
- ;
- — cerca «cpu throttling».
Report di errore di Kubernetes:
- ;
- ;
- .
Hai mai affrontato problemi simili nella tua pratica o hai esperienza relativa al throttling in ambienti di produzione containerizzati? Condividi la tua storia nei commenti!
P.S. dal traduttore
Leggi anche nel nostro blog:
- «»;
- «»;
- «».
Fonte: habr.com
