Faites le calcul : mise à l'échelle des applications de microservices avec des orchestrateurs
Publié: 2022-03-11Il n'est pas tout à fait surprenant que l'architecture des applications de microservices continue d'envahir la conception de logiciels. Il est beaucoup plus pratique de répartir la charge, de créer des déploiements hautement disponibles et de gérer les mises à niveau tout en facilitant le développement et la gestion des équipes.
Mais l'histoire n'est certainement pas la même sans les orchestrateurs de conteneurs.
Il est facile de vouloir utiliser toutes leurs fonctionnalités clés, en particulier la mise à l'échelle automatique. Quelle bénédiction de voir les déploiements de conteneurs fluctuer tout au long de la journée, dimensionnés en douceur pour gérer la charge actuelle, libérant ainsi notre temps pour d'autres tâches. Nous sommes fiers de ce que montrent nos outils de surveillance des conteneurs ; en attendant, nous venons de configurer quelques paramètres—oui, c'est (presque) tout ce qu'il a fallu pour créer la magie !
Cela ne veut pas dire qu'il n'y a aucune raison d'en être fier : nous sommes sûrs que nos utilisateurs ont une bonne expérience et que nous ne gaspillons pas d'argent avec une infrastructure surdimensionnée. C'est déjà assez considérable !
Et bien sûr, quel voyage ce fut pour en arriver là ! Car même si à la fin il n'y a pas beaucoup de paramètres à configurer, c'est beaucoup plus délicat qu'on ne le pense habituellement avant de pouvoir commencer. Nombre min/max de répliques, seuils d'upscale/downscale, périodes de synchronisation, délais de refroidissement - tous ces paramètres sont étroitement liés. La modification de l'un affectera très probablement l'autre, mais vous devez toujours organiser une combinaison équilibrée qui conviendra à la fois à votre application/déploiement et à votre infrastructure. Et pourtant, vous ne trouverez aucun livre de cuisine ni aucune formule magique sur Internet, car cela dépend fortement de vos besoins.
La plupart d'entre nous les définissons d'abord sur des valeurs "aléatoires" ou par défaut que nous ajustons ensuite en fonction de ce que nous trouvons lors de la surveillance. Cela m'a fait réfléchir : et si nous pouvions établir une procédure plus « mathématique » qui nous aiderait à trouver la combinaison gagnante ?
Calcul des paramètres d'orchestration des conteneurs
Lorsque nous pensons aux microservices à mise à l'échelle automatique pour une application, nous cherchons en fait à améliorer deux points majeurs :
- S'assurer que le déploiement peut évoluer rapidement en cas d'augmentation rapide de la charge (afin que les utilisateurs ne soient pas confrontés à des délais d'attente ou à des HTTP 500)
- Réduire le coût de l'infrastructure (c'est-à-dire éviter que les instances ne soient sous-chargées)
Cela signifie essentiellement optimiser les seuils du logiciel de conteneur pour la mise à l'échelle et la réduction. (L'algorithme de Kubernetes a un seul paramètre pour les deux).
Je montrerai plus tard que tous les paramètres liés à l'instance sont liés au seuil de mise à l'échelle. C'est le plus difficile à calculer, d'où cet article.
Remarque : Concernant les paramètres qui sont définis à l'échelle du cluster, je n'ai pas de bonne procédure pour eux, mais à la fin de cet article, je présenterai un logiciel (une page Web statique) qui les prend en compte lors du calcul les paramètres de mise à l'échelle automatique d'une instance. De cette façon, vous pourrez faire varier leurs valeurs pour tenir compte de leur impact.
Calcul du seuil de mise à l'échelle
Pour que cette méthode fonctionne, vous devez vous assurer que votre application répond aux exigences suivantes :
- La charge doit être répartie uniformément sur chaque instance de votre application (de manière circulaire)
- Les délais de requête doivent être plus courts que l' intervalle de vérification de charge de votre cluster de conteneurs .
- Il faut envisager d'exécuter la procédure sur un grand nombre d'utilisateurs (défini plus loin).
La principale raison de ces conditions vient du fait que l'algorithme ne calcule pas la charge comme étant par utilisateur mais comme une distribution (expliquée plus loin).
Obtenir tout gaussien
Nous devons d'abord formuler une définition pour une augmentation rapide de la charge ou, en d'autres termes, un scénario du pire. Pour moi, une bonne façon de le traduire est : avoir un grand nombre d'utilisateurs effectuant des actions consommatrices de ressources dans un court laps de temps - et il y a toujours la possibilité que cela se produise pendant qu'un autre groupe d'utilisateurs ou de services effectue d'autres tâches. Commençons donc par cette définition et essayons d'extraire quelques calculs. (Préparez votre aspirine.)
Présentation de quelques variables :
- $N_{u}$, le "grand nombre d'utilisateurs"
- $L_{u}(t)$, la charge générée par un seul utilisateur effectuant « l'opération consommatrice de ressources » ($t=0$ indique le moment où l'utilisateur démarre l'opération)
- $L_{tot}(t)$, la charge totale (générée par tous les utilisateurs)
- $T_{tot}$, la "courte période de temps"
Dans le monde mathématique, parlant d'un grand nombre d'utilisateurs effectuant la même chose en même temps, la distribution des utilisateurs dans le temps suit une distribution gaussienne (ou normale), dont la formule est :
\[G(t) = \frac{1}{\sigma \sqrt{2 \pi}} e^{\frac{-(t-\mu)^2}{2 \sigma^2}}\]Ici:
- µ est la valeur attendue
- σ est l'écart type
Et il est représenté graphiquement comme suit (avec $µ=0$) :
Cela rappelle probablement certains cours que vous avez suivis - rien de nouveau. Cependant, nous sommes confrontés à notre premier problème ici : pour être mathématiquement précis, nous devrions considérer une plage de temps de $-\infty$ à $+\infty$, qui ne peut évidemment pas être calculée.
Mais en regardant le graphique, on remarque que les valeurs en dehors de l'intervalle $[-3σ, 3σ]$ sont très proches de zéro et ne varient pas beaucoup, ce qui signifie que leur effet est vraiment négligeable et peut être mis de côté. C'est d'autant plus vrai, puisque notre objectif est de tester la mise à l'échelle de notre application, nous recherchons donc des variations d'un grand nombre d'utilisateurs.
De plus, puisque l'intervalle $[-3σ, 3σ]$ contient 99,7 % de nos utilisateurs, il est suffisamment proche du total pour travailler dessus, et nous avons juste besoin de multiplier $N_{u}$ par 1,003 pour compenser la différence. La sélection de cet intervalle nous donne $µ=3σ$ (puisque nous allons travailler à partir de $t=0$).
En ce qui concerne la correspondance avec $T_{tot}$, choisir qu'il soit égal à $6σ$ ($[-3σ, 3σ]$) ne sera pas une bonne approximation, puisque 95,4 % des utilisateurs sont dans l'intervalle $[- 2σ, 2σ]$, qui dure $4σ$. Ainsi, choisir $T_{tot}$ égal à $6σ$ ajoutera la moitié du temps pour seulement 4,3 % des utilisateurs, ce qui n'est pas vraiment représentatif. On choisit donc de prendre $T_{tot}=4σ$, et on en déduit :
\(σ=\frac{T_{tot}}{4}\) et \(µ=\frac{3}{4} * T_{tot}\)
Ces valeurs ont-elles été simplement sorties d'un chapeau? Oui. Mais c'est là leur but, et cela n'affectera pas la procédure mathématique. Ces constantes sont pour nous, et définissent des notions liées à notre hypothèse. Cela signifie seulement que maintenant que nous les avons définis, notre pire scénario peut être traduit par :
La charge générée par 99,7 % de $N{u}$, effectuant une opération consommatrice $L{u}(t)$ et où 95,4 % d'entre eux le font dans la durée $T{tot}$.
(Ceci vaut la peine d'être rappelé lors de l'utilisation de l'application Web.)
En injectant les résultats précédents dans la fonction de distribution de l'utilisateur (gaussienne), nous pouvons simplifier l'équation comme suit :
\[G(t) = \frac{4 N_{u}}{T_{tot} \sqrt{2 \pi}} e^\frac{-(4t-3T_{tot})^2}{T_{tot }^2}\]Désormais, ayant $σ$ et $µ$ définis, nous allons travailler sur l'intervalle $t \in [0, \frac{3}{2}T_{tot}]$ (d'une durée de $6σ$).

Quelle est la charge utilisateur totale ?
La deuxième étape des microservices à mise à l'échelle automatique consiste à calculer $L_{tot}(t)$.
Puisque $G(t)$ est une distribution , pour récupérer le nombre d'utilisateurs à un certain moment, nous devons calculer son intégrale (ou utiliser sa fonction de distribution cumulative). Mais comme tous les utilisateurs ne démarrent pas leurs opérations en même temps, ce serait un vrai gâchis d'essayer d'introduire $L_{u}(t)$ et de réduire l'équation à une formule utilisable.
Donc, pour faciliter cela, nous utiliserons une somme de Riemann, qui est une manière mathématique d'approximer une intégrale en utilisant une somme finie de petites formes (nous utiliserons ici des rectangles). Plus il y a de formes (subdivisions), plus le résultat est précis. Un autre avantage de l'utilisation des subdivisions vient du fait que l'on peut considérer que tous les utilisateurs d'une subdivision ont commencé leurs opérations en même temps.
De retour à la somme de Riemann, elle a la propriété suivante liée aux intégrales :
\[\int_{a}^{b} f( x )dx = \lim_{n \rightarrow \infty } \sum_{k=1}^{n} ( x_{k} - x_{k-1} ) f( x_{k} )\]Avec $x_k$ défini comme suit :
\[x_{ k } = une + k\frac{ b - une }{ n }, 0 \leq k \leq n\]Ceci est vrai où :
- $n$ est le nombre de subdivisions.
- $a$ est la borne inférieure, ici 0.
- $b$ est la borne supérieure, ici $\frac{3}{2}*T_{tot}$.
- $f$ est la fonction — ici $G$ — d'approximation de son aire.
Remarque : Le nombre d'utilisateurs présents dans une subdivision n'est pas un nombre entier. C'est la raison de deux des conditions préalables : avoir un grand nombre d'utilisateurs (pour que la partie décimale n'ait pas trop d'impact) et la nécessité de répartir uniformément la charge sur chaque instance.
Notez également que nous pouvons voir la forme rectangulaire de la subdivision sur le côté droit de la définition de la somme de Riemann.
Maintenant que nous avons la formule de somme de Riemann, nous pouvons dire que la valeur de charge à l'instant $t$ est la somme du nombre d'utilisateurs de chaque subdivision multipliée par la fonction de charge de l'utilisateur à l'instant correspondant . Cela peut être écrit comme suit :
\[L_{ tot }( t ) = \lim_{n \rightarrow \infty} \sum_{ k=1 }^{ n } ( x_{k} - x_{k-1} )G( x_{k} ) L_{ u }( t - x_{k} )\]Après avoir remplacé les variables et simplifié la formule, cela devient :
\[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 } )\]Et voila ! Nous avons créé la fonction load !
Trouver le seuil de mise à l'échelle
Pour finir, il suffit d'exécuter un algorithme de dichotomie qui fait varier le seuil pour trouver la valeur la plus élevée où la charge par instance ne dépasse jamais sa limite maximale sur toute la fonction de charge. (C'est ce qui est fait par l'application.)
Déduction d'autres paramètres d'orchestration
Dès que vous avez trouvé votre seuil de scale-up ($S_{up}$), d'autres paramètres sont assez faciles à calculer.
A partir de $S_{up}$ vous connaîtrez votre nombre maximum d'instances. (Vous pouvez également rechercher la charge maximale sur votre fonction de charge et diviser par la charge maximale par instance, arrondie.)
Le nombre minimum ($N_{min}$) d'instances est à définir en fonction de votre infrastructure. (Je recommanderais d'avoir au moins une réplique par AZ.) Mais il faut aussi tenir compte de la fonction de charge : comme une fonction gaussienne augmente assez rapidement, la répartition de la charge est plus intense (par réplique) au début, donc vous peut vouloir augmenter le nombre minimum de répliques pour amortir cet effet. (Cela augmentera très probablement vos $S_{up}$.)
Enfin, une fois que vous avez défini le nombre minimum de répliques, vous pouvez calculer le seuil de réduction ($S_{down}$) en tenant compte des éléments suivants : étant donné que la réduction d'une seule réplique n'a pas plus d'effet sur les autres instances que lors de la réduction à partir de $N_{min}+1$ à $N_{min}$, nous devons nous assurer que le seuil de mise à l'échelle ne sera pas déclenché juste après la réduction. Si c'est autorisé, cela aura un effet yo-yo. Autrement dit:
\[( N_{ min } + 1) S_{ bas } < N_{ min }S_{ haut }\]Ou:
\[S_{ bas } < \frac{N_{ min }}{N_{min}+1}S_{ haut }\]De plus, nous pouvons admettre que plus votre cluster est configuré pour attendre longtemps avant de réduire, plus il est sûr de définir $S_{down}$ plus près de la limite supérieure. Encore une fois, vous devrez trouver un équilibre qui vous convient.
Notez que lors de l'utilisation du système d'orchestration Mesosphere Marathon avec son autoscaler, le nombre maximal d'instances pouvant être supprimées simultanément du scaling à la baisse est lié à AS_AUTOSCALE_MULTIPLIER
($A_{mult}$), ce qui implique :
Qu'en est-il de la fonction de chargement de l'utilisateur ?
Oui, c'est un peu un problème, et pas le plus facile à résoudre mathématiquement - si c'est même possible.
Pour contourner ce problème, l'idée est d'exécuter une seule instance de votre application et d'augmenter le nombre d'utilisateurs effectuant la même tâche à plusieurs reprises jusqu'à ce que la charge du serveur atteigne le maximum qui lui a été attribué (mais pas plus). Divisez ensuite par le nombre d'utilisateurs et calculez le temps moyen de la requête. Répétez cette procédure avec chaque action que vous souhaitez intégrer à votre fonction de chargement d'utilisateurs, ajoutez un peu de temps, et vous y êtes.
Je suis conscient que cette procédure implique de considérer que chaque demande d'utilisateur a une charge constante sur son traitement (ce qui est évidemment incorrect), mais la masse d'utilisateurs créera cet effet car chacun d'eux n'est pas à la même étape de traitement au même moment . Je suppose donc que c'est une approximation acceptable, mais cela insinue une fois de plus que vous avez affaire à un grand nombre d'utilisateurs.
Vous pouvez également essayer d'autres méthodes, comme les graphiques de flamme du processeur. Mais je pense qu'il sera très difficile de créer une formule précise qui reliera les actions des utilisateurs à la consommation de ressources.
Présentation de l' app-autoscaling-calculator
Et maintenant, pour la petite application Web mentionnée tout au long : elle prend en entrée votre fonction de chargement, la configuration de votre orchestrateur de conteneurs et certains autres paramètres généraux et renvoie le seuil de mise à l'échelle et d'autres chiffres liés à l'instance.
Le projet est hébergé sur GitHub, mais une version live est également disponible.
Voici le résultat donné par l'application Web, exécuté par rapport aux données de test (sur Kubernetes) :
Mise à l'échelle des microservices : plus besoin de tâtonner dans le noir
En ce qui concerne les architectures d'applications de microservices, le déploiement de conteneurs devient un point central de l'ensemble de l'infrastructure. Et plus l'orchestrateur et les conteneurs sont bien configurés, plus l'exécution sera fluide.
Ceux d'entre nous qui travaillent dans le domaine des services DevOps sont toujours à la recherche de meilleures façons de régler les paramètres d'orchestration pour nos applications. Adoptons une approche plus mathématique des microservices à mise à l'échelle automatique !