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

Конфигурация и флаги рантайма: env, fail-fast и лимиты кучи

Читай конфиг один раз при старте в замороженный объект с проверкой по схеме и падай на плохой переменной до отдачи трафика — и задавай размер old-space кучи V8 в ~75% от лимита контейнера, иначе не-cgroup-aware Node перерастёт лимит и получит OOMKill с кодом 137 без JS-стека.

NODE Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Деплой в staging стал зелёным, канарейка приняла трафик, и через девяносто секунд каждый под на ноде свалился в CrashLoopBackOff. kubectl describe сказал Last State: Terminated, Reason: OOMKilled, Exit Code: 137. В логах не было ошибки нехватки кучи, не было JS-стека, ничего — процесс просто исчез посреди запроса. Сервис не менялся; изменился лимит памяти, срезанный с 1Gi до 512Mi в ходе оптимизации расходов. Node внутри по-прежнему считал, что владеет хостом на 32 ГБ, дал куче перерасти 512 МБ, и OOM-киллер ядра прибил его сигналом, которого V8 так и не увидел. Фиксом была одна переменная окружения. Три дня обвинений в адрес «утечки памяти» были нацелены совсем не на тот слой.

Конфиг живёт в окружении, а не в коде или образе

Спроси себя: если нужно выкатить сегодняшний образ в prod с другим URL базы данных — придётся пересобирать? Если да, ты запекаешь конфиг в образ — и уже сломал модель продвижения единого артефакта.

Правило 12-factor прямолинейно: конфигурация — всё, что меняется между деплоями — принадлежит окружению, а не коду и не запечена в образ. Выигрыш операционный. Ты собираешь один артефакт, и тот же SHA образа продвигается dev → staging → prod; меняются только переменные окружения. Как только config.prod.json едет внутри образа, ты теряешь это свойство: prod и staging теперь разные сборки, «работает в staging» перестаёт что-либо значить, а опечатка в конфиге требует полной пересборки вместо передеплоя.

Механизм, делающий это безопасным, — чтение окружения один раз, при старте, в типизированный и замороженный объект — никогда не разбрасывая чтения process.env.X по кодовой базе. Значения process.envвсегда строки (или undefined); process.env.PORT — это "3000", а не 3000, а process.env.DEBUG — строка "false", которая истинна. Централизованное чтение — место, где ты делаешь приведение типов ровно один раз, чтобы остальное приложение потребляло настоящие числа и булевы.

// config.js — read + coerce ONCE, then freeze
function num(v, name) {
  const n = Number(v);
  if (!Number.isFinite(n)) throw new Error(`env ${name} must be a number, got ${v}`);
  return n;
}

export const config = Object.freeze({
  nodeEnv: process.env.NODE_ENV ?? "development",
  port: num(process.env.PORT ?? "3000", "PORT"),
  databaseUrl: process.env.DATABASE_URL,           // validated below
  logLevel: process.env.LOG_LEVEL ?? "info",
  requestTimeoutMs: num(process.env.REQ_TIMEOUT_MS ?? "5000", "REQ_TIMEOUT_MS"),
});

Компромисс мал и того стоит: одно чтение при старте не подхватит переменную, изменённую в рантайме, поэтому смена конфига требует рестарта. В мире 12-factor это ровно правильно — деплои и есть способ менять конфиг, а замороженный объект значит, что ни один путь кода не мутирует config.port посреди запроса. Режим отказа обратной привычки коварен: разбросанные чтения process.env.RETRY_LIMIT, одни с дефолтом 3, другое с "3", со временем расходятся и дают конфиг, о котором команда уже не может рассуждать из единого файла.

Fail fast: проверь весь конфиг при старте, падай на плохой переменной

Чтения конфига мало; надо проверить всю форму при старте и выйти с ненулевым кодом, если что-то отсутствует или некорректнодо того, как сервер привяжет порт. Анти-паттерн ленив: DATABASE_URL не определена, при старте никто не замечает, и первый запрос, трогающий БД, падает в 3 часа ночи — 500 пользователю, пейдж тебе, спустя часы после деплоя, который это вызвал. Проверка при старте превращает весь этот класс рантайм-500 в один громкий, немедленный, отслеживаемый отказ старта, который пайплайн деплоя ловит, пока канарейка ещё разгоняется.

// config.js (top) — schema + fail-fast gate
import { z } from "zod";

const Schema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),               // required, no default → must be present
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  MAX_OLD_SPACE_MB: z.coerce.number().int().positive().optional(),
});

const parsed = Schema.safeParse(process.env);
if (!parsed.success) {
  console.error("✗ invalid configuration:\n" + z.prettifyError(parsed.error));
  process.exit(1);                              // die at boot, not at first request
}

export const config = Object.freeze(parsed.data);

z.coerce.number() решает проблему строк на слое схемы, а обязательный z.string().url() без дефолта значит, что отсутствующий DATABASE_URL валит safeParse и процесс делает exit(1) — деплой краснеет, трафик не переключается. Компромисс: строгая проверка добавляет несколько миллисекунд к старту и заставляет тебя объявить каждую переменную, которую читает приложение, что кажется бюрократией до того дня, когда спасает тебя. Режим отказа, который это убивает, — худший: деплой, который выглядит здоровым (контейнер стартовал, проба готовности ещё не сработала), но в одном вызове БД от каскада 500. С fail-fast нездоровый конфиг вообще не может дойти до состояния «обслуживает».

Секреты — это не конфиг, а NODE_ENV несущая переменная

Два различия внутри окружения важны для безопасности и корректности. Первое: секреты инъектируются в рантайме, никогда не запекаются. DATABASE_URL с паролем, API-ключ, секрет подписи — они приходят из хранилища секретов оркестратора (Kubernetes Secret, AWS Secrets Manager, Vault), смонтированного как env или файл при старте. Их никогда не коммитят и не пишут в слой образа, потому что слои образа кешируемы, скачиваемы и фактически вечны: секрет, запечённый в слой, — это секрет, утёкший всякому, кто может скачать образ, и его нельзя «распечь» без пересборки и перепубликации. Не-секретный конфиг (таймауты, фича-флаги, уровень логов) может лежать в обычном env или ConfigMap; секреты получают зашифрованный путь с контролем доступа.

Второе: NODE_ENV должна быть ровно "production" в prod, потому что удивительно большая часть экосистемы ветвится по ней. Express отключает подробные страницы ошибок и стеки, кеширует скомпилированные шаблоны представлений и пропускает работу, которую делает в разработке; многие библиотеки выключают dev-only проверки. Фугас в том, что проверка — простое строковое сравнение: NODE_ENV=Production, prod или лишний пробел тихо уводят тебя в не-production ветку, так что ты выкатываешь dev-страницы ошибок, утекающие стеки, и кеш представлений выключен, платя реальный налог по латентности без ошибки, на которую можно указать. Вот почему проверка NODE_ENV против enum (выше) — не педантизм: опечатка здесь тихо деградирует prod.

Почему это работает

Вот слой, в котором прячется OOMKill. V8 ограничивает old-space кучу — долгоживущие объекты — и исторически этот лимит авто-вычислялся из физической памяти машины (примерно 1.5–2 ГБ на старых дефолтах 64-бит, больше, когда V8 видит крупный хост). Ловушка: старые сборки Node были не cgroup-aware. Внутри контейнера с лимитом 512 МБ Node читал хостовые 32 ГБ через пробы вроде os.totalmem(), выставлял многогигабайтный потолок кучи и спокойно давал куче расти к нему. Задолго до того, как V8 упрётся в собственный лимит и бросит чистое JavaScript heap out of memory, процесс пересекает 512 МБ cgroup, и OOM-киллер ядра шлёт SIGKILL. Код выхода — 137 (128 + 9, сигнал 9). Критично: нет ошибки V8, нет JS-стека — V8 никто не спрашивал, ядро просто забрало процесс. Эта асимметрия и есть вся ловушка отладки: ты ищешь ошибку кучи, которой не может быть, потому что сработал не тот лимит.

Задай размер кучи под контейнер, через NODE_OPTIONS

Фикс — сказать V8 правду о его бюджете: задать --max-old-space-size в примерно 75–80% от лимита памяти контейнера, оставив запас всему, что не old-space куча — Buffer’ам (вне кучи), C++-стекам, нативным аддонам, коду и собственному молодому поколению V8. Для лимита 512 МБ это --max-old-space-size=384: V8 теперь начинает жёстко делать GC и, если действительно не влезает, бросает чистое JavaScript heap out of memory со стеком, по которому можно действовать — громкий, отслеживаемый отказ вместо тихого убийства ядром.

# V8-флаги передаём через NODE_OPTIONS, чтобы они дошли и до дочерних процессов (воркеры, spawn'ы).
# Лимит cgroup 512 МБ → ~75% для old-space кучи, запас для буферов/стеков/нативного.
NODE_OPTIONS="--max-old-space-size=384 --max-semi-space-size=64"
# В образе/манифесте, не в коде — тот же артефакт, окружение задаёт бюджет.
ENV NODE_OPTIONS="--max-old-space-size=384"

Клади флаги в NODE_OPTIONS, а не в строку CMD: эта переменная окружения наследуется каждым node, который ты порождаешь — worker-потоками, child_process.fork, воркерами кластерного мастера — так что лимит единообразен по всему дереву процессов, тогда как флаг на entrypoint применяется только к процессу PID 1. Второй флаг, --max-semi-space-size, задаёт размер молодого поколения (новые, короткоживущие объекты), которое V8 чистит быстрым копирующим сборщиком; дефолт мал (~16 МБ). Подъём (например, до 64 МБ) значит меньше и крупнее GC молодого поколения — выше пропускная способность для сервисов с интенсивной аллокацией ценой большей резидентной памяти, так что это берётся из того же бюджета, что ты только что задал. Числа, которые надо унести: лимит 512 МБ → куча 384 МБ; дефолтный semi-space ≈ 16 МБ; код выхода OOMKill = 137 без JS-стека; чистый V8 OOM выходит с 134 (abort) с сообщением heap out of memory.

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

Контейнерный Node-сервис с лимитом памяти 512 МБ постоянно получает OOMKill на коде выхода 137 без ошибки кучи. Как задать размер кучи?

Викторина

У контейнера лимит 512 МБ. Node запущен без --max-old-space-size на старой, не-cgroup-aware сборке. Под умирает с кодом выхода 137 и без ошибки кучи в логах. Почему?

Вспомните перед уходом
  1. 01
    Почему контейнер с лимитом 512 МБ получает OOMKill на коде выхода 137 без ошибки кучи V8, и в чём фикс?
  2. 02
    Зачем проверять весь конфиг при старте и падать, а не читать process.env лениво там, где значение нужно?
Итог

Конфигурация — всё, что меняется между деплоями, и принадлежит окружению — не коду, не запечено в образ — так что единый артефакт продвигается dev → staging → prod, меняя только переменные окружения. Читай это окружение ровно один раз при старте в типизированный, замороженный объект, приводя всегда-строковые значения process.env к настоящим числам и булевым в одном месте, а не разбрасывая чтения process.env.X повсюду. Проверяй всю форму схемой и делай process.exit(1) на любой отсутствующей или некорректной переменной до того, как сервер привяжет порт, превращая класс рантайм-500 в 3 ночи в единый громкий отказ старта, который ловит пайплайн деплоя — ценой малой: объявить каждую переменную. Держи секреты вне образа (инъектируй их в рантайме из хранилища секретов, никогда не коммить и не запекай) и фиксируй NODE_ENV ровно в production, ведь опечатка тихо роняет тебя в dev-ветки, утекающие стеки и пропускающие кеширование. Наконец, задавай размер рантайма под контейнер: старая, не-cgroup-aware Node авто-вычисляет old-space кучу V8 из физической памяти хоста, так что внутри лимита 512 МБ она даёт куче перерасти лимит, и ядро делает OOM-kill на коде выхода 137 без JS-стека — задай --max-old-space-size в ~75% от лимита (384) через NODE_OPTIONS, чтобы флаг также достигал дочерних процессов, по желанию подстрой --max-semi-space-size ради пропускной способности молодого поколения, и преврати тихое убийство ядром в чистый, отслеживаемый отказ. Теперь, когда увидишь код выхода 137 без JS-стека или сервис, дающий 500 в 3 ночи при зелёном деплое, — ты будешь знать, какой слой чинить в первую очередь.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.