Node.js Internals
HTTP Internals: Под капотом сервера
Каждый вызов `http.createServer()` или `fetch()` запускает под капотом невероятно сложную машину: парсер на C, управление тысячами соединений, оптимизация keep-alive. Понимание HTTP internals превращает разработчика из пользователя API в мастера, который знает, **почему** сервер тормозит и **как** это исправить.
- **Отладка 502 Bad Gateway:** Клиенты жалуются на ошибки за nginx. Причина - `server.keepAliveTimeout` (5s) меньше чем у nginx (60s) → race condition. Решение: `keepAliveTimeout = 65_000`. Проблема исчезает.
- **Оптимизация микросервисов:** Сервис делает 10K запросов к API, каждый тормозит. Создание `http.Agent` с `keepAlive: true` → скорость увеличивается в 100 раз (переиспользование соединений вместо 10K handshake'ов).
- **Переход на HTTP/2:** Веб-приложение загружает медленно (100 ресурсов, HTTP/1.1). Включение HTTP/2 → время загрузки уменьшается в 5 раз (multiplexing устраняет head-of-line blocking). Users happy.
Intro
За двумя словами `http.createServer()` в Node.js скрывается целая архитектура парсинга, управления соединениями и оптимизации сети. Это не просто обёртка над TCP-сокетами - это высокооптимизированная машина, которая обрабатывает тысячи HTTP-запросов в секунду на одном потоке.
Аналогия: HTTP-сервер - это ресторан. TCP-сокет - это входная дверь. Но мало просто открыть дверь - нужно встретить клиента (парсить HTTP-заголовки), посадить за столик (создать request/response объекты), принять заказ (прочитать тело запроса), приготовить блюдо (handler приложения), подать его (отправить ответ) и проводить клиента (закрыть соединение или переиспользовать для следующего запроса через keep-alive). Node.js делает всё это автоматически.
**Ключевая особенность HTTP в Node.js:** Модуль `http` построен поверх `net` (TCP) и использует Event Loop для асинхронной обработки. Один процесс может держать десятки тысяч одновременных соединений благодаря неблокирующему I/O. В Apache каждое соединение = отдельный поток (дорого), в Node.js = один файловый дескриптор (дёшево).
Что происходит при HTTP-запросе (шаг за шагом)
**1. Клиент открывает TCP-соединение** → Node.js получает событие 'connection' от ОС через epoll/kqueue **2. llhttp парсер читает байты из сокета:** ``` GET /api/users HTTP/1.1\r\n Host: localhost:3000\r\n Connection: keep-alive\r\n \r\n ``` **3. Парсер вызывает callback'и:** - `on_url` → сохраняет `/api/users` - `on_header_field` → сохраняет `Host`, `Connection` - `on_header_value` → сохраняет их значения - `on_headers_complete` → заголовки готовы, создаётся IncomingMessage **4. Node.js вызывает request handler приложения** **5. res.end()** → отправляет HTTP-ответ, решает keep-alive или закрыть сокет **6. Если keep-alive** → сокет возвращается в пул, ждёт следующий запрос
**Почему Node.js эффективен для HTTP:** В традиционных серверах (Apache, Tomcat) thread блокируется на чтение данных из сокета. В Node.js Event Loop просто регистрирует callback и переходит к следующей задаче. Когда данные готовы, ОС уведомляет через epoll → callback выполняется. Zero blocking, maximum throughput.
HTTP-сервер на Node.js получает 1000 одновременных запросов. Каждый запрос: 2ms CPU (парсинг + обработка) + 50ms ожидание БД. Сколько примерно времени займёт обработка всех запросов?
Parsing
В сердце HTTP-модуля находится **llhttp** - сверхбыстрый парсер HTTP-сообщений, написанный на C. До Node.js 12 использовался `http-parser` (тоже C), но llhttp в 2-3 раза быстрее благодаря кодогенерации и меньшему числу системных вызовов. llhttp компилируется из TypeScript-подобного DSL в оптимизированный C-код - это буквально машина состояний (state machine).
**Почему парсинг HTTP так важен?** HTTP - это текстовый протокол. Запрос приходит как поток байтов: ``` GET /api/data HTTP/1.1\r\nHost: example.com\r\nContent-Length: 13\r\n\r\n{"key":"value"} ``` Парсер должен: 1. Извлечь метод (`GET`), путь (`/api/data`), версию (`HTTP/1.1`) 2. Распарсить заголовки (key-value пары до `\r\n\r\n`) 3. Прочитать тело запроса (если есть `Content-Length` или `Transfer-Encoding: chunked`) 4. Валидировать синтаксис (защита от атак) 5. Сделать всё это **без копирования данных** (zero-copy где возможно)
**llhttp - это конечный автомат (FSM).** Парсер переходит между состояниями: `s_start_req` → `s_req_method` → `s_req_spaces_before_url` → `s_req_url` → и т.д. Каждый байт триггерит переход. Это невероятно быстро: ~1-2 CPU цикла на байт. Для сравнения, regex-based парсеры в 10-100 раз медленнее.
Chunked Transfer Encoding: как llhttp читает стримы
Когда клиент отправляет большой файл или стрим, он использует `Transfer-Encoding: chunked`: ``` POST /upload HTTP/1.1\r Transfer-Encoding: chunked\r\n\r 5\r Hello\r 5\r World\r 0\r \r ``` Каждый чанк: размер в hex (`5`) + `\r\n` + данные + `\r\n`. Конец: `0\r\n\r\n`. llhttp парсит это **инкрементально** - не ждёт весь запрос, а вызывает `on_body` для каждого чанка. Код приложения может обрабатывать данные по мере поступления: ```typescript server.on('request', (req, res) => { req.on('data', (chunk) => { console.log('Received chunk:', chunk.length, 'bytes'); // Можно писать в файл, не храня весь запрос в памяти }); }); ``` Это позволяет Node.js обрабатывать гигабайты данных с минимальной памятью.
**Безопасность парсера:** llhttp строгий к синтаксису для защиты от HTTP request smuggling атак. Например, запрещены пробелы в именах заголовков, дублирующиеся `Content-Length`, смешивание `Content-Length` и `Transfer-Encoding: chunked`. Если парсер находит невалидный HTTP, он закрывает соединение с ошибкой `Parse Error`.
**Оптимизация: избегать больших заголовков.** llhttp имеет лимиты: максимум 80KB на заголовки (по умолчанию). При отправке гигантских cookies или custom headers парсер выбросит ошибку `HPE_HEADER_OVERFLOW`. Решение: увеличить лимит через `server.maxHeaderSize` (Node.js 11.6+) или оптимизировать заголовки.
Клиент отправляет POST-запрос с телом 1GB через `Transfer-Encoding: chunked`. Когда llhttp парсер начнёт вызывать callback `on_body`?
Connection Handling
HTTP построен поверх TCP - надёжного, но медленного протокола (handshake занимает 1-2 RTT, с TLS - до 3 RTT). Открывать новое соединение для каждого запроса дорого. Поэтому Node.js (и все современные HTTP-серверы) используют **connection pooling** - переиспользование TCP-соединений для множества запросов.
**Два режима работы HTTP-соединений:** **1. HTTP/1.0 (старый):** Одно соединение = один запрос. После ответа сервер закрывает TCP-сокет. Клиент для следующего запроса открывает новое соединение. Это медленно: каждый запрос = новый TCP handshake (SYN → SYN-ACK → ACK). **2. HTTP/1.1 (по умолчанию):** Keep-Alive. Одно соединение = множество запросов. После ответа сокет остаётся открытым, клиент отправляет следующий запрос по тому же соединению. Это в 3-5 раз быстрее для множественных запросов.
**Как Node.js управляет соединениями (серверная сторона):** При вызове `http.createServer()` создаётся `net.Server` (TCP). Когда клиент подключается, Node.js: 1. Принимает соединение через `accept()` syscall 2. Регистрирует socket в Event Loop (epoll/kqueue) 3. Создаёт `Socket` объект (наследник `Duplex` stream) 4. Навешивает обработчик данных → llhttp parser 5. После обработки запроса НЕ закрывает сокет (если keep-alive) 6. Ждёт следующий HTTP-запрос на том же сокете
Производительность: Keep-Alive vs новое соединение
**Тест:** 100 последовательных HTTP-запросов к `localhost`. **Без Keep-Alive (HTTP/1.0):** - Каждый запрос: TCP handshake (~1ms локально) + запрос (~1ms) = 2ms - 100 запросов × 2ms = **200ms** **С Keep-Alive (HTTP/1.1):** - Первый запрос: TCP handshake (~1ms) + запрос (~1ms) = 2ms - Остальные 99 запросов: только запрос (~1ms) = 99ms - **Итого: 101ms** (в 2 раза быстрее) **Через интернет (RTT = 50ms):** - Без Keep-Alive: 100 × (50ms handshake + 50ms запрос) = **10 секунд** - С Keep-Alive: 50ms (handshake) + 100 × 50ms (запросы) = **5.05 секунды** Вот почему Keep-Alive критичен для производительности веб-приложений!
**Проблема: connection leaks.** Если клиент открывает соединение, но не отправляет запрос (или не закрывает после ответа), сокет висит вечно, занимая память и файловый дескриптор. Node.js защищается через таймауты: ```typescript server.timeout = 120_000; // 2 минуты (по умолчанию) server.keepAliveTimeout = 5_000; // 5 секунд после последнего запроса ``` После `keepAliveTimeout` сервер отправляет `Connection: close` и закрывает сокет. Это предотвращает накопление idle соединений.
**Мониторинг соединений:** Используйте `server.getConnections(callback)` для отслеживания активных соединений. В production важно мониторить метрики: - Число активных соединений - Средняя длительность соединения - Число timeout'ов (признак проблем с клиентами или атак) Если видите тысячи idle соединений → уменьшите `keepAliveTimeout`. Если много новых соединений (мало переиспользования) → увеличьте `keepAliveTimeout`.
HTTP-сервер на Node.js получает 1000 req/sec от клиента через интернет (RTT = 100ms). Клиент поддерживает Keep-Alive. Сколько TCP-соединений в среднем будет активно?
Keep Alive
**HTTP Keep-Alive** (persistent connections) - это механизм переиспользования TCP-соединений для множества HTTP-запросов. Вместо "запрос → ответ → закрыть сокет", соединение остаётся открытым: "запрос1 → ответ1 → запрос2 → ответ2 → ... → timeout → закрыть". Это радикально ускоряет HTTP-коммуникацию, особенно через интернет.
**Проблема, которую решает Keep-Alive:** TCP handshake дорогой. Локально это ~1ms, но через интернет (RTT = 50-200ms) каждое новое соединение = потеря сотен миллисекунд. Для веб-страницы с 50 ресурсами (HTML, CSS, JS, картинки) без Keep-Alive нужно 50 × 3 × RTT (SYN, SYN-ACK, ACK) = **десятки секунд только на handshake'и**. С Keep-Alive: 1 handshake, 50 запросов по одному соединению.
**Как работает Keep-Alive:** HTTP-заголовок `Connection: keep-alive` (HTTP/1.0) или отсутствие `Connection: close` (HTTP/1.1, по умолчанию). Сервер отвечает с тем же заголовком → клиент знает, что соединение останется открытым. После отправки ответа сервер **не закрывает** сокет, а регистрирует таймер. Если в течение `keepAliveTimeout` приходит новый запрос - обрабатывает, иначе - закрывает.
Реальный кейс: Keep-Alive за nginx
**Проблема:** Пользователи периодически получают 502 Bad Gateway от nginx. **Причина:** Race condition между nginx и Node.js: 1. nginx `keepalive_timeout = 60s` 2. Node.js `server.keepAliveTimeout = 5s` (дефолт в старых версиях) 3. Сценарий: - Клиент → nginx → Node.js (запрос) - 5 секунд idle - Node.js закрывает соединение с nginx - nginx думает, что соединение открыто (у него timeout 60s) - Новый запрос: nginx пытается использовать закрытый сокет → 502 Error **Решение:** ```typescript // Node.js keepAliveTimeout должен быть БОЛЬШЕ чем у nginx server.keepAliveTimeout = 65_000; // nginx + 5 секунд запас ``` Это гарантирует, что nginx закроет соединение первым (gracefully), а не Node.js (abruptly).
**Опасность: Keep-Alive без ограничений.** Если не установить `maxRequestsPerSocket`, клиент может держать одно соединение бесконечно долго, отправляя запросы каждые 4.9 секунды (если timeout 5s). Это проблема в контейнерах: при деплое новой версии старые процессы не могут завершиться, потому что у них висят активные Keep-Alive соединения. Решение: ```typescript server.maxRequestsPerSocket = 1000; // После 1000 запросов → Connection: close ``` Это заставляет клиентов периодически переподключаться, позволяя graceful shutdown.
**Отладка Keep-Alive:** Используйте `tcpdump` или Wireshark для просмотра TCP-пакетов: ```bash # Мониторинг HTTP-соединений sudo tcpdump -i lo -A 'tcp port 3000' ``` Ищите пакеты `FIN` (закрытие соединения). Если видите `FIN` после каждого HTTP-ответа - Keep-Alive не работает. Если `FIN` только после таймаута - работает корректно.
Node.js HTTP-сервер за nginx. nginx имеет `keepalive_timeout 60s`. Какое значение `server.keepAliveTimeout` правильное для Node.js?
Agents
**http.Agent** - это менеджер пула исходящих HTTP-соединений (когда Node.js выступает как клиент). Ситуация: микросервис делает 1000 запросов к API другого сервиса. Без Agent каждый запрос = новое TCP-соединение (медленно). С Agent - переиспользование соединений через connection pool.
**Зачем нужен Agent?** При вызове `http.get('http://api.example.com/users')` Node.js: 1. Проверяет: есть ли свободное соединение к `api.example.com:80` в пуле Agent'а? 2. Если есть - переиспользует (Keep-Alive) 3. Если нет - открывает новое TCP-соединение и добавляет в пул 4. После запроса соединение возвращается в пул (не закрывается!) 5. Если соединение idle дольше `timeout` - Agent закрывает его Это автоматический connection pooling для исходящих HTTP-запросов.
**Дефолтные параметры http.Agent:** - `maxSockets` = `Infinity` (безлимит одновременных соединений) - `maxFreeSockets` = 256 (максимум idle соединений в пуле) - `timeout` = нет (соединения живут бесконечно) - `keepAlive` = `false` (!!!) - по умолчанию отключен! Важно: глобальный `http.globalAgent` используется по умолчанию, но **keepAlive выключен**. Для production нужно создать свой Agent.
Реальный кейс: микросервис делает 10K запросов к внешнему API
**Без http.Agent (или keepAlive: false):** - 10,000 новых TCP-соединений - Каждое: 3-RTT handshake (50ms через интернет) = **500 секунд** только на handshake'и - Нагрузка на сервер: 10K одновременных соединений - Возможный результат: API rate-limiting или IP-бан ("слишком много соединений") **С http.Agent (keepAlive: true, maxSockets: 100):** - Открывается 100 TCP-соединений (первая партия) - Handshake: 100 × 50ms = **5 секунд** - Остальные 9,900 запросов переиспользуют те же 100 соединений - Общее время: 5s (handshake) + время на запросы - API видит стабильные 100 соединений (нормально) **Результат:** В 100x быстрее + friendly к серверу.
**Ловушка: глобальный Agent и memory leaks.** При запросах к множеству разных хостов с `http.globalAgent` пул растёт бесконечно (`maxSockets: Infinity`). В production это утечка памяти: ```typescript // ПЛОХО: утечка памяти for (const host of thousandsOfHosts) { http.get(`http://${host}/ping`); // globalAgent создаёт сокеты для всех хостов } // ХОРОШО: ограничить maxSockets const agent = new http.Agent({ keepAlive: true, maxSockets: 100 }); for (const host of thousandsOfHosts) { http.get(`http://${host}/ping`, { agent }); // Максимум 100 соединений total } ```
**Best practices для http.Agent:** 1. **Всегда включайте `keepAlive: true`** для production (иначе каждый запрос = новое соединение) 2. **Ограничивайте `maxSockets`** (50-100 на хост) чтобы не задушить сервер 3. **Используйте `scheduling: 'lifo'`** - переиспользовать свежие соединения (меньше шанс timeout'а) 4. **Устанавливайте `timeout`** для idle соединений (иначе они висят вечно) 5. **Вызывайте `agent.destroy()`** при graceful shutdown (иначе процесс не завершится) 6. **Мониторьте** `agent.getCurrentStatus()` для отладки (число активных/idle сокетов)
Performance
Производительность HTTP-модуля в Node.js - это комбинация эффективного парсинга (llhttp), умного управления соединениями (Keep-Alive, http.Agent) и понимания того, как переход на HTTP/2 меняет правила игры. В production разница между правильной и неправильной настройкой HTTP может быть в 10-100 раз.
**HTTP/1.1 vs HTTP/2: фундаментальная разница** **HTTP/1.1:** Head-of-line blocking. Одно соединение = один запрос за раз. Если запрос медленный (большой файл), следующие запросы ждут. Решение: браузеры открывают 6 параллельных соединений на домен. **HTTP/2:** Multiplexing. Одно соединение = сотни параллельных запросов. Нет head-of-line blocking на уровне HTTP (есть на уровне TCP, но это другая история). Бинарный протокол (быстрее парсится), сжатие заголовков (HPACK), Server Push.
**HTTP/2 в Node.js:** Модуль `http2` (стабильный с Node.js 10). API отличается от `http`, но концепции те же: streams, headers, push. Важно: HTTP/2 требует HTTPS (браузеры не поддерживают HTTP/2 через plain HTTP). В production HTTP/2 обычно терминируется на reverse proxy (nginx, Cloudflare), а Node.js работает с HTTP/1.1 внутри.
Benchmark: загрузка 100 ресурсов (10KB каждый)
**Условия:** RTT = 50ms, bandwidth = 100 Mbps **HTTP/1.1 без Keep-Alive:** - 100 ресурсов × (3 RTT handshake + 1 RTT запрос) = 400 RTT = **20 секунд** - Плюс время на передачу данных: 100 × 10KB / 100Mbps = ~80ms - **Итого: ~20 секунд** **HTTP/1.1 с Keep-Alive (6 параллельных соединений):** - 6 соединений × 3 RTT = 18 RTT = 900ms (handshake) - 100 ресурсов / 6 соединений = ~17 последовательных запросов/соединение - 17 × 1 RTT = 850ms (запросы) - **Итого: ~1.8 секунды** **HTTP/2 (1 соединение, multiplexing):** - 1 соединение × 3 RTT = 150ms (handshake) - Все 100 ресурсов запрашиваются параллельно: 1 RTT = 50ms - Передача данных: 1000KB / 100Mbps = ~80ms - **Итого: ~0.28 секунды** (в 6 раз быстрее HTTP/1.1!) Вот почему HTTP/2 критичен для современных веб-приложений.
**HTTP/2 Head-of-Line Blocking на TCP-уровне:** HTTP/2 решает HOL blocking на уровне HTTP, но TCP всё ещё страдает от этого. Если TCP-пакет теряется, все HTTP/2 streams блокируются до retransmit'а. Это особенно заметно на мобильных сетях (packet loss ~1-5%). Решение: **HTTP/3 (QUIC)** - работает поверх UDP, нет TCP HOL blocking. Пока в Node.js экспериментально.
**Мониторинг производительности HTTP:** ```typescript import { performance } from 'perf_hooks'; server.on('request', (req, res) => { const start = performance.now(); res.on('finish', () => { const duration = performance.now() - start; console.log(`${req.method} ${req.url} - ${res.statusCode} - ${duration.toFixe (2)}ms`); // Метрики для Prometheus/Grafana: // - http_request_duration_ms (histogram) // - http_requests_total (counter) // - http_active_connections (gauge) }); }); ``` **Ключевые метрики:** - P50, P95, P99 latency (медиана и перцентили) - Requests per second (throughput) - Active connections (текущая нагрузка) - Connection errors (таймауты, resets)
Ключевые идеи
- **llhttp парсер** - конечный автомат на C, парсит HTTP инкрементально (1-2 CPU цикла на байт). Поддерживает chunked encoding для стриминга гигабайт данных с минимальной памятью.
- **Keep-Alive** - переиспользование TCP-соединений для множества запросов. На сервере включён по умолчанию (HTTP/1.1), но критично правильно настроить таймауты (особенно за nginx). На клиенте нужен custom `http.Agent` с `keepAlive: true`.
- **http.Agent** - connection pool для исходящих запросов. Дефолтный `http.globalAgent` имеет `keepAlive: false` → каждый запрос = новое соединение. Для production создавайте Agent с `keepAlive: true`, `maxSockets` и `timeout`.
- **HTTP/2** - multiplexing устраняет head-of-line blocking. Одно соединение = сотни параллельных запросов. В 3-10 раз быстрее HTTP/1.1 для множественных ресурсов. В Node.js: модуль `http2`, требует HTTPS.
- **Production best practices:** Cluster mode (все CPU-ядра), правильные таймауты (`keepAliveTimeout` > nginx), мониторинг метрик (latency, throughput, connections), защита от медленных клиентов (`requestTimeout`).
Связанные темы
HTTP Internals тесно связан с другими аспектами Node.js и сетевых технологий:
- Event Loop — llhttp парсер работает в Event Loop через poll phase. Понимание фаз Event Loop объясняет, почему HTTP-сервер может обрабатывать тысячи соединений на одном потоке.
- Streams — IncomingMessage (req) и ServerResponse (res) - это streams. Chunked encoding работает через стриминг данных. Понимание streams критично для эффективной обработки больших запросов.
- TCP/IP — HTTP построен поверх TCP. Keep-Alive, connection pooling, handshake overhead - всё это TCP-концепции. HTTP/2 борется с TCP head-of-line blocking.
Вопросы для размышления
- Почему HTTP/2 multiplexing быстрее чем HTTP/1.1 pipelining (если бы он работал в браузерах)?
- Как отладить ситуацию, когда http.Agent переиспользует соединения, но latency всё равно высокая? (Подсказка: server-side keep-alive timeout)
- В чём фундаментальная проблема HTTP/2, которую решает HTTP/3 (QUIC)? Почему UDP, а не TCP?