Edge-ізоляцыя і Wasm проты Node.js: практычныя наследкі і стандарты выкарыстоўвання ў працоўным сераверы
Расглядаецца, як V8 адзяйляе ресурсы, і чаму WebAssembly працюе краща за Node.js на апаратным роўні, а таксама рассказваецца пра практыкі керування, якія робяць Node.js гатовым да выкарыстоўвання ў працэсе разработкі.
Крыніцы: Your Node.js Backend is Already Obsolete: The Wasm-on-Edge Revolution is Here, Beyond 200 OK: 5 Critical Backend Concepts Every Node.js Developer Must Master
Спосаб, яким вы працюеце над архітектурай backend, вплывае на всё, што выканаецца пасля таго, — ад таго, насколькі быстра адпавядае запыт, да таго, як будзе дзейсціваць той самы код, калі ён будзе працаваць у рэальных умовах. Две тэмы, якія розглядзаюцца ў гэтай дыскусіі, на самай працоўцы є адной і той жа: савэчная інжынерыя backend вымагае перапрацавкі прыпускаў, якія калі-то здаваліся незменнымі, чы то стосуецца самага средавання выконання вашага коду, чы то прычынков работы, на якія вы рэгулярна паследзеце, калі гэта средавання запускае рэальны трафік.
Перапрацавка средавання выконання: контейнеры, ізоляція і працоўкі проты традыцыйных развяртанняў Node.js
Прыблызнае дзесяць гадоў Node.js быў стандартным выборам для стварэння шыракіх веб-сервераў. Але запуск мікросэрвісаў на Node.js сёння значыць неабходнасць викорыстоўвання настолькі ж масівнага набора апрантаку. Ёжна для запуску нават простага HTTP-хэндлера з пяцідзесятых ліній коду зазвычай трэба цэлая операцыйная система Linux, кантейнерны рантайм на яе аднойчыне, а таксама самы двыжок V8 для JavaScript, які толькі інтэрпретуе ваш код.
Такі набор апрантаку не падходзіць для безсерверных завантажэнняў. Аналіз таго, як на самай працуе двыжок V8, паказвае болей лёгкую альтэрнатыўу такам шаравам падходу: ізоляцыя у поўнай злучэнні з WebAssembly.
Кантейнеры абстрагуець операцыйную систему, ізоляцыі — рантайм
Стандартны кантейнер Docker віртуалізуе операцыйную систему. Ізоляцыя, на адміністрацыйную працу, віртуалізуе рантаймовую среду безпосередна.
Калі ваша функцыя выканана ў сетцы типу edge, платформа не запускае новы процес Node.js для ўтратаўкі яе. У замен адзін дыягональны двыжок V8 стварае „ізолят“ — лёгкі, строго інсамавольна абмежаны контэкст выконання з сае спецыяльнаю памятчукою.
Разлік у часе запуску ўсёўцяшы:
- Запуск контейнера Node.js з нуля займае прыблізна ад 1 000 мс да 3 000 мс.
- Стварэнне ізолята займае ад 1 мс да 5 мс.
WebAssembly адмаўляе JIT
JavaScript ёсць быстрым для мовы з інтэрпретацыяй, але ён усё ж залежыць ад компілятора JIT: двыжок павінен пройсці дрэва синтаксу і компіляваць код у рэальны час, перш чым гэты код зможа выканацца.
WebAssembly, стандартызаваны W3C, цэлком адхоўвае гэты крок. Вы можете напісаць логіку свайго бэкенду на мове системнага програмавання, такой як Rust, скомпіляваць яе безпосередна у Wasm і передаць V8 бінарны файл, які вже оптымізаваны пад час компіляцыі — не трэба жадных додатковых процэсаў компіляцыі пад час выканання.
# Cargo.toml - Instructing the compiler to output a dynamic Wasm library
[lib]
crate-type = ["cdylib"]
// lib.rs - The exact memory layout is defined at compile time. Zero GC pauses.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn process_heavy_payload(data: &[u8]) -> Vec<u8> {
// High-performance CPU-bound work that would normally choke a Node.js event loop
data.iter().map(|b| b ^ 0x42).collect()
}
Пакалі распад памяці ўсталяны пад час компіляцыі коду на Rust, не існуе калектара сметлі, які мог бы зупініць выкананне падчас обработкі запиту — гэта прычына непрадбачальнай затрымкі ў стандартных заданнях на JavaScript.
Ізоляцыі усунулі цикл змагання як прычыну сповольнення
Node.js працюе на адной ніткі. Якщо запит вымагае парсавання вялікаг файла у формате JSON або вычыслення крыптаграфічнага хэша, гэта заварочвае цикл змагання, і кожны іншы запит, які чакае, змушаны ўсё ж дачакацца свайго чаргу.
Isolаты структурна адхваляюцься гэтай проблеме. Паколькі кожны з іх запускаецца за мілісекунды і выкарыстоўвае менш чым мегабайт памяці, платформы типу edge зовсім не патрабуюць циклу запуску для керавання спільным станом — яны проста ствараюць абсалютна новы ізолат для кожнага прыходжучага запиту.
Node.js заступаецца сэнсам для традыцыйнага патерна дзейна довгавечнага, монолітнага серверскага процэсу. Але калі метай є высокая праўоцэснасць і мінімальная затрымка ў сеткаванні мікросэрвісаў, модель, якая выкарыстоўвае контейнеры і на якой базуецца Node.js, пачынае здавацца застарелай. З гэтага выліваюцца калькі наступныя:
- Хавайце думкі пра 500MB контейнерах Docker для запуску API, якія выкарыстоўваюць мінімум ресурсаў.
- Вжывайце ізолаты, каб зменшыць затрымку запуску безсерверных рашэнняў з калькі секунд да калькі мілісекунд.
Аднак выбор правильнага модэлю выканання — толькі палова задачы. Колі ваш сервіс нарэшце запускаецца у працоўнай средзе, калькі оператыўных прычын вялі пра тое, чы робіцься ён надзеяным пад рэальным трафікам, і гэтыя прычыны маюць значэнне незалежна ад таго, чы ваш код выканаўцца ў контейнере чы ізолятэ.
Оператыўныя прычыны, якія адразняюць Node.js высокага стандарту ад коду-протатыпу
Адрэса, яка надзеямоўна адпавядае з кодам 200 OK, ёсць хорашым пачаткам, але гэта здалека не весь спектр задач. Калі прыложэнне расте, пачынаюць выяўляцца проблемы, такія як неконтрольваная адхіленне обяцанняў, медлівыя запыты да базы дадзеных і поступовая выцёрква памяці. Розумеў, як на самае працоўнае сэрвісае Node.js праяваецца пад тым прытыскам, і ўжо ўражае код, напісаны чалавекам, які ўсё ўчыцца, ад коду, які гатовы да вывароту.
Закрыцце без абрыву запытоў
Калі вы адправляеце новую версію прыложэння або ваш хостынгавы прадаўець перзапускае ваш контейнер, процэс Node.js атрымлівае сигнал завершэння, напрыклад SIGTERM. Якщо ігнораваць гэты сигнал, процэс проста негайна памірае — і тыя, хто ў той момент завантажае файл або завершае транзакцыю ў базе дадзеных, сталкнуцца з раптовым абрывам з’яўлення.
Адекватная замыкання аплікацыі запобегае такому рэзультату: сервер перестае прыймать новыя з’ўязкі, завершае запиты, якія ўжо адправлены, закрывае свои з’ўязкі з базай дадзеных і толькі пасля чаго выходзіць з роботы.
const express = require('express');
const app = express();
const server = app.listen(3000, () => console.log('Server running on port 3000'));
// Simulating a database connection
const db = {
close: () => new Promise((resolve) => setTimeout(resolve, 500))
};
// Listen for termination signals
const shutdown = (signal) => {
console.log(`Received ${signal}. Starting graceful shutdown...`);
// 1. Stop accepting new connections
server.close(async () => {
console.log('HTTP server closed.');
try {
// 2. Close database connections cleanly
await db.close();
console.log('Database connections closed.');
// 3. Exit the process safely
process.exit(0);
} catch (err) {
console.error('Error during shutdown:', err);
process.exit(1);
}
});
// Force exit if connections take too long to close (e.g., 10 seconds)
setTimeout(() => {
console.error('Forced shutdown due to timeout');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT')); // Handles Ctrl+C locally
Павторны выкарыстоўванне з’ўязків з базай дадзеных уместо ўтварэння іх занова
Адкрыванне новага з’ўязку з базай дадзеных для кожнага запиту є даскладным процесам. Кожны новы з’ўязак трэба прайсці через процедуру сетевага абмовлення, перагаворы па протаколу TLS і аутентыкацію, і все гэта спрычыняе значны затрыв у часе адпаведзення на кожны запит.
Пул з’ўязкаў рашае гэту проблему, заставляючы набор уже адкрывачыхся, неактыўных з’ўязкаў у гатовасці. Прыбываючы запит берае з’ўязак з гэтага пулу, выкананае яго заданне, а потым вяртае з’ўязак, ўжо каб наступны запит мог скора яго павторна выкарыстоўваць.
Якщо ваш проект выкарыстоўвае ORM, такі як Sequelize, або адаптар нижэйшага рангу, як pg, кэшаванне з’язкаў зазвычай можна налаштаваць з мінімальными зусіллямі.
const { Pool } = require('pg');
// Create a pool instead of a single client connection
const pool = new Pool({
host: 'localhost',
database: 'my_app',
user: 'developer',
max: 20, // Maximum number of clients in the pool
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
connectionTimeoutMillis: 2000, // Return an error if a connection takes > 2s
});
app.get('/users', async (req, res) => {
try {
// Automatically acquires a connection from the pool
const { rows } = await pool.query('SELECT * FROM users LIMIT 10');
res.json(rows);
} catch (err) {
res.status(500).send('Server Error');
}
// The connection is automatically released back to the pool here!
});
Обмежэнне колькасці запыткаў за дапамою лімітацыі частоты
Заставлянне канцэнтраў без захавы ўскладняе ситуацыю, незалежна ад таго, чы гэтыя проблемы вынікаюць з намеровага атакі на адміністрацыю, павтаральных спробы ўходу з вялікай сілой, чы проста з багоў у кліентскай частцы, якія надаюць великае количество запыткаў да вашага API.
Лімітацыя частоты обмежвае колькасць запыткаў, якія можа адправіць пэўны корыстнік чы IP-адрес за вялікі працоўны перыяд, чым запобiegае выкарыстоўванню всіх рэсурсаў вашаг сервера — а таксама квот трэціх сторон, ад якіх вы залежыце.
const rateLimit = require('express-rate-limit');
// Define the rate limit rule
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes window
max: 100, // Limit each IP to 100 requests per windowMs
message: {
status: 429,
error: 'Too many requests, please try again later.'
},
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
// Apply the middleware to your API routes
app.use('/api/', apiLimiter);
Обробка вялікіх пакетаў дадзеных за дапамою стрымоў
Node.js здобыў свою репутацію ў задачах з великай колькасцю операцый I/O завдяк сваім асінхронным прыемам, але гэтая репутація швытка зникае, якщо прабаваць запісваць вельмі большыя файлы безпосередна ў память.
Выклік fs.readFile() для файлу лога або экспорту у формате CSV размерам 2 ГБ прымушвае Node зберагаць усі данні ў буферы памяці V8, перш чым обробіць хоць адзін байт. У контейнеры з обмежэнням у 1 ГБ RAM гэта прыводзіць да мгновеннага збою з паведамленням Out of Memory.
Стрэмы узбегаюць гэтай проблемы, чытаючы та обробляючы данні поштоўна, пачастро — зазвычай па 64 КБ за раз — а не всіма сабой.
const fs = require('fs');
const http = require('http');
// BAD WAY: Loads the entire file into memory first
http.createServer((req, res) => {
fs.readFile(__dirname + '/huge-video.mp4', (err, data) => {
if (err) return res.end(err.message);
res.end(data);
});
}).listen(8000);
// GOOD WAY: Streams the file chunk-by-chunk
http.createServer((req, res) => {
const stream = fs.createReadStream(__dirname + '/huge-video.mp4');
// Pipes the incoming chunks directly to the HTTP response object
stream.pipe(res);
}).listen(8001);
Забезпечэнне прыемлівай стабільнасці пад час обробкі адзінакоў за дапамогою цэнтралізаванага керування
Размешчанне механізма обробкі, бягаў у всій базе коду, калі кожны контролер мае свой спецыяльны блок try/catch, які вяртае розныя форматы адпаведзей, прыводзіць да коду, які важка ў падтрымцы і склоннага да таго, што внутрашняя інфармацыя системы будзе випадкова адкрывана кінцавым корыстнікаў.
Централізаваная стратэгія обробкі бягаў направляе кожны оператыўны бяг у аднам спільным мідлвэры. Це дапамагае заставіць окремыя працоўнікі з обробкай маршрутаў чыстымі і гарантуе, што кліенты завжды атрымуюць адпаведзь з бягам, якая є аднородной і правільна сформаванай, а не хаотычным наборам дадзеных.
// 1. Define a custom AppError class to capture operational nuances
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true; // Distinguishes programmatic bugs from user errors
Error.captureStackTrace(this, this.constructor);
}
}
// 2. A clean async controller wrapper to avoid writing try/catch everywhere
const catchAsync = (fn) => {
return (req, res, next) => {
fn(req, res, next).catch(next); // Passes any thrown error to the global handler
};
};
app.get('/product/:id', catchAsync(async (req, res, next) => {
const product = await db.findProduct(req.params.id);
if (!product) {
// Simply throw or pass the custom error to next()
return next(new AppError('Product not found', 404));
}
res.json(product);
}));
// 3. Centralized Global Error Handling Middleware (Must have 4 arguments)
app.use((err, req, res, next) => {
err.statusCode = err.statusCode || 500;
// Hide detailed error stacks in production environments
const response = {
status: 'error',
message: err.message || 'Something went wrong'
};
if (process.env.NODE_ENV === 'development') {
response.stack = err.stack;
}
res.status(err.statusCode).json(response);
});
Стварэнне бэкенду, який можа расшырвацца, вырахоўвается ў дисціплінованам управлэнні рычасламі. Калі ваша прыкладна програма завершваецца адпаведна, практыкуеся паўторны выкарыстоўванне з’яносоў базы дадзенаў через пул, обмежваецца неконтрольаваны трафік за дапамогою лімітацыі частоты, великія пакеты даных перадаюцца у формате стрімоў замест абферавання іх, а выключэнні разв’язваюцца прыкладным, централізаваным спосабам; такі падход дазволяе усунуць большасць перываноў у роботе прыкладнай програмы яшчэ перад тым, какія вони могу выйсці.