open atlas
↑ К треку
Node.js с нуля до senior NODE · 05 · 01

Модуль http: серверы, клиенты и потоки

В сыром http req — это IncomingMessage (Readable-тело + заголовки), а res — ServerResponse (Writable). Стримь тело, уважай backpressure, переиспользуй сокеты через keep-alive и добавь таймауты и лимит тела, которые http не ставит, — иначе медленный клиент выжмет сокеты.

NODE Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Внутренний API замолчал во вторник днём — ни ошибок, ни падения, просто каждый запрос висит. Процесс Node здоров, CPU около нуля, heap ровный. Причиной был один кривой cron-клиент на нестабильном канале: он открывал соединения, слал несколько байт заголовков, а остальное — по байту раз в несколько секунд. У самописного http.createServer не было ни requestTimeout, ни headersTimeout, поэтому каждый из этих полуоткрытых сокетов вечно сидел в пуле соединений. За минуты медленный клиент тихо занял все доступные сокеты, и легитимный трафик больше не мог получить соединение. В коде ничего не было «неправильным» — просто не хватало таймаутов, которые любой фреймворк ставит за тебя.

К концу этого урока ты будешь знать именно те настройки, которые модуль http не выставляет сам, и поймёшь, почему сырой сервер без них — это скрытая уязвимость.

req — это Readable, res — это Writable

http.createServer((req, res) => …) отдаёт тебе два объекта-потока, и самая частая ошибка новичка — забыть об этом. req — это IncomingMessageReadable-поток тела запроса, украшенный req.method, req.url и req.headers. Заголовки разобраны и доступны сразу, но тело за тебя не буферизуется: оно приходит чанками потока, и если ты их не читаешь, ты не получишь тело (а сокет может застрять). res — это ServerResponseWritable-поток. Ты начинаешь его через res.writeHead(status, headers), толкаешь байты через res.write(chunk) и завершаешь через res.end().

Поскольку тело — это поток, ты потребляешь его потоковыми идиомами, а не тянешься к магическому свойству .body:

import { createServer } from "node:http";

const server = createServer(async (req, res) => {
  if (req.method === "POST") {
    let body = "";
    // req — async-iterable: каждый чанк это Buffer
    for await (const chunk of req) {
      body += chunk;            // (см. предупреждение про лимит размера ниже)
    }
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify({ received: body.length }));
    return;
  }
  res.writeHead(405).end();
});

server.listen(3000);

Эквивалентный событийный API — req.on("data", chunk => …), затем req.on("end", …) — делает то же самое; for await — это просто современная обёртка над ним. В любом случае обрабатывай ещё req.on("error", …) и случай 'aborted'/'close': клиент, отвалившийся посреди загрузки, генерит ошибку на потоке запроса, а необработанное событие 'error' потока роняет процесс.

Backpressure: write() может сказать «стоп»

Когда ты шлёшь большой или неизвестной длины ответ, нельзя просто долбить res.write() в цикле. res.write() возвращает булево: true — чанк сброшен в буфер сокета ядра; false — он поставлен в очередь в памяти твоего процесса, потому что буфер полон. Игнорировать этот false и продолжать писать — это как один медленный клиент раздувает heap Node: ты буферизуешь данные, которые сеть пока не может слить. Контракт такой: когда write() вернул false, остановись и жди событие 'drain' прежде чем писать дальше.

// Ручной backpressure — продолжаем только когда в буфере есть место
function writeChunk(res, chunk, next) {
  if (res.write(chunk)) {
    next();                       // в буфере было место, идём дальше
  } else {
    res.once("drain", next);      // ждём, пока опустеет
  }
}

На практике этот цикл почти никогда не пишут руками. Если твой источник сам поток — файл, ответ апстрима, трансформ — пайпи его и дай Node управлять backpressure из конца в конец:

import { createReadStream } from "node:fs";
// pipeline пробрасывает backpressure И сводит ошибки в одно место
import { pipeline } from "node:stream/promises";

await pipeline(createReadStream("./big.csv"), res);

Когда длина тела неизвестна (поток без content-length), Node автоматически переключает ответ на chunked transfer encoding, отправляя каждый write() как свой обрамлённый чанк, завершаемый нулевым чанком на end().

Почему это работает

Почему просто не буферизовать всё тело или ответ в память и не возиться со стримингом? Потому что память — это ресурс, который атакующий (или плохой клиент) бьёт первым. Загрузка в 4 ГБ, собранная в строку, — это 4 ГБ резидентного heap на запрос; сотня параллельных — это OOM-kill. Стриминг держит в полёте лишь один чанк за раз, так что память остаётся ограниченной независимо от размера полезной нагрузки — ровно поэтому лимит размера ниже так важен.

Клиент, Agent и keep-alive

Когда сервис делает тысячи исходящих запросов в секунду, стоимость установки соединения накапливается быстро — и именно для этого существует Agent (менеджер пула сокетов, управляющий keep-alive-соединениями). Тот же модуль — ещё и HTTP-клиент: http.request(options, cb) возвращает writable-поток запроса, а http.get — сокращение, которое вызывает req.end() за тебя. Ответ приходит как ещё один IncomingMessage, который ты обязан слить.

import { request } from "node:http";

const req = request("http://api.internal/users/1", (res) => {
  let data = "";
  res.on("data", (c) => (data += c));   // ты ОБЯЗАН потребить ответ
  res.on("end", () => console.log(res.statusCode, data));
});
req.on("error", console.error);          // сетевые ошибки прилетают сюда
req.end();

За каждым клиентским запросом стоит Agent, который пулит сокеты. Смысл пула — keep-alive: переиспользование живого TCP-соединения между запросами пропускает свежий TCP-хендшейк (~1 RTT) и, на HTTPS, полный TLS-хендшейк сверху — большая экономия на запрос, когда ты часто зовёшь один и тот же хост. Глобальный agent включает keepAlive по умолчанию с Node 19; до этого ты включал его явно через new Agent({ keepAlive: true }). Кнопка, которая кусается под нагрузкой, — maxSockets — предел числа параллельных соединений, которые agent открывает на хост. По умолчанию Infinity, так что неограниченный fan-out может открыть тысячи сокетов разом и исчерпать порты или захлестнуть апстрим; задай разумный maxSockets, когда жёстко молотишь одну зависимость.

Таймауты и лимиты тела: чего http НЕ делает за тебя

Это сеньорская часть, и она вся про то, что http опускает. Сырой http по умолчанию позволит клиенту не торопиться и прислать тело любого размера. И то и другое — векторы отказа в обслуживании. Кнопки таймаутов, ограничивающие медленного клиента, — это свойства сервера:

КнопкаЧто ограничиваетДефолт
server.requestTimeoutВремя на приём всего запроса (заголовки + тело)300000 мс (5 мин)
server.headersTimeoutВремя на приём полных заголовков запросаmin(requestTimeout, 60000)
server.keepAliveTimeoutПростой keep-alive-сокета в ожидании следующего запроса5000 мс
server.timeout (на сокет)Бездействие на сокете до его уничтожения0 (отключён)

Хук — ровно та брешь, которую закрывают requestTimeout и headersTimeout: атака Slowloris капает заголовками или телом медленно, чтобы держать сокеты открытыми. С дефолтным 300-секундным requestTimeout и 60-секундным headersTimeout полуоткрытый сокет отбирается, а не течёт вечно — но если ты сделаешь req.socket.setTimeout(0) или отключишь их на самописном сервере, ты снова откроешь дверь.

Другое упущение — размер тела. Встроенного лимита нет: for await (const chunk of req) body += chunk радостно накопит многогигабайтную загрузку в твой heap и убьёт процесс по OOM. Ты обязан ограничить его сам (это одна из главных вещей, что express.json({ limit }) или body-parser делают за тебя):

const MAX = 1_000_000; // 1 МБ
let size = 0, chunks = [];
for await (const chunk of req) {
  size += chunk.length;
  if (size > MAX) {
    res.writeHead(413).end("payload too large"); // 413 Payload Too Large
    req.destroy();                                // прекрати чтение, освободи сокет
    return;
  }
  chunks.push(chunk);
}
Викторина

В обработчике http.createServer ты логируешь req.body внутри колбэка и получаешь undefined для POST-запроса. Почему?

Викторина

Твой сервис делает тысячи исходящих вызовов к одному апстриму, и ты включаешь keep-alive на agent. Что keep-alive экономит на каждый запрос прежде всего?

Выбери лучший вариант

Ты принимаешь загрузки файлов на публичном эндпоинте, построенном на сыром http.createServer. Как не дать вредоносному клиенту убить процесс по OOM одной гигантской загрузкой?

Расставь шаги по порядку

Расставь шаги безопасной обработки стримовой загрузки в сыром http-обработчике — от прихода запроса до чистого завершения:

  1. 1 Проверь req.method и req.headers; отвергни всё, кроме ожидаемого метода/content-type, рано
  2. 2 Повесь req.on('error', …) и обработай 'aborted'/'close', чтобы обрыв посреди загрузки не уронил процесс
  3. 3 Стримь тело через for await…of req, считая бегущую сумму байт против жёсткого максимума
  4. 4 При превышении предела ответь 413 и сделай req.destroy(), чтобы прекратить чтение и освободить сокет
  5. 5 При успехе обработай собранное тело, затем res.writeHead + res.end ответа
Вспомните перед уходом
  1. 01
    Что значит, что res.write() вернул false, и что с этим надо делать?
  2. 02
    Почему самописный http.createServer — это риск Slowloris и OOM, которым фреймворк обычно не является, и какие именно дефолты закрывают брешь?
Итог

Модуль http отдаёт твоему обработчику два потока: reqIncomingMessage, который является Readable и несёт тело плюс method/url/headers, и resServerResponse, который является Writable и которым ты управляешь через writeHead/write/end. Авто-разобранного req.body нет — ты потребляешь тело через for await…of req (или события data/end) и обязан обработать случаи 'error'/'aborted' потока, иначе оборванная загрузка роняет процесс. Запись ответа означает уважение к backpressure: res.write() возвращает false, когда буфер ядра полон, так что жди 'drain' или просто pipeline()-ни поток-источник в res и дай Node управлять (он переключается на chunked encoding, когда длина неизвестна). Тот же модуль — клиент через http.request/http.get, пулящий сокеты через Agent, чей keep-alive (включён по умолчанию с Node 19) переиспользует живое соединение, чтобы пропустить RTT TCP-хендшейка и TLS-хендшейк на запрос, а maxSockets (дефолт Infinity) ограничивает параллелизм на хост. Сеньорская дисциплина — это всё, чего http не делает: ставь server.requestTimeout (дефолт 300000 мс) и server.headersTimeout (min(requestTimeout, 60000)), чтобы Slowloris не пришпилил твои сокеты, помни про keepAliveTimeout (5000 мс) и ограничивай размер тела сам — сырой http прочитает неограниченную нагрузку прямо в heap, так что считай байты и отвечай 413 после предела, ровно как фреймворки делают за тебя. Теперь, встретив http.createServer без таймаутов и без счётчика байт, ты сразу поймёшь: это открытая дверь для Slowloris.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.