Статья Node.js в Docker (Node.js Docker Cheat Sheet)
Ниже — практические рекомендации для безопасной и оптимизированной контейнеризации Node.js в продакшене. Материал полезен независимо от типа приложения, в том числе если вы:
- делаете SSR на Node.js для React;
- собираете образ для микросервисов на Fastify, NestJS или другом фреймворке.
1) Явные и детерминированные теги базового образа Docker
Кажется логичным брать образ node, но без тега подтягивается :latest — каждый раз может оказаться другая сборка.
Так вы всегда получаете последнюю сборку образа от Node.js Docker working group — это недетерминированно.
FROM node
Минусы образа node по умолчанию:
- Недетерминизм сборок. Как для npm мы фиксируем зависимости
npm ciчерез lockfile, так и базовый образ лучше фиксировать. Сnode(=node:latest) при каждой сборке может подтянуться новый базовый слой. - Большой образ. Полноценная ОС с лишними пакетами увеличивает размер, время pull/сборки и поверхность атаки (уязвимости в ненужных библиотеках).
Образ node без урезания часто содержит сотни известных уязвимостей и сотни мегабайт — плохая стартовая точка.
Рекомендации:
- Используйте минимальные образы — меньше кода в образе, меньше векторов атаки и быстрее CI.
- Фиксируйте образ по digest (SHA256) для детерминированных сборок.
Разумный компромисс — LTS Node.js и вариант alpine:
FROM node:lts-alpine
Тег lts-alpine всё равно обновляется. Digest смотрите на Docker Hub или после локального pull в поле Digest:
$ docker pull node:lts-alpine
lts-alpine: Pulling from library/node
0a6724ff3fcd: Already exists
9383f33fa9f3: Already exists
b6ae88d676fe: Already exists
565e01e00588: Already exists
Digest: sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
Status: Downloaded newer image for node:lts-alpine
docker.io/library/node:lts-alpineИли:
$ docker images --digests
REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE
node lts-alpine sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a 51d926a5599d 2 weeks ago 116MBDockerfile только с digest читается плохо. Лучше тег + digest:
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci
CMD "npm" "start"2) Только production-зависимости в образе
RUN npm ci без флагов тянет и devDependencies — лишний риск и размер.
npm ci даёт воспроизводимую установку по lockfile и падает при расхождении с ним — это хорошо для CI.
Для продакшена:
RUN npm ci --omit=dev
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --omit=dev
CMD "npm" "start"3) Настройки Node.js для продакшена
ENV NODE_ENV production
Даже при --omit=dev переменная важна: многие библиотеки включают оптимизации и более строгие настройки только при NODE_ENV=production (см. документацию Express). Влияние на производительность может быть существенным.
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --omit=dev
CMD "npm" "start"4) Не запускайте контейнер от root
Принцип наименьших привилегий: при компрометации приложения (command injection, path traversal) код выполняется от пользователя процесса. Если это root — внутри контейнера доступно почти всё, вплоть до попыток эскалации привилегий или выхода из контейнера.
Официальный образ node (включая alpine) содержит пользователя node, но только USER node недостаточно: файлы после COPY по умолчанию принадлежат root.
USER node
CMD "npm" "start"Полный вариант с COPY --chown:
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . /usr/src/app
RUN npm ci --omit=dev
USER node
CMD "npm" "start"5) Сигналы и корректное завершение Node.js в контейнере
Слабые паттерны (избегайте):
CMD "npm" "start"CMD ["yarn", "start"]CMD "node" "server.js"(shell form)CMD "start-app.sh"
Контекст:
- Оркестратор (Kubernetes, Swarm, Docker) шлёт процессу сигналы завершения (
SIGTERM,SIGKILL). - Если процесс запущен «через прослойку», сигнал может не дойти до Node.
- Процесс с PID 1 в Linux обрабатывается ядром особым образом.
CMD "npm" "start" — npm не обязан пробрасывать сигналы дочернему node. Проверка: обработчик SIGHUP:
function handle(signal) {
console.log(`*^!@4=> Received event: ${signal}`)
}
process.on('SIGHUP', handle)docker kill --signal=SIGHUP <container> — при запуске через npm реакции не будет.
Формы CMD: shell-форма оборачивает shell и может мешать сигналам; exec-форма (CMD ["npm", "start"]) шлёт сигналы процессу напрямую, но npm по-прежнему между вами и node.
Лучше вызывать Node напрямую:
CMD ["node", "server.js"]
Но процесс с PID 1 не ведёт себя как обычный процесс: по умолчанию нет «падения» по SIGTERM без обработчика. Цитата рекомендаций docker-node: Node.js не проектировался как PID 1 — возможны сюрпризы (например, нет реакции на SIGINT).
Решение — маленький init, например dumb-init:
RUN apk add dumb-init
CMD ["dumb-init", "node", "server.js"]FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
RUN apk add dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --omit=dev
USER node
CMD ["dumb-init", "node", "server.js"]docker stop / docker kill шлют сигнал процессу с PID 1. Если PID 1 — оболочка или скрипт без проброса дочерним процессам, приложение не получит SIGTERM.
6) Корректное завершение веб-приложений
При резком завершении обрываются активные запросы — в оркестрации с частыми перезапусками это плохо для пользователей.
Пример Fastify с задержкой 60 с:
fastify.get('/delayed', async (request, reply) => {
const SECONDS_DELAY = 60000
await new Promise(resolve => {
setTimeout(() => resolve(), SECONDS_DELAY)
})
return { hello: 'delayed world' }
})
const start = async () => {
try {
await fastify.listen(PORT, HOST)
console.log(`*^!@4=> Process id: ${process.pid}`)
} catch (err) {
fastify.log.error(err)
process.exit(1)
}
}
start()time curl https://localhost:3000/delayed и Ctrl+C на сервере — curl оборвётся. Так же ведёт себя внезапная остановка контейнера без graceful shutdown.
Сделайте:
- Обработчики
SIGINT,SIGTERM. - Ожидание закрытия БД, завершения запросов и т.д.
- Затем
process.exit().
У Fastify — fastify.close() (новые соединения получат 503).
async function closeGracefully(signal) {
console.log(`*^!@4=> Received signal to terminate: ${signal}`)
await fastify.close()
// await db.close() при необходимости
process.exit()
}
process.on('SIGINT', closeGracefully)
process.on('SIGTERM', closeGracefully)Это скорее про приложение, чем про Dockerfile, но в Kubernetes критично.
7) Поиск и устранение уязвимостей в образе
См. Docker Security Cheat Sheet — статический анализ.
8) Многостадийные сборки (multi-stage)
Разделение стадий снижает риск утечки секретов и позволяет собирать тяжёлыми средствами, а в финальный образ копировать только артефакты.
Утечка секретов при сборке
Частый сценарий — приватный npm и NPM_TOKEN в образе:
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
RUN apk add dumb-init
ENV NODE_ENV production
ENV NPM_TOKEN 1234
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
npm ci --omit=dev
USER node
CMD ["dumb-init", "node", "server.js"].npmrc с токеном остаётся в слоях. rm .npmrc отдельным RUN не удаляет данные из предыдущих слоёв. Одна команда:
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
npm ci --omit=dev; \
rm -rf .npmrcНо тогда секрет в истории сборки, если передавать через ARG и не осторожиться — и сам Dockerfile с токеном нельзя коммитить.
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
npm ci --omit=dev; \
rm -rf .npmrcСборка: docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234
Аргументы попадают в docker history:
docker history nodejs-tutorial
IMAGE CREATED CREATED BY SIZE COMMENT
b4c2c78acaba About a minute ago CMD ["dumb-init" "node" "server.js"] 0B buildkit.dockerfile.v0
<missing> About a minute ago USER node 0B buildkit.dockerfile.v0
<missing> About a minute ago RUN |1 NPM_TOKEN=1234 /bin/sh -c echo "//reg… 5.71MB buildkit.dockerfile.v0
<missing> About a minute ago ARG NPM_TOKEN 0B buildkit.dockerfile.v0
<missing> About a minute ago COPY . . # buildkit 15.3kB buildkit.dockerfile.v0
<missing> About a minute ago WORKDIR /usr/src/app 0B buildkit.dockerfile.v0
<missing> About a minute ago ENV NODE_ENV=production 0B buildkit.dockerfile.v0
<missing> About a minute ago RUN /bin/sh -c apk add dumb-init # buildkit 1.65MB buildkit.dockerfile.v0Токен виден в слое RUN — поэтому для секретов лучше multi-stage и/или mount secrets (ниже).
Многостадийная сборка для Node.js
Первая стадия (build) — установка зависимостей и при необходимости сборка нативных модулей. Вторая — минимальный продакшен-образ, который публикуете в registry. Промежуточный build-образ можно не пушить.
# --------------> Сборка
FROM node:latest AS build
ARG NPM_TOKEN
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
npm ci --omit=dev && \
rm -f .npmrc
# --------------> Продакшен
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
RUN apk add dumb-init
ENV NODE_ENV production
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]На стадии build можно взять более толстый образ (gcc и т.д.). NPM_TOKEN в финальном образе и в docker history продакшен-тега уже не фигурирует так, как при одностадийной сборке с тем же ARG в финале.
9) Не тащить лишнее в образ
Как .gitignore для git — для контекста сборки нужен .dockerignore:
.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignoreИгнор node_modules критичен: иначе локальная папка затрёт то, что ставит npm ci в контейнере.
FROM node@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci
CMD "npm" "start"Во второй стадии при COPY . /usr/src/app без ignore снова попадут локальные node_modules, .env, ключи — риск утечки и порча кэша слоёв при любых мелких изменениях на диске.
# --------------> Продакшен
FROM node:lts-alpine
RUN apk add dumb-init
ENV NODE_ENV production
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]Итог по .dockerignore:
- не копировать «сломанные» локальные
node_modules; - не тащить секреты из
.env,aws.jsonи т.п.; - ускорять сборки, не инвалидируя кэш из‑за логов и локальных конфигов.
10) Секреты при сборке (mount)
.dockerignore глобален для всех стадий: для build может понадобиться .npmrc, а в финальный образ он не должен попасть.
Вариант — Docker BuildKit secrets: файл монтируется на время RUN и не остаётся слоем в образе. Добавьте .npmrc в .dockerignore, чтобы не копировать его в контекст как обычный файл.
.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore
.npmrc# --------------> Сборка
FROM node:latest AS build
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN --mount=type=secret,mode=0644,id=npmrc,target=/usr/src/app/.npmrc npm ci --omit=dev
# --------------> Продакшен
FROM node:lts-alpine
RUN apk add dumb-init
ENV NODE_ENV production
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]Сборка:
docker build . -t nodejs-tutorial --secret id=npmrc,src=.npmrcПримечание. Секреты при сборке — относительно новая возможность; на старых Docker включите BuildKit, при необходимости:
DOCKER_BUILDKIT=1 docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234 --secret id=npmrc,src=.npmrc© Перевод на русский язык. Оригинальные материалы: OWASP Cheat Sheet Series.
Этот проект использует материалы OWASP, распространяемые по лицензии CC BY-SA 4.0.