Пошук уроків, статей та іншого контенту
Розподіляйте вхідні з’єднання між процесами та масштабуйте сервер на кілька ядер CPU.
Node.js виконує JavaScript-код в одному потоці. Це означає, що один процес Node.js зазвичай використовує лише одне ядро CPU для виконання JavaScript.
Якщо сервер працює на машині з кількома ядрами, можна запустити кілька процесів одного застосунку. Модуль cluster допомагає:
створити кілька worker-процесів;
змусити їх слухати один і той самий порт;
розподіляти вхідні TCP-з’єднання між процесами;
використовувати кілька ядер CPU;
автоматично запускати worker знову після його аварійного завершення.
Кожен worker є окремим процесом Node.js із власною пам’яттю та власним event loop.
clusterКластер складається з:
primary-процесу — керує worker-процесами;
worker-процесів — обробляють запити.
Перевірити тип поточного процесу можна через cluster.isPrimary:
const cluster = require('node:cluster');
if (cluster.isPrimary) {
console.log('Це primary-процес');
} else {
console.log('Це worker-процес');
}Primary-процес створює workers за допомогою cluster.fork():
const cluster = require('node:cluster');
if (cluster.isPrimary) {
cluster.fork();
cluster.fork();
} else {
// Код worker-процесу
}У результаті primary-процес і два worker-процеси виконуватимуть той самий файл, але кожен процес матиме власний process.pid.
Кілька worker-процесів можуть викликати server.listen() з одним і тим самим портом. Node.js використає механізм кластеризації, щоб розподіляти з’єднання між workers.
const cluster = require('node:cluster');
const http = require('node:http');
const PORT = 3000;
if (cluster.isPrimary) {
cluster.fork();
cluster.fork();
} else {
const server = http.createServer((request, response) => {
response.end(`Запит обробив процес ${process.pid}\n`);
});
server.listen(PORT, () => {
console.log(`Worker ${process.pid} слухає порт ${PORT}`);
});
}Запустіть файл:
node server.jsПісля цього відкрийте http://localhost:3000 кілька разів або надішліть кілька запитів. Запити можуть обробляти різні worker-процеси.
Важливо: розподіляються саме з’єднання. Якщо клієнт використовує довготривале HTTP-з’єднання, усі запити в межах цього з’єднання можуть залишатися в одному worker-процесі.
Кількість workers зазвичай пов’язують із кількістю доступних ядер CPU.
У сучасних версіях Node.js для цього можна використовувати os.availableParallelism():
const cluster = require('node:cluster');
const os = require('node:os');
const http = require('node:http');
const PORT = 3000;
if (cluster.isPrimary) {
const workerCount = os.availableParallelism();
console.log(`Primary ${process.pid} запускає ${workerCount} workers`);
for (let index = 0; index < workerCount; index += 1) {
cluster.fork();
}
} else {
const server = http.createServer((request, response) => {
response.writeHead(200, {
'Content-Type': 'text/plain; charset=utf-8',
});
response.end(`Відповідь від worker ${process.pid}\n`);
});
server.listen(PORT, () => {
console.log(`Worker ${process.pid} слухає порт ${PORT}`);
});
}os.availableParallelism() враховує доступність CPU для поточного середовища, зокрема обмеження контейнера. Це зазвичай точніше, ніж просте підрахування os.cpus().length.
Не завжди потрібно запускати worker для кожного ядра. Кількість процесів треба перевіряти навантажувальним тестуванням. Надмірна кількість workers збільшує витрати на пам’ять і перемикання між процесами.
Worker може завершитися через необроблену помилку, перевищення обмежень пам’яті або іншу проблему. Primary-процес отримує подію exit і може створити заміну:
const cluster = require('node:cluster');
const os = require('node:os');
const http = require('node:http');
const PORT = 3000;
if (cluster.isPrimary) {
const workerCount = os.availableParallelism();
let shuttingDown = false;
function startWorker() {
const worker = cluster.fork();
worker.on('exit', (code, signal) => {
console.log(
`Worker ${worker.process.pid} завершився: code=${code}, signal=${signal}`,
);
if (!shuttingDown) {
console.log('Запускаємо worker на заміну');
startWorker();
}
});
}
for (let index = 0; index < workerCount; index += 1) {
startWorker();
}
process.on('SIGTERM', () => {
shuttingDown = true;
for (const worker of Object.values(cluster.workers)) {
worker.disconnect();
});
});
} else {
const server = http.createServer((request, response) => {
response.end(`Worker ${process.pid}\n`);
});
server.listen(PORT, () => {
console.log(`Worker ${process.pid} готовий`);
});
process.on('SIGTERM', () => {
server.close(() => {
process.exit(0);
});
});
}У цьому прикладі:
Primary створює необхідну кількість workers.
Якщо worker завершується, primary отримує подію exit.
Якщо застосунок не завершується навмисно, створюється новий worker.
Під час завершення застосунку workers більше не перезапускаються.
server.close() дозволяє завершити вже прийняті з’єднання перед виходом.
Перезапуск worker не виправляє причину помилки. Якщо worker аварійно завершується через помилку в коді, новий процес, імовірно, зіткнеться з тією самою проблемою. Подію exit варто використовувати для підтримання потрібної кількості процесів, але причину завершення потрібно окремо дослідити.
Primary і workers — окремі процеси, тому вони не мають спільних JavaScript-змінних. Для обміну простими повідомленнями можна використовувати IPC через process.send() і подію message.
Primary:
const cluster = require('node:cluster');
if (cluster.isPrimary) {
const worker = cluster.fork();
worker.on('message', (message) => {
console.log('Primary отримав:', message);
});
worker.send({ type: 'ping' });
} else {
process.on('message', (message) => {
if (message.type === 'ping') {
process.send({
type: 'pong',
workerPid: process.pid,
});
}
});
}IPC підходить для невеликих службових повідомлень. Не слід передавати через нього великі обсяги даних на кожен запит, адже це створює додаткові витрати на серіалізацію та обмін між процесами.
Кожен worker має окрему пам’ять. Наприклад, така змінна не є спільною між workers:
let requestCount = 0;Якщо кожен worker збільшує requestCount, кожен процес матиме власне значення лічильника.
Це особливо важливо для:
сесій користувачів;
кешу в пам’яті;
лічильників;
черг завдань;
стану авторизації.
Дані, які мають бути доступними всім workers, потрібно зберігати у спільному зовнішньому сховищі. Кластеризація сама по собі не синхронізує стан між процесами.
Під час оновлення або зупинки застосунку важливо не обривати активні запити без потреби.
Типова послідовність graceful shutdown:
Primary припиняє створювати нові workers.
Workers припиняють приймати нові з’єднання.
Уже відкриті з’єднання отримують можливість завершитися.
Процеси завершуються після закриття серверів.
Для worker-процесу основним методом є server.close():
process.on('SIGTERM', () => {
server.close(() => {
process.exit(0);
});
});Без обробки завершення процес може зупинитися різко, а активний клієнт отримає помилку з’єднання.
Кластеризація корисна, коли:
застосунок працює на багатоядерному сервері;
один HTTP-сервер має обробляти багато одночасних з’єднань;
потрібно ізолювати workers один від одного;
необхідно автоматично підтримувати кілька екземплярів процесу.
Кластеризація не замінює балансувальник навантаження та не робить один worker швидшим. Вона збільшує загальну пропускну здатність застосунку, запускаючи кілька незалежних процесів.
Помилково викликати cluster.fork() без перевірки cluster.isPrimary. Тоді кожен новий worker намагатиметься створювати власних workers, що може призвести до неконтрольованого розмноження процесів.
Правильно:
if (cluster.isPrimary) {
cluster.fork();
} else {
// Логіка worker
}Змінна в пам’яті worker не спільна з іншими процесами. Дані різних workers можуть розходитися.
Для спільного стану потрібне зовнішнє сховище або обмін повідомленнями з primary для простих службових випадків.
Якщо worker завершився, кількість обробників запитів зменшується. Для довготривалого процесу потрібно слухати cluster.on('exit') або подію exit конкретного worker і вирішити, чи потрібно створити заміну.
Якщо primary завжди створює нового worker після exit, коректне завершення перетвориться на нескінченний перезапуск. Перед зупинкою потрібно встановити прапорець на кшталт shuttingDown.
Кожен worker споживає власну пам’ять. Запуск десятків процесів на машині з кількома ядрами може погіршити продуктивність, а не покращити її.
Локальний лічильник запитів у кожному worker не є загальним лічильником кластера. Для загального значення потрібно централізоване сховище або спеціальна логіка агрегації.
cluster дозволяє запускати кілька процесів Node.js для одного застосунку.
Primary-процес створює та контролює workers.
Workers можуть слухати один порт, а вхідні з’єднання розподіляються між ними.
Кожен worker має окрему пам’ять і власний event loop.
Кількість workers можна визначити через os.availableParallelism().
Подія exit дає змогу запустити заміну для worker, що завершився.
Стан у пам’яті worker не є спільним для всього кластера.
Для надійного застосунку потрібно передбачити graceful shutdown і не запускати зайву кількість процесів.