cluster: масштабирование на все ядра
Один процесс Node загружает лишь одно ядро — модуль cluster форкает несколько процессов-воркеров, и они вместе обслуживают один порт, занимая весь процессор.
cluster — встроенный модуль Node.js, который создаёт несколько процессов-копий приложения (воркеров), разделяющих один серверный сокет. Главный процесс (мастер) форкает воркеры, а входящие соединения распределяются между ними.
worker_threads из прошлого урока решали задачу тяжёлого вычисления внутри одного процесса. Но есть другая, более частая для веб-серверов проблема: типичный Node-сервер на 8-ядерной машине по умолчанию использует только одно ядро. Семь простаивают. Событийный цикл прекрасно жонглирует тысячами соединений, но всё это — на одном процессоре. Чтобы задействовать остальные ядра под обычную HTTP-нагрузку, поднимают несколько процессов, и для этого в Node есть cluster.
Зачем это на практике
Допустим, ваш API упирается в потолок ~3000 запросов в секунду и при этом грузит одно ядро на 100%, а машина 4-ядерная. Вертикально расти некуда — одно ядро уже занято полностью. Запустив 4 воркера через cluster, вы примерно в 3–4 раза поднимаете пропускную способность на той же машине, ничего не меняя в коде приложения. Бонус — отказоустойчивость: если один воркер упал из-за необработанного исключения, мастер поднимает новый, и сервис не падает целиком. Это базовый приём, который превращает «учебный сервер» в нечто, выдерживающее продакшен-нагрузку.
Минимальный кластер
Идея проста: мастер определяет число ядер и форкает столько же воркеров; каждый воркер — это обычный HTTP-сервер. Слушать порт им разрешает сам cluster.
// server.js
const cluster = require('cluster');
const http = require('http');
const os = require('os');
if (cluster.isPrimary) { // в старых версиях: cluster.isMaster
const cpus = os.cpus().length;
console.log('мастер ' + process.pid + ', форкаю ' + cpus + ' воркеров');
for (let i = 0; i < cpus; i++) cluster.fork();
cluster.on('exit', (worker, code) => {
console.log('воркер ' + worker.process.pid + ' упал (' + code + '), поднимаю новый');
cluster.fork(); // самовосстановление
});
} else {
// этот код выполняется в КАЖДОМ воркере
http.createServer((req, res) => {
res.end('ответил воркер ' + process.pid + '\n');
}).listen(3000);
console.log('воркер ' + process.pid + ' слушает :3000');
}
Запустите и постучитесь несколько раз — process.pid в ответе будет меняться: соединения распределяются между разными процессами. При этом порт 3000 «занят» как будто одним сервером — снаружи кластер неотличим от одиночного процесса.
Как несколько процессов слушают один порт
Обычно два процесса не могут забиндиться на один и тот же порт — ОС вернёт EADDRINUSE. Фокус в том, что в кластере порт открывает мастер, а не воркеры. Когда воркер вызывает listen(3000), cluster перехватывает это: вместо собственного сокета воркер получает дескриптор от мастера. Дальше возможны две схемы доставки соединений.
| Схема | Как распределяются соединения |
| round-robin (по умолчанию на Linux/macOS) | мастер сам принимает соединение и по очереди отдаёт его воркерам — равномерно |
| shared socket (по умолчанию на Windows) | все воркеры разделяют один сокет, и какой из них примет соединение, решает ОС |
Схему можно задать явно через cluster.schedulingPolicy до форка. Round-robin обычно даёт более ровную загрузку, поэтому Node выбрал его умолчанием на Unix.
// до cluster.fork():
cluster.schedulingPolicy = cluster.SCHED_RR; // round-robin принудительно
// cluster.schedulingPolicy = cluster.SCHED_NONE; // отдать решение ОС
Балансировка и общение мастер↔воркер
Мастер и воркеры — это отдельные процессы, у них нет общей памяти. Координация — только сообщениями через встроенный IPC-канал. Это пригодится, например, для согласованного завершения или сбора статистики.
// в мастере: разослать всем воркерам команду
for (const id in cluster.workers) {
cluster.workers[id].send({ cmd: 'shutdown' });
}
// в воркере: принять и отреагировать
process.on('message', (msg) => {
if (msg.cmd === 'shutdown') {
server.close(() => process.exit(0)); // дослужить активные запросы и выйти
}
});
Раз памяти нет, нельзя хранить состояние в переменной процесса и ждать, что его увидят остальные. Классическая ловушка — сессии в памяти: пользователь залогинился, его сессия легла в объект воркера №1, а следующий запрос round-robin отправил в воркер №3, который про сессию ничего не знает. Поэтому общее состояние (сессии, кеш, счётчики) выносят во внешнее хранилище — Redis, БД, — доступное всем воркерам одинаково.
Как это работает под капотом
Под капотом cluster.fork() опирается на child_process.fork() (его разберём в следующем уроке): мастер порождает дочерний процесс Node, запускающий тот же файл, плюс заранее настроенный IPC-канал. Ключевая магия — передача дескриптора сокета между процессами: операционные системы умеют пересылать открытые файловые дескрипторы по доменному сокету, и cluster этим пользуется, чтобы все воркеры «видели» один и тот же слушающий сокет. Воркеры при этом полностью изолированы: у каждого свой V8, своя память, своё адресное пространство — падение одного не роняет других. Но именно из-за полной изоляции cluster тяжелее worker_threads по памяти: 4 воркера — это 4 полноценных процесса Node, каждый со своим рантаймом. Зато изоляция надёжнее: утечка или креш в одном процессе не задевает соседей.
Частые ошибки
- Хранить состояние в памяти воркера. Сессии, кеши и счётчики в переменной процесса не видны другим воркерам; следующий запрос уйдёт в другой процесс. Состояние — во внешнее хранилище.
- Форкать больше воркеров, чем ядер. Смысла в этом нет: процессы начнут конкурировать за те же ядра, добавляя накладные расходы на переключение контекста без прироста.
- Считать, что cluster ускорит CPU-задачу в одном запросе. Кластер масштабирует число параллельных запросов, но один тяжёлый запрос по-прежнему займёт ровно один воркер целиком — для этого нужен worker_threads.
- Не перезапускать упавшие воркеры. Без обработчика
cluster.on('exit')воркеры будут вымирать один за другим, и кластер деградирует до неработающего. - Грубо убивать воркеры при деплое. Резкий
process.exit()обрывает активные запросы; корректнееserver.close()— дослужить текущие соединения и выйти (graceful shutdown).
Итоги
- Один процесс Node использует одно ядро;
clusterфоркает несколько процессов-воркеров, чтобы занять все ядра под HTTP-нагрузкой. - Порт открывает мастер; воркеры разделяют слушающий сокет, а соединения раздаются round-robin (Unix) или средствами ОС (Windows).
- Воркеры — отдельные процессы без общей памяти; координация только через IPC-сообщения (
worker.send/process.on('message')). - Общее состояние (сессии, кеш) выносят во внешнее хранилище, иначе round-robin отправит запрос в процесс, который о нём не знает.
- Cluster масштабирует число параллельных запросов и даёт отказоустойчивость, но один CPU-тяжёлый запрос всё равно требует worker_threads.