Пошук уроків, статей та іншого контенту
Розглянете автоматичне знаходження сервісів, реєстрацію екземплярів і балансування запитів у розподіленій системі.
Service Discovery — це механізм, за допомогою якого один сервіс знаходить мережеві адреси інших сервісів у розподіленій системі.
У простій системі адресу сервісу можна зберігати в конфігурації:
http://orders-service:8080Але в production-середовищі:
сервіс має кілька екземплярів;
екземпляри запускаються та зупиняються динамічно;
IP-адреси контейнерів або віртуальних машин змінюються;
окремі екземпляри можуть бути тимчасово недоступними;
потрібен розподіл запитів між доступними екземплярами.
Тому споживач сервісу не повинен покладатися на одну статичну адресу. Замість цього він отримує актуальний список доступних екземплярів через механізм Service Discovery.
Типова система Service Discovery складається з таких частин:
Service instance — запущений екземпляр сервісу.
Service registry — реєстр, у якому зберігаються адреси екземплярів.
Health checking — перевірка доступності екземплярів.
Discovery client або proxy — компонент, який отримує список екземплярів і вибирає один із них.
Балансування навантаження — алгоритм розподілу запитів.
Наприклад, якщо існує три екземпляри payment-service, реєстр може містити:
payment-service:
- 10.0.1.12:8080
- 10.0.1.13:8080
- 10.0.1.14:8080Коли checkout-service хоче виконати платіж, він звертається не до конкретної IP-адреси, а до списку екземплярів payment-service.
Екземпляр сервісу має повідомити системі discovery, що він доступний. Цей процес називається реєстрацією.
Зазвичай під час запуску сервіс передає:
логічне ім’я сервісу;
адресу хоста;
порт;
ідентифікатор екземпляра;
набір метаданих;
адресу health check;
час життя реєстрації або TTL.
Приклад запису:
{
"service": "payment-service",
"instanceId": "payment-7f9c",
"host": "10.0.1.12",
"port": 8080,
"healthCheck": "/health",
"zone": "eu-central-1a"
}У разі self-registration сам сервіс реєструє себе в registry:
1. Сервіс запускається.
2. Сервіс надсилає запит на реєстрацію.
3. Сервіс періодично поновлює TTL або надсилає heartbeat.
4. Під час завершення роботи сервіс видаляє себе з registry.Перевага цього підходу — простіша інфраструктура. Недолік — сервіс має знати протокол registry та коректно обробляти аварійні завершення.
У разі platform-managed registration реєстрацією керує платформа запуску:
оркестратор контейнерів;
внутрішній агент;
віртуальна машина або інший infrastructure-компонент.
Сервісу не потрібно напряму взаємодіяти з registry. Це зменшує зв’язність, але додає залежність від платформи.
Сам факт реєстрації не означає, що екземпляр досі працює. Процес може завершитися аварійно, а мережевий вузол — стати недоступним.
Є два основні підходи.
Екземпляр реєструється на обмежений час і періодично поновлює реєстрацію:
TTL = 30 секунд
heartbeat = кожні 10 секундЯкщо heartbeat не надходить протягом TTL, registry видаляє екземпляр.
Важливо, щоб інтервал heartbeat був меншим за TTL. Наприклад, heartbeat кожні 10 секунд при TTL 30 секунд дає запас для тимчасової затримки мережі.
Registry або окремий health-checker періодично перевіряє екземпляр:
GET /healthУспішна відповідь означає, що екземпляр доступний. Але health check повинен бути правильно спроєктований:
liveness відповідає на питання, чи працює процес;
readiness відповідає на питання, чи готовий він приймати трафік.
Екземпляр може бути живим, але не готовим обробляти запити, наприклад під час підключення до бази даних. Для Service Discovery зазвичай важливіша саме readiness-перевірка.
Registry повинен зберігати не лише адресу, а й стан екземпляра.
Приклад логічної моделі:
Instance:
serviceName
instanceId
host
port
status
metadata
lastHeartbeat
expiresAtПід час отримання списку registry має повертати лише актуальні екземпляри:
healthy instances =
registered instances
WHERE status = healthy
AND expiresAt > currentTimeВажливо, щоб instanceId був унікальним. Не варто використовувати лише host:port, якщо адреса може змінюватися або один хост запускає кілька екземплярів.
Клієнт сам звертається до registry, отримує список екземплярів і вибирає один із них.
Клієнт → Registry: список payment-service
Registry → Клієнт: A, B, C
Клієнт → Екземпляр B: HTTP-запитУ цьому випадку клієнт відповідає за:
кешування списку;
вибір екземпляра;
балансування;
повторні спроби;
видалення невдалого екземпляра з локального кешу.
Переваги:
немає окремого proxy на кожен запит;
клієнт може використовувати спеціалізовану стратегію балансування;
менше мережевих переходів.
Недоліки:
логіку discovery потрібно реалізувати для кожної технології;
клієнти стають залежними від протоколу registry;
складніше централізовано змінювати політики маршрутизації.
Клієнт надсилає запит до балансувальника або proxy, а той знаходить потрібний екземпляр.
Клієнт → Proxy → Екземпляр payment-serviceКлієнт знає лише адресу proxy, а логіка discovery централізована.
Переваги:
клієнти не знають про registry;
одна реалізація балансування;
простіше підтримувати різні типи клієнтів.
Недоліки:
додатковий мережевий компонент;
proxy може стати вузьким місцем;
потрібно масштабувати та моніторити proxy.
Після отримання списку екземплярів потрібно вибрати, куди надіслати запит.
Запити послідовно розподіляються між екземплярами:
A → B → C → A → B → CЦе простий алгоритм, який добре працює, якщо екземпляри мають приблизно однакову продуктивність, а запити мають схожу вартість.
Екземпляр вибирається випадково. Для великої кількості запитів навантаження зазвичай розподіляється достатньо рівномірно, але короткостроково можливі перекоси.
Новий запит надсилається екземпляру з найменшою кількістю активних з’єднань.
Цей підхід корисний, коли запити мають різну тривалість. Для його коректної роботи балансувальник повинен мати актуальну інформацію про активні з’єднання.
Екземплярам призначаються ваги:
A: 5
B: 3
C: 1Екземпляр із вагою 5 отримує приблизно в п’ять разів більше запитів, ніж екземпляр із вагою 1.
Ваги можуть враховувати:
кількість CPU;
обсяг пам’яті;
тип інстансу;
поточне навантаження;
розташування в зоні доступності.
Нижче наведено runnable-приклад на Node.js без зовнішніх залежностей. Він запускає:
registry;
два екземпляри сервісу;
клієнта, який отримує список екземплярів;
round-robin балансування;
heartbeat через TTL.
const http = require("node:http");
const crypto = require("node:crypto");
const registry = new Map();
const REGISTRY_PORT = 4000;
const TTL_MS = 5000;
function sendJson(response, statusCode, body) {
response.writeHead(statusCode, {
"Content-Type": "application/json",
});
response.end(JSON.stringify(body));
}
function readJson(request) {
return new Promise((resolve, reject) => {
let body = "";
request.on("data", (chunk) => {
body += chunk;
});
request.on("end", () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (error) {
reject(error);
}
});
request.on("error", reject);
});
}
function startRegistry() {
const server = http.createServer(async (request, response) => {
const url = new URL(request.url, `http://${request.headers.host}`);
try {
if (request.method === "POST" && url.pathname === "/register") {
const instance = await readJson(request);
if (
!instance.serviceName ||
!instance.instanceId ||
!instance.host ||
!instance.port
) {
return sendJson(response, 400, {
error: "Некоректні дані екземпляра",
});
}
const serviceInstances = registry.get(instance.serviceName) || [];
const updatedInstance = {
...instance,
expiresAt: Date.now() + TTL_MS,
};
const existingIndex = serviceInstances.findIndex(
(item) => item.instanceId === instance.instanceId
);
if (existingIndex === -1) {
serviceInstances.push(updatedInstance);
} else {
serviceInstances[existingIndex] = updatedInstance;
}
registry.set(instance.serviceName, serviceInstances);
return sendJson(response, 200, { registered: true });
}
if (request.method === "GET" && url.pathname === "/instances") {
const serviceName = url.searchParams.get("service");
if (!serviceName) {
return sendJson(response, 400, {
error: "Потрібен параметр service",
});
}
const instances = (registry.get(serviceName) || []).filter(
(instance) => instance.expiresAt > Date.now()
);
registry.set(serviceName, instances);
return sendJson(response, 200, { instances });
}
sendJson(response, 404, { error: "Маршрут не знайдено" });
} catch (error) {
sendJson(response, 500, { error: "Внутрішня помилка registry" });
}
});
server.listen(REGISTRY_PORT, () => {
console.log(`Registry працює на порту ${REGISTRY_PORT}`);
});
}
function requestJson(options, body) {
return new Promise((resolve, reject) => {
const request = http.request(options, (response) => {
let responseBody = "";
response.on("data", (chunk) => {
responseBody += chunk;
});
response.on("end", () => {
try {
const parsedBody = JSON.parse(responseBody);
if (response.statusCode >= 400) {
reject(new Error(parsedBody.error || "HTTP-помилка"));
return;
}
resolve(parsedBody);
} catch (error) {
reject(error);
}
});
});
request.on("error", reject);
if (body !== undefined) {
request.write(JSON.stringify(body));
}
request.end();
});
}
function registerInstance(instance) {
return requestJson(
{
hostname: "localhost",
port: REGISTRY_PORT,
path: "/register",
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
instance
);
}
function startService(port, name) {
const instanceId = `${name}-${crypto.randomUUID()}`;
const server = http.createServer((request, response) => {
if (request.url === "/health") {
return sendJson(response, 200, {
status: "ready",
instanceId,
});
}
sendJson(response, 200, {
service: name,
instanceId,
message: "Запит успішно оброблено",
});
});
server.listen(port, async () => {
const instance = {
serviceName: name,
instanceId,
host: "localhost",
port,
};
await registerInstance(instance);
console.log(`${instanceId} працює на порту ${port}`);
// Поновлюємо TTL, щоб registry не видалив екземпляр.
setInterval(() => {
registerInstance(instance).catch((error) => {
console.error(`Heartbeat не виконано: ${error.message}`);
});
}, TTL_MS / 2);
});
}
async function callService(serviceName, state) {
const result = await requestJson({
hostname: "localhost",
port: REGISTRY_PORT,
path: `/instances?service=${encodeURIComponent(serviceName)}`,
method: "GET",
});
const instances = result.instances;
if (instances.length === 0) {
throw new Error(`Немає доступних екземплярів ${serviceName}`);
}
// Round-robin вибирає наступний екземпляр у списку.
const instance = instances[state.index % instances.length];
state.index += 1;
return requestJson({
hostname: instance.host,
port: instance.port,
path: "/",
method: "GET",
});
}
startRegistry();
setTimeout(() => {
startService(5001, "catalog-service");
startService(5002, "catalog-service");
}, 100);
setTimeout(async () => {
const state = { index: 0 };
for (let attempt = 1; attempt <= 6; attempt += 1) {
try {
const response = await callService("catalog-service", state);
console.log(`Запит ${attempt}:`, response);
} catch (error) {
console.error(`Запит ${attempt} завершився помилкою:`, error.message);
}
}
}, 1000);Запуск:
node service-discovery.jsПриклад результату:
Registry працює на порту 4000
catalog-service-... працює на порту 5001
catalog-service-... працює на порту 5002
Запит 1: ...5001...
Запит 2: ...5002...
Запит 3: ...5001...Цей приклад демонструє базову модель, але не є production-ready registry. У реальній системі потрібно забезпечити:
конкурентний доступ до даних;
реплікацію registry;
автентифікацію;
перевірку health endpoint;
graceful deregistration;
захист від некоректних або застарілих записів;
тайм-аути та обмеження кількості запитів.
Клієнт не повинен звертатися до registry перед кожним запитом. Це створює зайве навантаження та робить registry критичним шляхом для кожної операції.
Типова схема:
1. Клієнт отримує список екземплярів.
2. Зберігає його локально на короткий час.
3. Використовує кеш для кількох запитів.
4. Періодично оновлює кеш.Кеш має мати обмежений час життя. Якщо кеш зберігати надто довго, клієнт продовжить надсилати запити до вже недоступних екземплярів.
З іншого боку, надто короткий TTL:
збільшує навантаження на registry;
створює більше мережевих запитів;
може погіршити стабільність під час коротких збоїв registry.
Практична стратегія — використовувати локальний кеш і оновлювати його у фоновому режимі, а не блокувати кожен бізнес-запит очікуванням відповіді registry.
Service Discovery не усуває мережеві помилки. Він лише допомагає знайти потенційно доступний екземпляр.
Клієнт повинен мати окремі політики для:
тайм-ауту підключення;
тайм-ауту відповіді;
повторних спроб;
вибору іншого екземпляра;
обмеження загального часу операції.
Наприклад:
Загальний deadline запиту: 2 секунди
Тайм-аут одного екземпляра: 500 мс
Максимум спроб: 2Важливо не робити нескінченні retry. Це може погіршити аварію та створити retry storm, коли кожен клієнт багаторазово повторює запити до перевантажених екземплярів.
Для операцій, які змінюють стан, повторна спроба може призвести до дублювання дії. Тому retry таких запитів потребує ідемпотентності або іншого механізму захисту.
Registry — це розподілений компонент, тому його дані можуть бути тимчасово неузгодженими.
Наприклад:
екземпляр уже завершив роботу;
один вузол registry ще вважає його доступним;
клієнт отримує застарілу адресу;
запит завершується помилкою.
Тому список із registry потрібно трактувати як кандидатів для запиту, а не як гарантію доступності.
Можливі моделі узгодженості:
сильна узгодженість — клієнти бачать актуальний стан, але система складніша та може мати більшу затримку;
eventual consistency — різні вузли тимчасово можуть повертати різні списки, зате система краще працює під час мережевих проблем.
У більшості систем клієнт додатково обробляє невдалий запит і видаляє непрацюючий екземпляр із локального кешу до наступного оновлення.
Якщо система працює в кількох зонах доступності або регіонах, балансування лише між усіма екземплярами може бути неефективним.
Запит бажано спочатку спрямувати:
до екземпляра в тій самій зоні;
потім до іншої зони того самого регіону;
і лише потім до іншого регіону.
Це зменшує:
мережеву затримку;
витрати на міжзонний трафік;
залежність від віддалених мережевих каналів.
Для цього registry має повертати метадані екземпляра, наприклад region, zone, version або capacity.
Service Discovery передає інфраструктурні дані, тому доступ до registry не повинен бути повністю відкритим.
Потрібно контролювати:
хто може реєструвати екземпляри;
хто може видаляти реєстрації;
хто може читати список сервісів;
чи перевіряється належність екземпляра до конкретного сервісу;
чи шифрується трафік між компонентами.
У production часто використовують:
автентифікацію сервісів;
авторизацію за сервісними ролями;
TLS для мережевих з’єднань;
підпис або перевірку службових метаданих.
Окремо потрібно захищати health endpoint. Якщо перевірка доступності змінює стан або повертає чутливі дані, її не слід залишати без контролю доступу.
Для діагностики Service Discovery варто вимірювати:
кількість зареєстрованих екземплярів;
кількість healthy і unhealthy екземплярів;
час відповіді registry;
кількість помилок реєстрації;
кількість пропущених heartbeat;
кількість запитів до кожного екземпляра;
кількість помилок після вибору екземпляра;
час оновлення локального кешу.
У логах корисно зберігати:
назву сервісу;
instanceId;
адресу вибраного екземпляра;
версію або зону;
причину повторної спроби;
стан discovery-кешу.
Без цих даних помилку «сервіс недоступний» важко відрізнити від проблеми registry, неправильного health check або невдалого балансування.
Повний життєвий цикл може мати такий вигляд:
1. Екземпляр запускається.
2. Він проходить внутрішню ініціалізацію.
3. Екземпляр стає ready.
4. Він реєструється в registry.
5. Надсилає heartbeat або проходить health checks.
6. Приймає трафік.
7. Переходить у стан draining перед завершенням.
8. Нові запити більше не спрямовуються до нього.
9. Поточні запити завершуються.
10. Екземпляр видаляється з registry.
11. Процес завершується.Стан draining особливо важливий під час rolling deployment. Якщо одразу завершити процес, частина запитів може потрапити на вже недоступний екземпляр.
IP-адреси динамічних екземплярів можуть змінюватися після перезапуску. Стабільною має бути логічна назва сервісу або адреса discovery-механізму.
Якщо реєстрація не має строку дії, registry накопичуватиме адреси вже неіснуючих екземплярів.
Health check виконується в конкретний момент. Одразу після нього екземпляр може стати недоступним, тому клієнт усе одно повинен мати тайм-аути та обробку помилок.
Це збільшує затримку та створює зайву залежність кожної операції від registry. Використовуйте локальне кешування з контрольованим TTL.
Retry без обмежень може посилити перевантаження. Встановлюйте максимальну кількість спроб, deadline та за потреби використовуйте exponential backoff.
До списку потрібно включати лише ready-екземпляри. Процес, який живий, але не готовий обробляти трафік, не повинен отримувати бізнес-запити.
Якщо неможливо однозначно ідентифікувати екземпляр, heartbeat, оновлення та видалення можуть змінювати неправильний запис.
Логіка пошуку екземпляра, кешування та вибору адреси має бути відокремлена від бізнес-операції. Це спрощує тестування й зміну стратегії балансування.
Service Discovery автоматично знаходить доступні екземпляри сервісів у розподіленій системі.
Registry зберігає адреси, ідентифікатори, метадані та стан екземплярів.
Екземпляри мають реєструватися, поновлювати TTL і коректно видалятися під час завершення роботи.
Client-side discovery передає логіку пошуку та балансування клієнту.
Server-side discovery делегує її proxy або балансувальнику.
Найпоширеніші алгоритми балансування — round-robin, random, least connections і weighted balancing.
Список із registry не гарантує доступність екземпляра, тому необхідні тайм-аути, retry та обробка помилок.
Health check має відрізняти стан процесу від готовності приймати трафік.
Кешування з обмеженим TTL зменшує навантаження на registry, але не повинно бути надто довгим.
У production важливі узгодженість registry, безпека, graceful shutdown і спостережуваність.