Що таке модуль cluster у Node.js?
Модуль cluster дозволяє Node.js-застосунку запускати кілька worker-процесів, які спільно використовують один TCP-порт, по одному на кожне ядро CPU. За замовчуванням Node.js-процес задіює одне ядро незалежно від того, скільки їх є на машині. Cluster це виправляє.
Теорія
Коротко
- Аналогія: один менеджер (primary) тримає вхідні двері, кілька кухарів (workers) обслуговують зі спільної адреси, кожен на своїй плиті (ядро CPU)
- Звичайний Node.js = 1 ядро; cluster = всі ядра, без зовнішнього проксі
- ОС автоматично розподіляє вхідні з'єднання між workers за алгоритмом round-robin
- Використовуй, якщо CPU-навантаження стабільно вище ~20% і доступно більше 2 ядер
- Пропускай для I/O-навантажених застосунків (запити до БД, зовнішні API) - async з event loop справляється без форкінгу
Швидкий приклад
const cluster = require('cluster');
const http = require('http');
const os = require('os');
if (cluster.isPrimary) {
for (let i = 0; i < os.cpus().length; i++) {
cluster.fork(); // один worker на кожне ядро CPU
}
cluster.on('exit', () => cluster.fork()); // перезапускаємо впалий worker
} else {
// кожен worker слухає на тому ж порті
http.createServer((req, res) => {
res.end(`Worker ${process.pid}\n`);
}).listen(8000);
}На машині з 4 ядрами чотири окремі процеси обслуговують порт 8000. Запусти curl localhost:8000 десять разів і побачиш різні PID. Ядро ОС розподіляє навантаження, більше нічого.
Як це працює всередині
Коли primary викликає cluster.fork(), V8 використовує системний виклик clone() для копіювання поточного процесу. Primary один раз прив'язує слухаючий сокет з прапором SO_REUSEPORT (Linux 3.9+). Workers успадковують файловий дескриптор і чекають на accept(), поки ядро не передасть їм з'єднання. Жодного проксі між клієнтом і worker немає. Round-robin реалізує саме ядро ОС.
IPC між primary і workers працює через Unix domain sockets. Через них передаються сигнали, події виходу та власні повідомлення через process.send().
Коли використовувати cluster
- CPU-навантажена робота (вище ~20%): форкінг по ядрах дає результат. Цикл з 10^8 ітерацій блокує один потік; розподіли по 4 workers і отримаєш у 4 рази більше паралельних запитів.
- I/O-навантажені застосунки: пропускай. Async/await з event loop масштабує запити до БД і API без накладних витрат форкінгу.
- Менше 2 ядер: витрати на запуск процесів перекривають будь-яку вигоду. Контейнери - найчастіший випадок, який я бачу. Хтось запускає cluster всередині Docker-контейнера з 1 ядром і дивується, чому нічого не покращилось.
- Docker або Kubernetes: краще організовуй репліки на рівні контейнерів. Cluster всередині однопоточного контейнера нічого не додає.
- Розгортання без простоїв: cluster добре справляється, якщо перезапускати workers по одному.
Зупинка без втрат
Найпоширеніша проблема в продакшені - обрив запитів, що обробляються, при зупинці worker. Без server.close() з'єднання розриваються посередині відповіді.
if (!cluster.isPrimary) {
const server = http.createServer(handler).listen(3000);
process.on('SIGTERM', () => {
server.close(() => process.exit(0)); // завершуємо поточні запити
setTimeout(() => process.exit(1), 30_000); // примусово через 30с
});
}
if (cluster.isPrimary) {
process.on('SIGTERM', async () => {
for (const id in cluster.workers) {
cluster.workers[id].kill('SIGTERM');
}
await new Promise(r => setTimeout(r, 30_000));
process.exit(0);
});
}Kubernetes надсилає SIGTERM при зупинці пода. Без цього патерну кожне rolling deploy скидає частину запитів.
Типові помилки
1. Фіксована кількість workers
// неправильно
for (let i = 0; i < 4; i++) cluster.fork();На сервері з 64 ядрами це замало; на контейнері з 2 ядрами - марна трата пам'яті. Завжди використовуй os.cpus().length.
2. Спільний стан через глобальні змінні
// неправильно - кожен worker має власну копію counter
let counter = 0;
http.createServer((req, res) => {
counter++;
res.end(counter.toString()); // кожен worker повертає 1, 2, 3 незалежно
}).listen(3000);Workers - це окремі процеси з окремими купами V8. Лічильник у worker 1 ніколи не потрапить до worker 2. Для спільного стану використовуй Redis (redis.incr('counter')) або повідомлення до primary.
3. Немає обробника виходу
Падіння одного worker на кластері з 4 ядер мовчки знижує потужність до 75%. Додай cluster.on('exit', () => cluster.fork()) і primary завжди підтримуватиме потрібну кількість workers.
4. Прослуховування в primary
// неправильно - primary обробляє весь трафік, workers простоюють
if (cluster.isPrimary) {
http.createServer(handler).listen(8000);
}Прослуховування порту - задача workers. Primary управляє їхнім життєвим циклом і нічого більше.
5. Сесії без Redis
req.session.user, збережений у пам'яті, живе всередині одного worker. Другий запит, що потрапить до іншого worker, не знайде сесії. Використовуй Redis-сховище для сесій або налаштуй sticky sessions на рівні проксі.
Де зустрічається в продакшені
- PM2: автоматично огортає cluster.
pm2 start app.js -i maxзапускає по одному worker на ядро. Більшість Node.js-продакшенів використовують PM2 замість сирого cluster. - Express API: обгорни
app.listen()у гілку worker; решта налаштувань застосунку не змінюється. - Ручний cluster vs PM2: raw cluster підходить, якщо потрібні нульові залежності або нестандартна логіка перезапуску. PM2 дає дашборди моніторингу, агрегацію логів та автоматичний перезапуск при падінні.
Питання на співбесіді
Q: Як балансування навантаження працює без проксі?
A: Primary прив'язує порт з SO_REUSEPORT. Ядро ОС розподіляє нові з'єднання між workers, що чекають на accept(). Жодного зовнішнього процесу немає.
Q: В чому різниця між cluster і child_process.fork()?
A: child_process.fork() створює підпроцес з IPC, але без спільного сокету. Cluster додає успадкування сокету, щоб всі workers могли приймати з'єднання на одному порті.
Q: Як обробляти WebSocket з cluster?
A: WebSocket потребує sticky sessions, бо з'єднання зберігається тривалий час. Використовуй балансувальник на кшталт HAProxy з маршрутизацією за IP, або передавай ID worker у URL з'єднання.
Q: Чому cluster може погіршити продуктивність I/O-застосунків?
A: Накладні витрати форкінгу плюс IPC. Один async Node.js-процес через event loop паралельно обробляє тисячі запитів до БД. Форкінг лише збільшує витрати пам'яті без виграшу в пропускній здатності.
Q: Як реалізувати zero-downtime deploy вручну?
A: Перезапускай workers по одному. Надішли SIGTERM одному worker, дочекайся завершення поточних запитів через server.close(), потім форкни новий. PM2 робить це автоматично через pm2 reload.
Q: Round-robin проти випадкового розподілу?
A: Node.js за замовчуванням використовує round-robin на Linux. Вимикається через змінну середовища CLUSTER_ROUND_ROBIN=false, але round-robin рівномірніше розподіляє CPU-навантаження для long-poll з'єднань. Порівняй обидва варіанти за допомогою ab -n 1000 -c 10 на своєму залізі.
Приклади
Базовий HTTP сервер з автоперезапуском
const cluster = require('cluster');
const http = require('http');
const os = require('os');
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} запускається`);
for (let i = 0; i < os.cpus().length; i++) {
cluster.fork();
}
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.process.pid} впав, перезапускаємо`);
cluster.fork();
});
} else {
http.createServer((req, res) => {
res.writeHead(200);
res.end(`Обслуговує worker ${process.pid}\n`);
}).listen(8000);
console.log(`Worker ${process.pid} слухає порт 8000`);
}Мінімальний продакшен-патерн. Primary форкує і перезапускає впалі workers, HTTP не чіпає. Workers обробляють все інше.
Express API на всіх ядрах CPU
const cluster = require('cluster');
const os = require('os');
const express = require('express');
if (cluster.isPrimary) {
for (let i = 0; i < os.cpus().length; i++) cluster.fork();
cluster.on('exit', () => cluster.fork());
} else {
const app = express();
app.get('/compute/:n', (req, res) => {
// CPU-навантажена робота - саме тут cluster дає результат
let sum = 0;
for (let j = 0; j < 1e8; j++) sum += j;
res.json({ n: req.params.n, pid: process.pid, sum });
});
app.listen(3000, () => console.log(`Worker ${process.pid} готовий`));
}Десять одночасних запитів до /compute/1 потраплять до різних workers на 4-ядерній машині. Без cluster вони стоять у черзі на одному ядрі. Саме в цьому і сенс.
IPC: повідомлення від worker до primary
if (cluster.isPrimary) {
const worker = cluster.fork();
worker.on('message', (msg) => {
if (msg.type === 'metrics') {
console.log(`Worker ${worker.process.pid} обробив ${msg.count} запитів`);
}
});
} else {
let count = 0;
http.createServer((req, res) => {
count++;
res.end('ok');
}).listen(3000);
// звітуємо до primary кожні 5 секунд
setInterval(() => {
process.send({ type: 'metrics', count });
}, 5000);
}Цей патерн дозволяє primary збирати статистику з усіх workers без спільної пам'яті.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.