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

Капстоун: проектируем и закаляем production Node-сервис

Production Node-сервис — это весь трек, собранный разом: типизированные ошибки за одной границей, незаблокированный loop, упорядоченный middleware с таймаутами и graceful shutdown, валидированный вход, env-конфиг и наблюдаемость. Каждая пропущенная забота — это инцидент.

NODE Senior ◷ 20 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Прототип работал на ноутбуке, поэтому его выкатили. Первый деплой под нагрузкой написал 200 OK половине запросов оформления заказа в полёте — и тут же их потерял: под пришёл SIGTERM, контейнер запускал приложение как PID 1 без обработчика сигнала, и Kubernetes жёстко прибил его через 30 секунд прямо посреди транзакции. Следующий инцидент — хвостовая задержка: один эндпоинт делал JSON.parse над телом в 4 МБ прямо в пути запроса и блокировал event loop, так что все остальные запросы вставали в очередь за ним, и p99 прыгнул с 40 мс до 9 секунд. Потом подделанная query-строка дошла до непараметризованной SQL-строки. Ничего экзотического. Каждое — это один юнит этого трека, который прототип пропустил, и каждое стало production-инцидентом в тот момент, когда пришёл реальный трафик.

Чек-лист и есть архитектура

К концу этого урока ты сможешь проследить один запрос через полностью закалённый сервис и назвать инцидент, который порождает каждый пропущенный слой — так что в следующий раз, когда pull request срежет угол, ты распознаешь это до того, как оно попадёт в production.

Путь от прототипа к production — это не переписывание, а применение каждого юнита этого трека к одному маленькому HTTP-сервису и отказ хоть один из них пропустить. Полезная линза — чек-лист жизненного цикла запроса: для каждой сквозной заботы назови что ты делаешь и конкретный сигнал готово, когда. Пропусти строку — и получишь не сервис поменьше, а будущий инцидент с именем. Манифест 12factor и репозиторий node-best-practices — это под капотом ровно этот список: конфиг в окружении, зависимости объявлены и залочены, процессы одноразовые, логи как потоки событий.

Сеньорский ход — относиться к этому как к слоям, которые компонуются, а не к бэклогу фич. Ошибки, event loop, порядок middleware, shutdown, валидация, конфиг и наблюдаемость — не независимые галочки, они сцеплены. Graceful shutdown бесполезен, если балансировщик так и не узнал, что ты сливаешься (это readiness-проба). Граница ошибок бесполезна, если заблокированный loop означает, что граница вообще не выполнится. Ниже — чек-лист готовности, который защищает весь этот урок.

Забота (юнит)Что ты делаешьГотово, когда
Ошибки (03)Типизированные доменные ошибки; одна граница мапит тип→статус; сеть процесса на uncaughtКлиенты видят безопасный JSON + коды; в логах полные стеки; сюрприз роняет, а не портит
Event loop (04)Никогда не блокируй; CPU — в worker pool; следи за loop lag; масштабируй по ядрамp99 ровный под нагрузкой; loop lag < ~50 мс; профиль в одну команду
HTTP (05)Упорядоченный middleware; таймауты запроса + заголовков + тела; graceful SIGTERMНи один запрос не бежит без границы; деплой сливает работу без потерянных запросов
Тесты (06)Юнит-тесты логики; интеграционные (supertest + реальная тестовая БД) для собранного APICI блокирует мерж на зелёном наборе, который гоняет реальные маршруты, а не моки
Безопасность (07)Валидируй вход на границе; параметризованные запросы; секреты из env; npm ci + auditНевалидированный вход не доходит до логики; секрета нет в git; залоченные, проверенные зависимости
Упаковка (08)Многостадийный slim/distroless образ, non-root, exec-форма CMD + init, healthcheckМаленький образ бежит non-root и пробрасывает сигналы; healthcheck отражает readiness
Конфиг и ops (12-factor)Конфиг через env; эндпоинты liveness + readiness; эндпоинт метрик; SLOОдин образ бежит во всех env через конфиг; есть пробы + метрики + трейсы; SLO отслеживаются

Жизненный цикл запроса, закалённый из конца в конец

Проследи один запрос — и увидишь, как каждый юнит делает свою работу. Ingress или балансировщик маршрутизирует в процесс, у HTTP-сервера которого уже выставлены таймауты — server.requestTimeout, headersTimeout и таймаут обработчика на маршрут, — так что ни один клиент не удержит соединение открытым вечно (юнит 05). Запрос входит в упорядоченный стек middleware, и порядок несущий: сначала логирование (чтобы логировать даже отклонённые запросы), затем парсер тела с лимитом размера (чтобы тело в 4 МБ не разорвало память и не заблокировало парс), затем auth, затем валидация по схеме, затем обработчик. Обработчик зовёт тонкий слой сервисов, тот — репозиторий, выдающий параметризованные запросы (никакой конкатенации строк), а доменный сбой становится типизированной ошибкой (юнит 07, юнит 03).

Сквозь всё это продёрнут контекст запроса. Структурный логгер pino несёт request/trace ID через AsyncLocalStorage (встроенный Node.js API для хранения данных, изолированных по async-цепочке), так что каждая строка лога из глубины стека скоррелирована со своим запросом без ручной передачи ID. diagnostics_channel (встроенный pub/sub-канал Node.js для низкоуровневой инструментации без жёсткой связности) даёт публиковать точки инструментирования (старт/конец запроса к БД, попадание в кэш), на которые экспортёры подписываются без связности. В самом конце стоит единая граница ошибок: middleware обработки ошибок, которая мапит тип ошибки в HTTP-статус (ValidationError→400, NotFoundError→404, всё неизвестное→500), отправляет безопасное JSON-тело без стека и логирует полный объект ошибки с цепочкой cause. Это стратегия юнита 03, выраженная как ровно одно место в проводке.

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

CPU-bound шаг (ресайз картинки, дорогой bcrypt, большой JSON-трансформ) сидит внутри горячего обработчика и под нагрузкой раздувает p99. Куда ты его положишь?

Bootstrap и shutdown: проводка, решающая аптайм

Два куска сантехники решают, будут деплои невидимыми или разрушительными: bootstrap, который ставит страховочную сеть до того, как что-либо начнёт обслуживать, и shutdown, который сливает работу до выхода. Когда смотришь на index.js нового сервиса, спроси себя: сеть процесса установлена до listen, а SIGTERM обработан до первого возможного запроса? На SIGTERM ты не захлопываешь сервер — ты переключаешь readiness в не готов (чтобы балансировщик перестал слать новые запросы), прекращаешь принимать соединения, даёшь запросам в полёте дойти, закрываешь пул БД и взводишь жёсткий таймаут, чтобы зависший запрос не блокировал shutdown вечно. Пропуск этого — инцидент с потерянными заказами из хука.

import { createServer } from "node:http";
import { app } from "./app.js";          // упорядоченный middleware + граница ошибок
import { logger } from "./logger.js";
import { closePool } from "./db.js";

let ready = false;
const server = createServer(app);
server.requestTimeout = 30_000;          // юнит 05: ограничь каждый запрос
server.headersTimeout = 10_000;

// юнит 03: сеть процесса — зафиксируй, потом умри; супервизор перезапустит чистым
process.on("uncaughtException", (err) => { logger.fatal({ err }); shutdown(1); });
process.on("unhandledRejection", (err) => { logger.fatal({ err }); shutdown(1); });

async function shutdown(code = 0) {
  ready = false;                         // readiness переключился → LB сливает нас
  server.close(async () => {             // прекрати принимать; доделай в полёте
    await closePool();
    process.exit(code);
  });
  setTimeout(() => process.exit(code), 10_000).unref(); // жёсткий кэп
}
process.on("SIGTERM", () => shutdown(0));

server.listen(3000, () => { ready = true; logger.info("listening"); });
// liveness = процесс жив; readiness = флаг `ready` (одноразовость по 12-factor)
Почему это работает

Почему переключение readiness должно происходить до server.close, а не после? Потому что балансировщик узнаёт, что ты уходишь, только опросом readiness-эндпоинта. Если закрыть сервер первым, новые запросы, которые LB ещё не перестал маршрутизировать, получат ошибку соединения вместо чистого слива. Переключи readiness, дай интервалу пробы момент это заметить, затем закрывай — этот порядок и есть разница между деплоем без простоя и всплеском 502 на каждом раскате.

Викторина

Rolling-деплой теряет горстку запросов в полёте с ошибками сброса соединения на каждом раскате. Какая пропущенная забота виновата?

Викторина

Ты хочешь, чтобы каждая строка лога из глубины стека несла один и тот же request/trace ID без продёргивания его через каждую сигнатуру функции. За чем тянешься?

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

Расставь пайплайн middleware/обработчика для одного входящего запроса — от края к данным и обратно через границу:

  1. 1 Логирование + назначение request-ID (чтобы даже отклонённые запросы попали в лог)
  2. 2 Парсер тела с лимитом размера (отклонить негабаритные payload рано)
  3. 3 Аутентификация / авторизация
  4. 4 Валидация распарсенного входа по схеме
  5. 5 Обработчик маршрута → сервис → репозиторий (параметризованный запрос)
  6. 6 Граница ошибок мапит любой брошенный тип → статус + безопасное тело
Вспомните перед уходом
  1. 01
    Пройди последовательность shutdown на SIGTERM и объясни, почему порядок важен для деплоя без простоя.
  2. 02
    Каждая строка чек-листа готовности мапится в конкретный production-инцидент, если её пропустить. Назови инцидент для: нет границы ошибок, заблокированный event loop, невалидированный вход и root-контейнер, игнорирующий SIGTERM.
Итог

Production Node-сервис — это весь этот трек, собранный в один маленький HTTP-сервис и защищённый чек-листом готовности, где каждая строка мапится в конкретный инцидент, если её пропустить. Ошибки (юнит 03): типизированные доменные ошибки распространяются с { cause }, единая граница ошибок мапит тип→статус и логирует полный объект, а сеть процесса на uncaughtException/unhandledRejection фиксирует и затем выходит, чтобы супервизор перезапустил чистым. Event loop (юнит 04) остаётся свободным — CPU-работа выносится в worker pool, loop lag мониторится, а CPU-профиль или heap snapshot в одну команду, когда задержка деградирует. HTTP (юнит 05) — это фреймворк с упорядоченным стеком middleware — логирование → парсер-тела-с-лимитом → auth → валидация → обработчик → граница ошибок — с таймаутами запроса, заголовков и тела и путём graceful SIGTERM, который переключает readiness, сливает, закрывает и жёстко кэпит. Тесты (юнит 06) блокируют CI на юнит-тестах плюс интеграционных против реальной тестовой БД. Безопасность (юнит 07) валидирует весь вход на границе, использует параметризованные запросы, грузит секреты из окружения и гоняет npm ci + audit с залоченным lockfile. Упаковка (юнит 08) поставляет многостадийный slim/distroless образ, бегущий non-root с exec-формой CMD + init, чтобы сигналы доходили до процесса. А конфиг/ops следует 12factor: конфиг через env, пробы liveness и readiness, эндпоинт метрик, логи/метрики/трейсы и SLO. Вся суть: production-готовность — это не лишние фичи, прикрученные позже, а сцепление этих слоёв. Теперь, когда встречаешь сервис без SIGTERM-обработчика, с bcrypt inline или без лимита размера тела, — ты уже знаешь название инцидента, который этот пропуск забронировал.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.