Возможно, вы уже сталкивались с необходимостью масштабировать свое приложение Node.js, чтобы повысить его доступность, не так ли? Что произойдет, если блокирующая операция ввода-вывода «заморозит» цикл обработки событий и поступит новый запрос? Обработка второго запроса займет больше времени. А что, если еще до того, как этот второй запрос будет решен, придет третий? В этой статье мы будем использовать встроенную функцию Node.js, которая позволит нам повысить доступность за счет балансировки нагрузки в нашем приложении.

Но для начала…

Что такое балансировка нагрузки?

Балансировка нагрузки — это метод, используемый для распределения входящего трафика между различными вычислительными ресурсами, такими как серверы, виртуальные машины или процессы, с целью оптимизации производительности, повышения доступности и предотвращения перегрузок.

Основная цель балансировки нагрузки — предотвратить перегрузку отдельного ресурса, обеспечивая эффективное распределение задач между всеми доступными ресурсами. Это особенно важно в средах, где трафик является переменным и непредсказуемым.

Сегодня существует множество сервисов и технологий, которые помогают в этом, например Kubernetes, Nginx, Amazon Elastic Load Balancer и многие другие.

Однако сегодня я остановлюсь на собственном модуле cluster Node.js.

Стоит отметить, что хотя модуль cluster эффективно распределяет трафик между процессами, он не обеспечивает сложную или адаптивную балансировку нагрузки. Для более сложных реализаций вы можете обратиться к внешним инструментам, таким как выделенные службы или технологии оркестрации контейнеров.

Что такое кластеры в Node.js?

В экосистеме Node.js кластеры представляют собой мощный метод повышения масштабируемости и производительности приложений, позволяющий им эффективно обрабатывать несколько одновременных подключений.

Кластеры позволяют создавать несколько процессов Node.js, известных как дочерние процессы, которые используют один и тот же IP-адрес и порт. Другими словами, кластеры позволяют выполнять несколько экземпляров Node.js параллельно, позволяя более эффективно использовать все доступные ядра ЦП и распределять рабочую нагрузку, используя для связи протокол межпроцессного взаимодействия (IPC).

Главный процесс отвечает за создание дочерних процессов и управление ими. Он выступает в роли координатора, распределяя входящие запросы между дочерними процессами. Каждый дочерний процесс способен обрабатывать запросы отдельно, максимально используя доступные ресурсы ЦП, памяти и ввода-вывода.

Распределяя рабочую нагрузку между несколькими дочерними процессами, кластеры могут привести к значительному улучшению производительности и скорости реагирования приложений Node.js. Более того, кластерный подход особенно полезен для приложений, которым необходимо управлять большим количеством одновременных запросов, таких как веб-серверы, приложения чата в реальном времени, многопользовательские игры и т. д.

Как работает связь между созданными процессами?

Как уже упоминалось, когда вы создаете кластер в Node.js, создается главный процесс вместе с несколькими дочерними процессами. Каждый дочерний процесс представляет собой независимую копию среды Node.js, которая может обрабатывать запросы отдельно.

Связь между процессами, созданными в кластере Node.js, в основном осуществляется посредством сообщений межпроцессного взаимодействия (IPC), которыми обмениваются главный процесс и дочерние процессы. Эти сообщения позволяют процессам координировать задачи, обмениваться информацией и синхронизировать действия. Вот пошаговое объяснение того, как работает технологическая коммуникация:

  1. Создание кластера. Первоначально создается главный процесс. Он отвечает за создание и управление дочерними процессами.
  2. Созданные дочерние процессы. Главный процесс создает несколько дочерних процессов, каждый из которых является отдельным экземпляром приложения.
  3. Обмен сообщениями. Главный процесс и дочерние процессы могут обмениваться сообщениями с помощью сообщений IPC.
  4. Отправка сообщений от главного процесса дочерним процессам:Главный процесс может отправлять сообщения дочерним процессам с помощью метода worker.send(data). При этом сообщение с указанными данными отправляется конкретному дочернему процессу.
  5. Получение сообщений в дочерних процессах:В дочерних процессах вы можете прослушивать сообщения, отправленные главным процессом, с помощью process.on('message', callback). Когда главный процесс отправляет сообщение, запускается обратный вызов для обработки сообщения в дочернем процессе.
  6. Отправка сообщений от дочерних процессов главному процессу:Дочерние процессы также могут отправлять сообщения обратно главному процессу с помощью process.send(data) в контексте дочернего процесса.
  7. Получение сообщений в главном процессе. В главном процессе вы можете прослушивать сообщения, отправленные определенными дочерними процессами, с помощью события worker.on('message') в объекте дочернего процесса.
  8. Координация и обмен. Сообщения можно использовать для координации действий между процессами, например для запуска определенной задачи. Их также можно использовать для обмена информацией, например обновлениями конфигурации.

Пример. Представьте, что вы хотите обновить конфигурацию приложения. Главный процесс может отправить сообщение с новой конфигурацией всем дочерним процессам. Каждый дочерний процесс получит сообщение и, прослушивая событие 'message', сможет соответствующим образом обновить свою конфигурацию.

Но существуют некоторые ограничения в обмене сообщениями, например, сериализация сообщений IPC, а это означает, что не все типы объектов могут быть отправлены непосредственно в виде сообщений.

Где мы можем использовать кластеры?

Давайте создадим практический пример того, как создать экземпляр нашего приложения Node.js, используя собственный модуль cluster.

Сначала давайте создадим файл с именем server.js и создадим простой Node-сервер (с использованием собственных модулей).

import { createServer } from "node:http";

createServer((req, res) => {
  res.end("Hello, world!");
})
  .listen(3000)
  .on("listening", () => {
    console.log(`server process is ${process.pid}`);
  });

// Output:
// server process is 8789

Здесь мы создаем простой HTTP-сервер и выводим номер процесса (8789) на консоль. Для подтверждения я запускаю команду pgrep node в терминале, чтобы найти идентификатор процесса с именем «узел».

Здесь мы имеем подтверждение того, что процесс Node с идентификатором 8789 соответствует нашему серверу.

Создание задачи

Давайте смоделируем действие блокировки, чтобы «заморозить» цикл событий и запретить нашему серверу обрабатывать запрос:

import { createServer } from "node:http";

createServer((req, res) => {
  for (let i = 0; i <= 1e8; i++);

  res.end("Hello, world!");
})
  .listen(3000)
  .on("listening", () => {
    console.log(`server process is ${process.pid}`);
  });

Теперь мы входим в цикл for, который выполняет итерацию от 0 до 100 000 000, прежде чем продолжить выполнение запроса.

Вот и проблема создана.

Каждый запрос должен будет дождаться завершения обработки, прежде чем перейти к следующей строке. В идеальном сценарии это не было бы большой проблемой… верно? Но как насчет сценария, в котором одновременно выполняется несколько запросов? Или когда поступает более одного запроса одновременно? В этом случае наше приложение зависнет, обрабатывая первый запрос, прежде чем приступить к остальным. И в этом проблема.

Я воспользуюсь пакетом autocannon для выполнения нагрузочного теста на нашем сервере и просмотра результата того, что было упомянуто.

  • Сначала я установлю пакет как зависимость разработки с помощью команды npm i -D autocannon.
  • Затем я запускаю команду npx autocannon -c 500 -d 30 -w 10 -l -W [ -c 1 -d 2 ] --renderStatusCodes localhost:3000, где localhost является нашим локальным адресом, c обозначает одновременные соединения, которые будут происходить, d указывает продолжительность теста, w указывает количество потоков, которые операционная система будет использовать во время теста, l для отображения задержка, W для прогрева, установления предварительного соединения с 1 параллелизмом в течение 2 секунд и -renderStatusCodes для отображения статуса запросов (документация по автопушке).

Этот нагрузочный тест предоставил мне несколько фрагментов информации, но наиболее важными из них являются:

2k requests in 30.13s, 32.2 kB read
1k errors (1k timeouts)

Это означает, что из двух тысяч запросов тысяча завершилась ошибками из-за таймаутов, что уже является существенной проблемой.

Теперь представим, что на нашем сервере произошло необработанное исключение, вызвавшее его сбой. Количество ошибок будет еще больше.

Я собираюсь добавить следующий фрагмент кода в конец нашего файла server.js, чтобы имитировать неожиданную ошибку на сервере, а затем запустить нагрузочный тест. Вот фрагмент кода:

setTimeout(() => {
  process.exit(1);
}, Math.random() * 2e4);

Результаты моих тестов таковы:

156k requests in 30.15s, 31.8 kB read
156k errors (786 timeouts)

Поскольку наш сервер вышел из строя во время теста, большое количество запросов (а также ошибок) указывает на то, что он не удался во время выполнения нагрузочного теста.

Но заметьте, практически все запросы не увенчались успехом.

Реализация собственных кластеров Node.js

Теперь, когда мы определили проблемы, пришло время найти решение.

Для этого я создам файл с именем cluster.js и импортирую модуль cluster из Node.js. Мы будем использовать некоторые методы из этого модуля:

  • cluster.isPrimary: определяет, является ли текущий процесс основным запущенным процессом.
  • cluster.fork(): Создает новые рабочие процессы и может быть вызван только основным процессом.
  • cluster.worker: Ссылка на текущий объект процесса, недоступная в основном процессе.

Кроме того, кластеры могут прослушивать события и реагировать на них. Основные события, которые нас интересуют:

  • exit: генерируется, когда процесс, созданный .fork(), завершается.
  • fork: генерируется, когда рабочий процесс создается родительским процессом.

Список событий

В файле cluster.js у нас будет следующий код:

import cluster from "node:cluster";
import { availableParallelism } from "node:os";

function runPrimaryCluster() {
  console.log(`Primary ${process.pid} running`);
  console.log(`Forking server for ${availableParallelism}\n`);

  for (let i = 0; i < availableParallelism; i++) {
    cluster.fork();
  }
}

async function runServer() {
  await import("./server.js");
}

cluster.isPrimary
 ? runPrimaryCluster()
 : runServer();

Здесь мы импортируем модуль cluster и целое число availableParallelism, которое по сути возвращает оценку уровня параллелизма по умолчанию, который должна использовать программа.

После этого мы проверяем, является ли это основным процессом, используя cluster.isPrimary. Если это основной процесс, мы выполняем функцию runPrimaryCluster, которая создает копию экземпляра Node.js для доступного параллелизма с использованием cluster.fork() (как если бы программа Node должна была «перечитать» этот же файл, поэтому в следующем повторе -читайте, значение cluster.isPrimary изменится, поскольку это процесс, порожденный .fork()). Но если это не основной процесс, мы используем Динамический импорт для выполнения файла server.js.

Теперь, запустив node cluster.js, мы выведем что-то вроде:

Primary 85400 running
Forking server for 8

server process is 85411
server process is 85413
server process is 85414
server process is 85412
server process is 85426
server process is 85443
server process is 85437
server process is 85420

И если мы сейчас запустим нагрузочный тест:

371k requests in 30.16s, 104 kB read
369k errors (0 timeouts)

У нас будет очень похожий на предыдущий результат, так как в нашем server.js было добавлено правило для имитации сбоя сервера.

Как сделать кластер устойчивым к ошибкам

Однако это не проблема, поскольку мы можем использовать стратегию для прослушивания события exit работника и сразу после этого создать другой кластер.

Просто добавьте следующую строку кода под for (let i = 0; i < availableParallelism; i++) {...}:

cluster.on("exit", (worker, code, signal) => {
  if (code !== 0 && !worker.exitedAfterDisconnect) {
    console.log(`Cluster ${worker.process.pid} died`);
    cluster.fork();
  }
});

Событие exit, создаваемое созданными процессами, имеет 3 параметра:

  1. работник = дочерний процесс, вызвавший событие.
  2. код = код выхода.
  3. сигнал = причина, по которой процесс был прекращен.

Таким образом, если код не равен 0, это означает, что процесс не был закрыт естественным образом (поскольку мы использовали process.exit(1) в нашем server.js, значение параметра кода равно 1), а worker.exitedAfterDisconnect возвращает, был ли рабочий процесс отключен с использованием метода worker.disconnect() (любой другой способ вернет false, поскольку это интерпретируется как случайное отключение).

Объединив эти две логики, мы можем создать новый процесс всякий раз, когда процесс, порожденный .fork(), неожиданно отключается.

Теперь запускаем нагрузочный тест с этой реализацией:

3k requests in 30.12s, 229 kB read
1k errors (1k timeouts)

Мы добились значительного улучшения устойчивости нашего сервера, поскольку даже при неожиданных ошибках, приводящих к его сбою, нам удалось увеличить его мощность, чтобы он оставался доступным.

Просто из любопытства, без ошибок сервера, время ответа было:

4k requests in 30.13s, 318 kB read
1k errors (1k timeouts)

Мы практически удвоили начальное время отклика, написав всего несколько строк кода.

Заключение

В этом посте мы рассмотрели концепцию балансировки нагрузки и способы ее достижения с помощью Node.js и встроенного модуля cluster.

Мы также изучили, как происходит межпроцессное взаимодействие, приведя практические примеры и используя методы нагрузочного тестирования. Благодаря устойчивой реализации обработки ошибок и возможности создавать новые экземпляры по мере необходимости мы эффективно удвоили пропускную способность нашего сервера.

Надеюсь, вам понравился этот контент. Спасибо, что оставались с нами до конца. До скорой встречи!