Sobre o aumento de simultaneidade 30x no Node.js

Qual é a melhor maneira de aumentar perfeitamente a simultaneidade no serviço Node.js. usado na produção? Essa é uma pergunta que minha equipe precisava responder alguns meses atrás.

Lançamos contêineres de 4000 nós (ou "trabalhadores"), que garantem a operação de nosso serviço de integração com bancos. O serviço foi originalmente projetado para que cada trabalhador fosse projetado para processar apenas uma solicitação por vez. Isso reduziu o impacto no sistema daquelas operações que poderiam bloquear inesperadamente o ciclo de eventos e nos permitiu ignorar as diferenças no uso de recursos por várias operações semelhantes. Porém, como nossas capacidades estavam limitadas à execução simultânea de apenas 4.000 solicitações, o sistema não pôde ser dimensionado adequadamente. A velocidade de resposta à maioria das solicitações não dependia da capacidade do equipamento, mas dos recursos da rede. Portanto, poderíamos melhorar o sistema e reduzir o custo de seu suporte se encontrássemos uma maneira de processar solicitações de forma confiável em paralelo.



Tendo estudado essa questão, não conseguimos encontrar um bom guia que discutisse a transição da "falta de paralelismo" no Node.js para um "alto nível de paralelismo". Como resultado, desenvolvemos nossa própria estratégia de migração, baseada em planejamento cuidadoso, boas ferramentas, ferramentas de monitoramento e uma boa dose de depuração. Como resultado, conseguimos aumentar o nível de paralelismo do nosso sistema em 30 vezes. Isso equivale a reduzir o custo de manutenção do sistema em cerca de 300 mil dólares por ano.

Este material é dedicado à história de como aumentamos a produtividade e a eficácia de nossos funcionários do Node.js. e sobre o que aprendemos por esse caminho.

Por que decidimos investir no paralelismo?


Pode parecer surpreendente que tenhamos chegado a essas dimensões sem o uso do paralelismo. Como isso aconteceu? Apenas 10% das operações de processamento de dados executadas pelas ferramentas do Plaid são iniciadas por usuários que estão sentados em computadores e conectaram suas contas ao aplicativo. O restante são transações para atualização periódica de transações realizadas sem a presença do usuário. A lógica foi adicionada ao sistema de balanceamento de carga que usamos para garantir que as solicitações feitas pelos usuários tenham precedência sobre as solicitações de atualização de transação. Isso nos permitiu lidar com rajadas de atividade de operações de acesso à API em 1000% ou mais. Isso foi feito por meio de transações destinadas a atualizar dados.

Embora esse esquema de compromisso funcionasse há muito tempo, era possível discernir vários momentos desagradáveis. Sabíamos que eles, no final, poderiam afetar adversamente a confiabilidade do serviço.

  • Os picos de solicitações de API provenientes de clientes estavam ficando cada vez mais altos. Estávamos preocupados que um grande aumento na atividade pudesse drenar nossos recursos de processamento de consultas.
  • O aumento repentino de atrasos no atendimento de solicitações aos bancos também levou a uma diminuição na capacidade dos trabalhadores. Devido ao fato de os bancos usarem uma variedade de soluções de infraestrutura, definimos intervalos muito conservadores para solicitações de saída. Como resultado, pode levar alguns minutos para concluir a operação de carregamento de determinados dados. Se acontecesse que os atrasos na execução de muitas solicitações aos bancos aumentassem repentinamente, resultaria que muitos trabalhadores simplesmente ficariam presos à espera de respostas.
  • A implantação no ECS tornou-se muito lenta e, embora tenhamos aprimorado a velocidade de implantação do sistema, não queremos continuar aumentando o tamanho do cluster.

Decidimos que a melhor maneira de lidar com gargalos de aplicativos e aumentar a confiabilidade do sistema é aumentar o nível de paralelismo nas solicitações de processamento. Além disso, esperávamos que, como efeito colateral, isso nos permitisse reduzir os custos de infraestrutura e ajudar a implementar melhores ferramentas para monitorar o sistema. Tanto isso quanto outro no futuro dariam frutos.

Como introduzimos as atualizações, cuidando da confiabilidade


▍Ferramentas e monitoramento


Temos nosso próprio balanceador de carga, que redireciona solicitações para os trabalhadores do Node.js. Cada trabalhador executa um servidor gRPC usado para processar solicitações. O Worker usa o Redis para informar ao balanceador de carga que ele está disponível. Isso significa que adicionar paralelismo ao sistema se resume a simplesmente alterar algumas linhas de código. Nomeadamente, o trabalhador, em vez de se tornar inacessível após a solicitação, deve informar que ele está disponível até que ele esteja ocupado processando os N pedidos que vieram para ele (cada um deles representado por seu próprio objeto Promise).

É verdade que nem tudo é tão simples. Ao implantar atualizações do sistema, sempre consideramos nosso principal objetivo manter sua confiabilidade. Portanto, não podemos apenas pegar e, guiados por algo como o princípio YOLO, colocar o sistema no modo de processamento de consultas paralelas. Esperávamos que essa atualização do sistema fosse especialmente arriscada. O fato é que isso teria um efeito imprevisível no uso do processador, memória e atrasos na execução de tarefas. Como o mecanismo V8 usado no Node.js lida com tarefas no loop de eventos, nossa principal preocupação era que pudéssemos fazer muito trabalho no loop de eventos e, assim, reduzir a taxa de transferência do sistema.

Para atenuar esses riscos, nós, mesmo antes do primeiro trabalhador paralelo entrar em produção, garantimos a disponibilidade das seguintes ferramentas e ferramentas de monitoramento no sistema:

  • A pilha ELK já usada por nós nos forneceu uma quantidade suficiente de informações registradas, o que poderia ser útil para descobrir rapidamente o que está acontecendo no sistema.
  • Adicionamos várias métricas do Prometheus ao sistema. Incluindo o seguinte:

    • Tamanho de heap da V8 obtido usando process.memoryUsage() .
    • Informações sobre operações de coleta de lixo usando o pacote gc-stats .
    • Dados sobre o tempo gasto para concluir tarefas, agrupados por tipo de operações relacionadas à integração com bancos e por nível de simultaneidade. Precisávamos disso para medir de maneira confiável como a concorrência afeta a taxa de transferência do sistema.
  • Criamos o painel de controle Grafana , projetado para estudar o grau de impacto da simultaneidade no sistema.
  • Para nós, a capacidade de alterar o comportamento do aplicativo sem a necessidade de reimplementar o serviço foi extremamente importante. Portanto, criamos um conjunto de sinalizadores LaunchDarkly projetados para controlar vários parâmetros. Com essa abordagem, a seleção dos parâmetros dos trabalhadores, calculados para atingirem o nível máximo de paralelismo, nos permitiu realizar rapidamente experimentos e encontrar os melhores parâmetros, gastando alguns minutos nisso.
  • Para descobrir como várias partes do aplicativo carregam o processador, criamos as ferramentas de coleta de dados do serviço de produção, com base em quais diagramas de chama foram construídos.

    • Usamos o pacote 0x porque as ferramentas do Node.js. eram fáceis de integrar em nosso serviço e porque a visualização final dos dados HTML suportava a pesquisa e nos dava um bom nível de detalhe.
    • Adicionamos um modo de criação de perfil ao sistema quando o trabalhador iniciou com o pacote 0x ativado e, ao sair, anotou os dados finais no S3. Em seguida, poderíamos fazer o download dos logs necessários no S3 e visualizá-los localmente usando um comando no formato 0x --visualize-only ./flamegraph .
    • Em certo período de tempo, começamos a criar perfis para apenas um trabalhador. A criação de perfil aumenta o consumo de recursos e reduz a produtividade; portanto, gostaríamos de limitar esses efeitos negativos a um único trabalhador.

▍ Iniciar implantação


Depois de concluir a preparação preliminar, criamos um novo cluster ECS para "trabalhadores paralelos". Esses foram os trabalhadores que usaram sinalizadores do LaunchDarkly para definir dinamicamente seu nível máximo de paralelismo.

Nosso plano de implantação do sistema incluiu um redirecionamento em fases do volume crescente de tráfego do cluster antigo para o novo. Durante isso, monitoraríamos atentamente o desempenho do novo cluster. Em cada nível de carga, planejamos aumentar o nível de paralelismo de cada trabalhador, elevando-o ao valor máximo em que não houve aumento na duração das tarefas ou deterioração de outros indicadores. Se estivéssemos com problemas, poderíamos, em alguns segundos, redirecionar dinamicamente o tráfego para o cluster antigo.

Como esperado, tivemos alguns problemas complicados. Precisávamos investigá-los e eliminá-los para garantir a operação correta do sistema atualizado. Foi aqui que a diversão começou.

Expandir, Explorar, Repetir


▍Aumentando o tamanho máximo da pilha do Node.js


Quando começamos a implantar o novo sistema, começamos a receber notificações da conclusão de tarefas com um código de saída diferente de zero. Bem, o que posso dizer - um começo promissor. Então nós enterramos em Kibana e encontramos o log necessário:

 FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - Javascript heap out of memory 1: node::Abort() 2: node::FatalException(v8::Isolate*, v8::Local, v8::Local) 3: v8::internal::V8::FatalProcessOutOfMemory(char const*, bool) 4: v8::internal::Factory::NewFixedArray(int, v8::internal::PretenureFlag) 

Era uma reminiscência dos efeitos de vazamentos de memória que já havíamos encontrado quando o processo foi encerrado inesperadamente, fornecendo uma mensagem de erro semelhante. Isso parecia bastante esperado: um aumento no nível de paralelismo leva a um aumento no nível de uso da memória.

Sugerimos que aumentar o tamanho máximo do heap do Node.js., definido como 1,7 GB por padrão, pode ajudar a resolver esse problema. Em seguida, começamos a executar o Node.js, configurando o tamanho máximo de heap para 6 GB (usando o sinalizador de linha de comando --max-old-space-size=6144 ). Esse foi o maior valor adequado para nossas instâncias do EC2. Para nossa satisfação, essa mudança nos permitiu lidar com o erro acima que ocorre na produção.

▍ Identificação de gargalo de memória


Depois que resolvemos o problema com a alocação de memória, começamos a encontrar uma baixa taxa de transferência de tarefas em trabalhadores paralelos. Ao mesmo tempo, um dos gráficos no painel de controle imediatamente chamou nossa atenção. Este foi um relatório sobre como os processos de trabalho paralelo usam muito.


Uso de heap

Algumas das curvas deste gráfico subiram continuamente - até que se transformaram, no nível do tamanho máximo da pilha, em linhas quase horizontais. Nós realmente não gostamos.

Usamos métricas de sistema no Prometheus para eliminar vazamentos de um descritor de arquivo ou soquete de rede das causas desse comportamento do sistema. Nossa suposição mais apropriada foi que a coleta de lixo não era realizada para objetos antigos com frequência suficiente. Isso poderia levar ao fato de que, à medida que as tarefas são processadas, o trabalhador acumularia cada vez mais memória alocada para objetos já desnecessários. Assumimos que a operação do sistema, durante a qual seu rendimento é degradado, se parece com o seguinte:

  • O trabalhador recebe uma nova tarefa e executa determinadas ações.
  • No processo de execução da tarefa, a memória é alocada na pilha de objetos.
  • Devido ao fato de uma determinada operação com a qual eles trabalham com o princípio de “feito e esquecido” (então ainda não estava claro qual) está incompleta, as referências aos objetos são salvas mesmo após a conclusão da tarefa.
  • A coleta de lixo é mais lenta devido ao fato de o V8 precisar varrer um número crescente de objetos na pilha.
  • Como a V8 implementa um sistema de coleta de lixo que funciona de acordo com o esquema de parar o mundo (interrompendo o programa pela duração da coleta de lixo), novas tarefas inevitavelmente receberão menos tempo do processador, o que reduz a taxa de transferência do trabalhador.

Começamos a procurar em nosso código operações que são executadas com base no princípio "pronto e esquecido". Eles também são chamados de "promessas flutuantes" ("promessa flutuante"). Era simples - bastava encontrar as linhas nas quais a regra do linter sem promessas flutuantes estava desativada. Um método atraiu nossa atenção. Ele fez uma ligação para compressAndUploadDebuggingPayload sem esperar pelos resultados. Parecia que essa chamada poderia continuar facilmente por um longo tempo, mesmo após o processamento da tarefa.

 const postTaskDebugging = async (data: TypedData) => {    const payload = await generateDebuggingPayload(data);       //       ,    //        .    // tslint:disable-next-line:no-floating-promises    compressAndUploadDebuggingPayload(payload)        .catch((err) => logger.error('failed to upload data', err)); } 

Queríamos testar a hipótese de que essas promessas flutuantes eram a principal fonte de problemas. Se você não cumprir esses desafios, que não afetaram a operação correta do sistema, podemos melhorar a velocidade das tarefas? Veja como eram as informações de uso de heap depois que nos livramos temporariamente das chamadas postTaskDebugging .


Usando heap após desativar o postTaskDebugging

Acabou! Agora, o nível de utilização da pilha em trabalhadores paralelos permanece estável por um longo período de tempo.

Havia uma sensação de que, no sistema, à medida que as tarefas eram concluídas, as "dívidas" das chamadas compressAndUploadDebuggingPayload foram acumuladas gradualmente. Se o trabalhador recebeu tarefas mais rapidamente do que conseguiu "pagar" essas "dívidas", os objetos sob os quais a memória foi alocada não foram submetidos a operações de coleta de lixo. Isso levou ao preenchimento da pilha até o topo, que consideramos acima, analisando o gráfico anterior.

Começamos a imaginar o que tornava essas promessas flutuantes tão lentas. Não queríamos remover completamente o compressAndUploadDebuggingPayload do código, pois essa chamada era extremamente importante para que nossos engenheiros pudessem depurar tarefas de produção em suas máquinas locais. Do ponto de vista técnico, poderíamos resolver o problema aguardando os resultados dessa chamada e depois concluindo a tarefa, eliminando a promessa flutuante. Mas isso aumentaria bastante o tempo de execução de cada tarefa que estamos processando.

Tendo decidido que usaríamos essa solução para o problema apenas como último recurso, começamos a pensar em otimizar o código. Como acelerar esta operação?

▍Fix gargalo S3


A lógica do compressAndUploadDebuggingPayload fácil de descobrir. Aqui, compactamos os dados de depuração, e eles podem ser muito grandes, pois incluem tráfego de rede. Em seguida, carregamos os dados compactados no S3.

 export const compressAndUploadDebuggingPayload = async (    logger: Logger,    data: any, ) => {    const compressionStart = Date.now();    const base64CompressedData = await streamToString(        bfj.streamify(data)            .pipe(zlib.createDeflate())            .pipe(new b64.Encoder()),    );    logger.trace('finished compressing data', {        compression_time_ms: Date.now() - compressionStart,    );           const uploadStart = Date.now();    s3Client.upload({        Body: base64CompressedData,        Bucket: bucket,        Key: key,    });    logger.trace('finished uploading data', {        upload_time_ms: Date.now() - uploadStart,    ); } 

Nos logs do Kibana, ficou claro que o download de dados para o S3, mesmo que seu volume seja pequeno, leva muito tempo. Inicialmente, não pensávamos que os soquetes pudessem se tornar um gargalo no sistema, pois o agente HTTPS padrão do Node.js. define o parâmetro maxSockets como Infinity . No entanto, no final, lemos a documentação da AWS no Node.js e descobrimos algo surpreendente para nós: o cliente S3 reduz o valor do parâmetro maxSockets para 50 . Escusado será dizer que esse comportamento não pode ser chamado de intuitivo.

Como levamos o trabalhador a um estado em que, no modo competitivo, mais de 50 tarefas foram executadas, a etapa de download se tornou um gargalo: previa a espera da liberação do soquete para carregar dados no S3. Melhoramos o tempo de carregamento de dados, fazendo a seguinte alteração no código de inicialização do cliente S3:

 const s3Client = new AWS.S3({    httpOptions: {        agent: new https.Agent({            //                 //          S3.            maxSockets: 1024 * 20,        }),    },    region, }); 

▍ Acelerando a serialização JSON


As melhorias no código S3 retardaram o crescimento do tamanho da pilha, mas não levaram a uma solução completa para o problema. Havia outro incômodo óbvio: de acordo com nossas métricas, a etapa de compactação de dados no código acima durou 4 minutos. Foi muito mais longo que o tempo normal de conclusão da tarefa, que é de 4 segundos. Sem acreditar em nossos olhos, sem entender como isso pode levar quatro minutos, decidimos usar benchmarks locais e otimizar o bloco de código lento.

A compactação de dados consiste em três estágios (aqui, para limitar o uso da memória, os fluxos Node.js. são usados). Nomeadamente, no primeiro estágio, os dados JSON da string são gerados; no segundo, os dados são compactados usando zlib; no terceiro, são convertidos para a codificação base64. Suspeitamos que a origem dos problemas possa ser a biblioteca de terceiros que usamos para gerar strings JSON - bfj . Escrevemos um script que examina o desempenho de diferentes bibliotecas para gerar dados de string JSON usando fluxos (o código correspondente pode ser encontrado aqui ). Aconteceu que o pacote JSON Big Friendly que estávamos usando não era nada amigável. Veja os resultados de algumas medidas obtidas durante o experimento:

 benchBFJ*100:    67652.616ms benchJSONStream*100: 14094.825ms 

Resultados surpreendentes. Mesmo em um teste simples, o pacote bfj mostrou-se 5 vezes mais lento que o outro pacote, JSONStream. Descobrindo isso, alteramos rapidamente o bfj para JSONStream e imediatamente vimos um aumento significativo no desempenho.

▍ Redução do tempo necessário para coleta de lixo


Depois de resolvermos os problemas de memória, começamos a prestar atenção à diferença de tempo necessária para processar tarefas do mesmo tipo entre trabalhadores regulares e paralelos. Essa comparação foi completamente legítima, de acordo com seus resultados, poderíamos julgar a eficácia do novo sistema. Portanto, se a proporção entre trabalhadores regulares e paralelos for de aproximadamente 1, isso nos dará confiança de que podemos redirecionar com segurança o tráfego para esses trabalhadores. Porém, durante o primeiro lançamento do sistema, o gráfico correspondente no painel de controle da Grafana se parecia com o mostrado abaixo.


A proporção do tempo de execução das tarefas pelos trabalhadores convencionais e paralelos

Observe que algumas vezes o indicador está na região de 8: 1, e isso apesar do nível médio de paralelização de tarefas ser relativamente baixo e na região de 30. Estávamos cientes de que as tarefas que estamos resolvendo em relação à interação com os bancos não criam carga pesada nos processadores. Também sabíamos que nossos contêineres “paralelos” não eram limitados de forma alguma. Sem saber onde procurar a causa do problema, fomos ler os materiais sobre a otimização dos projetos do Node.js. Apesar do pequeno número desses artigos, encontramos este material, que trata da conquista de 600 mil conexões competitivas de soquete da Web no Node.js.

Em particular, --nouse-idle-notification nossa atenção para o uso da --nouse-idle-notification . Nossos processos do Node.js podem gastar tanto tempo coletando lixo? Aqui, a propósito, o pacote gc-stats nos deu a oportunidade de analisar o tempo médio gasto na coleta de lixo.


Análise do tempo gasto na coleta de lixo

Havia uma sensação de que nossos processos passavam cerca de 30% do tempo coletando lixo usando o algoritmo Scavenge. Aqui não vamos descrever os detalhes técnicos sobre os vários tipos de coleta de lixo no Node.js. Se você está interessado neste tópico - dê uma olhada neste material. A essência do algoritmo Scavenge é que a coleta de lixo geralmente é iniciada para limpar a memória ocupada por pequenos objetos no heap do Node.j chamado "novo espaço".

Portanto, nos processos do Node.js., a coleta de lixo é iniciada com muita frequência. Posso desativar a coleta de lixo da V8 e executá-la eu mesmo? Existe uma maneira de reduzir a frequência de uma chamada de coleta de lixo? Descobriu-se que o primeiro dos itens acima não pode ser feito, mas o último - é possível! Podemos simplesmente aumentar o tamanho da área "novo espaço" aumentando o limite da área "semi-espaço" no Node.js usando o sinalizador de linha de comando --max-semi-space-size=1024 . Isso permite que você execute mais operações de alocação de memória para objetos de vida curta até que o V8 inicie a coleta de lixo. Como resultado, a frequência do lançamento de tais operações é reduzida.


Resultados de otimização da coleta de lixo

Mais uma vitória! O aumento na área “novo espaço” levou a uma redução significativa na quantidade de tempo gasto na coleta de lixo usando o algoritmo Scavenge - de 30% para 2%.

PtOtimize a utilização do processador


Depois de todo esse trabalho, o resultado nos convinha. As tarefas executadas em trabalhadores paralelos, com uma paralelização de trabalho em 20 vezes, funcionavam quase tão rapidamente quanto as realizadas separadamente em trabalhadores separados. Pareceu-nos que havíamos superado todos os gargalos, mas ainda não sabíamos exatamente quais operações retardavam o sistema na produção. Como não havia mais locais no sistema que obviamente precisavam de otimização, decidimos estudar como os trabalhadores usam os recursos do processador.

Com base nos dados coletados em um de nossos funcionários paralelos, um cronograma de fogo foi criado. Tínhamos uma visualização organizada à nossa disposição, com a qual poderíamos trabalhar na máquina local. Sim, eis um detalhe interessante: o tamanho desses dados era de 60 MB. Foi o que vimos pesquisando a palavra logger no gráfico 0x de fogo.


Análise de dados com ferramentas 0x

As áreas verde-azuladas destacadas nas colunas indicam que pelo menos 15% do tempo do processador foi gasto na geração do log do trabalhador. Como resultado, conseguimos reduzir esse tempo em 75%. É verdade que a história de como fizemos isso leva a um artigo separado. (Dica: usamos expressões regulares e fizemos muito trabalho com propriedades).

Após essa otimização, conseguimos processar simultaneamente até 30 tarefas em um trabalhador sem prejudicar o desempenho do sistema.

Sumário


A mudança para trabalhadores paralelos reduziu os custos anuais do EC2 em cerca de 300 mil dólares e simplificou bastante a arquitetura do sistema. Agora usamos na produção cerca de 30 vezes menos contêineres do que antes. Nosso sistema é mais resistente a atrasos no processamento de solicitações de saída e a solicitações de API de pico provenientes de usuários.

Paralelamente nosso serviço de integração com os bancos, aprendemos muitas coisas novas:

  • Nunca subestime a importância de ter métricas de sistema de baixo nível. A capacidade de monitorar dados relacionados à coleta de lixo e uso de memória nos proporcionou uma tremenda assistência na implantação e na finalização do sistema.
  • Gráficos flamejantes são uma ótima ferramenta. Agora que aprendemos como usá-los, podemos identificar facilmente novos gargalos no sistema com a ajuda deles.
  • Compreender os mecanismos de tempo de execução do Node.js. nos permitiu escrever um código melhor. Por exemplo, sabendo como a V8 aloca memória para objetos e como a coleta de lixo funciona, vimos o ponto de usar a técnica de reutilização de objetos o mais amplamente possível. Às vezes, para entender melhor tudo isso, você precisa trabalhar diretamente com a V8 ou experimentar os sinalizadores da linha de comando do Node.js.
  • É muito importante ler atentamente a documentação de todos os mecanismos que compõem o sistema. Confiamos nos dados maxSocketencontrados na documentação do Node.js., mas após muita pesquisa, constatamos que na AWS, o comportamento padrão do Node.js. está mudando. Talvez, em cada projeto baseado na infraestrutura de outra pessoa, algo semelhante possa acontecer.

Caros leitores! Como você otimiza seus projetos no Node.js.

Source: https://habr.com/ru/post/pt483688/


All Articles