Пошук уроків, статей та іншого контенту
Налаштуйте розподіл трафіку між екземплярами, health checks і стратегії балансування для стабільної роботи сервісу.
Балансування навантаження розподіляє вхідні запити між кількома екземплярами Node.js-сервісу.
Замість схеми:
Клієнти → один Node.js-процесвикористовується схема:
Клієнти → балансувальник → Node.js-1
→ Node.js-2
→ Node.js-3Балансувальник виконує кілька завдань:
приймає зовнішній трафік;
вибирає екземпляр для кожного запиту;
виключає недоступні екземпляри;
повертає клієнту відповідь;
іноді завершує TLS-з’єднання, обмежує швидкість запитів і веде журнали.
Node.js-процеси зазвичай запускають на різних портах або різних серверах. Балансувальник працює перед ними й приховує внутрішню структуру сервісу від клієнта.
Горизонтальне масштабування означає запуск кількох однакових екземплярів застосунку.
Щоб це працювало коректно, екземпляри мають бути максимально незалежними. Зокрема:
не зберігайте сесії лише в пам’яті одного процесу;
не покладайтеся на локальні файли як на єдине сховище даних;
не зберігайте важливий стан у глобальних змінних;
використовуйте спільне сховище для сесій, кешу або черг;
забезпечте однакову конфігурацію всіх екземплярів.
Якщо запит на авторизацію потрапив на node-1, а наступний запит користувача — на node-2, node-2 має мати доступ до необхідного стану.
Найпростіше рішення — використовувати самодостатні токени, наприклад JWT. Якщо стан сесії зберігається на сервері, його зазвичай розміщують у спільному сховищі.
Один Node.js-процес не використовує всі ядра процесора автоматично. Для балансування між процесами можна:
запустити кілька процесів вручну;
використовувати менеджер процесів;
розгорнути кілька контейнерів або віртуальних машин;
використати вбудовану кластеризацію Node.js.
На рівні інфраструктури балансувальнику байдуже, як саме запущено екземпляри. Для нього важливо, що кожен екземпляр доступний за окремою адресою і правильно відповідає на health checks.
Health check — це перевірка, за якою балансувальник визначає, чи можна надсилати трафік конкретному екземпляру.
Зазвичай потрібні два різні endpoints.
Liveness відповідає на питання:
Чи живий процес і чи здатен він приймати HTTP-запити?
Приклад:
GET /health/liveЯкщо цей endpoint повертає успішну відповідь, процес працює на базовому рівні.
Liveness не повинен перевіряти всі залежності сервісу. Якщо база даних тимчасово недоступна, процес може залишатися живим, але бути неготовим приймати звичайний трафік.
Readiness відповідає на питання:
Чи готовий цей екземпляр обробляти звичайні запити?
Приклад:
GET /health/readyReadiness може перевіряти:
підключення до бази даних;
доступність критичного зовнішнього сервісу;
завершення ініціалізації застосунку;
наявність необхідної конфігурації.
Якщо readiness повертає помилку, балансувальник припиняє надсилати нові запити цьому екземпляру.
const http = require('node:http');
const port = Number(process.env.PORT || 3000);
let isReady = true;
let isShuttingDown = false;
function sendJson(response, statusCode, body) {
const payload = JSON.stringify(body);
response.writeHead(statusCode, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(payload),
});
response.end(payload);
}
const server = http.createServer((request, response) => {
if (request.method === 'GET' && request.url === '/health/live') {
sendJson(response, 200, {
status: 'ok',
pid: process.pid,
});
return;
}
if (request.method === 'GET' && request.url === '/health/ready') {
if (!isReady || isShuttingDown) {
sendJson(response, 503, {
status: 'not_ready',
});
return;
}
sendJson(response, 200, {
status: 'ready',
pid: process.pid,
});
return;
}
if (request.method === 'GET' && request.url === '/') {
sendJson(response, 200, {
message: 'Hello from Node.js',
pid: process.pid,
});
return;
}
sendJson(response, 404, {
error: 'not_found',
});
});
server.listen(port, '0.0.0.0', () => {
console.log(`Server ${process.pid} is listening on port ${port}`);
});
function shutdown(signal) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
isReady = false;
console.log(`Received ${signal}. Stopping new requests`);
// Припиняємо приймати нові з'єднання, але даємо поточним завершитися.
server.close((error) => {
if (error) {
console.error('Failed to close server', error);
process.exitCode = 1;
return;
}
console.log('Server closed gracefully');
process.exit(0);
});
// Захист від завислого з'єднання або обробника.
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));Запуск двох екземплярів:
PORT=3001 node server.js
PORT=3002 node server.jsПеревірка:
curl http://127.0.0.1:3001/health/live
curl http://127.0.0.1:3001/health/readyПід час завершення процес спочатку повертає 503 для readiness, а потім припиняє приймати нові з’єднання. Це дає балансувальнику час виключити екземпляр із пулу.
Різке завершення процесу небезпечне для сервісу. Воно може:
обірвати активні HTTP-запити;
залишити транзакцію незавершеною;
перервати запис у зовнішню систему;
спричинити помилки для клієнтів.
Типовий порядок graceful shutdown:
Екземпляр позначає себе як not ready.
Балансувальник перестає надсилати йому нові запити.
Сервер припиняє приймати нові з’єднання.
Поточні запити завершуються.
Закриваються підключення до бази даних та інших ресурсів.
Процес завершується.
Health check не виключає екземпляр миттєво. Балансувальник перевіряє стан через певні інтервали, тому завершення має враховувати цю затримку.
Round robin послідовно розподіляє запити між серверами:
Запит 1 → node-1
Запит 2 → node-2
Запит 3 → node-3
Запит 4 → node-1Це проста стратегія, яка добре працює, якщо:
екземпляри мають приблизно однакову продуктивність;
запити мають подібну тривалість;
між запитами немає значної різниці в навантаженні.
Це часто найкращий початковий вибір для stateless HTTP API.
Екземплярам призначаються ваги. Потужніший сервер отримує більшу частку трафіку.
Наприклад:
node-1: weight 3
node-2: weight 1У середньому node-1 отримуватиме приблизно втричі більше запитів.
Таку стратегію використовують, якщо сервери мають різні ресурси або частина інфраструктури поступово виводиться з експлуатації.
Балансувальник надсилає новий запит на екземпляр із найменшою кількістю активних з’єднань.
Стратегія корисна, коли запити мають дуже різну тривалість. Наприклад:
один запит завершується за 10 мілісекунд;
інший утримує з’єднання кілька секунд.
Round robin у такій ситуації може тимчасово перевантажити один екземпляр, а least connections краще врахує поточний стан.
Балансувальник обчислює хеш від IP-адреси клієнта й намагається надсилати його на той самий екземпляр.
Це може бути корисним для сумісності зі stateful-сесіями, але має недоліки:
багато користувачів можуть мати одну зовнішню IP-адресу;
користувач може змінити мережу;
один екземпляр може отримати непропорційно багато трафіку;
збільшення або зменшення пулу змінює розподіл.
IP hash не замінює правильне спільне зберігання сесій.
Нижче наведено приклад балансування двох Node.js-екземплярів через HAProxy:
global
log stdout format raw local0
defaults
mode http
log global
timeout connect 5s
timeout client 30s
timeout server 30s
frontend http_in
bind *:80
default_backend node_api
backend node_api
balance roundrobin
option httpchk GET /health/ready
http-check expect status 200
server api_1 127.0.0.1:3001 check inter 3s fall 3 rise 2
server api_2 127.0.0.1:3002 check inter 3s fall 3 rise 2Параметри health check:
inter 3s — перевіряти екземпляр кожні три секунди;
fall 3 — виключити екземпляр після трьох невдалих перевірок;
rise 2 — повернути екземпляр у пул після двох успішних перевірок;
option httpchk — виконувати HTTP health check;
http-check expect status 200 — вважати екземпляр здоровим лише з кодом 200.
Захист від короткочасних мережевих збоїв забезпечують параметри fall і rise. Без них балансувальник може надто часто виключати та повертати екземпляр у пул.
Занадто рідкісні перевірки призводять до того, що трафік ще довго надходитиме на несправний екземпляр.
Занадто часті перевірки:
створюють зайвий трафік;
збільшують навантаження на сервіс;
можуть створити хибні спрацьовування під час коротких піків.
Під час вибору параметрів потрібно враховувати:
скільки часу допустимо надсилати запити на несправний екземпляр;
скільки невдалих перевірок вважати проблемою;
скільки успішних перевірок потрібно для повернення екземпляра;
тривалість запуску застосунку;
час graceful shutdown.
Health endpoint має бути швидким і не виконувати важкі операції.
Екземпляр не повинен отримувати трафік одразу після запуску, якщо він ще не завершив ініціалізацію.
Типовий процес:
Процес запускається.
isReady має значення false.
Відкриваються підключення до необхідних ресурсів.
Виконується перевірка конфігурації.
Після успішної ініціалізації isReady стає true.
Балансувальник починає надсилати трафік.
Якщо критична залежність недоступна, readiness повинен повертати 503, навіть коли сам Node.js-процес працює.
Перевірки залежностей мають мати обмежений час очікування. Health check не повинен зависати на десятки секунд через повільну базу даних або зовнішній API.
Припустімо, що пул складається з трьох екземплярів:
node-1 — healthy
node-2 — unhealthy
node-3 — healthyБалансувальник:
продовжує перевіряти node-2;
припиняє надсилати йому нові запити;
розподіляє нові запити між node-1 і node-3;
повертає node-2 у пул після достатньої кількості успішних перевірок.
Активний запит, який уже оброблявся несправним екземпляром, не завжди можна безпечно повторити. Це залежить від типу операції.
Повторювати GET зазвичай безпечніше, ніж POST, оскільки повторна операція запису може створити дубль. Якщо балансувальник або клієнт повторює запити, операції запису мають бути ідемпотентними або захищеними ідемпотентним ключем.
Важливо розрізняти:
помилку з’єднання з екземпляром;
HTTP-помилку, яку повернув сам застосунок;
тайм-аут;
помилку health check.
Якщо екземпляр не відповідає, балансувальник може спробувати інший екземпляр. Але автоматичний повтор небезпечний для операцій, які змінюють стан.
Зазвичай повтори дозволяють лише для окремих методів або типів помилок. Не слід безумовно повторювати всі запити після тайм-ауту.
Для діагностики балансування кожен екземпляр має додавати до журналів:
ідентифікатор екземпляра;
PID процесу;
HTTP-метод;
шлях;
статус відповіді;
тривалість обробки;
ідентифікатор запиту.
У прикладі вище PID повертається в API-відповіді. У production-сервісі краще використовувати окремий заголовок або журнал, а не покладатися на тіло відповіді.
Наприклад, різні PID у відповідях допомагають перевірити, що трафік дійсно розподіляється між процесами.
Відкритий TCP-порт не означає, що застосунок готовий працювати. Процес може зависнути під час доступу до бази даних або мати некоректну конфігурацію.
Health check має перевіряти саме той рівень готовності, який необхідний для приймання трафіку.
Якщо readiness залежить від бази даних, а база тимчасово недоступна, не обов’язково потрібно перезапускати процес.
Розділяйте:
liveness — процес ще працює;
readiness — процес зараз може обробляти трафік.
Після перемикання на інший екземпляр користувач може втратити сесію. Для масштабованого сервісу стан сесій має бути спільним або запит має містити всю необхідну інформацію для автентифікації.
Примусове завершення процесу збільшує кількість обірваних запитів і помилок під час деплою.
Перед завершенням спочатку вимикайте readiness, а потім закривайте сервер.
Одна короткочасна помилка мережі не завжди означає відмову екземпляра. Використовуйте кілька невдалих перевірок для виключення і кілька успішних — для повернення.
Якщо один екземпляр обробляє довгі запити, round robin може створити нерівномірне навантаження. Для такого профілю трафіку може краще підійти least connections.
Закріплення клієнта за конкретним екземпляром маскує проблему локального стану, але зменшує гнучкість балансування й ускладнює відновлення після відмови.
Спочатку проєктуйте сервіс як stateless, а sticky sessions використовуйте лише за обґрунтованої потреби.
Для перевірки конфігурації:
Запустіть два екземпляри на різних портах.
Додайте їх до backend балансувальника.
Переконайтеся, що /health/ready повертає 200.
Надішліть кілька запитів через адресу балансувальника.
Перевірте PID або ідентифікатор екземпляра у відповідях.
Завершіть один процес через SIGTERM.
Переконайтеся, що він повертає 503 для readiness.
Перевірте, що нові запити продовжують оброблятися другим екземпляром.
Запустіть перший екземпляр знову й дочекайтеся його повернення в пул.
Балансувальник розподіляє трафік між кількома Node.js-екземплярами.
Для горизонтального масштабування застосунок має бути stateless або використовувати спільне сховище стану.
Liveness перевіряє, чи живий процес.
Readiness перевіряє, чи готовий процес приймати трафік.
Round robin підходить для однакових екземплярів і схожих запитів.
Least connections краще працює при запитах із різною тривалістю.
Weighted round robin враховує різну потужність серверів.
Health checks мають виключати несправні екземпляри й повертати їх після стабілізації.
Graceful shutdown починається з вимкнення readiness.
Повтори запитів потрібно обмежувати, особливо для операцій, що змінюють стан.