O material, cuja tradução estamos publicando hoje, é dedicado à história de como o Airbnb otimiza as partes de servidor de aplicativos da Web, visando o crescente uso das tecnologias de renderização de servidor. Ao longo de vários anos, a empresa mudou gradualmente todo o front-end para uma arquitetura
uniforme , segundo a qual as páginas da web são estruturas hierárquicas dos componentes do React preenchidos com dados de sua API. Em particular, durante esse processo, houve um abandono sistemático do Ruby on Rails. De fato, o Airbnb planeja mudar para um novo serviço baseado exclusivamente no Node.js., graças ao qual as páginas totalmente preparadas renderizadas no servidor serão entregues aos navegadores dos usuários. Este serviço irá gerar a maior parte do código HTML para todos os produtos Airbnb. O mecanismo de renderização em questão difere da maioria dos serviços de back-end usados pela empresa devido ao fato de não estar escrito em Ruby ou Java. No entanto, difere dos serviços Node.j tradicionais altamente carregados, em torno dos quais os modelos mentais e as ferramentas auxiliares usadas no Airbnb são construídos.

Plataforma Node.js
Pensando na plataforma Node.js., você pode imaginar como um determinado aplicativo, construído levando em conta os recursos dessa plataforma para processamento de dados assíncrono, serve de maneira rápida e eficiente centenas ou milhares de conexões paralelas. O serviço extrai os dados necessários de qualquer lugar e os processa um pouco para atender às necessidades de um grande número de clientes. O proprietário de tal aplicativo não tem motivos para reclamar, está confiante no modelo leve de processamento simultâneo de dados usado por ele (neste material, usamos a palavra "simultâneo" para transmitir o termo "simultâneo", para o termo "paralelo" - "paralelo"). Ela resolve perfeitamente a tarefa definida para ela.
A renderização do lado do servidor (SSR) altera as idéias básicas que levam a uma visão semelhante do problema. Portanto, a renderização do servidor requer muitos recursos de computação. O código no ambiente Node.js é executado em um único encadeamento, como resultado, para resolver problemas computacionais (diferente das tarefas de E / S), o código pode ser executado simultaneamente, mas não em paralelo. A plataforma Node.js. é capaz de lidar com um grande número de operações de E / S paralelas; no entanto, quando se trata de computação, a situação muda.
Como ao aplicar a renderização do lado do servidor, a parte computacional da tarefa de processamento de solicitações aumenta em comparação com a parte relacionada à entrada / saída, as solicitações recebidas simultaneamente afetam a velocidade de resposta do servidor devido ao fato de competirem pelos recursos do processador. Deve-se notar que, ao usar a renderização assíncrona, a competição por recursos ainda está presente. A renderização assíncrona resolve a capacidade de resposta de um processo ou navegador, mas não melhora a situação com atrasos ou simultaneidade. Neste artigo, focaremos em um modelo simples que inclui exclusivamente cargas computacionais. Se falarmos sobre uma carga mista, que inclui operações de entrada / saída e cálculo, as solicitações recebidas simultaneamente aumentarão o atraso, mas levando em consideração a vantagem de uma maior taxa de transferência do sistema.
Considere um comando no formato
Promise.all([fn1, fn2])
. Se
fn1
ou
fn2
forem promessas resolvidas pelo subsistema de E / S, durante a execução deste comando, é possível obter a execução paralela de operações. É assim:
Execução paralela de operações por meio do subsistema de entrada / saídaSe
fn1
e
fn2
forem tarefas computacionais, elas serão executadas da seguinte maneira:
Tarefas de computaçãoUma das operações precisará aguardar a conclusão da segunda operação, pois há apenas um encadeamento no Node.js.
No caso de renderização do servidor, esse problema ocorre quando o processo do servidor precisa processar várias solicitações simultâneas. O processamento de tais solicitações será adiado até que as solicitações recebidas anteriormente sejam processadas. Aqui está como fica.
Processando solicitações simultâneasNa prática, o processamento de solicitações geralmente consiste em muitas fases assíncronas, mesmo que envolvam uma carga computacional séria no sistema. Isso pode levar a uma situação ainda mais difícil com a alternância de tarefas para processar essas solicitações.
Suponha que nossas consultas sejam compostas de uma cadeia de tarefas semelhante a essa:
renderPromise().then(out => formatResponsePromise(out)).then(body => res.send(body))
. Quando um par de solicitações chega ao sistema, com um pequeno intervalo entre elas, podemos observar a figura a seguir.
Processando solicitações que chegam em um pequeno intervalo, o problema da luta por recursos do processadorNesse caso, leva cerca de duas vezes mais tempo para processar cada solicitação do que para processar uma solicitação individual. Com um aumento no número de solicitações processadas simultaneamente, a situação se torna ainda pior.
Além disso, um dos objetivos típicos da implementação do SSR é a capacidade de usar o mesmo código ou um código muito semelhante no cliente e no servidor. A grande diferença entre esses ambientes é que o ambiente do cliente é essencialmente um ambiente no qual um cliente opera e os ambientes do servidor, por natureza, são ambientes com vários clientes. O que funciona bem no cliente, como singletones ou outras abordagens para armazenar o estado global do aplicativo, leva a erros, vazamentos de dados e, em geral, confusão, ao processar muitas solicitações que chegam ao servidor.
Esses recursos se tornam problemas em uma situação em que você precisa processar várias solicitações ao mesmo tempo. Tudo normalmente funciona normalmente com cargas mais baixas em um ambiente aconchegante do ambiente de desenvolvimento, que é usado por um cliente na pessoa de um programador.
Isso leva a uma situação muito diferente dos exemplos clássicos de aplicativos Node.js. Deve-se observar que usamos o tempo de execução JavaScript para o rico conjunto de bibliotecas disponíveis, e devido ao fato de ser suportado por navegadores, e não pelo modelo de processamento simultâneo de dados. Nesta aplicação, o modelo assíncrono de processamento simultâneo de dados demonstra todas as suas desvantagens, não compensadas pelas vantagens, que são muito poucas ou não são de todo.
Tutoriais do projeto Hypernova
Nosso novo serviço de renderização, Hyperloop, será o principal serviço com o qual os usuários do Airbnb irão interagir. Como resultado, sua confiabilidade e desempenho desempenham um papel crucial para garantir a conveniência de trabalhar com um recurso. Ao introduzir o Hyperloop na produção, levamos em conta a experiência que adquirimos ao trabalhar com nosso sistema anterior de renderização de servidores - o
Hypernova .
O Hypernova não funciona como nosso novo serviço. Este é um sistema de renderização puro. Ele é chamado a partir de nosso serviço monolítico de Trilho, chamado Monotrilho, e retorna apenas trechos HTML para componentes renderizados específicos. Em muitos casos, esse "trecho" representa a maior parte da página, e o Rails fornece apenas o layout da página. Com a tecnologia herdada, partes de uma página podem ser vinculadas usando o ERB. Em qualquer caso, no entanto, o Hypernova não carrega nenhum dado necessário para formar a página. Essa é a tarefa do Rails.
Assim, Hyperloop e Hypernova têm desempenho computacional semelhante. Ao mesmo tempo, o Hypernova, como um serviço de produção e processando volumes significativos de tráfego, fornece um bom campo para testes, levando a uma compreensão de como a substituição do Hypernova se comportará em condições de combate.
Fluxo de trabalho do HypernovaVeja como o Hypernova funciona. As solicitações dos usuários chegam ao nosso aplicativo principal do Rails, o Monorail, que coleta as propriedades dos componentes do React que precisam ser exibidas em uma página e faz uma solicitação ao Hypernova, passando essas propriedades e nomes de componentes. O Hypernova renderiza componentes com propriedades para gerar o código HTML que precisa ser retornado ao aplicativo Monorail, que incorpora esse código no modelo da página e envia tudo de volta ao cliente.
Enviando uma página finalizada para um clienteNo caso de uma emergência (isso pode ser um erro ou o tempo limite da resposta) no Hypernova, existe uma opção de fallback, ao usar quais componentes e suas propriedades são incorporados na página sem o HTML gerado no servidor, após o qual tudo isso é enviado ao cliente e renderizado lá esperançosamente bem sucedido. Isso nos levou ao fato de não considerarmos o serviço Hypernova uma parte crítica do sistema. Como resultado, poderíamos permitir a ocorrência de um certo número de falhas e situações nas quais um tempo limite é acionado. Ajustando os tempos limite da solicitação, nós, com base nas observações, os definimos para aproximadamente o nível P95. Como resultado, não é de surpreender que o sistema funcione com uma taxa de resposta de tempo limite inferior a 5%.
Em situações em que o tráfego atingiu valores máximos, pudemos ver que até 40% das solicitações ao Hypernova foram encerradas por tempos limite no monotrilho. No lado do Hypernova, vimos picos de
BadRequestError: Request aborted
altura inferior. Além disso, esses erros existiam em condições normais, enquanto em operação normal, devido à arquitetura da solução, os erros restantes não eram particularmente perceptíveis.
Valores de tempo limite de pico (linhas vermelhas)Como nosso sistema funcionava sem o Hypernova, não prestamos muita atenção a esses recursos, eles foram percebidos mais como insignificantes, do que como problemas sérios. Explicamos esses problemas pelos recursos da plataforma, porque o lançamento do aplicativo é lento devido à difícil operação inicial de coleta de lixo, devido às peculiaridades da compilação de código e do cache de dados, e por outros motivos. Esperávamos que os novos lançamentos React ou Node incluíssem melhorias de desempenho que mitigassem as deficiências do lento lançamento do serviço.
Suspeitei que o que estava acontecendo fosse muito provavelmente o resultado de um balanceamento de carga insuficiente ou a consequência de problemas na implantação da solução, quando atrasos crescentes foram manifestados devido à carga computacional excessiva nos processos. Adicionei uma camada auxiliar ao sistema para registrar informações sobre o número de solicitações processadas simultaneamente por processos individuais, bem como para registrar casos nos quais o processo recebeu mais de uma solicitação de processamento.
Resultados da pesquisaConsideramos o início lento do serviço o culpado dos atrasos, mas, na verdade, o problema foi causado por solicitações paralelas que lutavam pelo tempo da CPU. De acordo com os resultados da medição, verificou-se que o tempo gasto pela solicitação antes da conclusão do processamento de outras solicitações corresponde ao tempo gasto no processamento da solicitação. Além disso, isso significava que um aumento nos atrasos devido ao processamento simultâneo de solicitações parece o mesmo que um aumento nos atrasos devido a um aumento na complexidade computacional do código, o que leva a um aumento na carga do sistema ao processar cada solicitação.
Além disso, isso tornou mais óbvio que o
BadRequestError: Request aborted
não pôde ser explicado com segurança por uma inicialização lenta do sistema. O erro procedeu do código de análise do corpo da solicitação e ocorreu quando o cliente cancelou a solicitação antes que o servidor pudesse ler completamente o corpo da solicitação. O cliente parou de funcionar, fechou a conexão, privando-nos dos dados necessários para continuar processando a solicitação. É muito mais provável que isso tenha acontecido porque começamos a processar a solicitação, depois que o loop de eventos acabou sendo uma renderização bloqueada para outra solicitação e, em seguida, retornamos à tarefa interrompida para concluí-la, mas, como resultado, verificou-se que o cliente quem nos enviou esta solicitação já desconectou, abortando a solicitação. Além disso, os dados transmitidos nos pedidos à Hypernova eram bastante volumosos, em média, na região de várias centenas de kilobytes, e isso, é claro, não contribuiu para melhorar a situação.

Erro causado pela desconexão de um cliente que não esperou por uma respostaDecidimos lidar com esse problema usando algumas ferramentas padrão com as quais possuímos uma experiência considerável. Estamos falando de um servidor proxy reverso (
nginx ) e um balanceador de carga (
HAProxy ).
Proxy reverso e balanceamento de carga
Para tirar proveito da arquitetura do processador de vários núcleos, executamos vários processos Hypernova usando o módulo de
cluster Node.js. Como esses processos são independentes, podemos processar simultaneamente as solicitações recebidas.
Processamento paralelo de solicitações que chegam simultaneamenteO problema aqui é que cada processo do Nó fica completamente ocupado o tempo necessário para processar uma solicitação, incluindo a leitura do corpo da solicitação enviada pelo cliente (o monotrilho desempenha seu papel nesse caso). Embora possamos ler muitas consultas em um único processo ao mesmo tempo, quando se trata de renderização, isso leva a uma alternância de operações computacionais.
O uso dos recursos do processo do Nó está vinculado à velocidade do cliente e da rede.
Como solução para esse problema, podemos considerar um servidor proxy reverso em buffer, que nos permitirá manter sessões de comunicação com os clientes. A inspiração para essa idéia foi o servidor da web unicorn, que usamos para nossos aplicativos Rails.
Os princípios declarados pelo unicórnio explicam perfeitamente por que isso acontece. Para esse fim, usamos o nginx. O Nginx lê a solicitação do cliente no buffer e transmite a solicitação ao servidor Node somente após sua leitura completa. Essa sessão de transferência de dados é realizada na máquina local, através da interface de loopback ou usando soquetes de domínio Unix, e isso é muito mais rápido e confiável do que a transferência de dados entre computadores separados.
O Nginx armazena em buffer as solicitações e as envia para o servidor NodeDevido ao fato de o nginx agora estar envolvido em solicitações de leitura, conseguimos obter um carregamento mais uniforme dos processos do Nó.
Carregamento uniforme de processo usando nginxAlém disso, usamos o nginx para manipular algumas solicitações que não requerem acesso aos processos do Nó. A camada de detecção e roteamento de nosso serviço usa solicitações
/ping
que não criam uma grande carga no sistema para verificar a comunicação entre hosts. O processamento de tudo isso no nginx elimina uma fonte significativa de carga de trabalho adicional (embora pequena) para o Node.js.
A próxima melhoria diz respeito ao balanceamento de carga. Precisamos tomar decisões informadas sobre a distribuição de solicitações entre os processos do Nó. O módulo de
cluster
distribui solicitações de acordo com o algoritmo round-robin, na maioria dos casos com tentativas de ignorar processos que não respondem a solicitações. Com essa abordagem, cada processo recebe uma solicitação em ordem de prioridade.
O módulo de
cluster
distribui conexões, não solicitações, para que tudo isso não funcione conforme necessário. A situação fica ainda pior quando são usadas conexões persistentes. Qualquer conexão permanente do cliente está vinculada a um único fluxo de trabalho específico, o que complica a distribuição eficiente de tarefas.
O algoritmo round-robin é bom quando há baixa variabilidade nos atrasos nas solicitações. Por exemplo, na situação ilustrada abaixo.
Algoritmo round-robin e conexões através das quais as solicitações são recebidas de forma estávelEsse algoritmo já não é tão bom quando você precisa processar solicitações de tipos diferentes, para o processamento do qual custos de tempo completamente diferentes podem ser necessários. A solicitação mais recente enviada para um determinado processo é forçada a aguardar a conclusão do processamento de todas as solicitações enviadas anteriormente, mesmo se houver outro processo com a capacidade de processar essa solicitação.
Carga de processo desigualSe você distribuir as consultas mostradas acima de maneira mais racional, obterá algo como o mostrado na figura abaixo.
Distribuição racional de pedidos por encadeamentosCom essa abordagem, a espera é minimizada e torna-se possível enviar respostas às solicitações mais rapidamente.
Isso pode ser conseguido colocando solicitações em uma fila e atribuindo-as a um processo apenas quando não estiver ocupado processando outra solicitação. Para esse fim, usamos o HAProxy.
HAProxy e balanceamento de carga de processoQuando usamos o HAProxy para equilibrar a carga no Hypernova, eliminamos completamente os picos de tempo limite, bem como os erros
BadRequestErrors
.
Solicitações simultâneas também foram a principal causa de atrasos durante a operação normal; essa abordagem reduziu esses atrasos. Uma das conseqüências disso foi que agora apenas 2% das solicitações foram encerradas por tempo limite, e não 5%, com as mesmas configurações de tempo limite. O fato de termos conseguido passar de uma situação com erros de 40% para uma situação com um tempo limite acionado em 2% dos casos mostrou que estamos caminhando na direção certa. Como resultado, hoje nossos usuários veem a tela de carregamento do site com muito menos frequência. Deve-se notar que a estabilidade do sistema será de particular importância para nós com a transição esperada para um novo sistema que não possui o mesmo mecanismo de backup que o Hypernova.
Detalhes sobre o sistema e suas configurações
Para que tudo isso funcione, você precisa configurar os aplicativos nginx, HAProxy e Node. Aqui está
um exemplo de aplicativo semelhante usando nginx e HAProxy, analisando quais você pode entender o dispositivo do sistema em questão. Este exemplo é baseado no sistema que usamos na produção, mas é simplificado e modificado para que possa ser executado em primeiro plano em nome de um usuário não privilegiado. Na produção, tudo deve ser configurado usando algum tipo de supervisor (usamos runit ou, mais frequentemente, kubernetes).
A configuração do nginx é bastante padrão, ela usa um servidor que escuta na porta 9000, configurado para solicitações de proxy para o servidor HAProxy, que escuta na porta 9001 (em nossa configuração, usamos soquetes de domínio Unix).
Além disso, este servidor intercepta solicitações ao terminal
/ping
para atender diretamente às solicitações destinadas a verificar a conectividade da rede. nginx ,
worker_processes
1, nginx — HAProxy Node-. , , , Hypernova, ( ). .
Node.js
cluster
. HAProxy,
cluster
, .
pool-hall . — , , ,
cluster
, .
pool-hall
, .
HAProxy , 9001 , 9002 9005. —
maxconn 1
, . . HAProxy ( 8999).
HAProxyHAProxy . ,
maxconn
.
static-rr
(static round-robin), , , . , round-robin, , , , , . , , . .
, , . ( ). , , , , . , , .
HAProxy
HAProxy. , , , . , , ( ) . , ,
cluster
. , .
ab
(Apache Benchmark) 10000 . - . :
ab -l -c <CONCURRENCY> -n 10000 http://<HOSTNAME>:9000/render
15 4- -,
ab
, . (
concurrency=5
), (
concurrency=13
), , (
concurrency=20
). , .
, -, . , . , , , , . , , , .
, — .
maxconn 1
, , .
HTTP TCP , , , . ,
maxconn
, . , , (, , ).
, , , , , , .
— , .
option redispatch
retries 3
, , , , , , . .
, - , . , . , , . 100 , 10 , , . , . ,
accept
.
, (
backlog ) , . SYN-ACK (
, , , ACK ). , , , , .
, , , , . , , 1.
maxconn
. 0 , , , , , . , . - , , .
abortonclose
, . ,
abortonclose
. nginx.
, , . ( ) , , , , , . HAProxy , , ( ). , , , HTML. , , . , , ( , , ). , , . , , , . HAProxy, MAINT HAProxy.
, , ,
server.close
Node.js , HAProxy , , , . , , , , , .
, ,
balance first
, (
worker1
) 15% , , ,
balance static-rr
. , «» . . (12 ), , , - . , , , «» «». .
, , Node
server.maxconnections
, ( , ), , , , . ,
maxconnection
, , , . JavaScript, ( ). , , , . , , , HAProxy Node , . , , .
, , , ,
.
Node.js . , , , -. Node.js . , , , , , , , nginx HAProxy.
, Airbnb , Node.js .
Caros leitores! Você usa a renderização do lado do servidor em seus projetos?