RE:NODE

Приложения13 мин чтения

Express API в продакшене: порты, прокси, ошибки

Настройки Express, которые важны только тогда, когда приложение уже на сервере: привязка, trust proxy, порядок middleware, async-ошибки, 502 из-за keep-alive и чистая остановка.

0 прочтений

Приложение на Express, которое работает на вашей машине, отделяют от работающего на сервере примерно пятнадцать строк, и каждую из них учебники пропускают, потому что локально они не имеют значения. Приложение должно привязаться к порту, который выдал хост, и на всех интерфейсах. Оно должно знать, что стоит за прокси, иначе req.ip - это адрес прокси, и ваш ограничитель частоты считает всех одним человеком. Ему нужен обработчик ошибок, который ловит отклонённые промисы, остановка, которая дожидается запросов в работе, и тайминги keep-alive, не состязающиеся с тем, что стоит перед ним.

Это тот самый список, в порядке, в котором он больнее всего кусается. Он относится к любому HTTP-серверу на Node - Fastify, Koa, голому http.createServer, - но конкретика здесь про Express, потому что именно его чаще всего выкатывают.

Слушайте нужный порт на нужном интерфейсе#

Две строки, обе из которых в стандартном учебнике неверны:

javascript
const port = Number(process.env.PORT) || 3000;app.listen(port, () => {  console.log(`listening on ${port}`);});

Порт берётся из окружения. Хостинг-платформа выделяет его и сообщает вам через переменную; если жёстко прописать 3000, приложение либо не сможет привязаться, либо привяжется к порту, на который ничего не маршрутизируется. В RE:NODE тарифы для приложений включают одно выделение порта, оно показано на вкладке Network и доступно процессу как переменная окружения, заданная на вкладке Startup.

Интерфейс - более тонкая половина вопроса. app.listen(port) без хоста привязывается ко всем интерфейсам, а это именно то, что нужно. app.listen(port, "127.0.0.1") привязывается к loopback внутри контейнера, и тогда ничто снаружи, включая стоящий перед ним обратный прокси, не может подключиться. Симптом: приложение прекрасно стартует, пишет «listening» и ничего не отвечает - прокси получает отказ в соединении, браузер получает 502, а в вашем собственном логе нет ни одной строки. Если нужно указать явно, пишите "0.0.0.0".

То же относится ко всему остальному, что слушает порт. Эндпоинт метрик, отладочный сервер, путь для апгрейда до WebSocket: все они живут за тем же единственным выделением, так что подключайте их к тому же серверу, а не открывайте второй порт, которого у вас нет.

За прокси: trust proxy и настоящий IP клиента#

В продакшене ваше приложение разговаривает не с браузером. Оно разговаривает с обратным прокси, который завершил TLS, а исходные данные запроса сохраняются только в заголовках.

443выделенный портпосле middlewareКлиентHTTPSСлот проксиTLS, X-Forwarded-ForExpressстек middlewareОбработчик маршрутаваш кодСлот базы данныхзапросы
Что стоит между клиентом и вашим обработчиком

Без настройки Express сообщает адрес прокси в req.ip, http в req.protocol и false в req.secure. Всё, что от этого зависит, тихо оказывается неверным: ограничения частоты по IP превращаются в одну общую корзину, журналы аудита записывают один адрес для всех пользователей, а редирект на канонический хост отправляет людей на http://.

javascript
app.set("trust proxy", 1);

Единица означает «доверять одному прыжку». Она говорит Express брать последнюю запись из X-Forwarded-For как клиента, потому что ровно один прокси её добавил. Считайте прыжки: один прокси - это 1, CDN перед прокси - 2. Можно также передать подсеть, список или один из именованных пресетов, например loopback.

При верной настройке req.ip - это настоящий адрес клиента, req.protocol - https, а req.secure истинно. В RE:NODE слот прокси передаёт адрес клиента в X-Forwarded-For и сам занимается сертификатом: направьте запись A на показанный адрес, и сертификат будет выпущен и продлён автоматически в окне в 21 день. Механика описана в статьях что на самом деле делает обратный прокси и как направить домен на ваш сервер.

Если ваш API работает с WebSocket, прокси должен согласиться пропустить апгрейд, а таймауты простоя важнее, чем для обычных запросов. Этот случай разобран в статье WebSocket за обратным прокси.

Middleware в том порядке, в котором оно должно стоять#

Express выполняет middleware в порядке регистрации, и несколько проблем в продакшене - это проблемы порядка, а не конфигурации.

javascript
import express from "express";import helmet from "helmet";import compression from "compression";import cors from "cors";import pinoHttp from "pino-http";const app = express();app.set("trust proxy", 1);app.use(pinoHttp());                       // log everything, including failuresapp.use(helmet());                         // headers before any response bodyapp.use(compression());                    // before the routes that produce bodiesapp.use(cors({ origin: ["https://app.example.com"], credentials: true }));app.use(express.json({ limit: "100kb" }));

Что на самом деле делает каждый из них:

  • helmet выставляет группу заголовков ответа и убирает X-Powered-By. Для JSON API важнее всего X-Content-Type-Options: nosniff, X-Frame-Options, политика referrer и HSTS. Его Content Security Policy по умолчанию рассчитана на HTML, так что, если из того же приложения вы отдаёте документацию или страницу администратора, настройте политику, а не удаляйте helmet. Проверьте max-age для HSTS в установленной у вас версии, прежде чем называть кому-то число.
  • compression сжимает ответы gzip выше определённого порога. Для JSON любого размера это стоит того, а для изображений, видео и всего, что уже сжато, бессмысленно. Brotli он не поддерживает. Он также буферизует, что ломает server-sent events, если не вызывать res.flush() после каждой записи, и он излишен, если прокси перед вами уже сжимает: сжатие дважды тратит CPU впустую.
  • cors должен идти до ваших маршрутов, потому что preflight-запрос OPTIONS до них никогда не доходит. credentials: true нельзя сочетать с подстановочным origin; браузеры отвергают такую пару, поэтому перечисляйте свои origin явно.
  • `express.json` по умолчанию ограничивает тело запроса 100 КБ. Повышайте лимит осознанно, если принимаете более крупные данные, и не повышайте его глобально только потому, что один эндпоинт принимает загрузки: лимит тела - самая дешёвая защита от отказа в обслуживании из имеющихся. Остальная часть этой поверхности атаки разобрана в статье лимиты частоты и злоупотребления.

Логирование идёт первым, чтобы запрос, упавший внутри другого middleware, всё равно попал в журнал. Пишите логи в JSON в stdout: консоль панели покажет их вживую, а структурированные строки - это разница между поиском по логу и гаданием. Что хранить и как долго - в статье логи, которые стоит хранить.

Ошибки и обработчик, которого у вас, вероятно, нет#

Express определяет обработчик ошибок по числу аргументов: четыре, и зарегистрирован он после всего остального.

javascript
app.use((req, res) => {  res.status(404).json({ error: "not_found" });});app.use((err, req, res, next) => {  req.log.error({ err }, "request failed");  if (res.headersSent) return next(err);  res.status(err.status || 500).json({ error: "internal_error" });});

От трёх деталей зависит, заработает ли это.

Асинхронные обработчики. В Express 4 отклонённый промис внутри async (req, res) => {} Express вообще не перехватывает. Он превращается в необработанное отклонение, которое в современном Node завершает процесс, так что один неудавшийся вызов базы данных обрушивает все запросы в работе. Способы исправить: оборачивать обработчики, использовать пакет, который это исправляет, или перейти на Express 5, который передаёт отклонения из async-обработчиков вашему error middleware. Если вы обновитесь, учтите, что Express 5 также изменил парсер шаблонов маршрутов, так что несколько старых шаблонов путей придётся переписать; если маршруты перестали совпадать, загляните в заметки о миграции.

Тело ответа. Встроенный обработчик ошибок Express включает стек вызовов в ответ, если только NODE_ENV не равно production. Стек вызовов сообщает атакующему структуру ваших каталогов, версии зависимостей и нередко ваш драйвер базы данных. Задайте NODE_ENV=production на вкладке Startup и возвращайте код ошибки, а не сообщение из исключения.

Обработчики уровня процесса. Сначала записывайте в лог, а потом решайте, а не проглатывайте:

javascript
process.on("unhandledRejection", (err) => {  console.error("unhandled rejection", err);});process.on("uncaughtException", (err) => {  console.error("uncaught exception", err);  process.exit(1);});

Неперехваченное исключение оставляет процесс в неизвестном состоянии, поэтому правильная реакция - выйти и позволить платформе перезапустить его. Неправильная - перехватить его и продолжить работу, а именно так API оказывается обслуживающим запросы из процесса с наполовину закрытым пулом соединений с базой.

Конфигурация, которая падает сразу#

Читайте конфигурацию один раз, при запуске, и отказывайтесь стартовать, если чего-то не хватает:

javascript
const required = ["DATABASE_URL", "SESSION_SECRET"];const missing = required.filter((name) => !process.env[name]);if (missing.length) {  console.error(`missing configuration: ${missing.join(", ")}`);  process.exit(1);}

Процесс, который за две секунды выходит с понятной строкой, диагностировать несравнимо легче, чем тот, что запускается, принимает трафик и возвращает 500, потому что строка подключения оказалась undefined. Это ещё и хорошо сочетается с политиками перезапуска: сервер, который не может запуститься, не сидит в цикле, изображая работу.

Секретам место в переменных окружения на вкладке Startup, а не в репозитории и не в закоммиченном .env. Доводы в пользу этого и что делать в день, когда один из них утёк, изложены в статье где хранить секреты на сервере приложений. Учётные данные базы данных приходят из панели, когда вы создаёте слот базы данных - тарифы для приложений включают два, - а соединение работает через пул, и именно здесь большинство API допускают первую ошибку с ёмкостью. Почему пул из 100 соединений на небольшой базе медленнее пула из 10, объясняет статья пулы соединений и лимиты.

Запуск и остановка без потери запросов#

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

javascript
const server = app.listen(port);server.keepAliveTimeout = 65_000;server.headersTimeout = 70_000;process.on("SIGTERM", () => {  server.close(() => process.exit(0));  server.closeIdleConnections();  setTimeout(() => process.exit(1), 10_000).unref();});

server.close() перестаёт принимать новые соединения и ждёт завершения запросов в работе. Сам по себе он может зависнуть, потому что простаивающие keep-alive-соединения всё равно остаются соединениями; closeIdleConnections() (Node 18.2 и новее) их закрывает, а таймер - это страховка для запроса, который никогда не заканчивается.

Два таймаута - это лекарство от вполне конкретного и досадного симптома: редких 502 при небольшой нагрузке и ничего в логе приложения. По умолчанию keepAliveTimeout в Node равен 5 секундам. Если прокси перед вами держит простаивающие соединения дольше, он иногда отправит запрос по соединению, которое Node закрывает в ту же самую секунду, и прокси сообщит о плохом шлюзе. Если окно keep-alive у Node длиннее, чем у прокси, гонка исчезает, а headersTimeout должен быть ещё больше, иначе он закроет соединение первым.

Добавьте эндпоинт проверки состояния, который не обращается к базе данных:

javascript
app.get("/healthz", (req, res) => res.json({ ok: true, uptime: process.uptime() }));

Liveness отвечает на вопрос «жив ли этот процесс», и это всё, что он должен делать. Проверка состояния, которая выполняет запрос, превращает медленную базу данных в цикл перезапусков, а деградировавший сервис - в аварию. Если хотите показывать готовность, сделайте для неё второй, отдельный эндпоинт. Различие разобрано в статье graceful shutdown и проверки состояния.

Деплой#

Стартовая команда устанавливает зависимости по lock-файлу и запускает один процесс:

bash
npm ci --omit=dev && node dist/server.js

npm ci устанавливает ровно то, что записано в package-lock.json, и падает, если lock-файл и манифест расходятся, - а именно этого вы и хотите на сервере; npm install разрешает что-то новое, и так нетронутое приложение ломается при перезапуске. Если проект на TypeScript, либо компилируйте в CI и отправляйте результат, либо уберите --omit=dev, потому что компилятор - это devDependency. Подробности - в статье npm ci и npm install.

Подключайте репозиторий, а не загружайте файлы. В RE:NODE это GitHub через GitHub App с краткоживущими токенами, чтобы работали приватные репозитории, и с двумя независимыми переключателями: получать ветку при каждом запуске и деплоить при push, что перезапускает уже работающий сервер. Одна строка на каждый деплой показывает, какой push сейчас в работе. Пошаговое описание - в статье деплой приложения Node.js с GitHub.

Менеджер процессов вам не нужен. Панель и так перезапускает процесс, когда он завершается, показывает лог и рисует графики памяти и CPU относительно лимитов, а это большая часть того, ради чего люди ставят PM2, - честное сравнение есть в статье PM2 или панель хостинга. Кластеризация вам, вероятно, тоже не нужна: node:cluster умножает процессы, чтобы задействовать несколько ядер, а на тарифе с половиной ядра это добавляет расход памяти и переключения контекста с отрицательной пользой. Сначала измерьте и прочитайте почему ваше приложение Node падает на 2 ГБ при тарифе 4 ГБ, прежде чем полагать, что память ведёт себя так, как вы ожидаете.

Перезапуск не мгновенный, поэтому деплой стоит короткого разрыва. С одним процессом сделать его нулевым невозможно, но можно сделать маленьким: держите установку быстрой, деплойте, когда трафик низкий, и прочитайте деплой без простоя на сервере, где всего по одному - там описаны приёмы, которые действительно работают на одной машине.

Что ломается первым под нагрузкой#

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

Исходящие вызовы без таймаута. По умолчанию fetch ждёт бесконечно. Тогда один медленный внешний сервис удерживает все запросы, которые от него зависят, соединения накапливаются, и у процесса заканчивается память, пока он их держит. Дайте каждому исходящему вызову крайний срок:

javascript
const response = await fetch(url, { signal: AbortSignal.timeout(3_000) });

Заодно переиспользуйте соединения. Глобальный fetch в Node держит соединения открытыми для каждого origin, но библиотека, создающая новый агент на каждый вызов, платит за TLS-рукопожатие каждый раз, а при нескольких сотнях запросов в минуту это заметная доля вашего CPU.

Нет ограничения на конкурентность. Десять одновременных запросов, каждый из которых открывает соединение с базой и выделяет мегабайт, - это нормально. Пятьсот - нет. Естественное место, чтобы это ограничить, - пул, потому что запрос, ожидающий соединения, дёшев, а запрос, удерживающий его, - нет. Подбирайте размер пула по тому, что реально может обслужить база, а не по тому, чего хотелось бы вашему приложению.

Ничто из кэшируемого не помечено кэшируемым. Заголовок Cache-Control на ответе, который меняется раз в час, убирает большую часть трафика ещё до того, как тот до вас дойдёт, и стоит одной строки. ETag в Express включён по умолчанию, и повторные запросы превращаются в 304, но это помогает, только если клиенты перепроверяют. Полная картина - в статье заголовки HTTP-кэширования простыми словами; коротко: самый дешёвый запрос - тот, который вам не пришлось обслуживать.

Синхронная работа в цикле событий. Разбор большого JSON, хеширование пароля с высоким параметром стоимости, изменение размера изображения, сборка PDF: пока любое из этого выполняется, процесс никого не обслуживает. Хеширование паролей должно использовать асинхронный API той библиотеки, которую вы выбрали. Всё более тяжёлое место в воркере или очереди.

Над всем этим стоит лимит памяти. В RE:NODE контейнер, достигший лимита, останавливается и чисто перезапускается, а не остаётся в подкачке, поэтому утечка проявляется перезапуском раз в несколько часов, а не медленным упадком, а три неожиданных перезапуска за час вызывают предупреждение и автоматически открывают тикет. Такое поведение - хорошее значение по умолчанию и повод смотреть на график памяти в консоли после каждого деплоя, а не только тогда, когда что-то уже сломалось.

FAQ#

Почему мой API работает локально, но на сервере отвечает по таймауту?

Почти всегда дело в адресе привязки. localhost внутри контейнера недоступен снаружи. Слушайте 0.0.0.0 или вовсе опустите аргумент хоста, и используйте порт из окружения, а не прописанный жёстко.

Нужен ли nginx перед Express?

Вам нужно что-то, что завершает TLS, а на управляемой платформе оно уже есть. Запуск собственного nginx внутри контейнера это дублирует. Что действительно нужно - правильно заданный trust proxy, чтобы приложение знало, что сделал стоящий перед ним прокси.

Действительно ли важно NODE_ENV=production?

Да. Оно не даёт Express возвращать стеки вызовов в ответах с ошибками, включает кэширование представлений, и его читает длинный список библиотек, чтобы отключить поведение для разработки. Это одна переменная окружения, и она меняет реальное поведение.

Почему я иногда получаю 502 и ничего не вижу в логах?

Это описанная выше гонка keep-alive. Node закрывает простаивающее соединение в тот же момент, когда прокси его переиспользует. Задайте server.keepAliveTimeout больше, чем таймаут простоя у прокси, а server.headersTimeout - ещё больше.

Стоит ли запускать несколько процессов Node через cluster?

Только если у вас больше одного ядра, и только после измерений. Один цикл событий справляется с большим объёмом работы, упирающейся в ввод-вывод; кластеризация помогает, когда вы упираетесь в CPU, и стоит памяти на каждый процесс. Как перевести оценки трафика в число воркеров, показано в статье как подобрать тариф веб-приложения к дню запуска.

Куда девать загрузки и сгенерированные файлы?

Не в каталог деплоя, потому что следующий pull окажется на пути. Пишите в путь вне отслеживаемого дерева или в объектное хранилище и помните, что диск на небольшом тарифе измеряется однозначным числом гигабайт. Слот базы данных подходит для структурированных данных; случай, когда база перерастает сервер приложения, разобран на странице хостинг баз данных.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000