Node.js: gerenciando a memória disponível para aplicativos em execução em contêineres

Ao executar aplicativos Node.js em contêineres do Docker, as configurações tradicionais de memória nem sempre funcionam conforme o esperado. O material, cuja tradução publicamos hoje, é dedicado a encontrar a resposta para a pergunta de por que isso acontece. Ele também fornecerá recomendações práticas para gerenciar a memória disponível para aplicativos Node.js. executados em contêineres.



Revisão de recomendações


Suponha que um aplicativo Node.js seja executado em um contêiner com um limite de memória definido. Se estamos falando sobre o Docker, a opção --memory pode ser usada para definir esse limite. Algo semelhante é possível ao trabalhar com sistemas de orquestração de contêineres. Nesse caso, é recomendável que, ao iniciar o aplicativo --max-old-space-size , use a opção --max-old-space-size . Isso permite que você informe a plataforma sobre quanta memória está disponível e também leve em consideração o fato de que essa quantidade deve ser menor que o limite definido no nível do contêiner.

Quando o aplicativo Node.js for executado dentro do contêiner, configure a capacidade da memória disponível para ele, de acordo com o valor máximo do uso de memória ativa pelo aplicativo. Isso é feito se os limites de memória do contêiner puderem ser configurados.

Agora vamos falar sobre o problema do uso de memória em contêineres em mais detalhes.

Limite de memória do Docker


Por padrão, os contêineres não têm limites de recursos e podem usar a quantidade de memória que o sistema operacional permitir. O comando docker run possui opções de linha de comando que permitem definir limites em relação ao uso de memória ou recursos do processador.

O comando de inicialização do contêiner pode ficar assim:

 docker run --memory <x><y> --interactive --tty <imagename> bash 

Observe o seguinte:

  • x é o limite da quantidade de memória disponível para o contêiner, expressa em unidades de y .
  • y pode levar o valor b (bytes), k (kilobytes), m (megabytes), g (gigabytes).

Aqui está um exemplo de um comando de inicialização de contêiner:

 docker run --memory 1000000b --interactive --tty <imagename> bash 

Aqui, o limite de memória é definido para 1000000 bytes.

Para verificar o limite de memória definido no nível do contêiner, você pode, no contêiner, executar o seguinte comando:

 cat /sys/fs/cgroup/memory/memory.limit_in_bytes 

Vamos falar sobre o comportamento do sistema ao especificar o limite de memória do aplicativo --max-old-space-size usando a chave --max-old-space-size . Nesse caso, esse limite de memória corresponderá ao limite definido no nível do contêiner.

O que é chamado de "espaço antigo" no nome da chave é um dos fragmentos do heap controlado pelo V8 (o local em que os objetos JavaScript "antigos" são colocados). Essa tecla, se você não entrar nos detalhes que tocamos abaixo, controla o tamanho máximo da pilha. Detalhes sobre opções de linha de comando do Node.js. podem ser encontrados aqui .

Em geral, quando um aplicativo tenta usar mais memória do que o disponível no contêiner, sua operação é encerrada.

No exemplo a seguir (o arquivo do aplicativo é chamado test-fatal-error.js ), os objetos MyRecord são colocados na matriz da list , com um intervalo de 10 milissegundos. Isso leva ao crescimento descontrolado de heap, simulando um vazamento de memória.

 'use strict'; const list = []; setInterval(()=> { const record = new MyRecord(); list.push(record); },10); function MyRecord() { var x='hii'; this.name = x.repeat(10000000); this.id = x.repeat(10000000); this.account = x.repeat(10000000); } setInterval(()=> { console.log(process.memoryUsage()) },100); 

Observe que todos os exemplos de programas que discutiremos aqui são colocados na imagem do Docker, que pode ser baixada no Docker Hub:

 docker pull ravali1906/dockermemory 

Você pode usar esta imagem para experimentos independentes.

Além disso, você pode empacotar o aplicativo em um contêiner do Docker, coletar a imagem e executá-la com o limite de memória:

 docker run --memory 512m --interactive --tty ravali1906/dockermemory bash 

Aqui ravali1906/dockermemory é o nome da imagem.

Agora você pode iniciar o aplicativo especificando um limite de memória que exceda o limite do contêiner:

 $ node --max_old_space_size=1024 test-fatal-error.js { rss: 550498304, heapTotal: 1090719744, heapUsed: 1030627104, external: 8272 } Killed 

Aqui, a --max_old_space_size representa o limite de memória indicado em megabytes. O método process.memoryUsage() fornece informações sobre o uso da memória. Os valores são expressos em bytes.

O aplicativo em algum momento é forçado a terminar. Isso acontece quando a quantidade de memória usada por ele atravessa uma determinada borda. O que é essa fronteira? De que limitações podemos falar sobre a quantidade de memória?

O comportamento esperado de um aplicativo em execução com a chave é - max-old-space-size


Por padrão, o tamanho máximo de heap no Node.js (até a versão 11.x) é de 700 MB em plataformas de 32 bits e 1400 MB em plataformas de 64 bits. Você pode ler sobre como definir esses valores aqui .

Em teoria, se você usar a chave --max-old-space-size para --max-old-space-size limite de memória que exceda o limite de memória do contêiner, poderá esperar que o aplicativo seja finalizado pelo mecanismo de segurança do kernel do Linux OOM Killer.

Na realidade, isso pode não acontecer.

O comportamento real do aplicativo em execução com a chave é max-old-space-size


O aplicativo, imediatamente após o lançamento, não aloca toda a memória cujo limite é especificado usando --max-old-space-size . O tamanho da pilha do JavaScript depende das necessidades do aplicativo. Você pode avaliar quanta memória o aplicativo usa com base no valor do campo heapUsed do objeto retornado pelo método process.memoryUsage() . De fato, estamos falando sobre a memória alocada no heap para objetos.

Como resultado, concluímos que o aplicativo será encerrado à força se o tamanho do heap for maior que o limite definido pela chave --memory quando o contêiner for iniciado.

Mas, na realidade, isso também não pode acontecer.

Ao criar um perfil de aplicativos Node.js intensivos em recursos que são executados em contêineres com um determinado limite de memória, os seguintes padrões podem ser observados:

  1. OOM Killer é acionado muito depois do momento em que os heapUsed e heapUsed são significativamente maiores que os limites de memória.
  2. OOM Killer não responde a limites excedentes.

Uma explicação do comportamento dos aplicativos Node.js. nos contêineres


Um contêiner supervisiona um indicador importante dos aplicativos executados nele. Este é o RSS (tamanho do conjunto residente). Este indicador representa uma certa parte da memória virtual do aplicativo.

Além disso, é um pedaço de memória que é alocado para o aplicativo.

Mas isso não é tudo. O RSS faz parte da memória ativa alocada para o aplicativo.

Nem toda a memória alocada para um aplicativo pode estar ativa. O fato é que a “memória alocada” não é necessariamente alocada fisicamente até que o processo comece a realmente usá-la. Além disso, em resposta a solicitações de alocação de memória de outros processos, o sistema operacional pode despejar partes inativas da memória do aplicativo no arquivo de paginação e transferir o espaço livre para outros processos. E quando o aplicativo precisar novamente desses pedaços de memória, eles serão retirados do arquivo de troca e retornados à memória física.

A métrica RSS indica a quantidade de memória ativa e disponível para o aplicativo em seu espaço de endereço. É ele quem influencia a decisão sobre o encerramento forçado do aplicativo.

Evidência


▍ Exemplo nº 1. Um aplicativo que aloca memória para um buffer


O exemplo a seguir, buffer_example.js , mostra um programa que aloca memória para um buffer:

 const buf = Buffer.alloc(+process.argv[2] * 1024 * 1024) console.log(Math.round(buf.length / (1024 * 1024))) console.log(Math.round(process.memoryUsage().rss / (1024 * 1024))) 

Para que a quantidade de memória alocada pelo programa exceda o limite definido quando o contêiner foi iniciado, primeiro execute o contêiner com o seguinte comando:

 docker run --memory 1024m --interactive --tty ravali1906/dockermemory bash 

Depois disso, execute o programa:

 $ node buffer_example 2000 2000 16 

Como você pode ver, o sistema não concluiu o programa, embora a memória alocada pelo programa exceda o limite do contêiner. Isso aconteceu devido ao fato de o programa não funcionar com toda a memória alocada. O RSS é muito pequeno, não excede o limite de memória do contêiner.

▍ Exemplo No. 2. Aplicativo preenchendo o buffer com dados


No exemplo a seguir, buffer_example_fill.js , a memória não é apenas alocada, mas também preenchida com dados:

 const buf = Buffer.alloc(+process.argv[2] * 1024 * 1024,'x') console.log(Math.round(buf.length / (1024 * 1024))) console.log(Math.round(process.memoryUsage().rss / (1024 * 1024))) 

Execute o contêiner:

 docker run --memory 1024m --interactive --tty ravali1906/dockermemory bash 

Depois disso, execute o aplicativo:

 $ node buffer_example_fill.js 2000 2000 984 

Aparentemente, mesmo agora o aplicativo não termina! Porque O fato é que, quando a quantidade de memória ativa atinge o limite definido quando o contêiner foi iniciado e há espaço no arquivo de paginação, algumas das páginas antigas na memória do processo são movidas para o arquivo de paginação. A memória liberada é disponibilizada para o mesmo processo. Por padrão, o Docker aloca espaço para o arquivo de troca igual ao limite de memória definido usando o sinalizador --memory . Diante disso, podemos dizer que o processo possui 2 GB de memória - 1 GB na memória ativa e 1 GB no arquivo de paginação. Ou seja, devido ao fato de o aplicativo poder usar sua própria memória, cujo conteúdo é movido temporariamente para o arquivo de paginação, o tamanho do índice RSS está dentro do limite do contêiner. Como resultado, o aplicativo continua funcionando.

▍ Exemplo No. 3. Um aplicativo que preenche um buffer com dados em execução em um contêiner que não usa um arquivo de paginação


Aqui está o código com o qual experimentaremos aqui (este é o mesmo arquivo buffer_example_fill.js ):

 const buf = Buffer.alloc(+process.argv[2] * 1024 * 1024,'x') console.log(Math.round(buf.length / (1024 * 1024))) console.log(Math.round(process.memoryUsage().rss / (1024 * 1024))) 

Desta vez, execute o contêiner, configurando explicitamente os recursos de trabalho com o arquivo de permuta:

 docker run --memory 1024m --memory-swap=1024m --memory-swappiness=0 --interactive --tty ravali1906/dockermemory bash 

Inicie o aplicativo:

 $ node buffer_example_fill.js 2000 Killed 

Veja a mensagem Killed ? Quando o valor da chave --memory-swap é igual ao --memory chave --memory , isso informa ao contêiner que ele não deve usar o arquivo de --memory . Além disso, por padrão, o kernel do sistema operacional no qual o contêiner é executado pode despejar uma certa quantidade de páginas de memória anônima usadas pelo contêiner no arquivo de paginação. --memory-swappiness como 0 , desativamos esse recurso. Como resultado, o arquivo de paginação não é usado dentro do contêiner. O processo termina quando a métrica RSS excede o limite de memória do contêiner.

Recomendações gerais


Quando os aplicativos Node.js são iniciados com a chave --max-old-space-size , cujo valor excede o limite de memória definido quando o contêiner foi iniciado, pode parecer que o Node.js "não está prestando atenção" ao limite do contêiner. Mas, como pode ser visto nos exemplos anteriores, a razão óbvia para esse comportamento é o fato de o aplicativo simplesmente não usar todo o volume de heap especificado com o --max-old-space-size .

Lembre-se de que o aplicativo nem sempre se comportará da mesma maneira se usar mais memória do que a disponível no contêiner. Porque O fato é que a memória ativa do processo (RSS) é influenciada por muitos fatores externos que o próprio aplicativo não pode influenciar. Eles dependem da carga no sistema e nas características do ambiente. Por exemplo, esses são recursos do próprio aplicativo, o nível de paralelismo no sistema, recursos do planejador do sistema operacional, recursos do coletor de lixo e assim por diante. Além disso, esses fatores, de um lançamento para outro, podem mudar.

Recomendações sobre como definir o tamanho do heap do Node.js para os casos em que você pode controlar esta opção, mas não com restrições de memória no nível do contêiner


  • Execute o aplicativo Node.js mínimo no contêiner e meça o tamanho estático do RSS (no meu caso, para o Node.js.x, isso é cerca de 20 Mb).
  • O heap do Node.js contém não apenas o old_space, mas também outros (como new_space, code_space e assim por diante). Portanto, se você levar em consideração a configuração padrão da plataforma, deve confiar no fato de que o programa precisará de cerca de 20 MB a mais de memória. Se as configurações padrão foram alteradas, essas alterações também devem ser levadas em consideração.
  • Agora precisamos subtrair o valor obtido (suponha que seja 40 MB) da quantidade de memória disponível no contêiner. O que resta é um valor que, sem medo de que a --max-old-space-size do programa fique sem memória, possa ser especificado como o valor da chave - --max-old-space-size .

Recomendações para definir limites de memória do contêiner para casos em que esse parâmetro pode ser controlado, mas os parâmetros do aplicativo Node.js.


  • Execute o aplicativo nos modos que permitem descobrir os valores de pico da memória consumida por ele.
  • Analise a pontuação do RSS. Em particular, aqui, junto com o método process.memoryUsage() , o comando top do Linux pode ser útil.
  • Desde que no contêiner no qual está planejado executar o aplicativo, nada além de não ser executado, o valor obtido possa ser usado como limite de memória do contêiner. Para ser seguro, é recomendável aumentá-lo em pelo menos 10%.

Sumário


No Node.j 12.x, alguns dos problemas discutidos aqui são resolvidos ajustando adaptativamente o tamanho do heap, que é executado de acordo com a quantidade de RAM disponível. Esse mecanismo também funciona ao executar aplicativos Node.js. em contêineres. Mas as configurações podem diferir das configurações padrão. Isso, por exemplo, acontece quando a chave --max_old_space_size foi usada ao iniciar o aplicativo. Para esses casos, todos os itens acima permanecem relevantes. Isso sugere que qualquer pessoa que execute aplicativos Node.js. em contêineres seja cuidadosa e responsável com as configurações de memória. Além disso, o conhecimento dos limites padrão de uso de memória, que é bastante conservador, pode melhorar o desempenho do aplicativo alterando deliberadamente esses limites.

Caros leitores! Você ficou com problemas de memória ao executar aplicativos Node.js. nos contêineres do Docker?



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


All Articles