При сборке приложения в Docker первый инстинкт — положить в контейнер всё: компилятор или полный интерпретатор, сборщик зависимостей, исходники, кэш. Образ получается гигантским и несёт инструменты, которые в рабочем контейнере не нужны. Multi-stage сборка решает эту проблему: строим в одном образе, запускаем в другом, меньшем.
Дальше — та же история по тактам: что уезжает в прод, а что остаётся на сборочной машине, и что пересобирается после правки одного файла.
На этапе build остаются компилятор, исходники и кэш зависимостей. В финальный образ попадает только приложение и то, что нужно для его запуска.
На этапе build остаются компилятор, исходники и кэш зависимостей. В финальный образ попадает только приложение и то, что нужно для его запуска.
На этапе build остаются компилятор, исходники и кэш зависимостей. В финальный образ попадает только приложение и то, что нужно для его запуска.
Multi-stage решает размер: в прод уезжает только артефакт приложения с рантаймом. Порядок слоёв решает время: если исходники стоят выше файла зависимостей, правка одного файла заставляет скачивать все зависимости заново.
Проблема: образ с «кухней» внутри
Представьте, что вы готовите блюдо и отвозите гостю не тарелку с едой, а всю кухню целиком — со столом, плитой и ножами. Именно это происходит, когда в финальный Docker-образ попадают инструменты сборки, компилятор и исходники.
Типичный «наивный» Dockerfile выглядит так:
FROM eclipse-temurin:21-jdk
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests && mv target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o app ./cmd/app
ENTRYPOINT ["./app"]
FROM node:22
WORKDIR /app
COPY . .
RUN npm ci && npm run build
ENTRYPOINT ["node", "dist/main.js"]
FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt -r requirements-dev.txt
ENTRYPOINT ["uvicorn", "app.main:app", "--host", "0.0.0.0"]
Небольшая деталь для Maven, на которой спотыкаются в первый же раз: он кладёт результат не в target/app.jar, а в target/<имя-артефакта>-<версия>.jar. Поэтому в примере стоит mv target/*.jar app.jar — переименовать один раз и дальше не зависеть от версии. Второй путь — прописать в pom.xml строку <finalName>app</finalName>, тогда файл сразу будет называться app.jar.
Проблемы:
- Размер. Образ с компилятором весит сотни мегабайт (Eclipse Temurin JDK 21 около 450 МБ,
golang:1.23около 800 МБ,node:22около 1 ГБ), сборщик скачивает ещё сотни МБ зависимостей. В итоге образ легко переваливает за 700 МБ. - Безопасность. Компилятор, сборщик и исходный код попадают в продуктовый контейнер. Если в образе найдётся уязвимость в инструменте сборки, она окажется на рабочем сервере.
- Медленная передача. Большие образы дольше скачиваются на каждом сервере.
Multi-stage: собираем отдельно, запускаем отдельно
Multi-stage сборка — это когда один Dockerfile содержит несколько инструкций FROM. Каждая FROM начинает новый этап (stage). Из предыдущего этапа можно скопировать только то, что нужно, — всё остальное Docker отбросит.
Короткая формула: собрать артефакт там, где есть инструменты; запустить артефакт там, где нет ничего лишнего.
Развилка сборки: слева всё, что остаётся на сборочной машине, справа единственное, что переносит мостик COPY --from, внизу из чего складывается финальный образ.
# ── Этап 1: сборка ──────────────────────────────────────────
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
# сначала зависимости (кэшируются отдельно — подробнее ниже)
COPY pom.xml .
COPY .mvn/ .mvn/
COPY mvnw .
RUN ./mvnw dependency:go-offline -q
# теперь исходники
COPY src/ src/
RUN ./mvnw package -DskipTests -q
# ── Этап 2: финальный образ ──────────────────────────────────
FROM eclipse-temurin:21-jre
WORKDIR /app
# копируем только jar из этапа build (маска — чтобы не зависеть от версии в имени)
COPY --from=build /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
# ── Этап 1: сборка ──────────────────────────────────────────
FROM golang:1.23 AS build
WORKDIR /app
# сначала зависимости (кэшируются отдельно — подробнее ниже)
COPY go.mod go.sum ./
RUN go mod download
# теперь исходники
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
# ── Этап 2: финальный образ ──────────────────────────────────
FROM gcr.io/distroless/static-debian12
WORKDIR /app
# копируем только бинарник из этапа build
COPY --from=build /out/app app
ENTRYPOINT ["./app"]
# ── Этап 1: сборка ──────────────────────────────────────────
FROM node:22 AS build
WORKDIR /app
# сначала зависимости (кэшируются отдельно — подробнее ниже)
COPY package*.json ./
RUN npm ci
# теперь исходники
COPY . .
RUN npm run build && npm prune --omit=dev
# ── Этап 2: финальный образ ──────────────────────────────────
FROM node:22-slim
WORKDIR /app
# копируем только собранный код и рабочие зависимости из этапа build
COPY --from=build /app/dist dist
COPY --from=build /app/node_modules node_modules
ENTRYPOINT ["node", "dist/main.js"]
# ── Этап 1: сборка ──────────────────────────────────────────
FROM python:3.12 AS build
WORKDIR /app
# сначала зависимости (кэшируются отдельно — подробнее ниже)
COPY requirements.txt .
RUN python -m venv /venv && /venv/bin/pip install --no-cache-dir -r requirements.txt
# теперь исходники
COPY app/ app/
# ── Этап 2: финальный образ ──────────────────────────────────
FROM python:3.12-slim
WORKDIR /app
# копируем только виртуальное окружение и код из этапа build
COPY --from=build /venv /venv
COPY --from=build /app/app app
ENV PATH="/venv/bin:$PATH"
ENTRYPOINT ["uvicorn", "app.main:app", "--host", "0.0.0.0"]
Что здесь происходит:
AS build— имя первого этапа. Имя произвольное, используется вCOPY --from=.COPY --from=build ...— берём собранный артефакт из этапаbuildи кладём в текущий образ под коротким именем.- Финальный образ собирается на базе одного рантайма без компилятора и инструментов сборки (
eclipse-temurin:21-jre,node:22-slim,python:3.12-slim, а у Go — вовсе без рантайма,distroless/static). В Java база весит около 280 МБ, с вашим jar получается примерно 320 МБ вместо 770; у Go выходит 10–20 МБ.
В финальный образ не попадают: сборщик, компилятор, исходники, .git, тесты, кэш зависимостей.
То же с другим инструментом. Пример выше на Maven, потому что dependency:go-offline наглядно показывает разделение шагов. На Gradle, с vendor/ в Go, pnpm в Node или uv в Python идея та же, а команды другие:
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
# сначала то, что описывает зависимости — этот слой переживёт правку кода
COPY gradlew .
COPY gradle/ gradle/
COPY settings.gradle.kts build.gradle.kts ./
RUN ./gradlew dependencies --no-daemon -q || true
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test -q
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
FROM golang:1.23 AS build
WORKDIR /app
# сначала то, что описывает зависимости — этот слой переживёт правку кода
COPY go.mod go.sum ./
COPY vendor/ vendor/
COPY . .
RUN CGO_ENABLED=0 go build -mod=vendor -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
FROM node:22 AS build
WORKDIR /app
# сначала то, что описывает зависимости — этот слой переживёт правку кода
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build && pnpm prune --prod
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist dist
COPY --from=build /app/node_modules node_modules
ENTRYPOINT ["node", "dist/main.js"]
FROM python:3.12 AS build
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
# сначала то, что описывает зависимости — этот слой переживёт правку кода
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY app/ app/
FROM python:3.12-slim
WORKDIR /app
COPY --from=build /app/.venv .venv
COPY --from=build /app/app app
ENV PATH="/app/.venv/bin:$PATH"
ENTRYPOINT ["uvicorn", "app.main:app", "--host", "0.0.0.0"]
Для Gradle три отличия, о которых стоит знать. --no-daemon обязателен: демон Gradle в контейнере сборки только съедает память и умирает вместе со слоем. Готового аналога go-offline у Gradle нет — задача dependencies скачивает большую часть, но не всё, поэтому || true не даёт сборке упасть на несовпадении конфигураций; надёжнее решать это кэшем из следующего раздела. И bootJar вместо build, чтобы не гонять проверки дважды — тесты в образе не запускают, для них есть отдельный этап, о котором ниже.
Как работают слои и почему порядок инструкций важен
Каждая инструкция RUN, COPY, ADD в Dockerfile создаёт слой — неизменяемый снимок файловой системы. Docker кэширует слои: если инструкция и всё, что она использует, не изменились — слой берётся из кэша, заново не выполняется.
Вот почему порядок инструкций в Dockerfile критичен: как только один слой изменился, все последующие пересобираются.
Рассмотрим плохой пример:
# Плохо: исходники копируются до зависимостей
COPY src/ src/
COPY pom.xml .
RUN ./mvnw dependency:go-offline -q
RUN ./mvnw package -DskipTests
# Плохо: исходники копируются до зависимостей
COPY . .
COPY go.mod go.sum ./
RUN go mod download
# Плохо: исходники копируются до зависимостей
COPY src/ src/
COPY package*.json ./
RUN npm ci
# Плохо: исходники копируются до зависимостей
COPY app/ app/
COPY requirements.txt .
RUN pip install -r requirements.txt
Здесь изменение любого файла в src/ инвалидирует кэш COPY src/, и сборщик заново скачивает все зависимости — хотя файл зависимостей не менялся.
Правильный порядок:
# Хорошо: зависимости кэшируются отдельно от кода
COPY pom.xml .
RUN ./mvnw dependency:go-offline -q # этот слой меняется только при изменении pom.xml
COPY src/ src/
RUN ./mvnw package -DskipTests # этот слой меняется при изменении кода
# Хорошо: зависимости кэшируются отдельно от кода
COPY go.mod go.sum ./
RUN go mod download # этот слой меняется только при изменении go.mod и go.sum
COPY . .
RUN go build -o /out/app ./cmd/app
# Хорошо: зависимости кэшируются отдельно от кода
COPY package*.json ./
RUN npm ci # этот слой меняется только при изменении package-lock.json
COPY src/ src/
RUN npm run build
# Хорошо: зависимости кэшируются отдельно от кода
COPY requirements.txt .
RUN pip install -r requirements.txt # этот слой меняется только при изменении requirements.txt
COPY app/ app/
Теперь если вы изменили только исходник, зависимости повторно не скачиваются — они уже в кэше. Сборка ускоряется в несколько раз в конвейере.
Одну оговорку про dependency:go-offline стоит держать в голове: полной гарантии он не даёт. Часть плагинов эта задача не подтягивает, и следующий package всё равно сходит в сеть — просто за гораздо меньшим объёмом. Если хочется надёжности, кэш локального репозитория монтируют в сборку отдельно, но это уже приём для конвейера, а не для Dockerfile из учебника.
.dockerignore: что не должно попасть в контекст сборки
Когда вы запускаете docker build, содержимое директории с проектом становится контекстом сборки — набором файлов, из которого инструкции COPY могут что-то взять. Раньше Docker честно отправлял всю эту гору сборщику целиком; сейчас (BuildKit включён по умолчанию с Docker 23) он передаёт файлы по мере надобности. Но перебрать и сверить их всё равно приходится, и если не настроить исключения, в контекст попадут target/, .git/, IDE-файлы и всё остальное.
Файл .dockerignore работает по тем же правилам, что .gitignore, и решает три задачи:
- уменьшает контекст → сборка начинается быстрее;
- предотвращает инвалидацию кэша из-за несвязанных файлов;
- не допускает чувствительные данные в образ.
Минимальный .dockerignore (пример для проекта на Maven):
target/
.git/
.idea/
*.iml
.DS_Store
Для Gradle аналогично добавьте build/ и .gradle/, для Node — node_modules/ и dist/, для Python — .venv/ и __pycache__/, для Go — bin/.
Контекст сборки с файлом исключений и без него: смотрите на третью колонку, лишние каталоги не только раздувают контекст, но и сбрасывают кэш слоя COPY.
Кэш зависимостей, который живёт дольше слоя
Приём «сначала файл зависимостей, потом исходники» работает, пока сборка идёт на вашей машине. У него два предела, и оба всплывают в конвейере.
Первый: любое изменение в pom.xml или build.gradle.kts — добавили одну библиотеку — сбрасывает слой с зависимостями целиком, и все они качаются заново. Второй, более болезненный: кэш слоёв лежит на машине сборки, а исполнитель конвейера обычно чистый, и кэша там нет вовсе. То есть ускорение, обещанное правильным порядком инструкций, в конвейере не работает.
Оба лечит один механизм современного сборщика (BuildKit) — кэш-монтирование:
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/root/.gradle \
./gradlew bootJar --no-daemon -x test -q
FROM golang:1.23 AS build
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /out/app ./cmd/app
FROM node:22 AS build
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/root/.npm \
npm ci && npm run build
FROM python:3.12 AS build
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
Что здесь происходит. Каталог кэша зависимостей (/root/.gradle у Gradle, /root/.m2 у Maven, /go/pkg/mod у Go, /root/.npm у npm, /root/.cache/pip у pip) на время выполнения команды подключается из отдельного хранилища кэша, которое не является слоем образа: в образ он не попадает, размер не увеличивает, но между сборками сохраняется. Добавили одну библиотеку — скачается только она, остальные лежат в кэше. Порядок COPY при этом становится менее критичным, и Dockerfile упрощается.
Оговорка про конвейер: само кэш-монтирование по умолчанию тоже локально. Чтобы оно работало на чистом исполнителе, кэш подключают явно:
# сохранить кэш сборки в реестр рядом с образом
docker buildx build \
--cache-from type=registry,ref=ghcr.io/acme/app:buildcache \
--cache-to type=registry,ref=ghcr.io/acme/app:buildcache,mode=max \
-t ghcr.io/acme/app:1.4.2 --push .
В GitHub Actions вместо реестра берут встроенное хранилище (type=gha), в других системах — каталог на постоянном диске исполнителя (type=local). Без одной из этих настроек «правильный Dockerfile» в конвейере собирается с нуля каждый раз, и разговоры про порядок инструкций там бессмысленны.
И последняя деталь, ради которой это всё: mode=max сохраняет кэш всех этапов, включая этап сборки, а без него — только слои финального образа. Для многоэтапной сборки, где вся работа происходит в первом этапе, разница между «кэш есть» и «кэша нет» — ровно этот параметр.
Итог: насколько образ уменьшается
Для типичного Spring Boot-приложения (у Go, Node и Python пропорции те же, числа меньше):
| Подход | Примерный размер |
|---|---|
| JDK + Maven + исходники в одном образе | 700–900 МБ |
Multi-stage: JDK для сборки, eclipse-temurin:21-jre для запуска | 300–350 МБ |
| Multi-stage + alpine-вариант JRE | ≈ 220 МБ |
| Multi-stage + distroless | ≈ 230 МБ |
Цифры зависят от приложения, но порядок величин сохраняется.
Глубже: этапы: тесты, --target и параллельная сборкарасширенное
Многоэтапная сборка нужна не только для того, чтобы выбросить компилятор из финального образа. Этапы — это способ разложить всю сборку на части, и дальше ими можно пользоваться по отдельности.
Собрать до нужного этапа. Флаг --target останавливает сборку на указанном этапе:
docker build --target build -t my-app-build .
Зачем это надо: собрать образ с инструментами, чтобы залезть внутрь и разобраться, почему сборка падает; получить образ для отладки с компилятором и утилитами; или запустить в конвейере только проверки, не собирая финальный образ.
Отдельный этап для тестов. Если тесты должны идти внутри сборки (одинаковое окружение, ничего не зависит от исполнителя), для них делают свой этап:
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/root/.gradle ./gradlew bootJar --no-daemon -x test -q
FROM build AS test
RUN --mount=type=cache,target=/root/.gradle ./gradlew test --no-daemon
FROM eclipse-temurin:21-jre AS runtime
COPY --from=build /app/build/libs/*.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
FROM golang:1.23 AS build
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM build AS test
RUN --mount=type=cache,target=/go/pkg/mod go test ./...
FROM gcr.io/distroless/static-debian12 AS runtime
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
FROM node:22 AS build
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/root/.npm npm ci && npm run build
FROM build AS test
RUN npm test
FROM node:22-slim AS runtime
COPY --from=build /app/dist /app/dist
COPY --from=build /app/node_modules /app/node_modules
ENTRYPOINT ["node", "/app/dist/main.js"]
FROM python:3.12 AS build
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/root/.cache/pip python -m venv /venv && /venv/bin/pip install -r requirements.txt
FROM build AS test
RUN /venv/bin/pip install -r requirements-dev.txt && /venv/bin/pytest
FROM python:3.12-slim AS runtime
COPY --from=build /venv /venv
COPY --from=build /app/app /app/app
ENTRYPOINT ["/venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0"]
Здесь docker build --target test . прогоняет тесты, а обычная сборка их не выполняет — потому что этап test не нужен для финального образа, и BuildKit его просто не строит. Это важное свойство: строятся только те этапы, от которых зависит цель. Оговорка про тесты, которым нужна база: контейнер сборки не умеет поднимать соседние контейнеры, поэтому интеграционные тесты с Testcontainers внутри сборки не запускают — их место в конвейере, рядом со сборкой, а не внутри неё.
Независимые этапы собираются параллельно. Это то, что многоэтапная сборка даёт бесплатно, и о чём обычно не знают. Если этапы не зависят друг от друга, BuildKit строит их одновременно:
FROM eclipse-temurin:21-jdk AS backend
...
FROM node:22 AS frontend
...
FROM eclipse-temurin:21-jre
COPY --from=backend /app/build/libs/*.jar /app.jar
COPY --from=frontend /ui/dist/ /static/
FROM golang:1.23 AS backend
...
FROM node:22 AS frontend
...
FROM gcr.io/distroless/static-debian12
COPY --from=backend /out/app /app
COPY --from=frontend /ui/dist/ /static/
FROM node:22 AS backend
...
FROM node:22 AS frontend
...
FROM node:22-slim
COPY --from=backend /app/dist /app/dist
COPY --from=frontend /ui/dist/ /app/static/
FROM python:3.12 AS backend
...
FROM node:22 AS frontend
...
FROM python:3.12-slim
COPY --from=backend /venv /venv
COPY --from=frontend /ui/dist/ /app/static/
Сборка бэкенда и фронтенда пойдёт в два потока, и общее время окажется равно длительности медленного, а не сумме. Проверить, что так и происходит, можно по выводу сборки: BuildKit печатает шаги обоих этапов вперемешку с номерами.
Глубже: как посмотреть размер слоёврасширенное
После сборки можно проверить, что получилось:
# размер образов
docker images
# история слоёв с размерами
docker image history myapp:latest
Команда docker image history показывает каждый слой, команду, которая его создала, и его размер:
IMAGE CREATED BY SIZE
<missing> COPY приложение /app/ 48.2MB
<missing> RUN apt-get update && apt-get install -y curl 96MB
<missing> WORKDIR /app 0B
<missing> ... (слои базового рантайма)
Подозрителен слой, который весит больше самого приложения: здесь RUN apt-get принёс 96 МБ — это списки пакетов и кэш apt, которые не удалили в той же инструкции. Слой WORKDIR в 0 байт — норма: метаданные ничего не весят.
Глубже: свой runtime: jlink вместо полного JREрасширенное
Таблица размеров заканчивается на distroless, а есть шаг дальше, который для Java работает лучше любого выбора базового образа: собрать свой runtime, содержащий только те модули платформы, которые нужны приложению. У Go «свой runtime» уже внутри статического бинарника, а для Node и Python ближайший аналог — distroless-образ с одним интерпретатором.
Работает это так. Утилита jdeps анализирует jar и его зависимости и выводит список нужных модулей платформы. Утилита jlink собирает из JDK минимальный runtime ровно с этими модулями — получается каталог на 50–90 МБ вместо полного JRE на 180–280 МБ. Его копируют в финальный образ, и JDK там больше не нужен вовсе:
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/root/.gradle ./gradlew bootJar --no-daemon -x test -q
FROM eclipse-temurin:21-jdk AS runtime-build
COPY --from=build /app/build/libs/*.jar /app.jar
RUN jlink --add-modules $(jdeps --print-module-deps --ignore-missing-deps /app.jar) \
--strip-debug --no-man-pages --no-header-files --compress=zip-6 \
--output /javaruntime
FROM debian:12-slim
COPY --from=runtime-build /javaruntime /opt/java
COPY --from=build /app/build/libs/*.jar /app.jar
ENTRYPOINT ["/opt/java/bin/java", "-jar", "/app.jar"]
FROM golang:1.23 AS build
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
# у Go «свой runtime» уже внутри бинарника: финальному образу нужны только сертификаты и зоны времени
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
FROM node:22 AS build
WORKDIR /app
COPY . .
RUN npm ci && npm run build && npm prune --omit=dev
# минимальный runtime для Node — distroless с одним только node и libc, без оболочки и npm
FROM gcr.io/distroless/nodejs22-debian12
COPY --from=build /app/dist /app/dist
COPY --from=build /app/node_modules /app/node_modules
CMD ["/app/dist/main.js"]
FROM python:3.12 AS build
WORKDIR /app
COPY . .
RUN python -m venv /venv && /venv/bin/pip install --no-cache-dir -r requirements.txt
# для Python практический минимум — slim: distroless/python3 несёт интерпретатор из Debian (3.11),
# и собранное на 3.12 окружение с ним не совместимо
FROM python:3.12-slim
COPY --from=build /venv /venv
COPY --from=build /app/app /app/app
ENTRYPOINT ["/venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0"]
Итог обычно 120–160 МБ вместе с базовым образом — меньше, чем -jre на distroless, и без потери совместимости, потому что это та же платформа, только урезанная.
Цена у приёма есть, и о ней стоит знать до того, как это уедет в прод. Список модулей определяется статически, а Spring грузит классы отражением: jdeps не увидит модуль, который нужен только в рантайме (типичные пропуски — java.sql, java.naming, jdk.crypto.ec для TLS, java.management для метрик). Проявляется это не при сборке, а при запуске — NoClassDefFoundError на первом обращении к базе или к защищённому соединению. Поэтому модули добавляют явным списком, а не только выводом jdeps, и обязательно прогоняют приложение целиком на собранном runtime до выката. И второй момент: --compress экономит место ценой распаковки при старте, что для короткоживущих задач может оказаться дороже, чем сэкономленные мегабайты.
Когда брать: когда образ едет на тысячи узлов, когда реестр и трафик стоят денег, когда время скачивания образа входит в скорость выката. Для обычного сервиса, который выкатывается несколько раз в день на десяток узлов, разница между 200 и 140 МБ не стоит усложнения сборки.
Глубже: мусор после сборки: кэш и промежуточные этапырасширенное
«Многоэтапная сборка экономит место» — верно про итоговый образ и неверно про машину сборки. Всё, что было в промежуточных этапах, остаётся там в виде кэша сборки: слои с компилятором, скачанные зависимости, результаты каждого RUN. Через месяц ежедневных сборок это десятки гигабайт, и первым это замечает ноутбук, а не сервер.
Смотреть надо на отдельную строку в выводе docker system df — Build Cache. Чистится она своей командой:
docker builder prune # спросит подтверждение, удалит неиспользуемый кэш
docker builder prune --filter until=168h # аккуратнее: только то, чему больше недели
Две оговорки. Полная очистка означает, что следующая сборка пойдёт с нуля — это минуты, поэтому по возрасту чистить приятнее, чем целиком. И docker system prune -a тоже трогает кэш сборки, а с флагом --volumes заодно уносит тома с данными локальных баз — команда, которой не стоит пользоваться по привычке.
Сюда же относится и настройка, которая избавляет от ручной уборки: у сборщика можно задать предел размера кэша, и тогда он вытесняет старое сам. В Docker Desktop это поле в настройках, для отдельного сборщика — конфигурация BuildKit с gc и ограничением объёма.
Коротко
- Multi-stage сборка разделяет образ для сборки (компилятор и сборщик) и образ для запуска (рантайм и артефакт) — финальный образ не содержит инструментов разработки.
FROM ... AS <имя>именует этап;COPY --from=<имя>копирует файлы из него. - Слои кэшируются: инструкция не выполняется повторно, если она и её входные данные не изменились.
- Порядок инструкций определяет качество кэширования: сначала то, что меняется редко (зависимости), потом то, что меняется часто (исходники).
.dockerignoreсокращает контекст сборки и предотвращает лишние инвалидации кэша. - После разделения образ Java-приложения уменьшается с 700–900 МБ до 300–350 МБ, образ Go — до 10–20 МБ.
- Кэш слоёв живёт на машине сборки: в конвейере его нет, пока не подключён
--cache-from/--cache-to(реестр,type=ghaили локальный каталог), причёмmode=maxнужен, чтобы сохранился кэш этапа сборки, а не только финальные слои. RUN --mount=type=cacheдержит каталог зависимостей вне слоёв: добавленная библиотека качается одна, а образ не растёт.- Этапы — не только способ выбросить компилятор:
--targetсобирает до нужного этапа, отдельный этап для тестов не строится при обычной сборке, независимые этапы BuildKit собирает параллельно. - Свой runtime через
jdepsиjlinkдаёт Java-образ на 120–160 МБ, но модули, которые фреймворк грузит отражением, приходится дописывать вручную и проверять запуском; у Go свой runtime уже внутри статического бинарника вdistroless/static. - Промежуточные этапы остаются кэшем сборки на машине сборщика: смотреть строку
Build Cacheвdocker system df, чиститьdocker builder prune --filter until=168h.
Что почитать дальше
- Образы и Dockerfile — как устроены образы, синтаксис основных инструкций.
- Полный путь от кода до работающего контейнера: Spring Boot, Go, Node, Python.
- Лучшие практики работы с образами — дополнительные способы уменьшить размер и повысить безопасность образа.