Создание масштабируемого балансировщика нагрузки с помощью Node.js и Express

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

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

Обзор

Вот основные функции, которые мы реализуем в нашем балансировщике нагрузки:

  • Принимать запросы HTTPS и отключать SSL
  • Балансировка нагрузки между несколькими внутренними серверами приложений
  • Конечные точки проверки работоспособности на каждом сервере для мониторинга состояния
  • Взвешенный алгоритм циклического перебора для распределения нагрузки в зависимости от мощности сервера.
  • Привязка/привязанность сеанса — запросы маршрутизации от одного и того же клиента к одному и тому же серверу.
  • Грамотная обработка добавляемых или удаляемых серверов.
  • Настраиваемый с различными стратегиями балансировки
  • Подробное журналирование и метрики для мониторинга

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

Наш балансировщик нагрузки будет работать как автономное приложение Node.js. Мы будем использовать Express для управления общей маршрутизацией и логикой приложения. И мы создадим отдельные модули маршрутизатора для обработки проверок работоспособности, метрик и т. д.

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

Настройка проекта

Начнем с инициализации нового проекта Node.js.

mkdir load-balancer
cd load-balancer
npm init -y

Теперь мы установим Express и некоторые другие зависимости:

npm install express http-proxy-middleware cluster cookie-parser
  • express — веб-фреймворк
  • http-proxy-middleware — модуль для проксирования запросов.
  • кластер — основной модуль Node для многопоточной балансировки нагрузки.
  • cookie-parser — анализировать файлы cookie из запросов.

Завершение SSL

Нашему балансировщику нагрузки необходимо принимать HTTPS-трафик и завершать SSL перед передачей запросов на внутренние серверы.

Установите модуль HTTPS:

npm install https

В index.js добавим HTTPS-сервер:

const fs = require('fs');
const https = require('https');

const options = {
  key: fs.readFileSync('./ssl/key.pem'),
  cert: fs.readFileSync('./ssl/cert.pem'),
};

https.createServer(options, app).listen(443, () => {
  console.log('Load balancer started on port 443');
});

Нам нужно сгенерировать самозаверяющий сертификат и пару ключей для тестирования. Давайте создадим папку ssl и воспользуемся OpenSSL:

mkdir ssl
cd ssl
openssl req -nodes -new -x509 -keyout key.pem -out cert.pem

Это создаст файлы key.pem и cert.pem, которые сможет использовать наш HTTPS-сервер.

Теперь наш балансировщик нагрузки будет работать как HTTPS-сервер.

Логика балансировки нагрузки

Основная логика балансировки нагрузки будет реализована в routes/proxy.js, которая будет перенаправлять запросы на внутренние серверы.

Установите пакет http-proxy-middleware:

npm install http-proxy-middleware

Теперь создайте routes/proxy.js:

const express = require('express'); 
const proxy = require('http-proxy-middleware');

const router = express.Router();

const servers = [
  {
    host: 'localhost',
    port: 3000,
    weight: 1,
  },
  // Add more servers here
];

// Proxy middleware configuration
const proxyOptions = {
  target: '',
  changeOrigin: true,
  onProxyReq: (proxyReq, req) => {
    // Add custom header to request
    proxyReq.setHeader('X-Special-Proxy-Header', 'foobar');
  },
  logLevel: 'debug' 
};

// Next server index
let currIndex = 0;

// Get next server
function getServer() {

  // Round robin
  currIndex = (currIndex + 1) % servers.length;

  return servers[currIndex];
}


// Proxy requests
router.all('*', (req, res) => {
  
  // Get next target server
  const target = getServer();
  proxyOptions.target = `http://${target.host}:${target.port}`;
  
  // Forward request
  proxy(proxyOptions)(req, res); 
});

module.exports = router;

Это реализует базовую циклическую маршрутизацию прокси-сервера.

В index.js мы можем его смонтировать:

const proxyRouter = require('./routes/proxy');

app.use('/app', proxyRouter);

Теперь запросы к /app будут пересылаться на бэкенды.

Давайте также добавим простой внутренний HTTP-сервер, который отвечает своим именем хоста:

server.js:

const express = require('express');

const app = express();

app.get('/app', (req, res) => {
  res.send(`Hello from server! Host: ${process.env.HOSTNAME}`);
});

app.listen(3000, () => {
  console.log('Backend server running on port 3000');
});

Мы можем протестировать проксирование и увидеть пересылку запроса:

$ curl -k https://localhost/app
Hello from server! Host: host1

Далее мы реализуем другие важные функции балансировки нагрузки.

Проверка здоровья

Проверки работоспособности гарантируют, что мы отправляем трафик только на «здоровые» внутренние серверы.

Давайте создадим маршрутизатор проверки работоспособности в routes/health.js:

const express = require('express');
const axios = require('axios');

const router = express.Router();

router.get('/health', async (req, res) => {

  const results = [];

  // Loop through servers and health check each 
  for (let i = 0; i < servers.length; i++) {
    const server = servers[i];

    try {
      const response = await axios.get(`http://${server.host}:${server.port}/app/healthcheck`);
      // Check status
      if (response.status === 200) {
        results.push({
          id: server.id,
          status: 'passing'
        });  
      } else {
        results.push({
          id: server.id,
          status: 'failing' 
        });
      }
      
    } catch (error) {
      results.push({
        id: server.id,
        status: 'failing'
      });
    }
  }

  // Return summarized results
  res.json(results); 
});

module.exports = router;

Мы можем проверить /health, чтобы увидеть состояние каждого бэкэнда.

Теперь в промежуточном программном обеспечении прокси давайте перенаправим данные только на исправные бэкэнды:

let healthyServers = [];

router.all('*', (req, res) => {

  if (healthyServers.length === 0) {
    return res.status(500).send('No healthy servers!');
  }  

  // Load balance across only healthy servers
  // ...

});

// Update list of healthy servers
function updateHealthyServers() {
  // Check health status
  // ...

  healthyServers = // Filter only healthy servers 
}

// Call initially 
updateHealthyServers();

// Update health periodically
setInterval(updateHealthyServers, 10000);

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

Взвешенный круговой турнир

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

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

// Server object
{
  host: 'localhost',
  port: 3000,
  // Higher weighted servers get more requests
  weight: 1 
}

// Total weights
let totals = [];

// Generate list of cumulative weights 
function initWeights() {

  totals = [];
  let runningTotal = 0;

  for (let i = 0; i < servers.length; i++) {
    runningTotal += servers[i].weight;
    totals.push(runningTotal); 
  }

}

function getServer() {

  const random = Math.floor(Math.random() * totals[totals.length - 1]) + 1;

  // Find server at index for this weight
  for (let i = 0; i < totals.length; i++) {
    if (random <= totals[i]) {
      return servers[i];
    }
  }

}

При этом «доли» запросов распределяются в зависимости от веса сервера.

Теперь мы можем настраивать серверы с разным весом для соответствующего распределения нагрузки.

Привязка сеанса

Благодаря привязке сеансов запросы из одного клиентского сеанса всегда направляются на один и тот же внутренний сервер. Это обеспечивает согласованность состояния сеанса пользователя.

Мы можем использовать сходство на основе файлов cookie:

// Generate cookie name
const COOKIE_NAME = 'lb-affinity';

app.use(cookieParser());

router.all('*', (req, res) => {

  // No affinity for first request
  if (!req.cookies[COOKIE_NAME]) { 
    // Set cookie 
    res.cookie(COOKIE_NAME, selectedServer.id, {
      httpOnly: true
    });

  } else {
    // Re-route request to previously selected server
    const affinityId = req.cookies[COOKIE_NAME];
    
    selectedServer = servers.find(s => s.id === affinityId);
  }

  // Route request  
});

Теперь последующие запросы пользователя будут попадать на тот же внутренний сервер.

Это привязывает клиента к одному серверу для его сеанса.

Грациозные обновления сервера

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

Чтобы корректно обрабатывать обновления:

const draining = [];
const drained = [];

// Drain server - leave in proxying but don't add new clients
function drain(serverId) {
  draining.push(serverId);
} 

// Mark server as fully drained 
function drained(serverId) {
  drained.push(serverId);
}

router.all('*', (req, res) => {

  if (!req.cookies[COOKIE_NAME]) {

    const server = // Select server
    
    // Check if draining
    if (draining.includes(server.id)) {
      return sendToBackup(req, res); // Bypass if draining
    }

  } else { 

    // Route to selected server

  }

})

// Fully remove from pool
function removeServer(serverId) {

  const index = servers.findIndex(s => s.id === serverId);

  if (index !== -1) {
    servers.splice(index, 1);
  }

  drained.splice(drained.indexOf(serverId), 1);

}

// Add back to pool 
function addServer(server) {
  servers.push(server);
}

Это обеспечивает плавное удаление соединений с обновляемых серверов.

Настраиваемые стратегии балансировки

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

Сначала определите интерфейс стратегии:

// Strategy interface 
function Strategy() {
  this.select = null;
}

// Random strategy
function RandomStrategy() {

  // Set select method
  this.select = function() {
    // Return random server
  }

}

// Round robin strategy
function RoundRobinStrategy() {
  
  // Set select method
  this.select = function() {
    // Return next server in round robin fashion
  }

}

// Map strategies to readable names
const Strategies = {
  'random': RandomStrategy,
  'roundrobin': RoundRobinStrategy  
};

Теперь мы можем разрешить указание стратегии:

// Get selected strategy
const Strategy = Strategies[config.strategy]; 

const selector = new Strategy();

// Use strategy to select server
const server = selector.select();

Это позволяет легко внедрять новые стратегии и заменять их.

Метрики и журналирование

Сбор метрик и журналов жизненно важен для мониторинга балансировщика нагрузки.

Мы можем использовать обратные вызовы onProxyReq и onProxyRes:

// Proxy options
const options = {
  onProxyReq: (proxyReq, req) => {
    // Log details like request timestamps, headers etc
    
  },
  onProxyRes: (proxyRes) => {
    // Log response status, time taken, etc.

  }
}

И записывайте ключевые показатели, такие как:

  • Запросов в секунду
  • Задержки запросов
  • Распределение трафика по бэкэндам
  • Частота ошибок
  • Задержки проверки работоспособности

Эти метрики можно отправить в сборщик статистики, например StatsD.

Подробные журналы позволяют отслеживать запросы и устранять проблемы.

Заключение

В этой статье мы реализовали полнофункциональный балансировщик нагрузки в Node.js с помощью:

  • прекращение SSL
  • Проверка здоровья
  • Гибкий выбор сервера с настраиваемыми стратегиями
  • Грамотная обработка изменений топологии
  • Подробные метрики и журналы

Основные преимущества:

Высокая доступность — вышедшие из строя серверы быстро выводятся из ротации.

Гибкость — поддержка гетерогенных серверных частей, изменение стратегий балансировки.

Масштабируемость — масштабирование на нескольких серверах и в разных географических регионах.

Видимость – метрики дают представление о производительности и использовании.

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

Стеккадемический

Спасибо, что дочитали до конца. Прежде чем уйти:

  • Пожалуйста, рассмотрите возможность аплодировать и следовать автору! 👏
  • Следуйте за нами в Twitter(X), LinkedIn и YouTube.
  • Посетите Stackademic.com, чтобы узнать больше о том, как мы демократизируем бесплатное образование в области программирования во всем мире.