Сборка Node.js-приложения: как делать правильно в 2024
Почему npm install в Dockerfile раздувает образ до гигабайта, зачем нужны две стадии сборки и чем npm ci отличается от npm install на CI.

С чего начинается неправильная сборка
Типичная история: проект на Node.js 20, образ Docker весит 1,3 ГБ, деплой на CI занимает восемь минут, а на проде время от времени вылезает ошибка вроде Cannot find module, хотя локально всё работает. Причина почти всегда одна и та же — сборку собирали как придётся: npm install прямо в финальный образ, dev-зависимости уехали в прод, а node_modules с локальной машины скопировали в контейнер поверх контейнерных.
Разберём, как отделить установку зависимостей от сборки артефакта, чем npm ci отличается от npm install, и как собрать образ, который весит десятки мегабайт вместо гигабайта. Ориентируюсь на Node.js 20.x LTS и npm 10, но общие принципы работают и на 18.x.
npm install и npm ci: не одно и то же
На локальной машине вы пишете npm install — он читает package.json, разрешает диапазоны версий, дописывает недостающее в node_modules и обновляет package-lock.json. Это удобно, когда вы добавляете зависимость. Но на сборочном сервере такое поведение вредит: одна и та же команда в разное время может поставить разные версии, если в lock-файле указан диапазон, а вышел патч.
npm ci устроен иначе. Он требует наличия package-lock.json, полностью удаляет существующий node_modules и ставит ровно те версии, что зафиксированы в lock-файле. Если package.json и lock-файл рассогласованы — команда падает с ошибкой, а не молча чинит. Это то, что нужно на CI: воспроизводимость важнее гибкости.
Правило простое: разработчик пишет npm install, сборочный конвейер — npm ci. Если на CI используется install, рано или поздно два деплоя из одного коммита дадут разный набор зависимостей.
Проверьте, что package-lock.json закоммичен в репозиторий. Без него npm ci просто не запустится — выдаст The `npm ci` command can only install with an existing package-lock.json.
Разделяйте зависимости на dev и runtime
В package.json есть два раздела: dependencies и devDependencies. В dependencies должно лежать то, без чего приложение не запустится в проде: express, драйвер базы, валидаторы. В devDependencies — то, что нужно только для сборки и тестов: TypeScript, webpack, esbuild, eslint, jest.
Ошибка новичка — держать typescript и типы в dependencies. В прод-образе они не нужны, но раздувают его и увеличивают поверхность для уязвимостей. Устанавливать в проде нужно с флагом:
# Только runtime-зависимости, без dev
npm ci --omit=devФлаг --omit=dev появился в npm 7 и заменил устаревший --production. Оба ещё работают в npm 10, но в новых проектах используйте --omit=dev.
Multi-stage Docker: собираем в одном слое, запускаем в другом
Главный приём для контейнеров — многоступенчатая сборка. Идея в том, чтобы весь инструментарий (компилятор TypeScript, бандлер) жил в промежуточном образе, а в финальный попали только собранный код и runtime-зависимости.
# syntax=docker/dockerfile:1
# --- Стадия 1: сборка ---
FROM node:20-slim AS builder
WORKDIR /app
# Копируем сначала манифесты, чтобы кешировать слой с зависимостями
COPY package.json package-lock.json ./
RUN npm ci
# Теперь исходники и сама сборка
COPY . .
RUN npm run build
# --- Стадия 2: рантайм ---
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Только прод-зависимости
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Забираем готовый артефакт из стадии сборки
COPY --from=builder /app/dist ./dist
# Запуск не от root
USER node
CMD ["node", "dist/index.js"]Что здесь важно и почему именно так:
- Сначала копируем только манифесты, потом всё остальное. Слой с
npm ciпересобирается только при измененииpackage.jsonили lock-файла. Если вы поменяли строчку кода, зависимости берутся из кеша Docker. - Две стадии. В финальный образ не попадают TypeScript, esbuild и прочий сборочный мусор — только
distи прод-зависимости. - ENV NODE_ENV=production. Многие библиотеки (express в том числе) в этом режиме отключают лишние проверки и работают быстрее.
- USER node. Образ
nodeуже содержит пользователяnode. Запуск не от root — базовая гигиена безопасности.
Не забудьте .dockerignore
Без него COPY . . затащит в образ локальный node_modules, собранный под вашу ОС. На Linux-контейнере это в лучшем случае бесполезно, в худшем — ломает нативные модули. Минимальный .dockerignore:
node_modules
dist
.git
.env
npm-debug.log
Dockerfile
.dockerignoreСобирать ли TypeScript через tsc или бандлером
Если у вас сервер, а не браузерный код, есть два пути. Голый tsc просто транспилирует файлы в JS, сохраняя структуру. Бандлеры вроде esbuild собирают всё в один файл и делают это на порядок быстрее.
| Подход | Скорость | Когда выбирать |
|---|---|---|
| tsc | Медленно, но проверяет типы | Небольшой сервис, нужна проверка типов на сборке |
| esbuild | Очень быстро, типы не проверяет | Крупный проект, типы гоняются отдельной командой |
| tsc --noEmit + esbuild | Быстро + есть проверка | Оптимальный компромисс для прода |
Частая связка: tsc --noEmit для проверки типов как отдельный шаг CI, а сам JS собирает esbuild. Так вы не платите скоростью за типобезопасность. Точные флаги и опции сверяйте в документации esbuild — API между минорными версиями иногда меняется.
Если сборка не работает
npm ciпадает сcan only install with an existing package-lock.json. Lock-файл не закоммичен или в.gitignore. Уберите его из игнора и закоммитьте.npm ciругается на рассогласование lock-файла. Вы правилиpackage.jsonвручную. Запустите локальноnpm install, дайте ему обновить lock, закоммитьте оба файла.- В проде
Cannot find module, локально всё есть. Модуль лежит вdevDependencies, а вы ставили с--omit=dev. Перенесите вdependencies. - Образ всё равно огромный. Проверьте
.dockerignore— локальныйnode_modulesкопируется в образ. И убедитесь, что используете multi-stage, а не одну стадию. - Нативные модули падают при запуске в контейнере. Их скопировали с хоста вместо пересборки в контейнере.
node_modulesдолжен ставиться внутри образа командойnpm ci, а не приезжать черезCOPY.
Что настроить дальше
Базовая правильная сборка готова. Следующие шаги, каждый — отдельная тема:
- Кеширование npm между запусками CI через
--mount=type=cacheв BuildKit — ускоряет установку на повторных сборках. - Проверка уязвимостей: добавьте
npm auditв конвейер и решите, на каком уровне severity падать. - Фиксация версии Node через
enginesвpackage.jsonи.nvmrc, чтобы CI и локальные машины собирали на одной версии. - Мониторьте размер образа в CI — если он вдруг вырос вдвое, это первый признак, что dev-зависимость уехала в прод.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: npm ci — официальная документация npm, Docker: multi-stage builds
Частые вопросы
У меня Node.js 18, а не 20 — примеры подойдут?
Чем npm ci лучше npm install на сборочном сервере?
Собрал по инструкции, но образ всё равно весит под гигабайт — что не так?
Можно ли копировать node_modules с локальной машины в образ, чтобы не ставить заново?
Нужен ли отдельный шаг проверки типов, если я собираю через esbuild?
Что делать, если npm ci падает на рассогласовании lock-файла?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.