Статья 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 по умолчанию:

  1. Недетерминизм сборок. Как для npm мы фиксируем зависимости npm ci через lockfile, так и базовый образ лучше фиксировать. С node (= node:latest) при каждой сборке может подтянуться новый базовый слой.
  2. Большой образ. Полноценная ОС с лишними пакетами увеличивает размер, время pull/сборки и поверхность атаки (уязвимости в ненужных библиотеках).

Образ node без урезания часто содержит сотни известных уязвимостей и сотни мегабайт — плохая стартовая точка.

Рекомендации:

  1. Используйте минимальные образы — меньше кода в образе, меньше векторов атаки и быстрее CI.
  2. Фиксируйте образ по 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         116MB

Dockerfile только с 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"

Контекст:

  1. Оркестратор (Kubernetes, Swarm, Docker) шлёт процессу сигналы завершения (SIGTERM, SIGKILL).
  2. Если процесс запущен «через прослойку», сигнал может не дойти до Node.
  3. Процесс с 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.

Сделайте:

  1. Обработчики SIGINT, SIGTERM.
  2. Ожидание закрытия БД, завершения запросов и т.д.
  3. Затем 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.