Почему Docker-образы раздуваются до гигабайтов
Типичный Dockerfile для Rails-приложения: берём ruby:3.2, ставим Node.js для сборки ассетов, добавляем build-essential для нативных гемов, копируем код, запускаем bundle install и assets:precompile. Результат — образ на 1.5-2 GB. В нём компилятор GCC, заголовочные файлы, Node.js с npm, кэш bundler, исходники гемов с нативными расширениями — всё то, что в runtime не нужно.
Каждый лишний мегабайт — это медленнее pull с registry, дольше deploy, больше места на диске, шире поверхность атаки. На CI с 50 деплоями в день разница между 2 GB и 150 MB образом — это часы ожидания и терабайты трафика в месяц.
Multi-stage builds решают проблему: один Dockerfile, несколько этапов (FROM). Каждый этап — отдельное окружение. Финальный образ содержит только то, что нужно для запуска. Компиляторы, build-зависимости, исходники — остаются в промежуточных этапах и не попадают в production-образ.
Принцип multi-stage builds
Простейший пример
# Этап 1: сборка
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server
# Этап 2: runtime
FROM alpine:3.20
RUN apk --no-cache add ca-certificates
COPY --from=builder /server /server
EXPOSE 8080
CMD ["/server"]
Что происходит:
- Первый
FROM golang:1.22 AS builder— создаёт этап с Go-компилятором (~800 MB) - В нём компилируется бинарник
/server - Второй
FROM alpine:3.20— начинает с чистого Alpine (~7 MB) COPY --from=builder /server /server— копирует только бинарник из первого этапа- Финальный образ: Alpine + бинарник = ~15 MB вместо 800 MB
Ключевые правила
# COPY --from=<stage> — копировать из любого предыдущего этапа
COPY --from=builder /app/dist ./dist
COPY --from=0 /app/binary ./binary # по номеру этапа
# Можно копировать из внешних образов
COPY --from=nginx:alpine /etc/nginx/nginx.conf /etc/nginx/nginx.conf
# Каждый FROM начинает с нуля — предыдущие слои не наследуются
# Только COPY --from переносит файлы между этапами
Rails: от 1.8 GB до 120 MB
Типичный монолитный Dockerfile (плохой)
# НЕ ДЕЛАЙТЕ ТАК
FROM ruby:3.2
RUN apt-get update && apt-get install -y \
nodejs npm build-essential libpq-dev \
&& npm install -g yarn
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle install
COPY package.json yarn.lock ./
RUN yarn install
COPY . .
RUN RAILS_ENV=production bundle exec rails assets:precompile
EXPOSE 3000
CMD ["rails", "server", "-b", "0.0.0.0"]
# Размер: ~1.8 GB
Что не так:
- build-essential (GCC, make, headers) — 300+ MB, не нужны в runtime
- nodejs + npm + yarn — 200+ MB, нужны только для сборки ассетов
- Кэш bundler — 100+ MB дублирование установленных гемов
- Кэш yarn/npm — ещё 100+ MB
- Полный ruby:3.2 вместо slim-варианта
Multi-stage Dockerfile (правильный)
# ============================================
# Этап 1: базовый образ с общими зависимостями
# ============================================
FROM ruby:3.2-slim AS base
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV RAILS_ENV=production \
BUNDLE_DEPLOYMENT=1 \
BUNDLE_WITHOUT="development:test"
# ============================================
# Этап 2: сборка гемов
# ============================================
FROM base AS gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \
build-essential \
libpq-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache
# ============================================
# Этап 3: сборка ассетов (JS/CSS)
# ============================================
FROM base AS assets
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install --no-install-recommends -y nodejs && \
npm install -g yarn && \
rm -rf /var/lib/apt/lists/*
COPY --from=gems /usr/local/bundle /usr/local/bundle
COPY Gemfile Gemfile.lock ./
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile --production
COPY . .
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile && \
rm -rf node_modules tmp/cache vendor/javascript
# ============================================
# Этап 4: финальный production-образ
# ============================================
FROM base AS production
COPY --from=gems /usr/local/bundle /usr/local/bundle
COPY --from=assets /app /app
RUN bundle exec bootsnap precompile --gemfile app/ lib/
RUN useradd -m rails && chown -R rails:rails /app
USER rails
EXPOSE 3000
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
Результат:
| Этап | Содержимое | Размер |
|---|---|---|
| base | ruby:3.2-slim + libpq5 | ~180 MB |
| gems | + build-essential + скомпилированные гемы | ~450 MB (не попадает в финал) |
| assets | + Node.js + скомпилированные ассеты | ~600 MB (не попадает в финал) |
| production | base + гемы + ассеты (без исходников JS) | ~120 MB |
С 1.8 GB до 120 MB — в 15 раз меньше.
Node.js / TypeScript
Express + TypeScript
# Этап 1: зависимости
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production && \
cp -R node_modules /prod_modules && \
npm ci
# Этап 2: сборка TypeScript
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build # tsc → dist/
# Этап 3: production
FROM node:22-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /prod_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 3000
CMD ["node", "dist/index.js"]
# Размер: ~80 MB (вместо ~400 MB)
Трюк с /prod_modules: в этапе deps сначала ставятся production-зависимости и копируются в отдельную папку, потом — все зависимости (включая dev) для сборки. В финальный образ идут только production node_modules.
Next.js с standalone output
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:22-alpine AS production
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER app
EXPOSE 3000
CMD ["node", "server.js"]
# Размер: ~120 MB (вместо ~1 GB)
output: 'standalone' в next.config.js — Next.js сам создаёт минимальный сервер с только нужными зависимостями.
Python / Django / FastAPI
FastAPI с Poetry
# Этап 1: зависимости
FROM python:3.12-slim AS builder
RUN pip install poetry==1.8.0
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false && \
poetry install --no-dev --no-interaction --no-ansi && \
poetry export -f requirements.txt -o requirements.txt
# Этап 2: production
FROM python:3.12-slim AS production
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY . .
RUN useradd -m app
USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Размер: ~150 MB
Django с multi-stage
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt
FROM python:3.12-slim AS production
RUN apt-get update && apt-get install --no-install-recommends -y \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
RUN python manage.py collectstatic --noinput
RUN useradd -m django
USER django
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
# Размер: ~180 MB (вместо ~700 MB)
Ключевой трюк: pip install --prefix=/install ставит пакеты в отдельную директорию, которую легко скопировать в финальный образ через COPY --from.
PHP / Laravel
Laravel с PHP-FPM
# Этап 1: Composer-зависимости
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-interaction \
--no-scripts \
--prefer-dist \
--ignore-platform-reqs
# Этап 2: JS-ассеты
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json vite.config.js ./
COPY resources/ ./resources/
RUN npm ci && npm run build
# Этап 3: production
FROM php:8.4-fpm-alpine AS production
RUN apk add --no-cache \
libpq postgresql-dev \
&& docker-php-ext-install pdo_pgsql opcache \
&& apk del postgresql-dev
COPY docker/php.ini /usr/local/etc/php/conf.d/99-app.ini
WORKDIR /app
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY . .
RUN php artisan config:cache && \
php artisan route:cache && \
php artisan view:cache
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 9000
CMD ["php-fpm"]
# Размер: ~95 MB
Три этапа: Composer (PHP-зависимости), Node (Vite-ассеты), PHP-FPM (runtime). Composer и Node.js не попадают в финальный образ.
Go: минимальные образы
Static binary + scratch
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-w -s" -o /server ./cmd/server
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
# Размер: ~8 MB
scratch — пустой образ, 0 байт. В нём нет shell, нет libc, нет ничего. Только бинарник и SSL-сертификаты. Идеально для Go с CGO_ENABLED=0.
-ldflags="-w -s" — убирает debug-информацию и symbol table. Экономит 30-40% размера бинарника.
С distroless (если нужен немного больше, чем scratch)
FROM golang:1.22 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o /server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
# Размер: ~12 MB
Distroless — образ от Google без shell, package manager, утилит. Есть только runtime (glibc или static). Безопаснее чем Alpine (меньше поверхность атаки), удобнее чем scratch (есть /tmp, SSL-сертификаты, timezone data).
Оптимизация кэширования слоёв
Порядок COPY имеет значение
# Плохо: изменение ЛЮБОГО файла инвалидирует кэш bundle install
COPY . .
RUN bundle install
# Хорошо: сначала файлы зависимостей, потом код
COPY Gemfile Gemfile.lock ./
RUN bundle install # кэшируется пока Gemfile не изменился
COPY . . # изменение кода не ломает кэш bundle
Каждая инструкция в Dockerfile создаёт слой. Если слой не изменился — Docker использует кэш. Порядок инструкций определяет, насколько эффективен кэш.
.dockerignore — обязателен
# .dockerignore
.git
.github
node_modules
tmp
log
storage
.env*
coverage
spec
test
*.md
docker-compose*.yml
.dockerignore
Dockerfile*
Без .dockerignore каждый COPY . . копирует .git (может быть сотни MB), node_modules, tmp, логи. Это замедляет build context transfer и ломает кэширование.
BuildKit cache mounts
# syntax=docker/dockerfile:1
FROM ruby:3.2-slim AS gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential libpq-dev
COPY Gemfile Gemfile.lock ./
# --mount=type=cache сохраняет кэш между сборками
RUN --mount=type=cache,target=/root/.bundle/cache \
bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache
FROM node:22-alpine AS assets
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
--mount=type=cache — кэш bundler/npm/pip сохраняется между сборками, но НЕ попадает в образ. Повторный bundle install при добавлении одного гема — секунды вместо минут.
Безопасность
Non-root пользователь
# Создать пользователя
RUN useradd -m -s /bin/bash appuser
# или в Alpine:
RUN addgroup -S app && adduser -S app -G app
# Переключиться
USER appuser
# Все последующие RUN, CMD, ENTRYPOINT выполняются от appuser
Никогда не запускайте приложение от root в контейнере. Даже если контейнер изолирован — container escape эксплойты существуют. Non-root пользователь — дополнительный слой защиты.
Минимизация поверхности атаки
# Плохо: полный Ubuntu с кучей утилит
FROM ubuntu:24.04
# Лучше: slim-вариант
FROM python:3.12-slim
# Ещё лучше: Alpine
FROM python:3.12-alpine
# Лучше всего: distroless (нет shell, нет package manager)
FROM gcr.io/distroless/python3-debian12
| Базовый образ | Размер | CVE (типично) | Shell | Package manager |
|---|---|---|---|---|
| ubuntu:24.04 | 78 MB | 50-100 | Да | apt |
| python:3.12-slim | 45 MB | 20-40 | Да | apt |
| python:3.12-alpine | 17 MB | 5-15 | Да | apk |
| distroless | 3-15 MB | 0-5 | Нет | Нет |
| scratch | 0 MB | 0 | Нет | Нет |
Сканирование уязвимостей
# Trivy — бесплатный сканер
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image myapp:latest
# Docker Scout (встроен в Docker Desktop)
docker scout cves myapp:latest
# Snyk
snyk container test myapp:latest
Встроить сканирование в CI:
# .github/workflows/docker.yml
- name: Scan for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
CI/CD: сборка и публикация
GitHub Actions
name: Docker Build & Push
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
target: production
cache-from: type=gha — использует GitHub Actions cache для Docker слоёв. Повторные сборки в 3-5 раз быстрее.
Multi-platform builds
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Build multi-platform
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
Один Dockerfile — образы для x86 и ARM (Apple Silicon, AWS Graviton). Buildx компилирует под обе архитектуры через QEMU.
Docker Compose для разработки с multi-stage
# docker-compose.yml
services:
app:
build:
context: .
target: base # используем этап base, не production
dockerfile: Dockerfile
volumes:
- .:/app # монтируем код для hot-reload
- bundle:/usr/local/bundle
ports:
- "3000:3000"
command: bin/rails server -b 0.0.0.0
environment:
RAILS_ENV: development
DATABASE_URL: postgres://postgres:postgres@db/myapp_dev
depends_on:
- db
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: postgres
volumes:
bundle:
pgdata:
target: base — для разработки используем базовый этап без production-оптимизаций. Код монтируется через volume — не нужно пересобирать образ при каждом изменении.
Сравнение размеров: до и после
| Стек | Монолитный Dockerfile | Multi-stage | Экономия |
|---|---|---|---|
| Rails 8 | 1.8 GB | 120 MB | 93% |
| Next.js 15 | 1.0 GB | 120 MB | 88% |
| Laravel 11 | 800 MB | 95 MB | 88% |
| Django 5 | 700 MB | 150 MB | 79% |
| FastAPI | 500 MB | 80 MB | 84% |
| Go (gin) | 800 MB | 8 MB | 99% |
| Express + TS | 400 MB | 80 MB | 80% |
Go с scratch — абсолютный чемпион: 8 MB production-образ. Для интерпретируемых языков 80-150 MB — отличный результат.
Итоговый чеклист
Структура Dockerfile
- [ ] Минимум 2 этапа: builder + production
- [ ] Для Rails/Laravel/Next.js: 3-4 этапа (base, gems/deps, assets, production)
- [ ] Финальный этап на slim/alpine/distroless — не на полном образе
- [ ]
.dockerignore— исключить .git, node_modules, tmp, log, tests
Кэширование
- [ ] Сначала COPY файлы зависимостей, потом код
- [ ]
--mount=type=cacheдля bundler/npm/pip кэша - [ ] BuildKit включён:
DOCKER_BUILDKIT=1илиdocker buildx - [ ] CI:
cache-from: type=ghaдля GitHub Actions
Безопасность
- [ ] Non-root USER в финальном этапе
- [ ] Нет secrets в образе (используйте build-time secrets:
--mount=type=secret) - [ ] Сканирование: Trivy/Scout/Snyk в CI pipeline
- [ ] Базовый образ: обновлять регулярно (CVE)
Production
- [ ]
HEALTHCHECKинструкция для оркестратора - [ ] Размер образа < 200 MB для интерпретируемых, < 20 MB для Go/Rust
- [ ] Multi-platform: linux/amd64 + linux/arm64
- [ ] Теги:
:latest+:sha(для rollback) - [ ] Не хранить данные в контейнере — volumes для uploads, logs
Комментарии (0)