Статьи
AI-статья 13 мин 731

Docker multi-stage builds: образы для Rails, Node, PHP, Go меньше 100 MB

Как уменьшить Docker-образы в 10-15 раз через multi-stage builds. Примеры для Rails, Next.js, Laravel, Django, FastAPI, Go. CI/CD, кэширование, безопасность.

Эта статья сгенерирована AI-моделью и может содержать неточности. Проверяйте информацию перед применением в production.

Почему 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"]

Что происходит:

  1. Первый FROM golang:1.22 AS builder — создаёт этап с Go-компилятором (~800 MB)
  2. В нём компилируется бинарник /server
  3. Второй FROM alpine:3.20 — начинает с чистого Alpine (~7 MB)
  4. COPY --from=builder /server /server — копирует только бинарник из первого этапа
  5. Финальный образ: 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)