Graceful shutdown, кэширование и мониторинг

Блок вопросов «а как вы это эксплуатируете»: деплой без потерянных запросов, кэш, логи и метрики. Здесь проверяют опыт, а не знание API.

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

Что проверяет интервьюер

Понимаете ли вы, что происходит при деплое. Оркестратор посылает контейнеру SIGTERM и ждёт (обычно 30 секунд), потом присылает SIGKILL. Если приложение не обрабатывает сигнал, оно умирает мгновенно — вместе с запросами, которые в этот момент были в работе. Пользователь видит 502, а вы — «мистические» ошибки ровно во время каждого релиза.

Корректное завершение

const server = app.listen(3000);
let shuttingDown = false;

// health-проба должна начать отвечать «не готов» ДО закрытия сервера
app.get('/readyz', (req, res) => {
  if (shuttingDown) return res.status(503).json({ status: 'shutting_down' });
  res.json({ status: 'ok' });
});

async function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  logger.info({ signal }, 'начинаем graceful shutdown');

  // страховка: если за 15 с не уложились — выходим принудительно
  const force = setTimeout(() => {
    logger.error('shutdown timeout, выходим принудительно');
    process.exit(1);
  }, 15000).unref();

  server.close(async () => {            // перестали принимать новые соединения
    try {
      await queue.close();              // дорабатываем фоновые задачи
      await db.end();                   // закрываем пул соединений
      await redis.quit();
      clearTimeout(force);
      logger.info('shutdown завершён');
      process.exit(0);
    } catch (err) {
      logger.error({ err }, 'ошибка при завершении');
      process.exit(1);
    }
  });

  server.closeIdleConnections?.();      // keep-alive соединения не держат нас вечно
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Три детали, которые ценятся отдельно. Первая: сначала переключаем readiness-пробу в «не готов», чтобы балансировщик убрал инстанс из ротации, и только потом закрываем сервер — иначе часть запросов успеет прилететь в закрывающийся процесс. Вторая: keep-alive соединения не закрываются сами, и server.close() без closeIdleConnections может ждать очень долго. Третья: принудительный таймаут обязателен, иначе зависший запрос не даст процессу выйти вовсе.

Кэширование

Стандартный ответ — слоями, от дешёвого к дорогому: HTTP-кэш и CDN на краю, общий Redis для данных, разделяемых между инстансами, и небольшой кэш в памяти процесса для «горячих» справочников. У каждого слоя обязателен TTL — кэш без срока жизни неизбежно превращается в источник устаревших данных и в утечку памяти.

Два вопроса, которые задают следом. Инвалидация: надёжнее всего короткий TTL плюс явное удаление ключа при записи; сложные схемы инвалидации по зависимостям ломаются первыми. Cache stampede — когда популярный ключ протухает и сотня запросов одновременно идёт в базу; лечится блокировкой на пересчёт (только один запрос идёт в базу, остальные ждут) или фоновым обновлением до истечения срока.

Логирование и мониторинг

const pino = require('pino');
const { AsyncLocalStorage } = require('node:async_hooks');

const als = new AsyncLocalStorage();
const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  redact: ['req.headers.authorization', 'password', '*.token'],   // секреты не логируем
});

// сквозной идентификатор запроса без передачи его через все функции
app.use((req, res, next) => {
  const requestId = req.headers['x-request-id'] || crypto.randomUUID();
  als.run({ requestId }, next);
});

function log(obj, msg) {
  logger.info({ ...obj, ...als.getStore() }, msg);
}

Логи в проде — структурный JSON в stdout, а не отформатированные строки: их читает не человек, а система сбора. Сквозной requestId позволяет собрать все записи одного запроса, а AsyncLocalStorage хранит контекст через всю цепочку асинхронных вызовов, не протаскивая его аргументом.

Из метрик минимально нужны RED (Rate, Errors, Duration) по эндпоинтам и — специфичная для Node вещь — event loop lag. Растущий lag прямо означает, что цикл занят синхронной работой и все запросы начинают тормозить, даже если CPU далёк от 100%. Ещё смотрят на RSS/heapUsed, число открытых дескрипторов, размер очередей и время ответа зависимостей.

Масштабирование: короткие тезисы

  • Stateless-процессы: состояние в Redis и базе, тогда добавление реплики — тривиальная операция.
  • Тяжёлое — в очередь: отчёты, письма, обработка файлов уходят воркерам, HTTP отвечает быстро.
  • Вертикально сначала: часто дешевле убрать N+1 запрос и добавить индекс, чем удваивать число подов.
  • Ограничения на зависимости: таймауты, ретраи с экспоненциальной задержкой и circuit breaker, иначе один медленный сервис положит всю цепочку.
  • Пулы соединений: 20 реплик по 20 соединений — это 400 подключений к базе, и она этого может не пережить.

Типичные ошибки кандидатов

  • Не знают, что деплой начинается с SIGTERM, и не обрабатывают сигналы вообще.
  • Закрывают сервер, но не закрывают пул БД — процесс не завершается.
  • Забывают принудительный таймаут выхода.
  • Кэшируют без TTL и без ограничения размера.
  • Логируют строками с интерполяцией и складывают в лог токены и пароли.
  • Не знают про event loop lag как метрику здоровья Node-сервиса.

Как ответить кратко

По SIGTERM сначала переключаю readiness-пробу в «не готов», чтобы балансировщик убрал инстанс из ротации, потом закрываю сервер, дорабатываю активные запросы, закрываю пул БД, Redis и очереди — и всё это под принудительным таймаутом, иначе процесс может не выйти. Кэш строю слоями с обязательным TTL и защитой от stampede. Логи — структурный JSON в stdout со сквозным requestId через AsyncLocalStorage и с маскированием секретов. Из метрик обязательно RED по эндпоинтам плюс event loop lag: это главный ранний признак того, что Node-сервису плохо.

Проверьте себя
1. Что нужно сделать в первую очередь при получении SIGTERM?
AНемедленно вызвать process.exit(0)
BПереключить readiness-пробу в «не готов», чтобы балансировщик перестал слать новые запросы
CСбросить кэш в Redis
DСнять heap snapshot
2. Зачем в реализации graceful shutdown нужен принудительный таймаут выхода?
AЧтобы ускорить деплой на пару секунд
BЧтобы зависший запрос или незакрытое соединение не мешали процессу завершиться до прихода SIGKILL
CЧтобы Node успел выполнить сборку мусора
DОн не нужен, server.close всегда завершается сам
3. Какая метрика специфична именно для Node.js и раньше других сигнализирует о проблемах?
AЧисло открытых вкладок браузера
BEvent loop lag — задержка обработки цикла событий
CРазмер файла package-lock.json
DКоличество зависимостей в node_modules