Пошук уроків, статей та іншого контенту
Налаштуєте коректне завершення Node.js-сервера, обробку сигналів і завершення активних з’єднань без втрати запитів.
Під час звичайного запуску Node.js-процес працює, доки має активні події або з’єднання. Якщо завершити його примусово, наприклад через process.exit() або kill -9, поточні запити можуть:
не отримати відповіді;
перервати транзакцію або запис даних;
залишити клієнта з помилкою мережевого з’єднання;
втратити дані, які ще перебувають у буфері.
Graceful shutdown — це контрольований сценарій завершення сервера:
припинити приймання нових з’єднань;
дозволити активним запитам завершитися;
закрити неактивні keep-alive-з’єднання;
дочекатися завершення ресурсів;
примусово закрити процес після тайм-ауту, якщо щось зависло.
Такий підхід особливо важливий для серверів, які перезапускаються під час деплою або завершуються системою керування контейнерами.
Node.js може реагувати на сигнали операційної системи через process.on():
SIGINT — зазвичай надходить після натискання Ctrl+C;
SIGTERM — стандартний сигнал для коректного завершення процесу;
SIGKILL — примусове завершення, яке неможливо перехопити.
process.on('SIGTERM', () => {
console.log('Отримано SIGTERM');
});
process.on('SIGINT', () => {
console.log('Отримано SIGINT');
});Після додавання власного обробника Node.js більше не завершує процес автоматично у відповідь на ці сигнали. Тому обробник має сам запустити процедуру завершення.
SIGKILL обробити неможливо. Якщо процес отримав цей сигнал, він завершується негайно, без очікування активних запитів.
Метод server.close():
припиняє приймання нових з’єднань;
дозволяє поточним HTTP-запитам завершитися;
викликає callback після закриття сервера та його з’єднань.
Виклик server.close() не означає миттєве завершення процесу. Node.js продовжує працювати, поки активні запити, таймери або інші ресурси не будуть закриті.
const http = require('node:http');
const server = http.createServer((req, res) => {
setTimeout(() => {
res.end('OK');
}, 2000);
});
server.listen(3000, () => {
console.log('Сервер працює на порту 3000');
});
process.on('SIGTERM', () => {
server.close(() => {
console.log('Сервер завершив роботу');
});
});Якщо під час отримання сигналу один запит ще виконується, callback server.close() буде викликано лише після його завершення.
HTTP-клієнти можуть повторно використовувати TCP-з’єднання для кількох запитів. Такі з’єднання називаються keep-alive.
Під час graceful shutdown важливо не залишити неактивні keep-alive-з’єднання відкритими надовго. У сучасних версіях Node.js server.close() закриває неактивні з’єднання. Для явного керування можна також використати:
server.closeIdleConnections();Метод closeIdleConnections() доступний у сучасних версіях Node.js. Перевірка існування методу допомагає зберегти сумісність із попередніми версіями:
if (typeof server.closeIdleConnections === 'function') {
server.closeIdleConnections();
}Активне з’єднання не слід закривати одразу: воно може обслуговувати запит. Для таких з’єднань потрібен тайм-аут graceful shutdown.
Наведений сервер:
обробляє SIGINT і SIGTERM;
не приймає нові з’єднання після початку завершення;
відстежує активні відповіді;
додає Connection: close до відповідей, які ще не почалися;
закриває неактивні keep-alive-з’єднання;
має максимальний час очікування;
не використовує process.exit() у звичайному сценарії завершення.
const http = require('node:http');
const PORT = 3000;
const SHUTDOWN_TIMEOUT = 10_000;
let shuttingDown = false;
let forceTimer;
const activeResponses = new Set();
const sockets = new Set();
const server = http.createServer(async (req, res) => {
activeResponses.add(res);
let tracked = true;
const removeResponse = () => {
if (!tracked) {
return;
}
tracked = false;
activeResponses.delete(res);
};
res.once('finish', removeResponse);
res.once('close', removeResponse);
if (shuttingDown) {
// Забороняємо клієнту залишати це з'єднання для наступного запиту.
res.setHeader('Connection', 'close');
}
try {
const url = new URL(req.url, `http://${req.headers.host}`);
const delay = Math.min(
Math.max(Number(url.searchParams.get('delay') || 0), 0),
30_000,
);
await new Promise((resolve) => {
setTimeout(resolve, delay);
});
if (!res.writableEnded) {
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
});
res.end(JSON.stringify({
ok: true,
shuttingDown,
}));
}
} catch (error) {
console.error('Помилка під час обробки запиту:', error);
if (!res.headersSent) {
res.writeHead(500, {
'Content-Type': 'application/json; charset=utf-8',
});
}
if (!res.writableEnded) {
res.end(JSON.stringify({
ok: false,
error: 'Internal Server Error',
}));
}
}
});
server.on('connection', (socket) => {
sockets.add(socket);
socket.once('close', () => {
sockets.delete(socket);
});
if (shuttingDown) {
// Нові з'єднання після початку завершення не обслуговуємо.
socket.destroy();
}
});
function shutdown(signal) {
if (shuttingDown) {
console.log('Завершення вже виконується');
return;
}
shuttingDown = true;
console.log(`Отримано ${signal}. Починаємо graceful shutdown`);
forceTimer = setTimeout(() => {
console.error(
`Не всі з'єднання завершилися за ${SHUTDOWN_TIMEOUT} мс`,
);
// Знищуємо з'єднання, які не завершилися вчасно.
for (const socket of sockets) {
socket.destroy();
}
// Це аварійний шлях, тому процес завершується з помилкою.
process.exit(1);
}, SHUTDOWN_TIMEOUT);
// Таймер не повинен сам утримувати процес, якщо сервер уже завершився.
forceTimer.unref();
server.close((error) => {
clearTimeout(forceTimer);
if (error) {
console.error('Помилка під час закриття сервера:', error);
process.exitCode = 1;
return;
}
console.log(
`HTTP-сервер закрито. Активних відповідей: ${activeResponses.size}`,
);
// process.exit() тут не потрібен:
// Node.js завершить процес після закриття всіх ресурсів.
process.exitCode = 0;
});
if (typeof server.closeIdleConnections === 'function') {
server.closeIdleConnections();
}
}
server.listen(PORT, () => {
console.log(`Сервер працює на http://localhost:${PORT}`);
});
process.on('SIGINT', () => {
shutdown('SIGINT');
});
process.on('SIGTERM', () => {
shutdown('SIGTERM');
});Запустити приклад можна так:
node server.jsЩоб перевірити очікування активного запиту, в іншому терміналі виконайте:
curl "http://localhost:3000/?delay=5000"Поки запит виконується, надішліть серверу сигнал SIGTERM:
kill -TERM <PID>Сервер перестане приймати нові з’єднання, але дочекається завершення запиту з затримкою. Якщо запит не завершиться протягом 10 секунд, сервер знищить з’єднання та завершить процес із кодом 1.
Сигнал може надійти кілька разів. Наприклад, shutdown може запускатися одночасно через SIGTERM і внутрішню логіку завершення.
Тому обробник має бути ідемпотентним: повторний виклик не повинен запускати другий server.close() або створювати додаткові таймери.
Для цього використовується прапорець:
if (shuttingDown) {
return;
}
shuttingDown = true;У більш складних сервісах замість булевого прапорця можна зберігати Promise поточної процедури завершення. Усі повторні виклики тоді очікують той самий Promise.
Без тайм-ауту процес може чекати нескінченно, якщо:
запит завис під час виконання;
клієнт не закриває з’єднання;
код обробника не завершує відповідь;
зовнішній ресурс не повертає результат.
Тому graceful shutdown має два етапи:
М’яке завершення — очікування активних запитів.
Примусове завершення — закриття з’єднань після обмеженого часу.
Примусове завершення є захисним механізмом, а не основним сценарієм. Занадто короткий тайм-аут може перервати нормальні запити, а занадто довгий — затримати перезапуск сервера.
process.exit() та process.exitCodeНе слід використовувати process.exit() одразу після отримання сигналу:
process.on('SIGTERM', () => {
process.exit(0);
});Такий код завершує процес негайно й може перервати активні відповіді.
У звичайному сценарії краще встановити код завершення:
process.exitCode = 0;Node.js завершить процес природним шляхом, коли в event loop не залишиться активних ресурсів.
process.exit() доречний лише як останній крок після перевищення тайм-ауту, коли потрібно примусово припинити завислий процес.
process.exit()Це перериває активні запити, потоки запису та інші операції.
Правильно: спочатку викликати server.close(), а примусове завершення залишити для тайм-ауту.
SIGINTЛокально сервер зазвичай завершується через Ctrl+C, але середовища запуску часто надсилають SIGTERM.
Правильно: обробляти обидва сигнали, використовуючи одну функцію shutdown.
Кілька обробників або повторні сигнали можуть запустити кілька процедур закриття.
Правильно: зробити shutdown ідемпотентним.
Один завислий запит може заблокувати весь деплой або перезапуск.
Правильно: встановити максимальний час очікування й після нього закрити залишкові з’єднання.
Лічильник активних запитів корисний для діагностики, але сам по собі не закриває TCP-з’єднання. На сервері можуть залишатися keep-alive-з’єднання або інші мережеві ресурси.
Правильно: поєднувати server.close() із керуванням idle-з’єднаннями та тайм-аутом.
Якщо знищити всі сокети на початку shutdown, поточні запити втратять відповіді.
Правильно: спочатку припинити приймання нових з’єднань і зачекати завершення активних запитів. Знищувати сокети лише після завершення тайм-ауту.
SIGINT і SIGTERM потрібно обробляти власною процедурою завершення.
server.close() припиняє приймання нових з’єднань і дозволяє поточним запитам завершитися.
Неактивні keep-alive-з’єднання слід закривати під час shutdown.
Обробник сигналу має бути ідемпотентним.
Graceful shutdown повинен мати максимальний тайм-аут.
process.exit() не слід викликати одразу після отримання сигналу.
Примусове знищення з’єднань — це резервний сценарій для завислих запитів.