У меня есть правило: если 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 - это не магия, а методичная работа:
- Правильный базовый образ: -60%
- Multi-stage сборка: -30%
- Только production зависимости: -65%
- Distroless runtime: -40%
- Финальная очистка: -15%
Меньший образ = быстрее деплой, меньше места, меньше surface для атак. Win-win-win.