Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке модуль cluster у Node.js?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Модуль cluster** у Node.js запускає кілька worker-процесів, які спільно використовують один порт, по одному на кожне ядро CPU. Без нього Node.js задіює одне ядро незалежно від розміру машини. ```js if (cluster.isPrimary) { for (let i = 0; i < os.cpus().length; i++) cluster.fork(); } else { http.createServer((req, res) => res.end(`pid: ${process.pid}`)).listen(3000); } ``` **Головне:** ОС розподіляє з'єднання між workers за round-robin, зовнішній проксі не потрібен.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Модуль 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 справляється без форкінгу ### Швидкий приклад ```js 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()` з'єднання розриваються посередині відповіді. ```js 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** ```js // неправильно for (let i = 0; i < 4; i++) cluster.fork(); ``` На сервері з 64 ядрами це замало; на контейнері з 2 ядрами - марна трата пам'яті. Завжди використовуй `os.cpus().length`. **2. Спільний стан через глобальні змінні** ```js // неправильно - кожен 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** ```js // неправильно - 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 сервер з автоперезапуском ```js 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 ```js 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 ```js 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 без спільної пам'яті.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.