У меня есть правило: если Docker образ больше 500MB - что-то пошло не так. Недавно помогал коллеге оптимизировать образ Node.js приложения. Начальный размер - 2.1GB. Финальный - 48MB. Расскажу пошагово, как мы это сделали.

Исходная точка: 2.1GB кошмара

Вот с чего начиналось:

FROM ubuntu:latest
RUN apt-get update && apt-get install -y \
    curl \
    wget \
    git \
    build-essential \
    python3 \
    nodejs \
    npm
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["npm", "start"]

Проверяем размер:

docker build -t myapp:bloated .
docker images myapp:bloated
# REPOSITORY   TAG       SIZE
# myapp        bloated   2.13GB 😱

Шаг 1: Правильный базовый образ (-1.2GB)

Ubuntu для Node.js приложения - как ехать на танке в магазин.

# Было: FROM ubuntu:latest
FROM node:18-alpine

Результат:

docker build -t myapp:step1 .
docker images myapp:step1
# SIZE: 876MB (-1.25GB)

Шаг 2: Multi-stage сборка (-600MB)

Разделяем сборку и runtime:

# Stage 1: Сборка
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Stage 2: Runtime
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
CMD ["node", "dist/index.js"]

Результат:

docker build -t myapp:step2 .
docker images myapp:step2
# SIZE: 276MB (-600MB)

Шаг 3: Оптимизация зависимостей (-180MB)

Анализируем, что реально нужно в production:

FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
# Устанавливаем только production зависимости
RUN npm ci --production

# Отдельно dev зависимости для сборки
FROM node:18-alpine AS dev-deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=development

# Сборка
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
COPY --from=dev-deps /app/node_modules ./node_modules
COPY --from=builder /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Runtime - только production
FROM node:18-alpine
RUN apk add --no-cache tini
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]

Результат:

docker build -t myapp:step3 .
docker images myapp:step3
# SIZE: 96MB (-180MB)

Шаг 4: Минимальный runtime (-40MB)

Переходим на distroless:

# Сборка как раньше
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --production && npm cache clean --force
COPY . .
RUN npm run build

# Компилируем в один файл (если возможно)
FROM node:18-alpine AS bundler
WORKDIR /app
COPY --from=builder /app .
RUN npm install -g @vercel/ncc && \
    ncc build dist/index.js -o final

# Минимальный runtime
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
COPY --from=bundler /app/final/index.js ./
CMD ["index.js"]

Результат:

docker build -t myapp:step4 .
docker images myapp:step4
# SIZE: 56MB (-40MB)

Шаг 5: Сжатие и очистка (-8MB)

Финальные оптимизации:

FROM node:18-alpine AS builder
WORKDIR /app
# Кэшируем слой с зависимостями
COPY package*.json ./
RUN npm ci --production --no-audit --no-fund && \
    npm cache clean --force

# Копируем исходники
COPY . .
RUN npm run build && \
    # Удаляем исходники после сборки
    rm -rf src/ tests/ .git/ && \
    # Удаляем ненужные файлы из node_modules
    find node_modules -name "*.md" -delete && \
    find node_modules -name "*.txt" -delete && \
    find node_modules -name "test" -type d -exec rm -rf {} + && \
    find node_modules -name "tests" -type d -exec rm -rf {} +

# Production образ
FROM gcr.io/distroless/nodejs18-debian11
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["dist/index.js"]

Финальный результат:

docker build -t myapp:final .
docker images myapp:final
# SIZE: 48MB 🎉

Дополнительные трюки

1. Используйте .dockerignore

# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
coverage
tests

2. Анализируйте слои с dive

dive myapp:final
# Показывает, что занимает место в каждом слое

3. Минимизируйте количество слоев

# Плохо - много слоев
RUN apt-get update
RUN apt-get install curl
RUN apt-get install wget

# Хорошо - один слой
RUN apt-get update && \
    apt-get install -y curl wget && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

4. Порядок команд важен

# Сначала редко меняющееся
COPY package*.json ./
RUN npm ci

# Потом часто меняющееся
COPY . .

5. BuildKit и кэш монтирование

# syntax=docker/dockerfile:1
FROM node:18-alpine
RUN --mount=type=cache,target=/root/.npm \
    npm ci --production

Сравнение подходов

Подход Размер Плюсы Минусы
Ubuntu + всё подряд 2.1GB Просто Огромный
Node Alpine 876MB Официальный Всё ещё большой
Multi-stage 276MB Чистый runtime Сложнее
Production deps 96MB Только нужное Нужен анализ
Distroless 48MB Минимальный Нет shell

Чек-лист оптимизации

  • Использую минимальный базовый образ (alpine/distroless)
  • Настроен multi-stage build
  • Удалены dev зависимости из production
  • Есть .dockerignore
  • Оптимизирован порядок слоёв для кэша
  • Очищены временные файлы и кэши
  • Объединены RUN команды где возможно
  • Проанализирован с dive

Итоги

Уменьшение Docker образа с 2GB до 50MB - это не магия, а методичная работа:

  1. Правильный базовый образ: -60%
  2. Multi-stage сборка: -30%
  3. Только production зависимости: -65%
  4. Distroless runtime: -40%
  5. Финальная очистка: -15%

Меньший образ = быстрее деплой, меньше места, меньше surface для атак. Win-win-win.