Fai i conti: ridimensionare le applicazioni di microservizi con gli orchestrator
Pubblicato: 2022-03-11Non sorprende che l'architettura dell'applicazione dei microservizi continui a invadere la progettazione del software. È molto più conveniente distribuire il carico, creare distribuzioni ad alta disponibilità e gestire gli aggiornamenti, semplificando allo stesso tempo lo sviluppo e la gestione del team.
Ma la storia sicuramente non è la stessa senza gli orchestratori di contenitori.
È facile voler utilizzare tutte le loro funzionalità chiave, in particolare il ridimensionamento automatico. Che benedizione è guardare le distribuzioni di container fluttuanti per tutto il giorno, dimensionate delicatamente per gestire il carico attuale, liberando il nostro tempo per altre attività. Siamo orgogliosi di ciò che stanno mostrando i nostri strumenti di monitoraggio dei container; nel frattempo, abbiamo appena configurato un paio di impostazioni: sì, è (quasi) tutto ciò che è servito per creare la magia!
Questo non vuol dire che non ci sia motivo di essere orgogliosi di questo: siamo sicuri che i nostri utenti stiano vivendo una buona esperienza e che non stiamo sprecando denaro con un'infrastruttura di grandi dimensioni. Questo è già abbastanza considerevole!
E, naturalmente, che viaggio è stato per arrivarci! Perché anche se alla fine non ci sono molte impostazioni che devono essere configurate, è molto più complicato di quanto potremmo pensare di solito prima di poter iniziare. Numero minimo/massimo di repliche, soglie di upscale/downscale, periodi di sincronizzazione, ritardi di raffreddamento: tutte queste impostazioni sono strettamente legate tra loro. La modifica di uno molto probabilmente influirà su un altro, ma devi comunque organizzare una combinazione equilibrata che si adatti sia alla tua applicazione/distribuzione che alla tua infrastruttura. Eppure, non troverai nessun libro di cucina o nessuna formula magica su Internet, poiché dipende molto dalle tue esigenze.
La maggior parte di noi prima li imposta su valori "casuali" o predefiniti che regoliamo in seguito in base a ciò che troviamo durante il monitoraggio. Questo mi ha fatto pensare: e se potessimo stabilire una procedura più "matematica" che ci aiutasse a trovare la combinazione vincente?
Calcolo dei parametri di orchestrazione del contenitore
Quando pensiamo alla scalabilità automatica dei microservizi per un'applicazione, stiamo effettivamente cercando di migliorare su due punti principali:
- Assicurarsi che la distribuzione possa aumentare rapidamente in caso di un rapido aumento del carico (in modo che gli utenti non debbano affrontare timeout o HTTP 500)
- Ridurre il costo dell'infrastruttura (ovvero evitare che le istanze siano sottocaricate)
Ciò significa fondamentalmente ottimizzare le soglie del software del contenitore per l'aumento e la riduzione. (L'algoritmo di Kubernetes ha un unico parametro per i due).
Mostrerò in seguito che tutti i parametri relativi all'istanza sono legati alla soglia di upscale. Questo è il più difficile da calcolare, quindi questo articolo.
Nota: per quanto riguarda i parametri impostati a livello di cluster, non ho alcuna buona procedura per essi, ma alla fine di questo articolo introdurrò un software (una pagina Web statica) che ne tenga conto durante il calcolo i parametri di ridimensionamento automatico di un'istanza. In questo modo, sarai in grado di variare i loro valori per considerare il loro impatto.
Calcolo della soglia di scale-up
Affinché questo metodo funzioni, devi assicurarti che la tua applicazione soddisfi i seguenti requisiti:
- Il carico deve essere distribuito uniformemente su ogni istanza dell'applicazione (in modo round robin)
- I tempi delle richieste devono essere inferiori all'intervallo di controllo del carico del cluster di contenitori .
- Devi considerare di eseguire la procedura su un gran numero di utenti (definito in seguito).
Il motivo principale di tali condizioni deriva dal fatto che l'algoritmo non calcola il carico come per utente ma come una distribuzione (spiegata più avanti).
Ottenere tutto gaussiano
Per prima cosa dobbiamo formulare una definizione per un rapido aumento del carico o, in altre parole, uno scenario peggiore. Per me, un buon modo per tradurlo è: avere un gran numero di utenti che eseguono azioni che consumano risorse in un breve periodo di tempo, e c'è sempre la possibilità che accada mentre un altro gruppo di utenti o servizi esegue altre attività. Quindi partiamo da questa definizione e proviamo a estrarre un po' di matematica. (Prepara la tua aspirina.)
Introducendo alcune variabili:
- $N_{u}$, il "grande numero di utenti"
- $L_{u}(t)$, il carico generato da un singolo utente che esegue l'"operazione di consumo di risorse" ($t=0$ indica il momento in cui l'utente avvia l'operazione)
- $L_{tot}(t)$, il carico totale (generato da tutti gli utenti)
- $T_{tot}$, il "breve periodo di tempo"
Nel mondo matematico, parlando di un gran numero di utenti che eseguono la stessa cosa contemporaneamente, la distribuzione degli utenti nel tempo segue una distribuzione gaussiana (o normale), la cui formula è:
\[G(t) = \frac{1}{\sigma \sqrt{2 \pi}} e^{\frac{-(t-\mu)^2}{2 \sigma^2}}\]Qui:
- µ è il valore atteso
- σ è la deviazione standard
Ed è rappresentato graficamente come segue (con $µ=0$):
Probabilmente ricorda alcuni corsi che hai preso, niente di nuovo. Tuttavia, affrontiamo il nostro primo problema qui: per essere matematicamente accurati, dovremmo considerare un intervallo di tempo da $-\infty$ a $+\infty$, che ovviamente non può essere calcolato.
Ma osservando il grafico, notiamo che i valori al di fuori dell'intervallo $[-3σ, 3σ]$ sono molto prossimi allo zero e non variano molto, il che significa che il loro effetto è davvero trascurabile e può essere messo da parte. Questo è più vero, dal momento che il nostro obiettivo è testare la scalabilità verticale della nostra applicazione, quindi stiamo cercando variazioni per un numero elevato di utenti.
Inoltre, poiché l'intervallo $[-3σ, 3σ]$ contiene il 99,7% dei nostri utenti, è abbastanza vicino al totale per lavorarci sopra e dobbiamo solo moltiplicare $N_{u}$ per 1,003 per compensare la differenza. Selezionando questo intervallo si ottiene $µ=3σ$ (poiché lavoreremo da $t=0$).
Per quanto riguarda la corrispondenza con $T_{tot}$, scegliere che sia uguale a $6σ$ ($[-3σ, 3σ]$) non sarà una buona approssimazione, poiché il 95,4 percento degli utenti si trova nell'intervallo $[- 2σ, 2σ]$, che dura $4σ$. Quindi scegliere $T_{tot}$ uguale a $6σ$ aggiungerà metà del tempo solo per il 4,3% degli utenti, il che non è realmente rappresentativo. Quindi scegliamo di prendere $T_{tot}=4σ$, e possiamo dedurre:
\(σ=\frac{T_{tot}}{4}\) e \(µ=\frac{3}{4} * T_{tot}\)
Quei valori sono stati appena tirati fuori da un cappello? Sì. Ma questo è il loro scopo, e questo non influirà sulla procedura matematica. Queste costanti sono per noi e definiscono nozioni relative alla nostra ipotesi. Ciò significa solo che ora che li abbiamo impostati, il nostro scenario peggiore può essere tradotto come:
Il carico generato dal 99,7% di $N{u}$, eseguendo un'operazione di consumo $L{u}(t)$ e dove il 95,4% di loro lo sta facendo entro la durata $T{tot}$.
(Questo è qualcosa che vale la pena ricordare quando si utilizza l'app Web.)
Iniettando i risultati precedenti nella funzione di distribuzione utente (gaussiana), possiamo semplificare l'equazione come segue:
\[G(t) = \frac{4 N_{u}}{T_{tot} \sqrt{2 \pi}} e^\frac{-(4t-3T_{tot})^2}{T_{tot }^2}\]D'ora in poi, avendo $σ$ e $µ$ definiti, lavoreremo sull'intervallo $t \in [0, \frac{3}{2}T_{tot}]$ (della durata di $6σ$).

Qual è il carico utente totale?
Il secondo passaggio della scalabilità automatica dei microservizi consiste nel calcolo di $L_{tot}(t)$.
Poiché $G(t)$ è una distribuzione , per recuperare il numero di utenti in un determinato momento, dobbiamo calcolarne l'integrale (o utilizzare la sua funzione di distribuzione cumulativa). Ma dal momento che non tutti gli utenti iniziano le loro operazioni contemporaneamente, sarebbe un vero pasticcio cercare di introdurre $L_{u}(t)$ e ridurre l'equazione a una formula utilizzabile.
Quindi, per semplificare, useremo una somma di Riemann, che è un modo matematico per approssimare un integrale usando una somma finita di piccole forme (qui useremo i rettangoli). Più forme (suddivisioni), più accurato è il risultato. Un altro vantaggio dell'utilizzo delle suddivisioni deriva dal fatto che possiamo considerare che tutti gli utenti all'interno di una suddivisione abbiano iniziato le loro operazioni contemporaneamente.
Tornando alla somma di Riemann, ha la seguente proprietà che si collega agli integrali:
\[\int_{a}^{b} f( x )dx = \lim_{n \rightarrow \infty } \sum_{k=1}^{n} ( x_{k} - x_{k-1} ) f( x_{k} )\]Con $x_k$ definito come segue:
\[x_{ k } = a + k\frac{ b - a }{ n }, 0 \leq k \leq n\]Questo è vero dove:
- $n$ è il numero di suddivisioni.
- $a$ è il limite inferiore, qui 0.
- $b$ è il limite superiore, qui $\frac{3}{2}*T_{tot}$.
- $f$ è la funzione, qui $G$, per approssimare la sua area.
Nota: il numero di utenti presenti in una suddivisione non è un numero intero. Questo è il motivo di due dei prerequisiti: avere un numero elevato di utenti (quindi la parte decimale non ha un impatto eccessivo) e la necessità che il carico sia distribuito uniformemente su ogni istanza.
Si noti inoltre che possiamo vedere la forma rettangolare della suddivisione sul lato destro della definizione della somma di Riemann.
Ora che abbiamo la formula della somma di Riemann, possiamo dire che il valore del carico al tempo $t$ è la somma del numero di utenti di ogni suddivisione moltiplicato per la funzione del carico dell'utente al momento corrispondente . Questo può essere scritto come:
\[L_{ tot }( t ) = \lim_{n \rightarrow \infty} \sum_{ k=1 }^{ n } ( x_{k} - x_{k-1} )G( x_{k} ) L_{ u }( t - x_{k} )\]Dopo aver sostituito le variabili e semplificato la formula, questo diventa:
\[L_{ tot }( t ) = \frac{6 N_{u}}{\sqrt{2 \pi}} \lim_{n \rightarrow \infty} \sum_{ k=1 }^{ n } (\ frac{1}{n}) e^{-{(\frac{6k}{n} - 3)^{2}}} L_{ u }( t - k \frac{3 T_{tot}}{2n } )\]E voilà ! Abbiamo creato la funzione di caricamento!
Trovare la soglia di scale-up
Per finire, dobbiamo solo eseguire un algoritmo di dicotomia che varia la soglia per trovare il valore più alto in cui il carico per istanza non supera mai il suo limite massimo in tutta la funzione di carico. (Questo è ciò che fa l'app.)
Dedurre altri parametri di orchestrazione
Non appena hai trovato la tua soglia di scale-up ($S_{up}$), altri parametri sono abbastanza facili da calcolare.
Da $S_{up}$ conoscerai il numero massimo di istanze. (Puoi anche cercare il carico massimo sulla tua funzione di carico e dividerlo per il carico massimo per istanza, arrotondato per eccesso.)
Il numero minimo ($N_{min}$) di istanze deve essere definito in base alla tua infrastruttura. (Raccomanderei di avere almeno una replica per AZ.) Ma deve anche tenere conto della funzione di carico: poiché una funzione gaussiana aumenta abbastanza rapidamente, la distribuzione del carico è più intensa (per replica) all'inizio, quindi tu potrebbe voler aumentare il numero minimo di repliche per attutire questo effetto. (Questo molto probabilmente aumenterà il tuo $S_{up}$.)
Infine, una volta definito il numero minimo di repliche, puoi calcolare la soglia di riduzione ($S_{down}$) considerando quanto segue: poiché il ridimensionamento di una singola replica non ha più effetto su altre istanze rispetto a quando il ridimensionamento da $N_{min}+1$ a $N_{min}$, dobbiamo assicurarci che la soglia di aumento non venga attivata subito dopo il ridimensionamento. Se è consentito, questo avrà un effetto yo-yo. In altre parole:
\[( N_{ min } + 1) S_{ giù } < N_{ min }S_{ su }\]O:
\[S_{ giù } < \frac{N_{ min }}{N_{min}+1}S_{ su }\]Inoltre, possiamo ammettere che più a lungo il cluster è configurato per attendere prima di ridimensionare, più sicuro è impostare $S_{down}$ più vicino al limite superiore. Ancora una volta, dovrai trovare un equilibrio adatto a te.
Si noti che quando si utilizza il sistema di orchestrazione Mesosphere Marathon con il suo ridimensionamento automatico, il numero massimo di istanze che possono essere rimosse contemporaneamente dal ridimensionamento è legato a AS_AUTOSCALE_MULTIPLIER
($A_{mult}$), il che implica:
Che dire della funzione di caricamento utente?
Sì, questo è un po' un problema, e non il più facile da risolvere matematicamente, ammesso che sia possibile.
Per ovviare a questo problema, l'idea è di eseguire una singola istanza dell'applicazione e aumentare il numero di utenti che eseguono la stessa attività ripetutamente finché il carico del server non raggiunge il massimo assegnato (ma non oltre). Quindi dividi per il numero di utenti e calcola il tempo medio della richiesta. Ripeti questa procedura con ogni azione che desideri integrare nella funzione di caricamento dell'utente, aggiungi un po' di tempo e il gioco è fatto.
Sono consapevole che questa procedura implica considerare che ogni richiesta dell'utente ha un carico costante sulla sua elaborazione (che è ovviamente errata), ma la massa degli utenti creerà questo effetto in quanto ognuno di loro non si trova nella stessa fase di elaborazione contemporaneamente . Quindi immagino che questa sia un'approssimazione accettabile, ma ancora una volta insinua che hai a che fare con un gran numero di utenti.
Puoi anche provare con altri metodi, come i grafici flame della CPU. Ma penso che sarà molto difficile creare una formula accurata che collegherà le azioni dell'utente al consumo di risorse.
Presentazione app-autoscaling-calculator
E ora, per la piccola app Web menzionata in tutto: prende come input la tua funzione di caricamento, la configurazione dell'agente di orchestrazione del contenitore e alcuni altri parametri generali e restituisce la soglia di scalabilità verticale e altre cifre relative all'istanza.
Il progetto è ospitato su GitHub, ma ha anche una versione live disponibile.
Ecco il risultato fornito dall'app Web, eseguito rispetto ai dati del test (su Kubernetes):
Ridimensionamento dei microservizi: niente più armeggiare nel buio
Quando si tratta di architetture applicative di microservizi, la distribuzione dei container diventa un punto centrale dell'intera infrastruttura. E migliore sarà la configurazione dell'agente di orchestrazione e dei contenitori, migliore sarà il runtime.
Quelli di noi nel campo dei servizi DevOps sono sempre alla ricerca di modi migliori per ottimizzare i parametri di orchestrazione per le nostre applicazioni. Adottiamo un approccio più matematico alla scalabilità automatica dei microservizi!