[Перевод] Node.js: управление памятью, доступной приложениям, выполняемым в контейнерах

При запуске Node.js-приложений в контейнерах Docker традиционные настройки памяти не всегда работают так, как ожидается. Материал, перевод которого мы сегодня публикуем, посвящён поиску ответа на вопрос о том, почему это так. Здесь же будут приведены практические рекомендации по управлению памятью, доступной Node.js-приложениям, работающим в контейнерах.

9_rp-s7libv7ncp6mubs2h_qjzu.png

Обзор рекомендаций


Предположим, Node.js-приложение выполняется в контейнере с установленным лимитом памяти. Если речь идёт о Docker, то для установки этого лимита могла быть использована опция --memory. Нечто подобное возможно и при работе с системами оркестрации контейнеров. В таком случае рекомендуется, при запуске Node.js-приложения, использовать опцию --max-old-space-size. Это позволяет сообщить платформе о том, какой объём памяти ей доступен, а так же учесть то, что этот объём должен быть меньше лимита, заданного на уровне контейнера.

Когда Node.js-приложение выполняется внутри контейнера, задавайте ёмкость доступной ему памяти в соответствии с пиковым значением использования активной памяти приложением. Это делается в том случае, если ограничения памяти контейнера можно настраивать.

Теперь поговорим о проблеме использования памяти в контейнерах подробнее.

Лимит памяти Docker


По умолчанию контейнеры не имеют ограничений по ресурсам и могут использовать столько памяти, сколько позволяет им операционная система. У команды docker run имеются опции командной строки, позволяющие задавать лимиты, касающиеся использования памяти или ресурсов процессора.

Команда запуска контейнера может выглядеть так:

docker run --memory  --interactive --tty  bash


Обратите внимание на следующее:

  • x — это лимит объёма памяти, доступной контейнеру, выраженный в единицах измерения y.
  • y может принимать значение b (байты), k (килобайты), m (мегабайты), g (гигабайты).


Вот пример команды запуска контейнера:

docker run --memory 1000000b --interactive --tty  bash


Здесь лимит памяти установлен в 1000000 байт.

Для проверки лимита памяти, установленного на уровне контейнера, можно, в контейнере, выполнить следующую команду:

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


Поговорим о поведении системы при указании с помощью ключа --max-old-space-size лимита памяти Node.js-приложения. При этом данный лимит памяти будет соответствовать лимиту, установленному на уровне контейнера.

То, что в имени ключа называется «old-space», представляет собой один из фрагментов кучи, управляемой V8 (то место, где размещаются «старые» JavaScript-объекты). Этот ключ, если не вдаваться в детали, которых мы коснёмся ниже, контролирует максимальный размер кучи. Подробности о ключах командной строки Node.js можно почитать здесь.

В общем случае, когда приложение пытается использовать больше памяти, чем доступно в контейнере, его работа завершается.

В следующем примере (файл приложения называется test-fatal-error.js) в массив list, с интервалом в 10 миллисекунд, помещают объекты MyRecord. Это приводит к бесконтрольному росту кучи, имитируя утечку памяти.

'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);


Обратите внимание на то, что все примеры программ, которые мы будем тут рассматривать, помещены в образ Docker, который можно загрузить с Docker Hub:

docker pull ravali1906/dockermemory


Вы можете воспользоваться этим образом для самостоятельных экспериментов.

Кроме того, можно упаковать приложение в контейнер Docker, собрать образ и запустить его с указанием лимита памяти:

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


Здесь ravali1906/dockermemory — это имя образа.

Теперь можно запустить приложение, указав лимит памяти для него, превышающий лимит контейнера:

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


Здесь ключ --max_old_space_size представляет собой лимит памяти, указываемый в мегабайтах. Метод process.memoryUsage() даёт сведения об использовании памяти. Значения показателей выражены в байтах.

Работа приложения в некий момент времени принудительно завершается. Происходит это тогда, когда объём использованной им памяти переходит некую границу. Что это за граница? О каких ограничениях на объём памяти можно говорить?

Ожидаемое поведение приложения, запущенного с ключом --max-old-space-size


По умолчанию максимальный размер кучи в Node.js (вплоть до версии 11.x) составляет 700 Мб на 32-битных платформах, и 1400 Мб на 64-битных. О настройке этих значений можно почитать здесь.

В теории, если установить с помощью ключа --max-old-space-size лимит памяти, превышающий лимит памяти контейнера, можно ожидать, что приложение будет завершено защитным механизмом ядра Linux OOM Killer.

В реальности этого может и не случиться.

Реальное поведение приложения, запущенного с ключом --max-old-space-size


Приложению, сразу после запуска, не выделяется вся память, лимит которой указан с помощью --max-old-space-size. Размер JavaScript-кучи зависит от нужд приложения. О том, какой размер памяти использует приложение, можно судить на основании значения поля heapUsed из объекта, возвращаемого методом process.memoryUsage(). Фактически, речь идёт о памяти, выделенной в куче под объекты.

В результате мы приходим к выводу о том, что приложение будет принудительно завершено в том случае, если размер кучи окажется больше лимита, заданного ключом --memory при запуске контейнера.

Но в реальности этого тоже может не случиться.

При профилировании ресурсоёмких Node.js-приложений, которые запущены в контейнерах с заданным лимитом памяти, можно наблюдать следующие паттерны:

  1. OOM Killer срабатывает гораздо позже того момента, когда значения heapTotal и heapUsed оказываются значительно превышающими ограничения на объём памяти.
  2. OOM Killer никак не реагирует на превышение ограничений.


Объяснение особенностей поведения Node.js-приложений в контейнерах


Контейнер наблюдает за одним важным показателем приложений, которые в нём выполняются. Это — RSS (resident set size). Этот показатель представляет некую часть виртуальной памяти приложения.

Более того, он представляет собой фрагмент памяти, которая выделена приложению.

Но и это ещё не всё. RSS — это часть активной памяти, выделенной приложению.

Активной может быть не вся память, выделенная приложению. Дело в том, что «выделенная память» не обязательно физически выделяется до тех пор, пока процесс не начнёт ей по-настоящему пользоваться. Кроме того, в ответ на запросы на выделение памяти от других процессов, операционная система может сбросить в файл подкачки неактивные фрагменты памяти приложения и передать освободившееся пространство другим процессам. А когда приложению снова понадобятся эти фрагменты памяти, они будут взяты из файла подкачки и возвращены в физическую память.

Показатель RSS указывает на объём активной и доступной приложению памяти в его адресном пространстве. Именно он влияет на принятие решения о принудительном завершении работы приложения.

Доказательства


▍Пример №1. Приложение, которое выделяет память под буфер


В следующем примере, buffer_example.js, показана программа, которая выделяет память под буфер:

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)))


Для того чтобы объём памяти, выделяемой программой, превысил бы лимит, заданный при запуске контейнера, сначала запустим контейнер следующей командой:

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


После этого запустим программу:

$ node buffer_example 2000
2000
16


Как видно, система не завершила выполнение программы, хотя при этом выделенная программой память и превышает лимит контейнера. Произошло это из-за того, что программа не работает со всей выделенной памятью. Показатель RSS очень мал, он не превышает лимит памяти контейнера.

▍Пример №2. Приложение, заполняющее буфер данными


В следующем примере, 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)))


Запустим контейнер:

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


После этого запустим приложение:

$ node buffer_example_fill.js 2000
2000
984


Как видно, даже теперь приложение не завершается! Почему? Дело в том, что когда объём активной памяти достигает лимита, заданного при запуске контейнера, и при этом в файле подкачки есть место, некоторые из старых страниц памяти процесса перемещаются в файл подкачки. Освобождённая память оказывается доступной тому же самому процессу. По умолчанию Docker выделяет под файл подкачки пространство, равное лимиту памяти, заданному с помощью флага --memory. Учитывая это можно сказать, что у процесса есть 2 Гб памяти — 1 Гб в активной памяти, и 1 Гб — в файле подкачки. То есть, благодаря тому, что приложение может пользоваться своей же памятью, содержимое которой временно перемещается в файл подкачки, размер показателя RSS находится в пределах лимита контейнера. В результате приложение продолжает работать.

▍Пример №3. Приложение, заполняющее буфер данными, выполняющееся в контейнере, в котором файл подкачки не используется


Вот код, с которым мы будем здесь экспериментировать (это — тот же файл 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)))


На этот раз запустим контейнер, явным образом настроив особенности работы с файлом подкачки:

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


Запустим приложение:

$ node buffer_example_fill.js 2000
Killed


Видите сообщение Killed? Когда значение ключа --memory-swap равно значению ключа --memory, это указывает контейнеру на то, что ему не следует использовать файл подкачки. Кроме того, по умолчанию ядро операционной системы, в которой работает сам контейнер, может сбрасывать в файл подкачки некий объём анонимных страниц памяти, используемых контейнером. Мы, устанавливая в 0 флаг --memory-swappiness, отключаем эту возможность. В результате оказывается, что внутри контейнера файл подкачки не используется. Процесс завершается тогда, когда показатель RSS превышает лимит памяти контейнера.

Общие рекомендации


Когда Node.js-приложения запускают с ключом --max-old-space-size, значение которого превышает лимит памяти, заданный при запуске контейнера, может показаться, что Node.js «не обращает внимания» на лимит контейнера. Но, как видно из предыдущих примеров, явной причиной подобного поведения является тот факт, что приложение просто не использует весь объём кучи, заданный с помощью флага --max-old-space-size.

Помните о том, что приложение не всегда будет вести себя одинаково в том случае, если оно использует больше памяти, чем доступно в контейнере. Почему? Дело в том, что на активную память процесса (RSS) влияет множество внешних факторов, на которые не может воздействовать само приложение. Они зависят от нагруженности системы и от особенностей окружения. Например — это особенности самого приложения, уровень параллелизма в системе, особенности работы планировщика операционной системы, особенности работы сборщика мусора, и так далее. Кроме того, эти факторы, от запуска к запуску приложения, могут меняться.

Рекомендации о настройке размера Node.js-кучи для тех случаев, когда можно управлять этим параметром, но не ограничениями памяти уровня контейнера


  • Запустите минимальное Node.js-приложение в контейнере и измерьте статический размер RSS (в моём случае, для Node.js 10.x, это примерно 20 Мб).
  • Куча Node.js содержит не только область old_space, но и другие (такие, как new_space, code_space, и так далее). Поэтому, если учитывать стандартную конфигурацию платформы, стоит рассчитывать на то, что программе понадобится ещё около 20 Мб памяти. Если стандартные настройки менялись — эти изменения также нужно учитывать.
  • Теперь нужно вычесть полученное значение (предположим, это будет 40 Мб) из объёма памяти, доступной в контейнере. То, что осталось, представляет собой значение, которое, не опасаясь завершения программы от нехватки памяти, можно указать в качестве значения ключа --max-old-space-size.


Рекомендации по настройке лимитов памяти контейнеров для тех случаев, когда этот параметр контролировать можно, а параметры Node.js-приложения — нет


  • Запускайте приложение в режимах, позволяющих выяснить пиковые значения потребляемой им памяти.
  • Проанализируйте показатель RSS. В частности, тут может, наряду с методом process.memoryUsage(), пригодиться и команда Linux top.
  • При условии, что в контейнере, в котором планируется запускать приложение, ничего кроме него выполняться не будет, полученное значение можно использовать в качестве лимита памяти контейнера. Для того чтобы подстраховаться, рекомендуется увеличить его хотя бы на 10%.


Итоги


В Node.js 12.x некоторые из рассмотренных здесь проблем решаются путём адаптивной настройки размера кучи, выполняемой в соответствии с объёмом доступной оперативной памяти. Этот механизм работает и при запуске Node.js-приложений в контейнерах. Но настройки могут и отличаться от настроек, используемых по умолчанию. Это, например, происходит в тех случаях, когда при запуске приложения использовался ключ --max_old_space_size. Для таких случаев всё вышесказанное остаётся актуальным. Это говорит о том, что тот, кто запускает Node.js-приложения в контейнерах, должен внимательно и ответственно относиться к настройкам памяти. Кроме того, знание стандартных ограничений на использование памяти, довольно консервативных, позволяет улучшить работу приложений благодаря обдуманному изменению этих ограничений.

Уважаемые читатели! Сталкивались ли вы с проблемами нехватки памяти при запуске Node.js-приложений в контейнерах Docker?

1ba550d25e8846ce8805de564da6aa63.png

© Habrahabr.ru