Пошук уроків, статей та іншого контенту
Розберете алгоритми балансування навантаження, health checks і розподіл трафіку між екземплярами сервісу.
Load balancer, або балансувальник навантаження, розподіляє вхідні запити між кількома екземплярами одного сервісу.
Замість того щоб клієнти зверталися безпосередньо до конкретного сервера:
Клієнт → Сервер 1вони звертаються до єдиної адреси балансувальника:
Клієнт → Load Balancer → Сервер 1
Сервер 2
Сервер 3Балансувальник може:
рівномірно розподіляти запити;
не направляти трафік на недоступні екземпляри;
враховувати поточну кількість активних з’єднань;
використовувати різні ваги для серверів;
завершувати TLS-з’єднання;
додавати або видаляти екземпляри без зміни адреси для клієнта.
Один екземпляр сервісу має обмежену пропускну здатність. Якщо всі запити надходять до нього, виникають такі проблеми:
збільшується час відповіді;
зростає кількість помилок;
один сервер стає єдиною точкою відмови;
складно виконувати оновлення без простою.
Кілька екземплярів дають змогу:
обробляти більше запитів;
переживати відмову окремого сервера;
масштабувати сервіс горизонтально;
поступово розгортати нову версію.
Балансувальник сам також має бути відмовостійким. Один балансувальник, через який проходить увесь трафік, може стати єдиною точкою відмови. У production-системах зазвичай використовують керований сервіс або кілька балансувальників із резервуванням.
Балансувальник працює на транспортному рівні та враховує:
IP-адресу;
TCP або UDP;
порт;
стан з’єднання.
L4-балансувальник не аналізує HTTP-метод, шлях або заголовки. Він просто передає мережевий трафік до одного з доступних серверів.
Переваги:
висока продуктивність;
мала затримка;
підходить не лише для HTTP.
Недоліки:
неможливо маршрутизувати запити за шляхом або заголовком;
складніше виконувати HTTP-specific health checks;
балансувальник має менше інформації про сам запит.
Балансувальник працює на рівні HTTP і може аналізувати:
HTTP-метод;
шлях запиту;
заголовки;
cookies;
статус відповіді.
Наприклад:
/api/* → API-сервери
/images/* → сервери статичних файлів
/admin/* → окремий пул серверівL7-балансування дає більше можливостей, але потребує обробки HTTP-запитів і тому може мати більші вимоги до ресурсів.
Балансувальник послідовно передає запити кожному доступному екземпляру:
Запит 1 → Сервер 1
Запит 2 → Сервер 2
Запит 3 → Сервер 3
Запит 4 → Сервер 1Це простий і передбачуваний алгоритм.
Він добре працює, коли:
екземпляри мають приблизно однакові ресурси;
запити мають приблизно однакову вартість;
тривалість обробки запитів схожа.
Round robin може працювати погано, якщо один запит обробляється 10 мілісекунд, а інший — 10 секунд. У такому випадку кількість призначених запитів не відображає реальне навантаження.
Кожному екземпляру задається вага. Сервер із більшою вагою отримує більше запитів.
Наприклад:
Сервер 1: вага 5
Сервер 2: вага 3
Сервер 3: вага 2За 10 запитів приблизний розподіл буде таким:
Сервер 1: 5 запитів
Сервер 2: 3 запити
Сервер 3: 2 запитиЦе корисно, коли сервери мають різну потужність або нова версія сервісу тимчасово повинна отримувати лише частину трафіку.
Вага не гарантує точного розподілу для малої кількості запитів. Вона визначає розподіл на довшому проміжку часу.
Новий запит передається екземпляру з найменшою кількістю активних з’єднань.
Сервер 1: 8 активних з’єднань
Сервер 2: 3 активних з’єднання
Сервер 3: 5 активних з’єднань
Новий запит → Сервер 2Цей алгоритм краще за round robin підходить для запитів із різною тривалістю виконання.
Водночас кількість з’єднань не завжди показує реальне навантаження. Одне з’єднання може виконувати дуже важку операцію, а десятки інших — бути майже неактивними.
Це варіант least connections, який враховує вагу сервера. Умовний показник може обчислюватися так:
ефективне навантаження = активні з’єднання / вагаСервер із більшою вагою може прийняти більше з’єднань до того, як його показник стане найбільшим.
Балансувальник обирає сервер, який має найменший час відповіді або найкраще поєднання часу відповіді та кількості активних з’єднань.
Перевага — можливість враховувати фактичну продуктивність серверів.
Недоліки:
потрібні вимірювання;
значення можуть коливатися;
короткочасний сплеск затримки може тимчасово змінити розподіл;
алгоритм складніший у реалізації.
Балансувальник обчислює хеш від певного значення, наприклад IP-адреси клієнта або ключа сесії:
hash(clientId) % кількість_серверівОдин і той самий клієнт зазвичай потрапляє до того самого екземпляра.
Це іноді використовують для legacy-сесій, коли стан зберігається в пам’яті сервера. Проте надійніша архітектура — зберігати сесії у спільному сховищі, а самі екземпляри робити stateless.
Звичайний hash може перерозподілити велику кількість клієнтів після додавання або видалення одного сервера. Для зменшення такого ефекту використовують consistent hashing.
Балансувальник не повинен направляти трафік на екземпляр, який не може обробляти запити. Для цього він виконує перевірки стану — health checks.
Балансувальник самостійно періодично надсилає запит до сервера:
GET /health HTTP/1.1Якщо сервер відповідає успішно, він вважається доступним. Якщо відповіді немає або вона має помилковий статус, сервер може бути тимчасово виключений із пулу.
Поширені параметри:
інтервал між перевірками;
timeout;
кількість невдалих перевірок для виключення;
кількість успішних перевірок для повернення;
допустимі HTTP-статуси.
Не слід робити health endpoint надто важким. Він має виконувати лише необхідні перевірки.
У сервісах часто розділяють дві перевірки.
Liveness відповідає на запитання:
Чи процес ще працює?
Якщо liveness-перевірка постійно не проходить, процес можна перезапустити.
Readiness відповідає на запитання:
Чи готовий процес приймати трафік?
Процес може бути запущеним, але не готовим обробляти запити, наприклад під час:
запуску;
міграції;
тимчасової втрати з’єднання з критичною залежністю;
контрольованого вимкнення.
Балансувальник зазвичай має орієнтуватися саме на readiness.
Балансувальник також може аналізувати реальні відповіді на запити:
timeout;
помилки з’єднання;
велику кількість відповідей 5xx.
Якщо сервер регулярно не відповідає, балансувальник тимчасово припиняє надсилати йому трафік.
Найкраще поєднувати active і passive checks:
active check заздалегідь виявляє проблему;
passive check враховує реальну поведінку під навантаженням.
Flapping — це часте перемикання сервера між станами «доступний» і «недоступний».
Наприклад:
доступний → недоступний → доступний → недоступнийЩоб уникати flapping, не потрібно виключати сервер після однієї невдалої перевірки. Замість цього використовують пороги:
3 невдалі перевірки → виключити сервер
2 успішні перевірки → повернути серверТакі пороги залежать від вимог системи та тривалості перевірки.
Балансувальник має список backend-екземплярів:
service-a:
- 10.0.0.11:8080
- 10.0.0.12:8080
- 10.0.0.13:8080Для кожного екземпляра потрібно зберігати щонайменше:
адресу;
порт;
статус health check;
вагу, якщо використовується weighted algorithm;
кількість активних з’єднань або запитів.
Після health check балансувальник працює не з усім списком, а лише з доступними екземплярами:
Усі екземпляри: 1, 2, 3
Доступні екземпляри: 1, 3Трафік має йти тільки до серверів 1 і 3.
Якщо жоден сервер не пройшов health check, балансувальник не може успішно обробити запит.
Зазвичай клієнт отримує:
503 Service Unavailable;
або іншу помилку, визначену архітектурою.
Не варто безконтрольно повторювати запит на всі сервери. Це може збільшити навантаження під час аварії.
Вага повинна змінюватися контрольовано. Наприклад, під час canary deployment можна налаштувати:
Стара версія: 90%
Нова версія: 10%Після перевірки метрик частку нової версії можна поступово збільшувати.
Важливо контролювати:
частку помилок;
latency;
навантаження на CPU і пам’ять;
бізнес-метрики;
кількість активних з’єднань.
Нижче наведено runnable-приклад. Він:
запускає два backend-сервери;
запускає HTTP-балансувальник;
використовує round robin;
періодично виконує health check;
не направляє запити на екземпляри, які не пройшли перевірку;
повертає 503, якщо доступних екземплярів немає.
Збережіть код у файл load-balancer.js і запустіть командою:
node load-balancer.jsconst http = require('node:http');
const backends = [
{
name: 'backend-1',
port: 3001,
healthy: false,
},
{
name: 'backend-2',
port: 3002,
healthy: false,
},
];
let nextBackendIndex = 0;
function startBackend(backend) {
const server = http.createServer((request, response) => {
if (request.url === '/health') {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ status: 'ok' }));
return;
}
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify({
backend: backend.name,
path: request.url,
}),
);
});
server.listen(backend.port, () => {
console.log(`${backend.name} слухає порт ${backend.port}`);
});
}
function checkBackend(backend) {
return new Promise((resolve) => {
const request = http.get(
{
hostname: '127.0.0.1',
port: backend.port,
path: '/health',
timeout: 500,
},
(response) => {
// Health check вважається успішним лише для статусів 2xx.
const isHealthy =
response.statusCode >= 200 && response.statusCode < 300;
response.resume();
resolve(isHealthy);
},
);
request.on('timeout', () => {
request.destroy();
resolve(false);
});
request.on('error', () => {
resolve(false);
});
});
}
async function updateHealthStatuses() {
for (const backend of backends) {
backend.healthy = await checkBackend(backend);
}
const statuses = backends
.map((backend) => `${backend.name}: ${backend.healthy ? 'UP' : 'DOWN'}`)
.join(', ');
console.log(`Стан екземплярів: ${statuses}`);
}
function chooseBackend() {
const healthyBackends = backends.filter((backend) => backend.healthy);
if (healthyBackends.length === 0) {
return null;
}
// Round robin виконується лише серед доступних екземплярів.
const backend = healthyBackends[nextBackendIndex % healthyBackends.length];
nextBackendIndex = (nextBackendIndex + 1) % healthyBackends.length;
return backend;
}
const loadBalancer = http.createServer((request, response) => {
const backend = chooseBackend();
if (!backend) {
response.writeHead(503, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify({
error: 'Усі екземпляри сервісу недоступні',
}),
);
return;
}
const proxyRequest = http.request(
{
hostname: '127.0.0.1',
port: backend.port,
path: request.url,
method: request.method,
headers: request.headers,
},
(backendResponse) => {
response.writeHead(
backendResponse.statusCode,
backendResponse.headers,
);
backendResponse.pipe(response);
},
);
proxyRequest.on('error', () => {
// У production тут також потрібні метрики та обмежене повторне виконання.
if (!response.headersSent) {
response.writeHead(502, { 'Content-Type': 'application/json' });
response.end(
JSON.stringify({
error: 'Помилка з’єднання з backend',
}),
);
} else {
response.destroy();
}
});
request.pipe(proxyRequest);
});
for (const backend of backends) {
startBackend(backend);
}
setTimeout(async () => {
await updateHealthStatuses();
setInterval(updateHealthStatuses, 2000);
loadBalancer.listen(3000, () => {
console.log('Load balancer слухає порт 3000');
console.log('Перевірка: curl http://localhost:3000/orders');
});
}, 100);Після запуску кілька запитів до http://localhost:3000/orders будуть почергово передаватися різним backend-екземплярам:
{"backend":"backend-1","path":"/orders"}{"backend":"backend-2","path":"/orders"}У прикладі health check виконується кожні дві секунди. Якщо один із backend-процесів зупинити, він буде позначений як DOWN, і балансувальник припинить надсилати йому нові запити.
Балансувальник повинен мати обмеження часу для:
встановлення з’єднання;
очікування заголовків відповіді;
отримання всього тіла відповіді.
Без timeout завислий backend може утримувати ресурси нескінченно довго.
Значення timeout має бути узгоджене з очікуваною тривалістю операцій. Надто малий timeout створює помилки для нормальних повільних запитів, а надто великий — довго утримує ресурси.
Під час вимкнення або оновлення екземпляр не слід одразу видаляти, якщо в ньому ще виконуються запити.
Процес зазвичай має такий вигляд:
Екземпляр позначається як not ready.
Балансувальник припиняє направляти до нього нові запити.
Поточні запити завершуються.
Після timeout екземпляр примусово зупиняється.
Це називають connection draining або graceful shutdown.
Повторне виконання запиту може допомогти, якщо один backend тимчасово недоступний. Але retry небезпечний для операцій, які змінюють стан.
Наприклад, якщо запит на створення замовлення успішно дійшов до сервера, але відповідь загубилася, повтор може створити друге замовлення.
Retry потрібно використовувати обережно:
лише для операцій, які безпечно повторювати;
з обмеженою кількістю спроб;
із timeout;
бажано з idempotency key для змінювальних операцій.
Для балансувальника важливо збирати метрики:
кількість запитів на кожен екземпляр;
кількість відповідей 2xx, 4xx і 5xx;
latency;
кількість timeout;
кількість активних з’єднань;
кількість здорових і нездорових екземплярів;
частку трафіку кожної версії.
Корисно додавати до логів:
ідентифікатор запиту;
обраний backend;
час очікування;
статус відповіді;
причину виключення backend.
Без цих даних складно зрозуміти, чи проблема виникла на балансувальнику, у конкретному екземплярі або в залежності сервісу.
Round robin розподіляє запити, але не враховує:
різну потужність серверів;
різну тривалість запитів;
поточну кількість активних з’єднань.
Для простих однорідних сервісів цього достатньо, але для нерівномірного навантаження краще розглянути least connections або weighted algorithm.
Процес може відповідати на /health, але не мати доступу до бази даних або черги повідомлень. Якщо сервіс без цих залежностей не може обробляти запити, readiness check має враховувати критичні залежності.
Водночас не всі тимчасові проблеми залежностей повинні автоматично виключати екземпляр. Health check має відображати саме готовність приймати трафік, а не перевіряти абсолютно все.
Мережевий пакет може бути втрачений, а короткий сплеск навантаження — тимчасовим. Виключення після однієї помилки створює flapping. Використовуйте пороги невдалих і успішних перевірок.
Без timeout один проблемний backend може зайняти всі з’єднання балансувальника. Кожна мережева операція повинна мати обмеження часу.
Якщо стан користувача зберігається лише в пам’яті одного екземпляра, звичайний розподіл трафіку може спричинити втрату сесії. Sticky sessions можуть тимчасово вирішити проблему, але вони ускладнюють масштабування та відмовостійкість.
Проксі має коректно передавати:
метод;
шлях;
потрібні заголовки;
тіло запиту;
статус і заголовки відповіді.
Також потрібно враховувати великі тіла запитів і коректно завершувати з’єднання після помилок.
Якщо екземпляр одразу зупиняється під час деплою, активні запити можуть завершитися помилкою. Спочатку потрібно прибрати його з розподілу трафіку, а потім дочекатися завершення поточних операцій.
Орієнтовний вибір:
Round robin — однакові екземпляри та приблизно однакові запити.
Weighted round robin — екземпляри мають різну потужність або потрібен контроль частки трафіку.
Least connections — запити мають різну тривалість.
Least response time — важливо враховувати фактичну latency.
Consistent hashing — потрібна приблизна стабільність маршрутизації за ключем.
Незалежно від алгоритму необхідні:
health checks;
timeout;
обмеження кількості retry;
graceful shutdown;
метрики та логування.
Load balancer розподіляє трафік між кількома екземплярами сервісу.
L4 працює з мережевими з’єднаннями, а L7 розуміє HTTP-запити.
Round robin простий, але не враховує фактичне навантаження.
Weighted round robin дає змогу розподіляти різні частки трафіку.
Least connections орієнтується на кількість активних з’єднань.
Health checks визначають, які екземпляри готові приймати трафік.
Для стабільності потрібні timeout, пороги health checks і connection draining.
Балансувальник потрібно моніторити так само, як і backend-сервіси.