← Назад к вопросам

Оптимизация Dockerfile с multi-stage build

2.3 Middle🔥 161 комментариев
#Docker и контейнеризация

Условие

Вам дан неоптимизированный Dockerfile для Node.js приложения:

FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]

Задача

  1. Перепишите Dockerfile с использованием multi-stage build
  2. Уменьшите размер итогового образа
  3. Оптимизируйте кэширование слоев
  4. Добавьте non-root пользователя для безопасности

Объясните

  • Почему multi-stage build уменьшает размер образа?
  • Как правильно организовать слои для лучшего кэширования?
  • Какой базовый образ лучше использовать для production?

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение

1. Оптимизированный Dockerfile с multi-stage build

# Stage 1: Builder (build environment)
FROM node:18-alpine AS builder

WORKDIR /app

# Копируем только package.json и package-lock.json для лучшего кэширования
COPY package*.json ./

# Устанавливаем зависимости (обе для production и dev)
RUN npm ci --only=production && \
    npm cache clean --force

# Копируем исходный код
COPY . .

# Устанавливаем dev dependencies только для build
RUN npm ci && \
    npm run build && \
    npm cache clean --force


# Stage 2: Runtime (production environment)
FROM node:18-alpine

# Создаем non-root пользователя для безопасности
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

WORKDIR /app

# Копируем только production node_modules из builder
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules

# Копируем только собранный код
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./

# Переключаемся на non-root пользователя
USER nodejs

EXPOSE 3000

# Healthcheck для container orchestration
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"

CMD ["node", "dist/index.js"]

2. Альтернативный вариант для очень больших проектов

Если приложение использует native модули:

# Stage 1: Зависимости
FROM node:18-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

# Stage 2: Builder с dev dependencies
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: Runtime
FROM node:18-alpine
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
WORKDIR /app
COPY --from=dependencies --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
USER nodejs
EXPOSE 3000
CMD ["node", "dist/index.js"]

3. Почему multi-stage build уменьшает размер образа?

Проблема с исходным Dockerfile

Оригинальный Dockerfile в финальном образе содержит:

  • node_modules: ~500MB (обе production и dev зависимости)
  • Исходный код (src/): ~10-50MB
  • Build инструменты: npm, webpack, TypeScript компилятор, etc.
  • Системные зависимости для build: make, python, gcc, etc.

Итоговый размер: 1.2-1.5 GB

Решение с multi-stage

Мульти-stage build работает так:

  1. Stage 1 (builder): Содержит ВСЕ инструменты, исходный код, dev зависимости. Размер может быть 2-3 GB (это в RAM).

  2. Stage 2 (runtime): Копирует ТОЛЬКО необходимое:

    • Production node_modules (~200MB вместо 500MB)
    • Скомпилированный dist/ код
    • package.json
    • НЕ копирует: исходный код, build tools, dev dependencies, .git, tests
  3. Финальный образ: Содержит только stage 2, из Stage 1 ничего не остается!

Результат:

  • Исходный: ~1.2-1.5 GB
  • Оптимизированный: ~300-400 MB
  • Сжатие в 3-5 раз!

Что именно исключается

# В finальном образе НЕ будет:
- node_modules/.bin/ (исполняемые файлы dev tools)
- typescript компилятор
- webpack, babel, eslint (build dependencies)
- исходные .ts файлы (есть только .js в dist/)
- node-gyp и компиляция native модулей
- .git история
- tests/ и test-related packages
- documentation
- конфиги (webpack.config.js, babel.config.js, etc.)

4. Оптимизация кэширования слоев

Правило: Копировать от медленнее-меняющегося к быстрее-меняющемуся

# ❌ ПЛОХО - каждое изменение кода перекэшивает npm install
COPY . .
RUN npm install

# ✅ ХОРОШО - npm install кэшируется, изменения кода не перебилдятся
COPY package*.json ./
RUN npm install
COPY . .

Слои в оптимальном порядке

FROM node:18-alpine AS builder
WORKDIR /app

# Слой 1: Практически никогда не меняется
COPY package*.json ./

# Слой 2: Дорогой - npm install (может быть в кэше)
RUN npm ci --only=production

# Слой 3: Меняется часто - исходный код
COPY . .

# Слой 4: Быстрый - компиляция (зависит от слоя 3)
RUN npm run build

Как это экономит время

# Сценарий 1: Меняете исходный код (app.ts)
# Пересчитываются слои: 3, 4 (~30 сек)
# Слой 2 (npm install) кэшируется! (~5 минут сэкономлено)

# Сценарий 2: Меняется package.json (добавили зависимость)
# Пересчитываются слои: 2, 3, 4 (~5-7 минут)
# Слой 1 кэшируется

# Сценарий 3: Меняется только Dockerfile
# Все слои пересчитываются (~7 минут)

5. Выбор базового образа для production

Вариант 1: node:18-alpine ✅ РЕКОМЕНДУЕТСЯ

Размер: ~180 MB

Плюсы:

  • Самый маленький базовый образ
  • Достаточно для Node.js приложений
  • Хорошо поддерживается

Минусы:

  • Alpine использует musl libc (отличается от glibc)
  • Иногда проблемы с нативными модулями
  • Немного медленнее glibc
FROM node:18-alpine

Вариант 2: node:18-slim

Размер: ~300 MB

Плюсы:

  • Меньше чем full node
  • Использует glibc (совместимость лучше)
  • Хорошо для прода
FROM node:18-slim

Вариант 3: node:18-bullseye

Размер: ~900 MB

Плюсы:

  • Полный набор инструментов
  • Совместимость с большинством пакетов

Минусы:

  • Много ненужного для production
  • Больше атак поверхность
FROM node:18-bullseye

Сравнение

ОбразРазмерLibcРекомендуемость
alpine180 MBmusl✅ Лучший выбор
slim300 MBglibc✅ Хорошо если native модули
full900 MBglibc❌ Для build только

6. Безопасность: non-root пользователь

Почему важно

# ❌ С root (опасно)
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

USER root  # или вообще не указан
CMD ["node", "app.js"]

# Если хакер скомпрометирует приложение, он получит root доступ!

# ✅ С non-root
USER nodejs
CMD ["node", "app.js"]

# Хакер получит доступ только nodejs пользователя (ограниченные права)

Пояснение команды создания пользователя

RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

# addgroup -g 1001 -S nodejs
#   -g 1001: GID (group ID) = 1001
#   -S: системный аккаунт (без home directory)
#   nodejs: имя группы

# adduser -S nodejs -u 1001
#   -S: системный аккаунт
#   nodejs: имя пользователя
#   -u 1001: UID (user ID) = 1001

Проверка прав доступа

# Убедитесь, что все файлы принадлежат nodejs
COPY --chown=nodejs:nodejs /app/dist ./dist

# Проверка в контейнере
ls -la /app/dist
# Должно быть: nodejs nodejs (не root root)

7. Дополнительные оптимизации

Использование .dockerignore

# Создайте .dockerignore в корне проекта
node_modules
npm-debug.log
.git
.gitignore
.env
.env.local
.DS_Store
dist
.next
build
.vscode
.idea
COVERAGE
.nyc_output
coverage
README.md
LICENSE

Использование npm ci вместо npm install

# ❌ npm install - может изменять package-lock.json
RUN npm install

# ✅ npm ci - точно следует package-lock.json
RUN npm ci

Очистка кэша npm

RUN npm ci && \
    npm cache clean --force

# Экономит ~100-200 MB в слое

Healthcheck

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"

# Позволяет container orchestration (Docker, Kubernetes) знать, жив ли контейнер

8. Сравнение размеров образов

# Исходный (без оптимизации)
docker build -f Dockerfile.bad -t myapp:bad .
docker images myapp:bad
# REPOSITORY  TAG    SIZE
# myapp       bad    1.45GB

# Оптимизированный
docker build -f Dockerfile -t myapp:optimized .
docker images myapp:optimized
# REPOSITORY  TAG         SIZE
# myapp       optimized   320MB

# Экономия: 1.45GB - 0.32GB = 1.13GB (77% меньше!)

9. Команды для тестирования

# Сборка
docker build -t myapp:latest .

# Запуск
docker run -it -p 3000:3000 myapp:latest

# Проверка размера
docker images myapp:latest

# Проверка слоев
docker history myapp:latest

# Проверка, кто запускает контейнер
docker exec -it <container_id> whoami
# Должно быть: nodejs (не root)

# Просмотр логов
docker logs -f <container_id>