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