Создание масштабируемого балансировщика нагрузки с помощью 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, чтобы узнать больше о том, как мы демократизируем бесплатное образование в области программирования во всем мире.