← назад к разделу

Сервис на Node в контейнере — это интерпретатор, собранный из TypeScript код и каталог node_modules, который обычно весит больше самого приложения. Отсюда и типичные грабли: в образ уезжают зависимости для разработки и исходники на TypeScript, процесс запускают через npm start, который не передаёт сигналы, а latest подкладывает новую мажорную версию Node в ночь перед выкатом. Разберём Dockerfile от наивного к рабочему и каждую из граблей по дороге.

Обязательно

Самый простой Dockerfile и что в нём дорого

FROM node:22
WORKDIR /app
COPY . .
RUN npm install && npm run build
CMD ["npm", "start"]

Работает и стоит дорого. node:22 на Debian с компиляторами и python3 для сборки нативных модулей весит около 1,1 ГБ. npm install ставит зависимости для разработки (typescript, jest, eslint) и не проверяет package-lock.json: на двух машинах могут получиться разные версии. COPY . . до установки зависимостей сбрасывает кэш слоя при любой правке кода, и npm install идёт заново минутами. А npm start поднимает процесс node дочерним к npm, и docker stop упирается в то, что npm сигнал дальше не передаёт — об этом отдельный раздел. Общий принцип разделения сборки и запуска разобран в статье про multi-stage, здесь — его версия для Node.

Многоэтапная сборка: npm ci, сборка TypeScript, только рабочие зависимости

# syntax=docker/dockerfile:1

# ── Этап 1: все зависимости и сборка ─────────────────────────
FROM node:22 AS build
WORKDIR /app

# зависимости отдельно: слой переживёт правку кода
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

COPY tsconfig*.json nest-cli.json ./
COPY src/ src/
RUN npm run build

# ── Этап 2: только рабочие зависимости ───────────────────────
FROM node:22 AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

# ── Этап 3: финальный образ ──────────────────────────────────
FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps --chown=node:node /app/node_modules node_modules
COPY --from=build --chown=node:node /app/dist dist
COPY --chown=node:node package.json ./
EXPOSE 3000
USER node
CMD ["node", "dist/main.js"]

Что здесь важно. npm ci вместо npm install: он ставит ровно то, что записано в package-lock.json, и падает, если файл блокировки разошёлся с package.json, то есть сборка воспроизводима. Кэш-монтирование /root/.npm переживает пересборки, но в образ не попадает. Отдельный этап deps ставит зависимости с --omit=dev: это самый надёжный способ не притащить typescript и jest в прод, надёжнее, чем npm prune после сборки. Финальный образ — node:22-slim, около 200 МБ с зависимостями; в нём есть node, npm и минимальный Debian, но нет компиляторов. NODE_ENV=production выставляют в образе: Express и часть библиотек по этой переменной отключают подробные ошибки и включают кэш шаблонов, а npm ci без флага в таком окружении сам пропускает зависимости разработки.

Размеры для ориентира:

ВариантРазмер
node:22 со сборкой внутри и всеми зависимостями1,1–1,3 ГБ
node:22-slim + рабочие node_modules180–250 МБ
node:22-alpine + рабочие node_modules120–180 МБ
distroless/nodejs22 + рабочие node_modules150–200 МБ

Разница между вариантами — в node_modules, а не в базе: сто мегабайт зависимостей с собственными тестами и документацией внутри пакетов никакой базовый образ не уберёт. На это смотрят npm ls --omit=dev --all | wc -l и du -sh node_modules/* | sort -h | tail.

CMD node, не npm start: кто получает SIGTERM

docker stop посылает SIGTERM процессу с номером 1 и через десять секунд добивает SIGKILL. При CMD ["npm", "start"] номер 1 — это npm, а ваш node — его дочерний процесс. npm сигнал получает, но до node не доносит, и контейнер каждый раз останавливается ровно десять секунд с кодом 137: запросы оборваны, соединения с базой не закрыты. Поэтому точка входа — всегда сам node в exec-форме: CMD ["node", "dist/main.js"].

Сам по себе SIGTERM для node означает немедленный выход: рантайм завершает процесс с кодом 143, не дожидаясь текущих запросов. Чтобы остановка стала мягкой, в NestJS включают обработчики завершения:

const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(3000);

После этого на SIGTERM вызываются onModuleDestroy, beforeApplicationShutdown и onApplicationShutdown у провайдеров: HTTP-сервер перестаёт принимать новые соединения и дожидается текущих, пул соединений с базой закрывается, потребители очередей отписываются. Время на всё это должно укладываться в таймаут docker stop и terminationGracePeriodSeconds в Kubernetes.

Процесс node как PID 1 не плодит зомби, пока сам не запускает дочерние процессы. Если сервис зовёт внешние команды через child_process, добавьте docker run --init или init: true в Compose: за осиротевшими процессами будет следить крошечный init.

От кого работает процесс: USER node

В официальном образе Node уже есть пользователь node с uid 1000, создавать своего не нужно. Два правила: файлы приложения копируют с --chown=node:node, иначе после USER node процесс не сможет прочитать node_modules, созданные от root, и переключение USER node стоит до CMD. Kubernetes с runAsNonRoot: true проверяет числовой uid, и node ему подходит; в distroless/nodejs22 пользователь называется nonroot с uid 65532.

Приложению после этого нужно место для записи: временные файлы загрузок, кэш sharp. Каталог создают заранее от root и отдают пользователю: RUN mkdir /tmp/app && chown node:node /tmp/app, а приложению говорят про него переменной TMPDIR=/tmp/app. Писать в /app рядом с кодом не стоит: в проде файловую систему контейнера часто делают только для чтения.

Конфигурация и секреты: NODE_ENV, переменные и файлы

В образ не зашивают ни адреса, ни пароли: один образ едет через все окружения. Настройки читают из переменных среды при старте, в NestJS через @nestjs/config со схемой проверки, чтобы отсутствие обязательной переменной роняло процесс на старте, а не на первом запросе:

ConfigModule.forRoot({
  validationSchema: Joi.object({
    PORT: Joi.number().default(3000),
    DATABASE_URL: Joi.string().required(),
    HTTP_TIMEOUT_MS: Joi.number().default(5000),
  }),
})

Файл .env — удобство разработки, а не способ доставки настроек: его добавляют в .dockerignore, иначе локальные пароли уедут в слой COPY . . этапа сборки. Секреты лучше получать файлами, а не переменными: переменные видны в docker inspect и в дочерних процессах. Docker Compose и Kubernetes монтируют секреты в /run/secrets/<имя>, приложение читает файл один раз при старте: readFileSync('/run/secrets/db_password', 'utf8').trim(). Договорённость DATABASE_PASSWORD_FILE с путём к файлу, когда самой DATABASE_PASSWORD нет, знакома по официальным образам баз данных и работает и для своего сервиса.

HEALTHCHECK: terminus, start-period и проверка без curl

Проверку здоровья в NestJS даёт @nestjs/terminus: эндпоинт /health собирает индикаторы базы, памяти и диска и отвечает 200 или 503. В node:22-slim нет ни curl, ни wget, поэтому инструкция HEALTHCHECK дёргает ручку самим node:

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD ["node", "-e", "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]

--start-period нужен: NestJS с десятками модулей и подключением к базе поднимается несколько секунд, а большой бандл с source-map-support — дольше. Без этого параметра оркестратор успеет посчитать контейнер неработоспособным ещё на старте и перезапустить его. В Kubernetes инструкция HEALTHCHECK игнорируется, те же проверки описывают пробами, и ручка /health от terminus пригодится им.

Дополнительно: при первом чтении можно пропустить

Глубже: нативные модули и Alpineрасширенное

node:22-alpine на сотню мегабайт меньше slim, но Alpine использует musl вместо glibc. Пакеты с нативными расширениями — sharp, bcrypt, argon2, драйверы с C-частью — поставляют готовые сборки под glibc и под musl по-разному: часть пакетов имеет обе, часть под musl собирается из исходников прямо в npm ci, для чего в образ придётся поставить python3, make и g++, то есть вернуть всё, ради чего брали Alpine. Проверять просто: npm ci в node:22-alpine на чистой машине и журнал установки, где gyp означает сборку из исходников. Для обычного сервиса без нативных модулей slim на glibc проще и предсказуемее.

Глубже: архитектура: arm64 на ноутбуке, amd64 на серверерасширенное

Образ, собранный на ноутбуке с Apple Silicon, получается linux/arm64 и на сервере amd64 не стартует: exec format error. У Node кросс-компиляции нет, поэтому образ под другую архитектуру собирают либо в конвейере на машине с нужной архитектурой, либо через эмуляцию docker build --platform linux/amd64 — медленно, но для редкой ручной сборки терпимо. Нативные модули при этом должны быть собраны под целевую архитектуру: node_modules, скопированные с хоста вместо npm ci внутри сборки, на другой архитектуре не загрузятся. Общая механика мультиплатформенных образов — в статье про реестры и CI.

Коротко

  • Рабочий образ — node:22-slim с dist/ и рабочими node_modules, 180–250 МБ; сборка TypeScript и зависимости разработки остаются в первых этапах.
  • npm ci вместо npm install: воспроизводимо и падает при расхождении package-lock.json; отдельный этап npm ci --omit=dev надёжнее npm prune; кэш-монтирование /root/.npm.
  • CMD ["node", "dist/main.js"], не npm start: npm не передаёт SIGTERM, и остановка всегда длится десять секунд с кодом 137. Мягкий останов — app.enableShutdownHooks().
  • Пользователь node (uid 1000) уже есть в образе: COPY --chown=node:node и USER node до CMD; место для записи — отдельный каталог и TMPDIR.
  • NODE_ENV=production в образе; настройки из переменных со схемой проверки в @nestjs/config; .env в .dockerignore; секреты файлами из /run/secrets.
  • HEALTHCHECK через node -e "fetch(...)", потому что curl в slim нет; --start-period под медленный старт; в Kubernetes то же делают пробы на /health от terminus.
  • Alpine меняет glibc на musl: нативные модули собираются из исходников или не ставятся; для обычного сервиса slim.
  • Кросс-компиляции нет: другую архитектуру собирают в конвейере или через --platform с эмуляцией, node_modules с хоста не копируют.

Что почитать дальше