Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Docker multi-stage и оптимизация образов: размер, кэш, безопасность

Оптимизация Docker-образов: multi-stage сборка, слои, кэш и безопасный минимум

Как уменьшить образы в разы: multi-stage Dockerfile, порядок слоёв под кэш, alpine/distroless, .dockerignore, сканирование уязвимостей и типовые ошибки локальной сборки.

Прямой ответ

Multi-stage сборка решает две задачи сразу: образ содержит только рантайм-необходимое (компиляторы, исходники и кэши остаются в отброшенной стадии), а сборка остаётся в одном Dockerfile. Типичный эффект: Go/Rust-приложение — с 800 МБ (на базе build-образа) до 15–40 МБ (статический бинарник на scratch/distroless); Node-приложение — с 1,5 ГБ до 150–250 МБ. Второй рычаг — порядок инструкций под кэш: редко меняющееся (зависимости) раньше, часто меняющееся (код) позже; и .dockerignore, без которого в контекст сборки улетают node_modules, .git и локальные секреты.

Анатомия multi-stage Dockerfile

# ---- стадия сборки ----
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download          # кэшируемый слой: зависимости
COPY . .
RUN CGO_ENABLED=0 go build -o /app .

# ---- стадия рантайма ----
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER nonroot
ENTRYPOINT ["/app"]

Что здесь важно: go mod download до копирования кода — слой зависимостей переиспользуется, пока не изменился go.sum (пересборка только кода — секунды); CGO_ENABLED=0 даёт статический бинарник, которому не нужны библиотеки базового образа; distroless-база не содержит ни shell, ни пакетного менеджера — поверхность атаки минимальна. Для Rust схема идентична (с учётом кэша cargo через cargo-chef или монтируемый кэш BuildKit), для Node — копирование только node_modules из стадии npm ci --omit=dev.

Базовые образы: компромиссы выбора

База Размер Когда уместна
scratch 0 статические бинарники (Go/Rust), минимум атакуемой поверхности
distroless 2–20 МБ то же + ca-сертификаты/без root; нет shell — отладка через ephemeral-контейнеры
alpine 5–8 МБ нужен shell/apk; помните про musl вместо glibc (редкие несовместимости)
debian-slim 30–80 МБ нужна glibc и привычный набор утилит
Полный ubuntu/node:20 200 МБ – 1 ГБ только стадия сборки, не рантайм

Правило: алпайн не «обязательно лучше» — для Python/Node-glibc-зависимостей slim-образы часто надёжнее; выигрыш в размере измеряйте по финальному образу, а не по вкусу.

Кэш слоёв и скорость локальной сборки

  1. Контекст сборки — в .dockerignore: node_modules, .git, .env*, target/, артефакты. Кроме скорости это блокирует утечку .env в слои — критично по правилам статьи про секреты и .env; работа с переменными окружения стека описана в материале про Compose.
  2. BuildKit по умолчанию (Docker 24+): параллельные стадии, кэш монтированием (RUN --mount=type=cache,target=/root/.cache/go-build) — кэш сборки живёт между сборками, как GOCACHE в статье про Go-тулчейн.
  3. Не ломайте кэш мелочами: COPY . . в начале Dockerfile инвалидирует всё при любом изменении; apt-get update без && apt-get install в том же слое — классическая ошибка.
  4. Кэш образов на диске растёт: периодический docker builder prune освобождает гигабайты; бюджеты — в статье про WSL2 и Docker.

Безопасность рантайм-образа

  • non-root пользователь в рантайм-стадии (дистроless — встроенный nonroot; в alpine — USER 1000).
  • Фиксируйте версии: тег node:20.11-slim, а не latest — воспроизводимость и контролируемые обновления.
  • Сканируйте образы: docker scout quickview <image> или trivy покажут известные CVE в базовых слоях; для продакшена — регулярное, не разовое.
  • Никаких секретов в слоях: COPY .env или ARG PASSWORD остаются в истории образа навсегда — только runtime-инъекция (env_file, secrets оркестратора).
  • Проверка размера: docker history <image> покажет, какой слой сколько весит — быстрая диагностика «кто принёс 400 МБ».

FAQ

Multi-stage замедляет сборку? Локально — почти нет (стадии параллелятся BuildKit), в CI первая сборка дольше на стадию компиляции, повторные ускоряются кэшем зависимостей.

Нужен ли multi-stage для Python? Менее критичен (интерпретатор нужен в рантайме), но полезен: стадия установки wheel-пакетов с компиляцией отбрасывается, финальный образ — только site-packages и slim-база.

Почему distroless-контейнер «не запускается» с shell-командой? Shell в образе нет — это фича. Отладка: docker run --rm -it --entrypoint sh не сработает; используйте ephemeral debug-контейнер (kubectl debug) или временно debian-slim.

Какой реальный выигрыш от alpine для Node-приложения? Образ node:20-alpine против node:20 — разница около 700–800 МБ, но с multi-stage (копирование только prod-зависимостей) выигрыш достигается и на slim-базе, с меньшим риском musl-несовместимостей.

Числа для калибровки ожиданий

Чтобы оптимизация была осмысленной, полезно знать порядок величин. Статический Go-бинарник среднего сервиса — 10–25 МБ; на scratch-образе финальный образ почти равен бинарнику. Rust-релиз с strip = true и lto — 5–15 МБ. Python-приложение на debian-slim с numpy/pandas — 250–500 МБ (интерпретатор и библиотеки не выкинуть); Node-приложение с prod-зависимостями — 150–300 МБ. Если ваш образ выбивается из этих порядков в разы — docker history покажет виновный слой: чаще всего это скопированные артефакты сборки, кэш пакетного менеджера или весь репозиторий вместо нужных файлов.

Второй калибровочный ориентир — время pull: на типовом домашнем канале образ в 30 МБ разворачивается секунды, в 800 МБ — минуты; в Kubernetes-сценариях размер образа напрямую превращается в скорость масштабирования. Поэтому «образ меньше 100 МБ» — не эстетика, а практический порог комфорта для деплоя. Локально выигрыш тот же: меньше слоёв — быстрее пересборки и меньше места в кэше Docker, который на ноутбуке и так конкурирует с проектами за диск.

Как проверить, что в образ не попали секреты? docker history --no-trunc <image> покажет команды слоёв, а docker scout / dive — содержимое файловой системы по слоям. Если .env или ключ копировались на любой стадии (даже отброшенной) без BuildKit-секретов — считайте их скомпрометированными и ротируйте: отброшенные стадии multi-stage не удаляют следы из истории сборки локально, а в pushed-образах их не будет только при корректном multi-stage.

Даст ли --squash или экспорт образа меньший размер? Схлопывание слоёв экономит место на диске, но ломает переиспользование кэша между образами; в BuildKit достаточно правильного multi-stage — финальная стадия и так содержит минимум слоёв.

Насколько часто пересобирать базовые образы? Разумный ритм — ежемесячно или при выходе обновлений безопасности базовой ОС: CVE в старых слоях накапливаются незаметно. В CI это решается запланированным workflow, который пересобирает и пушит образы по расписанию, а не «когда вспомнили».

Источники: документация Docker Build (multi-stage, best practices), проект distroless (ссылки в шапке).