← назад к разделу

Node однопоточен, и кажется, что вопросов про ядра у него нет, а память он считает сам. Пока сервис не падает с FATAL ERROR: Reached heap limit при лимите в гигабайт, которого он не израсходовал, или наоборот не умирает с кодом 137 без единой строки в логе. Обе истории — про то, как V8 решает, сколько памяти ему можно, и про квоту процессора, которую один поток выбирает быстрее, чем кажется.

Обязательно

Память: куча V8 и --max-old-space-size

Память процесса Node делится на кучу V8, где живут объекты JavaScript, и всё остальное: буферы (Buffer, ArrayBuffer), память нативных модулей, скомпилированный код, стеки потоков libuv. Размер кучи V8 выбирает сам по объёму памяти, которую видит, и у этого выбора есть потолок в несколько гигабайт, который не зависит от вашего лимита. Отсюда два симптома.

Первый: лимит контейнера 512 МБ, а куча считает, что ей можно больше. Сервис растёт, пока ядро не убьёт его за превышение лимита cgroup — код 137, OOMKilled: true в docker inspect, никакой трассировки, потому что процессу не дали ничего написать. Второй: лимит контейнера 4 ГБ, а куча упёрлась в свой потолок на двух и упала с FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory, код 134 (SIGABRT), хотя память в контейнере оставалась.

Лечение одно — задать размер кучи явно под лимит контейнера:

ENV NODE_OPTIONS="--max-old-space-size=384"

при --memory=512m. Число в мегабайтах, и это только старое поколение кучи; оставшиеся 25–30 % лимита нужны буферам, нативной памяти, коду и молодому поколению. Сервису, который гоняет через себя файлы и большие ответы, доля вне кучи нужна больше: буферы Buffer живут снаружи V8. Переменная NODE_OPTIONS удобнее флага в CMD: её читает и основной процесс, и воркеры, и команда проверки здоровья, а оркестратор может переопределить её без пересборки образа.

Сборщик мусора V8 тоже стоит учитывать: когда куча близка к потолку, он работает всё чаще и съедает процессорное время, задержки растут ещё до падения. На графике это выглядит как рост heapUsed к heapTotal при неизменной нагрузке — обычный признак утечки: закрытия, держащие большие объекты, растущие Map без очистки, слушатели событий, которые никто не снимает.

OOMKilled против heap limit: два разных конца

Код 137 без трассировки — убийство ядром за лимит cgroup: смотреть лимит, --max-old-space-size и память вне кучи. Код 134 с FATAL ERROR — V8 упёрся в свой потолок кучи: либо потолок мал для честной нагрузки, либо утечка. Для второго случая есть флаг, который сохраняет снимок кучи прямо перед падением: --heapsnapshot-near-heap-limit=3 пишет до трёх снимков в рабочий каталог, когда куча подходит к пределу, и их открывают в Chrome DevTools. Писать снимки нужно в том, а не в файловую систему контейнера, иначе они умрут вместе с ним.

Для наблюдения на живом процессе: process.memoryUsage() отдаёт rss, heapTotal, heapUsed, external и arrayBuffers, и именно rss сравнивают с лимитом контейнера, а не heapUsed. Эти числа выводят в метрики, а v8.getHeapStatistics().heap_size_limit показывает, какой потолок V8 выбрал на самом деле — первое, что проверяют при подозрении на неверный расчёт.

Ядра: один поток, квота процессора и пул libuv

JavaScript в Node выполняется в одном потоке, поэтому один процесс не использует больше одного ядра на обработчиках запросов. Квота меньше ядра (--cpus=0.5) означает, что этот единственный поток половину каждых 100 мс стоит на паузе квоты: задержки прыгают на десятки миллисекунд, хотя процессор «свободен». Сервису на Node дают целое ядро и масштабируют репликами, а не долями.

Второе ядро процессу Node пригодится всё же: libuv держит пул из четырёх потоков для файловых операций, DNS и части криптографии (crypto.pbkdf2, scrypt), а сборщик мусора V8 работает на своих потоках параллельно. Размер пула задаёт переменная UV_THREADPOOL_SIZE: сервису, который хеширует пароли и читает файлы, её поднимают до числа доступных ядер, но не выше квоты контейнера — иначе потоки толкаются за ту же паузу.

Чтобы занять несколько ядер одним контейнером, есть модуль cluster и менеджеры вроде pm2: они поднимают несколько процессов Node за одним портом. В контейнерах этот путь редко оправдан: оркестратор и так умеет запускать реплики, у каждой свой лимит и своя проверка здоровья, а падение одной не трогает остальных. Если cluster всё же нужен, число воркеров берут из квоты контейнера, а не из os.cpus().length: это число ядер хоста. os.availableParallelism() в свежих версиях Node учитывает ограничения процесса, но проверить, что он показывает внутри контейнера с квотой, стоит до того, как по нему запускать воркеров.

Сигналы и PID 1: что происходит при docker stop

На SIGTERM без обработчика Node завершает процесс немедленно с кодом 143: текущие запросы обрываются, соединения с базой не закрываются. Мягкий останов включают обработчиком: в NestJS это app.enableShutdownHooks(), в чистом Node — process.on('SIGTERM', () => server.close(...)). И сигнал должен дойти: точка входа CMD ["node", "dist/main.js"], а не npm start и не строковая форма через оболочку, об этом в статье про Dockerfile для Node. Таймаут мягкого останова держат короче десяти секунд docker stop и terminationGracePeriodSeconds.

Полная конфигурация: минимальный рабочий пример

FROM node:22-slim
ENV NODE_ENV=production \
    NODE_OPTIONS="--max-old-space-size=384" \
    UV_THREADPOOL_SIZE=4
WORKDIR /app
COPY --chown=node:node node_modules node_modules
COPY --chown=node:node dist dist
USER node
CMD ["node", "dist/main.js"]
services:
  app:
    image: myapp:1.4.2
    deploy:
      resources:
        limits:
          cpus: "1"
          memory: 512M
    environment:
      NODE_OPTIONS: --max-old-space-size=384

Или то же для docker run: --cpus=1 --memory=512m -e NODE_OPTIONS=--max-old-space-size=384. При старте сервис пишет в лог версию Node, v8.getHeapStatistics().heap_size_limit и лимит из cgroup — три числа, с которых начинается разбор любого инцидента про память.

Дополнительно: при первом чтении можно пропустить

Глубже: cgroup v1 и v2: откуда читать лимитрасширенное

Лимиты контейнера лежат в файлах /sys/fs/cgroup: в cgroup v2 квота процессора в cpu.max в виде 100000 100000 (одно ядро), лимит памяти в memory.max; в v1 — cpu/cpu.cfs_quota_us, cpu/cpu.cfs_period_us и memory/memory.limit_in_bytes. Прочитать их из Node — пара строк с readFileSync, и это надёжнее, чем доверять os.totalmem() и os.cpus(), которые отвечают про хост. Готовые пакеты вроде cgroup-metrics делают то же, но зависимость ради двух файлов не обязательна. Один нюанс v2: в memory.current входит файловый кэш прочитанных файлов, поэтому с лимитом сравнивают число за вычетом inactive_file из memory.stat, как делает Kubernetes.

Глубже: холодный старт: за что уходят секундырасширенное

Node стартует за десятки миллисекунд, а приложение на NestJS — за секунды: загрузка сотен модулей из node_modules, разбор source map, построение дерева зависимостей, подключение к базе. Что ускоряет: сборка в один файл бандлером (esbuild, webpack в режиме Nest) сокращает число файлов на диске в сотни раз; source-map-support включают только там, где трассировки читают люди; тяжёлые модули грузят лениво. Остальное время — ваша инициализация, и её выносят в проверку готовности: контейнер готов, когда база ответила, а не когда процесс запустился. Для --start-period и initialDelaySeconds мерят реальный старт в проде, а не на ноутбуке.

Коротко

  • V8 выбирает размер кучи сам, по видимой памяти и со своим потолком: задавайте --max-old-space-size явно через NODE_OPTIONS, около 75 % лимита контейнера; остальное — буферам, нативной памяти и коду.
  • Код 137 без трассировки — OOMKilled ядром за лимит cgroup; код 134 с FATAL ERROR: Reached heap limit — потолок кучи V8. --heapsnapshot-near-heap-limit пишет снимок перед вторым, docker inspect подтверждает первый.
  • Сравнивать с лимитом нужно rss из process.memoryUsage(), а не heapUsed; v8.getHeapStatistics().heap_size_limit показывает выбранный потолок.
  • JavaScript идёт в одном потоке: квота меньше ядра даёт паузы CFS и скачки задержки, Node-сервису дают целое ядро и масштабируют репликами.
  • UV_THREADPOOL_SIZE (по умолчанию 4) — пул для файлов, DNS и части криптографии; не выше квоты контейнера.
  • cluster и pm2 в контейнерах заменяют реплики оркестратора; число воркеров — из квоты, а не из os.cpus().length.
  • SIGTERM без обработчика роняет процесс сразу; enableShutdownHooks() и CMD ["node", ...] делают останов мягким.
  • Лимиты читают из /sys/fs/cgroup/cpu.max и memory.max; os.totalmem() и os.cpus() рассказывают про хост.

Что почитать дальше