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()рассказывают про хост.
Что почитать дальше
- Dockerfile для NestJS — как собрать образ, в который попадут эти настройки.
- Запуск контейнеров — флаги
--memory,--cpusи что происходит без лимитов. - Kubernetes: деплой и конфигурация — requests и limits, из которых берутся те же cgroup.
- Наблюдаемость на Node: метрики — как вывести память процесса и задержки на график.