Пошук уроків, статей та іншого контенту
Обробіть сигнали завершення, припиніть приймання запитів і безпечно закрийте з’єднання перед зупинкою процесу.
Під час зупинки Node.js-процесу важливо не просто завершити його через process.exit(). Різке завершення може:
перервати активні HTTP-запити;
обірвати з’єднання з базою даних або чергою повідомлень;
залишити клієнтів без відповіді;
призвести до втрати даних, які ще записуються.
Коректне завершення, або graceful shutdown, зазвичай складається з таких кроків:
Отримати сигнал завершення.
Припинити приймання нових запитів.
Дочекатися завершення активних запитів.
Закрити зовнішні з’єднання та ресурси.
Завершити процес.
Якщо завершення затягнулося — примусово зупинити процес після тайм-ауту.
Операційна система надсилає процесу сигнали. Найчастіше для серверного застосунку використовуються:
SIGINT — зазвичай надсилається після натискання Ctrl+C;
SIGTERM — стандартний сигнал для коректної зупинки процесу, часто використовується контейнерами та менеджерами процесів.
У Node.js сигнали обробляються через подію process
process.on('SIGTERM', () => {
console.log('Отримано SIGTERM');
});
process.on('SIGINT', () => {
console.log('Отримано SIGINT');
});Сам факт обробки сигналу змінює стандартну поведінку Node.js. Тому обробник має самостійно виконати необхідне завершення.
Для HTTP-сервера використовується метод server.close():
server.close(() => {
console.log('Сервер більше не приймає з’єднання');
});Після виклику server.close() сервер перестає приймати нові з’єднання, але вже відкриті з’єднання не перериваються автоматично. Node.js очікує, поки активні з’єднання завершаться.
Колбек server.close() викликається після закриття всіх з’єднань, які утримували сервер відкритим. Саме тому його зручно використовувати як сигнал, що HTTP-рівень завершив роботу.
Окремий запит або з’єднання може зависнути надовго. Без обмеження часу процес може ніколи не завершитися.
Тому graceful shutdown зазвичай має тайм-аут:
початок завершення → очікування → примусове закриття після тайм-аутуПримусове закриття — це запасний сценарій. У нормальній ситуації всі запити мають завершитися до спливу тайм-ауту.
Нижче сервер:
обробляє SIGINT і SIGTERM;
не запускає процедуру завершення повторно;
припиняє приймання нових з’єднань;
очікує активні з’єднання;
закриває умовний зовнішній ресурс;
примусово закриває сокети після тайм-ауту.
const http = require('node:http');
const PORT = 3000;
const SHUTDOWN_TIMEOUT = 10_000;
const sockets = new Set();
let isShuttingDown = false;
let forceShutdownTimer;
const server = http.createServer(async (req, res) => {
if (req.url === '/slow') {
// Імітація довгого запиту
await new Promise((resolve) => setTimeout(resolve, 3000));
}
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Відповідь сервера\n');
});
server.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => {
sockets.delete(socket);
});
});
server.listen(PORT, () => {
console.log(`Сервер запущено на порту ${PORT}`);
});
async function closeExternalResources() {
// Тут можуть бути закриття бази даних, черги або інших клієнтів
await new Promise((resolve) => setTimeout(resolve, 100));
console.log('Зовнішні ресурси закрито');
}
function closeServer() {
return new Promise((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
async function shutdown(signal) {
if (isShuttingDown) {
console.log('Завершення вже виконується');
return;
}
isShuttingDown = true;
console.log(`Отримано ${signal}. Починається завершення роботи`);
forceShutdownTimer = setTimeout(() => {
console.error('Тайм-аут завершення. Примусово закриваємо з’єднання');
for (const socket of sockets) {
socket.destroy();
}
// Це аварійний шлях, тому незавершені операції можуть бути перервані
process.exit(1);
}, SHUTDOWN_TIMEOUT);
try {
await closeServer();
console.log('Нові з’єднання більше не приймаються');
await closeExternalResources();
clearTimeout(forceShutdownTimer);
console.log('Завершення роботи виконано успішно');
process.exitCode = 0;
} catch (error) {
clearTimeout(forceShutdownTimer);
console.error('Помилка під час завершення:', error);
process.exitCode = 1;
}
}
process.on('SIGINT', () => {
void shutdown('SIGINT');
});
process.on('SIGTERM', () => {
void shutdown('SIGTERM');
});Збережіть код у файлі server.js і запустіть:
node server.jsВ іншому терміналі можна виконати запит, який триває кілька секунд:
curl http://localhost:3000/slowЯкщо під час цього запиту натиснути Ctrl+C у терміналі сервера, Node.js не перерве його одразу. Спочатку сервер перестане приймати нові з’єднання, а потім дочекається завершення активного запиту.
Сигнал може бути надісланий кілька разів. Наприклад, користувач може натиснути Ctrl+C повторно, поки перше завершення ще триває.
Без прапорця кілька викликів можуть одночасно:
викликати server.close();
закривати ті самі ресурси;
змінювати код завершення;
запускати кілька таймерів.
Змінна isShuttingDown робить процедуру завершення ідемпотентною: вона запускається лише один раз.
HTTP-клієнт може залишати TCP-з’єднання відкритим для повторного використання завдяки keep-alive. Таке з’єднання може бути неактивним, але все ще утримувати сервер відкритим.
У різних версіях Node.js поведінка щодо неактивних keep-alive-з’єднань може відрізнятися. Якщо застосунку потрібен жорсткий контроль, з’єднання можна відстежувати через подію connection, як у прикладі.
Множина sockets потрібна для аварійного сценарію:
for (const socket of sockets) {
socket.destroy();
}Цей код не виконується під час нормального завершення. Він використовується лише після спливу тайм-ауту, коли очікування більше небезпечне, ніж переривання з’єднань.
Зазвичай ресурси закриваються в такому порядку:
Припинити приймання нових HTTP-запитів.
Дочекатися завершення запитів, які вже виконуються.
Закрити клієнти бази даних, черги та інші з’єднання.
Завершити процес.
Якщо закрити базу даних до завершення HTTP-запитів, активний обробник може спробувати використати вже закрите з’єднання. Якщо спочатку закрити сервер, нові запити не створюватимуть нову роботу, а наявні запити зможуть завершитися.
Для повідомлення операційній системі про результат роботи використовується process.exitCode:
process.exitCode = 0; // успішне завершення
process.exitCode = 1; // завершення через помилкуБажано встановлювати process.exitCode, а не викликати process.exit() у нормальному сценарії. Так Node.js зможе завершити операції, які вже стоять у черзі подій.
process.exit() доречний лише як аварійний вихід після тайм-ауту, коли процес потрібно зупинити незалежно від стану незавершених операцій.
У реальному сервері функція завершення може мати такий вигляд:
async function shutdown(signal) {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
try {
await stopAcceptingRequests();
await waitForActiveRequests();
await closeDatabase();
await closeMessageQueue();
process.exitCode = 0;
} catch (error) {
console.error(error);
process.exitCode = 1;
}
}Назви функцій залежать від конкретних бібліотек, але порядок дій залишається тим самим.
process.exit()process.on('SIGTERM', () => {
process.exit(0);
});Такий код не дає запитам і ресурсам часу на завершення. Клієнти можуть отримати обірване з’єднання.
Якщо чекати завершення з’єднань без обмеження часу, один завислий клієнт може залишити процес активним назавжди.
Потрібен максимальний час очікування та аварійний сценарій.
Без перевірки стану кілька сигналів можуть запустити кілька процедур завершення одночасно.
Використовуйте прапорець на кшталт isShuttingDown.
Якщо база даних закривається до завершення HTTP-запитів, активні обробники можуть завершитися з помилкою.
Спочатку припиніть приймання нової роботи, потім дочекайтеся наявної і лише після цього закривайте залежності.
Закриття сервера або зовнішнього ресурсу може завершитися помилкою. Такі помилки потрібно обробляти та встановлювати ненульовий process.exitCode.
server.close() очікує завершення відкритих з’єднань. Якщо з’єднання не закривається, без тайм-ауту graceful shutdown може не завершитися.
SIGINT і SIGTERM використовуються для запуску коректного завершення.
server.close() припиняє приймання нових з’єднань.
Уже розпочатим запитам потрібно дати час завершитися.
Зовнішні ресурси слід закривати після припинення приймання нової роботи.
Процедура завершення має виконуватися лише один раз.
Тайм-аут захищає процес від завислих з’єднань.
process.exitCode краще використовувати у штатному сценарії, а process.exit() залишати для аварійного завершення.