Капстоун: проектируем и закаляем production Node-сервис
Production Node-сервис — это весь трек, собранный разом: типизированные ошибки за одной границей, незаблокированный loop, упорядоченный middleware с таймаутами и graceful shutdown, валидированный вход, env-конфиг и наблюдаемость. Каждая пропущенная забота — это инцидент.
Прототип работал на ноутбуке, поэтому его выкатили. Первый деплой под нагрузкой написал 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 + реальная тестовая БД) для собранного API | CI блокирует мерж на зелёном наборе, который гоняет реальные маршруты, а не моки |
| Безопасность (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 Логирование + назначение request-ID (чтобы даже отклонённые запросы попали в лог)
- 2 Парсер тела с лимитом размера (отклонить негабаритные payload рано)
- 3 Аутентификация / авторизация
- 4 Валидация распарсенного входа по схеме
- 5 Обработчик маршрута → сервис → репозиторий (параметризованный запрос)
- 6 Граница ошибок мапит любой брошенный тип → статус + безопасное тело
- 01Пройди последовательность shutdown на SIGTERM и объясни, почему порядок важен для деплоя без простоя.
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.