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

Контейнеризация Node: PID 1, сигналы и тонкие образы

Процесс Node как PID 1 по умолчанию игнорирует SIGTERM, поэтому деплои висят весь grace-период и жёстко убиваются. Запускай node в exec-форме за init, собирай multi-stage тонкий non-root образ, добавь healthcheck и .dockerignore.

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

Каждый деплой сервиса оформления заказов занимал ровно на тридцать секунд дольше, чем должен, и тонкая прослойка запросов отдавала 502 при каждом релизе. Команда винила Kubernetes. Настоящий виновник — одна строка в Dockerfile: CMD npm start. npm бежал как PID 1, форкал node дочерним процессом, и когда выкатка слала SIGTERM, npm — без обработчика сигнала и без проброса — давал ему испариться. Node так и не слышал команду на остановку, продолжал держать открытые соединения, и Kubernetes ждал весь terminationGracePeriodSeconds, прежде чем SIGKILL рвал под прямо посреди запроса. Фикс — четыре символа JSON: ["node","server.js"]. Образ тихо воевал с оркестратором на каждом релизе.

PID 1 особенный, а Node под него не рассчитан

Внутри Linux-контейнера первый запущенный процесс — это PID 1, и ядро относится к PID 1 не так, как к любому другому процессу. Два правила бьют именно по Node. Первое: у PID 1 нет обработчиков сигналов по умолчанию — для обычного процесса ядро ставит действие по умолчанию для SIGTERM (завершиться), но для PID 1 действия по умолчанию нет, так что SIGTERM без явного обработчика просто игнорируется. Второе: PID 1 обязан реапить зомби: когда дочерний процесс завершается, родитель должен сделать wait(), а осиротевшие потомки переусыновляются к PID 1. Если PID 1 не реапит, мёртвые потомки копятся в таблице процессов как зомби.

Node ставит свой путь слушателя SIGTERM, только если ты сам его добавишь, — но смертельный вариант это запуск Node косвенно. CMD npm start делает PID 1 именно npm; npm порождает node дочерним и не пробрасывает ему сигналы. Так что SIGTERM оркестратора попадает в npm (который игнорирует его как PID 1) и никогда не доходит до приложения. Контейнер будто зависает на остановке.

# ❌ npm — это PID 1; SIGTERM умирает в npm, node его не слышит
CMD npm start

# ❌ shell-форма: /bin/sh -c "node server.js" → PID 1 это sh, проброса тоже нет
CMD node server.js

# ✅ exec-форма: PID 1 это node, твой in-app обработчик SIGTERM сработает
CMD ["node", "server.js"]

Shell-форма CMD node server.js — та же ловушка в другой шляпе: Docker оборачивает её как /bin/sh -c "node server.js", так что PID 1 становится sh, а node — его дочерним; сигналы застревают в шелле. Всегда используй exec-форму (JSON-массив) для сервера, чтобы node был PID 1 напрямую.

Обрабатывай SIGTERM или ставь init впереди

Есть два взаимодополняющих фикса, и production-Node обычно хочет оба.

Первый — запускать node напрямую (exec-форма) и обрабатывать SIGTERM в приложении ради graceful shutdown: перестать принимать новые соединения, дотянуть запросы в полёте, закрыть пул БД, затем выйти. Именно это превращает жёсткий kill в чистый дренаж.

const server = app.listen(3000);

process.on("SIGTERM", () => {
  // прекращаем брать новую работу, дотягиваем открытое, затем выходим чисто
  server.close(() => {
    pool.end().then(() => process.exit(0));
  });
});

Второй — запускать init как PID 1, чтобы кто-то реапил зомби и пробрасывал сигналы, даже если твой код забудет. У Docker такой есть: docker run --init подсовывает tini как PID 1, который пробрасывает SIGTERM дочернему node и реапит сирот. Можно и запечь tini/dumb-init в образ как ENTRYPOINT. Init важнее всего, когда твой процесс порождает потомков (сборочный инструмент, child_process, скрипт-обёртка) — без него эти сироты становятся зомби.

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

Почему бы не полагаться всегда на init и не выкинуть in-app обработчик? Потому что tini пробрасывает сигнал — он не дренажит твои соединения за тебя. Без слушателя SIGTERM node получит проброшенный сигнал и выйдет немедленно, уронив запросы в полёте. Init гарантирует, что node получит сигнал; твой обработчик решает, что значит graceful. Init — для доставки и реапа, обработчик приложения — для дренажа: они решают разные половины.

Multi-stage сборка и тонкая non-root база

Одностадийный образ запекает твой компилятор, dev-зависимости и весь тулчейн в то, что ты отгружаешь. Multi-stage сборка разделяет это: стадия builder запускает npm ci и сборку, а тонкая runtime-стадия копирует только нужные ей артефакты.

# ---- builder ----
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci                      # полные зависимости для сборки
COPY . .
RUN npm run build               # → /app/dist

# ---- runtime ----
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev           # только прод-зависимости
COPY --from=builder /app/dist ./dist
USER node                       # официальный образ несёт non-root пользователя 'node'
CMD ["node", "dist/server.js"]

Две детали окупают себя. Копируй package*.json и запускай npm ci до копирования исходников. Docker кэширует каждый слой по его входам; если входы слоя зависимостей (lock-файл) не меняются, весь слой npm ci переиспользуется из кэша, и медленный install пропускается — только изменение исходников инвалидирует дешёвый COPY . . под ним. (Кэширование слоёв детально разбирается в треке про деплой; здесь Node-специфичный пойнт — lock-файл раньше исходников.) И выбирай тонкую базу: полный образ node — примерно 1 ГБ, тогда как node:slim это несколько сотен МБ, а distroless runtime для Node — это десятки МБ: меньший образ быстрее тянется и открывает куда меньшую CVE-поверхность, потому что в нём нет шелла или пакетного менеджера для атаки.

ЛовушкаСимптом в продеФикс
CMD npm start как PID 1SIGTERM умирает в npm; деплои висят ~30с, затем SIGKILL роняет запросыExec-форма CMD [“node”,“server.js”]
Shell-форма CMD node server.jssh это PID 1, глотает сигнал, копятся зомбиExec-форма CMD и/или —init / tini
Бежит от rootRCE в коде владеет контейнером; шире радиус пораженияUSER node (non-root)
Одностадийный образОбраз ~1 ГБ, медленные pull, огромная CVE-поверхность, отгружены dev-зависимостиMulti-stage + slim/distroless runtime
Нет healthcheck, .env в образеМёртвые поды остаются в ротации; секреты текут в слои образаHEALTHCHECK / probes + .dockerignore

Харденинг: non-root, healthcheck и .dockerignore

Три дешёвых хода отделяют игрушечный образ от production. Беги от non-root: официальный образ node уже несёт непривилегированного пользователя node, так что одна строка USER node означает, что баг с исполнением кода не сможет тривиально завладеть контейнером. Добавь healthcheck, чтобы оркестратор знал, когда процесс реально обслуживает: Docker HEALTHCHECK, дёргающий роут /health, или в Kubernetes livenessProbe/readinessProbe, бьющие в /health и /ready; без него зависший процесс остаётся в ротации балансировщика. Добавь .dockerignore со списком node_modules, .git и .env: он держит build-контекст маленьким (быстрее сборки) и, что критично, не даёт закоммиченному .env попасть в слой образа, где секрет живёт вечно и достаётся любым, кто запуллит образ.

# .dockerignore
node_modules
.git
.env
*.log

В Kubernetes числа упираются в PID 1: при удалении пода kubelet шлёт SIGTERM, ждёт terminationGracePeriodSeconds (по умолчанию 30с) и только потом шлёт SIGKILL. Если node не получает SIGTERM (npm/shell как PID 1) или не имеет обработчика, каждый деплой сжигает все 30 секунд и затем жёстко убивает работу в полёте — ровно медленная и теряющая запросы выкатка из хука.

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

Ты хочешь, чтобы контейнеризованный Node-сервис останавливался graceful на деплое и никогда не копил зомби-потомков. Какая PID-1 стратегия — production-дефолт?

Викторина

Почему CMD npm start заставляет деплои висеть, а затем ронять запросы в полёте?

Викторина

Почему предпочесть exec-форму CMD (JSON-массив, node + server.js) с USER node вместо shell-формы CMD от root?

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

Расставь строки харденной multi-stage runtime-стадии так, чтобы кэширование зависимостей работало, а образ был безопасным:

  1. 1 FROM node:22-slim AS runtime (маленькая, тонкая база)
  2. 2 ENV NODE_ENV=production
  3. 3 COPY package*.json ./ потом RUN npm ci --omit=dev (зависимости до исходников → кэшируемый слой)
  4. 4 COPY --from=builder /app/dist ./dist (только собранные артефакты)
  5. 5 USER node (сброс на non-root пользователя)
  6. 6 CMD ["node","dist/server.js"] (exec-форма, node это PID 1)
Вспомните перед уходом
  1. 01
    Что особенного в PID 1 в контейнере и почему именно из-за этого CMD npm start ломает graceful shutdown?
  2. 02
    Как multi-stage Dockerfile уменьшает образ Node и его CVE-поверхность и почему npm ci должен идти до COPY исходников?
Итог

Первый процесс контейнера — это PID 1, и ядро не даёт PID 1 обработчиков сигналов по умолчанию и делает его ответственным за реап зомби (сбор завершившихся дочерних процессов) — ни то ни другое Node не рассчитан делать. CMD npm start делает PID 1 именно npm, который игнорирует SIGTERM и никогда не пробрасывает его дочернему node; shell-форма CMD node server.js делает PID 1 /bin/sh с тем же эффектом. Так или иначе node не слышит остановку, поэтому в Kubernetes kubelet выжидает весь terminationGracePeriodSeconds (по умолчанию 30с) и затем делает SIGKILL посреди запроса, подвешивая каждый деплой и роняя работу в полёте. Фикс — exec-форма CMD ["node","server.js"], чтобы node был PID 1 и получал сигнал, in-app обработчик SIGTERM, который перестаёт принимать соединения и дренажит перед выходом, и init (--init / tini) для проброса сигналов и реапа сирот. Заверни это в multi-stage сборку — жирный builder, запускающий npm ci и сборку, тонкий или distroless runtime, копирующий только dist и прод-зависимости через npm ci --omit=dev, — и копируй package*.json + npm ci до исходников, чтобы слой зависимостей оставался в кэше. Заверши USER node для non-root, HEALTHCHECK или liveness/readiness probes и .dockerignore (node_modules, .git, .env), чтобы build-контекст оставался лёгким и ни один секрет не был запечён в слой. Теперь, когда увидишь деплой, зависающий ровно на terminationGracePeriodSeconds, а затем роняющий запросы, — ты сразу узнаешь ловушку PID 1 и знаешь, как исправить за четыре символа.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.