Пошук уроків, статей та іншого контенту
Розберемо Keep-Alive, заголовок Connection, повторне використання TCP-з’єднань і коректне завершення сервера.
У HTTP/1.1 TCP-з’єднання за замовчуванням може використовуватися для кількох послідовних HTTP-запитів. Це називається persistent connection або Keep-Alive.
Без повторного використання з’єднання кожен запит вимагав би:
встановити TCP-з’єднання;
виконати TCP handshake;
за HTTPS — додатково виконати TLS handshake;
передати HTTP-запит;
отримати відповідь;
закрити TCP-з’єднання.
Для наступного запиту цей процес повторився б знову. Keep-Alive дозволяє залишити TCP-з’єднання відкритим після відповіді та використати його повторно.
Це зменшує:
кількість TCP handshake;
кількість TLS handshake;
затримку для наступних запитів;
навантаження на сервер і клієнт.
Keep-Alive не означає, що з’єднання буде відкритим безстроково. Сервер або клієнт можуть закрити його через тайм-аут, перевищення лімітів або під час завершення роботи.
ConnectionУ HTTP/1.1 заголовок Connection використовується для керування властивостями конкретного TCP-з’єднання.
Найпоширеніші значення:
Connection: keep-aliveКлієнт повідомляє, що хоче зберегти з’єднання після відповіді.
Connection: closeСторона повідомляє, що з’єднання потрібно закрити після завершення поточного обміну.
Для HTTP/1.1 Keep-Alive є типовою поведінкою, тому заголовок:
Connection: keep-aliveчасто не обов’язково вказувати явно.
З’єднання може бути закрито, якщо:
одна зі сторін передала Connection: close;
сплив тайм-аут простоювання;
сервер досяг ліміту активних або вільних з’єднань;
сталася помилка читання чи запису;
сервер завершує роботу;
відповідь не дозволяє надійно визначити її кінець.
HTTP/1.1 має визначити межу відповіді. Зазвичай це робиться через:
Content-Length;
Transfer-Encoding: chunked;
закриття з’єднання.
Якщо сервер закриває з’єднання, щоб позначити кінець відповіді, повторно використати його вже неможливо.
Connection — hop-by-hop заголовокConnection стосується лише поточного мережевого переходу між двома сторонами. Це hop-by-hop заголовок.
Проксі не повинен бездумно передавати значення Connection на наступний мережевий перехід. Наприклад, клієнтське з’єднання з проксі та з’єднання проксі з Node.js-сервером можуть мати різний життєвий цикл.
Node.js автоматично підтримує повторне використання HTTP/1.1-з’єднань. Обробник request може викликатися кілька разів для одного TCP-сокета:
TCP socket
├── HTTP request 1 → HTTP response 1
├── HTTP request 2 → HTTP response 2
└── HTTP request 3 → HTTP response 3Ці запити виконуються послідовно. HTTP/1.1 Keep-Alive не забезпечує мультиплексування: новий запит зазвичай очікує завершення попереднього на цьому самому з’єднанні.
Для HTTP-сервера важливі кілька різних тайм-аутів:
server.keepAliveTimeout — скільки мілісекунд сервер чекає на наступний запит після завершення відповіді;
server.headersTimeout — максимальний час очікування повних HTTP-заголовків;
server.requestTimeout — максимальний час отримання всього запиту;
server.timeout — тайм-аут неактивності сокета, якщо його налаштовано.
keepAliveTimeout стосується саме періоду між запитами, а не часу обробки активного запиту.
import http from 'node:http';
const server = http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8'
});
res.end(JSON.stringify({
method: req.method,
url: req.url
}));
});
server.keepAliveTimeout = 5_000;
server.headersTimeout = 6_000;
server.requestTimeout = 30_000;
server.listen(3000, () => {
console.log('Сервер слухає порт 3000');
});Тайм-аут headersTimeout має бути більшим за keepAliveTimeout. Це допомагає уникати ситуацій, коли сервер завершує очікування заголовків раніше, ніж очікування вільного Keep-Alive-з’єднання.
Конкретні значення залежать від характеру застосунку:
для API з короткими запитами часто підходять невеликі значення;
для повільних клієнтів потрібні обережніші ліміти;
надто великі тайм-аути можуть дозволити клієнтам займати сокети надовго;
надто малі тайм-аути збільшують кількість повторних з’єднань.
На стороні Node.js повторне використання TCP-з’єднань контролює http.Agent.
Якщо кожен запит створювати без налаштованого агента повторного використання, клієнт може частіше створювати нові TCP-з’єднання. Для великої кількості запитів це неефективно.
import http from 'node:http';
const agent = new http.Agent({
keepAlive: true,
maxSockets: 10,
maxFreeSockets: 2,
scheduling: 'lifo'
});
function makeRequest(path) {
return new Promise((resolve, reject) => {
const request = http.request({
hostname: '127.0.0.1',
port: 3000,
path,
method: 'GET',
agent
}, (response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
body += chunk;
});
response.on('end', () => {
resolve({
statusCode: response.statusCode,
body,
localPort: response.socket?.localPort
});
});
});
request.on('error', reject);
request.end();
});
}
const first = await makeRequest('/first');
const second = await makeRequest('/second');
console.log(first);
console.log(second);
agent.destroy();keepAlive: true залишає вільні TCP-з’єднання в пулі агента.
http.AgentmaxSockets — максимальна кількість одночасних сокетів для одного хоста;
maxFreeSockets — максимальна кількість вільних сокетів, які можна залишати в пулі;
scheduling — порядок вибору вільного сокета:
lifo — найновіший вільний сокет;
fifo — найстаріший вільний сокет.
Пул з’єднань ведеться окремо для різних комбінацій протоколу, хоста та порту. З’єднання з 127.0.0.1:3000 не буде використане для іншого хоста або порту.
У попередньому прикладі localPort — локальний порт клієнта. Якщо два послідовні запити використали той самий сокет, локальний порт зазвичай буде однаковим.
Це діагностичний сигнал, а не контракт застосунку. З’єднання може бути закрито сервером між запитами, тому клієнт повинен коректно обробляти створення нового сокета.
Наступний приклад:
запускає HTTP-сервер;
виконує два послідовні запити через один Agent;
дозволяє повторно використати Keep-Alive-з’єднання;
відстежує відкриті сокети;
демонструє коректне завершення сервера.
import http from 'node:http';
const sockets = new Set();
const server = http.createServer((req, res) => {
// Невелика затримка робить життєвий цикл з'єднання помітнішим
setTimeout(() => {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8'
});
res.end(JSON.stringify({
path: req.url,
message: 'Відповідь отримано'
}));
}, 50);
});
server.keepAliveTimeout = 5_000;
server.headersTimeout = 6_000;
server.requestTimeout = 30_000;
server.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => {
sockets.delete(socket);
});
});
function request(agent, path) {
return new Promise((resolve, reject) => {
const req = http.request({
hostname: '127.0.0.1',
port: 3000,
path,
method: 'GET',
agent
}, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
resolve({
statusCode: res.statusCode,
body,
connectionHeader: res.headers.connection,
localPort: res.socket?.localPort
});
});
});
req.on('error', reject);
req.end();
});
}
function closeServer() {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
// Припиняємо очікування нових з'єднань і закриваємо вільні Keep-Alive-сокети
server.closeIdleConnections?.();
});
}
async function main() {
await new Promise((resolve) => {
server.listen(3000, '127.0.0.1', resolve);
});
const agent = new http.Agent({
keepAlive: true,
maxSockets: 1,
maxFreeSockets: 1
});
try {
const first = await request(agent, '/first');
const second = await request(agent, '/second');
console.log(first);
console.log(second);
console.log(`Відкритих сокетів сервера: ${sockets.size}`);
} finally {
// Агент більше не створює і не зберігає клієнтські сокети
agent.destroy();
await closeServer();
console.log('Сервер коректно завершив роботу');
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});У нормальному сценарії другий запит може використати той самий TCP-сокет, що й перший. Але це залежить від того, чи сервер не закрив з’єднання між запитами.
Коректне завершення називають graceful shutdown. Його мета — не обривати активні запити та не залишати клієнтів у невизначеному стані.
Типова послідовність:
перестати приймати нові TCP-з’єднання;
дозволити активним HTTP-запитам завершитися;
закрити вільні Keep-Alive-з’єднання;
дочекатися закриття сокетів;
після крайнього терміну примусово закрити те, що залишилося.
server.close()Виклик:
server.close();припиняє приймання нових з’єднань. Активні запити мають можливість завершитися.
Сам по собі виклик не означає миттєве знищення всіх сокетів. Якщо клієнт тримає Keep-Alive-з’єднання у вільному стані або запит завис, процес може продовжити працювати.
У сучасних версіях Node.js server.close() також має поведінку для закриття вільних з’єднань, але для явного контролю можна використовувати:
server.closeIdleConnections();Метод:
server.closeAllConnections();закриває всі HTTP-з’єднання, включно з активними. Його не слід використовувати як перший крок graceful shutdown, оскільки він може перервати поточні запити.
У production-застосунку процес зазвичай отримує SIGTERM під час зупинки контейнера або сервісу.
let shuttingDown = false;
async function gracefulShutdown(signal) {
if (shuttingDown) {
return;
}
shuttingDown = true;
console.log(`Отримано ${signal}, починаємо завершення`);
server.closeIdleConnections?.();
const forceCloseTimer = setTimeout(() => {
console.error('Час graceful shutdown вичерпано');
for (const socket of sockets) {
socket.destroy();
}
process.exit(1);
}, 10_000);
forceCloseTimer.unref();
server.close((error) => {
clearTimeout(forceCloseTimer);
if (error) {
console.error('Помилка завершення сервера:', error);
process.exit(1);
}
console.log('Усі HTTP-з’єднання закрито');
process.exit(0);
});
}
process.on('SIGTERM', () => {
void gracefulShutdown('SIGTERM');
});
process.on('SIGINT', () => {
void gracefulShutdown('SIGINT');
});Відстеження сокетів потрібне для крайнього терміну. Наприклад, клієнт може підключитися, але не завершити запит, або upstream-сервіс може не відповісти. Без deadline такий сокет здатен затримати завершення процесу.
Перед примусовим закриттям варто:
перестати приймати нові з’єднання;
дати активним запитам розумний час;
закрити вільні Keep-Alive-з’єднання;
лише потім знищувати сокети.
TCP-сокет може бути:
активним — зараз передає запит або відповідь;
вільним Keep-Alive — попередній запит завершено, але клієнт може надіслати наступний;
закритим — більше не може бути використаний.
Це важливо під час graceful shutdown. Вільне Keep-Alive-з’єднання не має незавершеного запиту, тому його можна закрити одразу. Активне з’єднання потрібно залишити до завершення запиту або до спливу deadline.
Keep-Alive працює коректно, коли клієнт і сервер узгоджують життєвий цикл з’єднання.
Проблеми виникають, коли:
клієнт зберігає сокет довше, ніж сервер;
сервер закрив з’єднання, а клієнт намагається використати його повторно;
пул клієнта занадто великий;
тайм-аут очікування відповіді плутають із тайм-аутом Keep-Alive;
під час shutdown клієнт продовжує створювати нові з’єднання.
Клієнтський Agent повинен бути частиною життєвого циклу застосунку. Якщо він більше не потрібен, його слід завершити:
agent.destroy();Це закриває сокети агента та запобігає утриманню процесу через вільні з’єднання.
За Keep-Alive один сокет може обслуговувати багато послідовних запитів. Не варто прив’язувати стан застосунку до припущення, що подія connection відбувається для кожного HTTP-запиту.
Примусове встановлення:
res.setHeader('Connection', 'close');вимикає повторне використання з’єднання для цього обміну. Це може бути виправдано в окремих випадках, але як загальна стратегія збільшує затримки та навантаження.
keepAliveTimeout з тайм-аутом обробки запитуkeepAliveTimeout починає діяти після завершення відповіді, коли сервер очікує наступний запит. Він не обмежує час виконання активного обробника.
AgentВільні сокети агента можуть підтримувати процес Node.js активним або утримувати непотрібні мережеві ресурси.
closeAllConnections() як graceful shutdownЦей метод може перервати активні запити. Спочатку потрібно зупинити приймання нових з’єднань і дати поточним запитам завершитися.
Один завислий клієнт або запит може заблокувати завершення сервера назавжди. Graceful shutdown повинен мати deadline і примусовий fallback.
Connection через проксі без розуміння його семантикиConnection — hop-by-hop заголовок. Його значення не можна розглядати як універсальну інструкцію для всього ланцюжка проксі та серверів.
Keep-Alive дозволяє повторно використовувати TCP-з’єднання для кількох HTTP-запитів.
У HTTP/1.1 Keep-Alive є типовою поведінкою, якщо з’єднання не закривається явно.
Connection: close забороняє подальше використання поточного з’єднання.
На сервері Node.js життєвий цикл Keep-Alive контролює server.keepAliveTimeout.
На клієнті повторне використання з’єднань налаштовується через http.Agent({ keepAlive: true }).
maxSockets і maxFreeSockets допомагають контролювати розмір пулу.
server.close() зупиняє приймання нових з’єднань, але shutdown має враховувати активні та вільні сокети.
Надійне завершення складається з graceful-фази та примусового завершення після deadline.
Connection є hop-by-hop заголовком і стосується лише поточного мережевого переходу.