Faça as contas: dimensionando aplicativos de microsserviços com orquestradores
Publicados: 2022-03-11Não é uma surpresa que a arquitetura de aplicativos de microsserviços continue a invadir o design de software. É muito mais conveniente distribuir a carga, criar implementações altamente disponíveis e gerenciar atualizações enquanto facilita o desenvolvimento e o gerenciamento de equipe.
Mas a história certamente não é a mesma sem os orquestradores de contêineres.
É fácil querer usar todos os seus principais recursos, especialmente o dimensionamento automático. Que bênção é observar as implantações de contêineres flutuando durante todo o dia, dimensionadas suavemente para lidar com a carga atual, liberando nosso tempo para outras tarefas. Estamos orgulhosos do que nossas ferramentas de monitoramento de contêiner estão mostrando; enquanto isso, acabamos de configurar algumas configurações - sim, isso é (quase) tudo o que precisamos para criar a mágica!
Isso não quer dizer que não haja motivo para se orgulhar disso: temos certeza de que nossos usuários estão tendo uma boa experiência e que não estamos desperdiçando dinheiro com infraestrutura superdimensionada. Isso já é bastante considerável!
E, claro, que jornada foi chegar lá! Porque mesmo que no final não haja muitas configurações que precisem ser configuradas, é muito mais complicado do que normalmente pensamos antes de começarmos. Número mínimo/máximo de réplicas, limites de upscale/downscale, períodos de sincronização, atrasos de resfriamento — todas essas configurações estão muito interligadas. Modificar um provavelmente afetará outro, mas você ainda precisa organizar uma combinação equilibrada que se adapte ao seu aplicativo/implantação e à sua infraestrutura. E, no entanto, você não encontrará nenhum livro de receitas ou nenhuma fórmula mágica na Internet, pois depende muito de suas necessidades.
A maioria de nós os define primeiro para valores “aleatórios” ou padrão que ajustamos depois de acordo com o que encontramos durante o monitoramento. Isso me fez pensar: e se pudéssemos estabelecer um procedimento mais “matemático” que nos ajudasse a encontrar a combinação vencedora?
Como calcular parâmetros de orquestração de contêiner
Quando pensamos em microsserviços de dimensionamento automático para um aplicativo, estamos realmente procurando melhorar em dois pontos principais:
- Garantir que a implantação possa escalar rapidamente no caso de um rápido aumento de carga (para que os usuários não enfrentem tempos limite ou HTTP 500s)
- Reduzindo o custo da infraestrutura (ou seja, evitando que as instâncias sejam sobrecarregadas)
Isso basicamente significa otimizar os limites do software do contêiner para aumentar e diminuir a escala. (O algoritmo do Kubernetes tem um único parâmetro para os dois).
Mostrarei mais tarde que todos os parâmetros relacionados à instância estão vinculados ao limite de upscale. Este é o mais difícil de calcular – daí este artigo.
Observação: em relação aos parâmetros definidos em todo o cluster, não tenho nenhum procedimento bom para eles, mas no final deste artigo, apresentarei um software (uma página da Web estática) que os leva em consideração durante o cálculo parâmetros de escalonamento automático de uma instância. Dessa forma, você poderá variar seus valores para considerar seu impacto.
Calculando o limite de aumento de escala
Para que esse método funcione, você precisa garantir que seu aplicativo atenda aos seguintes requisitos:
- A carga deve ser distribuída uniformemente em todas as instâncias do seu aplicativo (de maneira round-robin)
- Os tempos de solicitação devem ser menores que o intervalo de verificação de carga do cluster de contêiner .
- Você deve considerar a execução do procedimento em um grande número de usuários (definido posteriormente).
A principal razão para essas condições vem do fato de que o algoritmo não calcula a carga como sendo por usuário , mas como uma distribuição (explicada posteriormente).
Obtendo tudo gaussiano
Primeiro temos que formular uma definição para um aumento rápido de carga ou, em outras palavras, um cenário de pior caso. Para mim, uma boa maneira de traduzir isso é: ter um grande número de usuários executando ações que consomem recursos em um curto período de tempo – e sempre há a possibilidade de que isso aconteça enquanto outro grupo de usuários ou serviços está executando outras tarefas. Então vamos começar com esta definição e tentar extrair alguma matemática. (Prepare sua aspirina.)
Apresentando algumas variáveis:
- $N_{u}$, o “grande número de usuários”
- $L_{u}(t)$, a carga gerada por um único usuário realizando a “operação consumidora de recursos” ($t=0$ aponta para o momento em que o usuário inicia a operação)
- $L_{tot}(t)$, a carga total (gerada por todos os usuários)
- $T_{tot}$, o “curto período de tempo”
No mundo matemático, falando de um grande número de usuários realizando a mesma coisa ao mesmo tempo, a distribuição dos usuários ao longo do tempo segue uma distribuição gaussiana (ou normal), cuja fórmula é:
\[G(t) = \frac{1}{\sigma \sqrt{2 \pi}} e^{\frac{-(t-\mu)^2}{2 \sigma^2}}\]Aqui:
- µ é o valor esperado
- σ é o desvio padrão
E é representado graficamente da seguinte forma (com $µ=0$):
Provavelmente uma reminiscência de algumas aulas que você fez – nada de novo. No entanto, enfrentamos nosso primeiro problema aqui: Para ser matematicamente preciso, teríamos que considerar um intervalo de tempo de $-\infty$ a $+\infty$, que obviamente não pode ser calculado.
Mas olhando para o gráfico, notamos que valores fora do intervalo $[-3σ, 3σ]$ são muito próximos de zero e não variam muito, ou seja, seu efeito é realmente desprezível e pode ser deixado de lado. Isso é mais verdadeiro, pois nosso objetivo é testar a escalabilidade de nosso aplicativo, por isso estamos procurando variações de um grande número de usuários.
Além disso, como o intervalo $[-3σ, 3σ]$ contém 99,7% de nossos usuários, é próximo o suficiente do total para trabalhar nele, e precisamos apenas multiplicar $N_{u}$ por 1,003 para compensar A diferença. Selecionando este intervalo nos dá $µ=3σ$ (já que vamos trabalhar a partir de $t=0$).
Em relação à correspondência com $T_{tot}$, optar por ser igual a $6σ$ ($[-3σ, 3σ]$) não será uma boa aproximação, pois 95,4% dos usuários estão no intervalo $[- 2σ, 2σ]$, que dura $4σ$. Portanto, escolher $T_{tot}$ para ser igual a $6σ$ adicionará metade do tempo para apenas 4,3% dos usuários, o que não é realmente representativo. Assim, escolhemos $T_{tot}=4σ$, e podemos deduzir:
\(σ=\frac{T_{tot}}{4}\) e \(µ=\frac{3}{4} * T_{tot}\)
Esses valores foram tirados de um chapéu? sim. Mas esse é o propósito deles, e isso não afetará o procedimento matemático. Essas constantes são para nós e definem noções relacionadas à nossa hipótese. Isso significa apenas que agora que os configuramos, nosso pior cenário pode ser traduzido como:
A carga gerada por 99,7 por cento de $N{u}$, realizando uma operação de consumo $L{u}(t)$ e onde 95,4 por cento deles estão fazendo isso dentro da duração $T{tot}$.
(Isso é algo que vale a pena lembrar ao usar o aplicativo da web.)
Injetando os resultados anteriores na função de distribuição do usuário (Gaussiana), podemos simplificar a equação da seguinte forma:
\[G(t) = \frac{4 N_{u}}{T_{tot} \sqrt{2 \pi}} e^\frac{-(4t-3T_{tot})^2}{T_{tot }^2}\]A partir de agora, com $σ$ e $µ$ definidos, trabalharemos no intervalo $t \in [0, \frac{3}{2}T_{tot}]$ (com duração de $6σ$).

Qual é a carga total de usuários?
A segunda etapa nos microsserviços de escalonamento automático é calcular $L_{tot}(t)$.
Como $G(t)$ é uma distribuição , para recuperar o número de usuários em um determinado momento, temos que calcular sua integral (ou usar sua função de distribuição cumulativa). Mas como nem todos os usuários iniciam suas operações ao mesmo tempo, seria uma verdadeira bagunça tentar introduzir $L_{u}(t)$ e reduzir a equação a uma fórmula utilizável.
Então, para tornar isso mais fácil, usaremos uma soma de Riemann, que é uma maneira matemática de aproximar uma integral usando uma soma finita de pequenas formas (usaremos retângulos aqui). Quanto mais formas (subdivisões), mais preciso será o resultado. Outro benefício do uso de subdivisões vem do fato de podermos considerar que todos os usuários dentro de uma subdivisão iniciaram suas operações ao mesmo tempo.
De volta à soma de Riemann, ela tem a seguinte propriedade de conexão com integrais:
\[\int_{a}^{b} f( x )dx = \lim_{n \rightarrow \infty } \sum_{k=1}^{n} ( x_{k} - x_{k-1} ) f( x_{k} )\]Com $x_k$ definido da seguinte forma:
\[x_{ k } = a + k\frac{ b - a }{ n }, 0 \leq k \leq n\]Isso é verdade onde:
- $n$ é o número de subdivisões.
- $a$ é o limite inferior, aqui 0.
- $b$ é o limite superior, aqui $\frac{3}{2}*T_{tot}$.
- $f$ é a função—aqui $G$—para aproximar sua área.
Nota: O número de usuários presentes em uma subdivisão não é um número inteiro. Esta é a razão para dois dos pré-requisitos: ter um grande número de usuários (para que a parte decimal não seja muito impactante) e a necessidade de que a carga seja distribuída uniformemente em todas as instâncias.
Observe também que podemos ver a forma retangular da subdivisão no lado direito da definição da soma de Riemann.
Agora que temos a fórmula da soma de Riemann, podemos dizer que o valor da carga no momento $t$ é a soma do número de usuários de cada subdivisão multiplicado pela função de carga do usuário no momento correspondente . Isso pode ser escrito como:
\[L_{ tot }( t ) = \lim_{n \rightarrow \infty} \sum_{ k=1 }^{ n } ( x_{k} - x_{k-1} )G( x_{k} ) L_{ u }( t - x_{k} )\]Depois de substituir as variáveis e simplificar a fórmula, isso se torna:
\[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á ! Criamos a função de carregamento!
Encontrando o limite de escalonamento
Para finalizar, basta executar um algoritmo de dicotomia que varia o limite para encontrar o valor mais alto onde a carga por instância nunca excede seu limite máximo em toda a função de carga. (Isso é o que é feito pelo aplicativo.)
Deduzindo outros parâmetros de orquestração
Assim que você encontrar seu limite de aumento de escala ($S_{up}$), outros parâmetros são muito fáceis de calcular.
A partir de $S_{up}$ você saberá seu número máximo de instâncias. (Você também pode procurar a carga máxima em sua função de carga e dividir pela carga máxima por instância, arredondada.)
O número mínimo ($N_{min}$) de instâncias deve ser definido de acordo com sua infraestrutura. (Eu recomendaria ter um mínimo de uma réplica por AZ.) Mas também precisa levar em conta a função de carga: Como uma função gaussiana aumenta muito rapidamente, a distribuição de carga é mais intensa (por réplica) no início, então você pode querer aumentar o número mínimo de réplicas para amortecer esse efeito. (Isso provavelmente aumentará seu $S_{up}$.)
Por fim, depois de definir o número mínimo de réplicas, você pode calcular o limite de redução ($S_{down}$) considerando o seguinte: Como a redução de uma única réplica não tem mais efeito em outras instâncias do que na redução de $N_{min}+1$ a $N_{min}$, temos que garantir que o limite de expansão não seja acionado logo após a redução. Se for permitido, isso terá um efeito ioiô. Em outras palavras:
\[( N_{ min } + 1) S_{ down } < N_{ min }S_{ up }\]Ou:
\[S_{ para baixo } < \frac{N_{ min }}{N_{min}+1}S_{ para cima }\]Além disso, podemos admitir que quanto mais tempo seu cluster estiver configurado para esperar antes de reduzir, mais seguro será definir $S_{down}$ mais próximo do limite mais alto. Mais uma vez, você terá que encontrar um equilíbrio que combine com você.
Observe que, ao usar o sistema de orquestração Mesosphere Marathon com seu autoescalador, o número máximo de instâncias que podem ser removidas de uma vez da redução está vinculado a AS_AUTOSCALE_MULTIPLIER
($A_{mult}$), o que implica:
E quanto à função de carregamento do usuário?
Sim, isso é um pouco problemático, e não o mais fácil de resolver matematicamente - se é que é possível.
Para contornar esse problema, a ideia é executar uma única instância do seu aplicativo e aumentar o número de usuários que executam a mesma tarefa repetidamente até que a carga do servidor atinja o máximo atribuído (mas não mais). Em seguida, divida pelo número de usuários e calcule o tempo médio da solicitação. Repita este procedimento com cada ação que você deseja integrar à sua função de carregamento de usuário, adicione algum tempo e pronto.
Estou ciente de que este procedimento implica considerar que cada solicitação de usuário tem uma carga constante sobre seu processamento (o que obviamente é incorreto), mas a massa de usuários criará esse efeito, pois cada um deles não está na mesma etapa de processamento ao mesmo tempo . Então eu acho que esta é uma aproximação aceitável, mas mais uma vez insinua que você está lidando com um grande número de usuários.
Você também pode tentar com outros métodos, como gráficos de chama de CPU. Mas acho que será muito difícil criar uma fórmula precisa que vincule as ações do usuário ao consumo de recursos.
Apresentando o app-autoscaling-calculator
E agora, para o pequeno aplicativo da Web mencionado ao longo: ele recebe como entrada sua função de carregamento, sua configuração do orquestrador de contêiner e alguns outros parâmetros gerais e retorna o limite de escalabilidade vertical e outras figuras relacionadas à instância.
O projeto está hospedado no GitHub, mas também tem uma versão ao vivo disponível.
Aqui está o resultado fornecido pelo aplicativo da web, executado nos dados de teste (no Kubernetes):
Dimensionamento de microsserviços: não há mais confusão no escuro
Quando se trata de arquiteturas de aplicativos de microsserviços, a implantação de contêineres se torna um ponto central de toda a infraestrutura. E quanto melhor o orquestrador e os contêineres estiverem configurados, mais suave será o tempo de execução.
Aqueles de nós na área de serviços de DevOps estão sempre buscando melhores maneiras de ajustar os parâmetros de orquestração para nossos aplicativos. Vamos adotar uma abordagem mais matemática para microsserviços de dimensionamento automático!