Контейнеризация Node: PID 1, сигналы и тонкие образы
Процесс Node как PID 1 по умолчанию игнорирует SIGTERM, поэтому деплои висят весь grace-период и жёстко убиваются. Запускай node в exec-форме за init, собирай multi-stage тонкий non-root образ, добавь healthcheck и .dockerignore.
Каждый деплой сервиса оформления заказов занимал ровно на тридцать секунд дольше, чем должен, и тонкая прослойка запросов отдавала 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 1 | SIGTERM умирает в npm; деплои висят ~30с, затем SIGKILL роняет запросы | Exec-форма CMD [“node”,“server.js”] |
Shell-форма CMD node server.js | sh это PID 1, глотает сигнал, копятся зомби | Exec-форма CMD и/или —init / tini |
| Бежит от root | RCE в коде владеет контейнером; шире радиус поражения | 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 FROM node:22-slim AS runtime (маленькая, тонкая база)
- 2 ENV NODE_ENV=production
- 3 COPY package*.json ./ потом RUN npm ci --omit=dev (зависимости до исходников → кэшируемый слой)
- 4 COPY --from=builder /app/dist ./dist (только собранные артефакты)
- 5 USER node (сброс на non-root пользователя)
- 6 CMD ["node","dist/server.js"] (exec-форма, node это PID 1)
- 01Что особенного в PID 1 в контейнере и почему именно из-за этого CMD npm start ломает graceful shutdown?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.