Docker-слои и кеш: почему образ весит 1.5 ГБ и как ужать до 150 МБ

Инфраструктура4 мин чтения
  • #docker
  • #dockerfile
  • #multi-stage
  • #distroless
  • #caching

Docker-образ размером в гигабайт для микросервиса на 500 строк кода — обычное дело, и почти всегда это следствие непонимания слоёв и кеша. Образ можно сжать в 5–10 раз без потери функциональности, если понять, как Dockerfile превращается в стек слоёв. Разберём механику и главные инструменты сжатия.

Что такое слой

Каждая инструкция в Dockerfile, которая меняет файловую систему (RUN, COPY, ADD), создаёт новый слой — копию файловой системы поверх предыдущего. Образ — это стек слоёв, один поверх другого. Когда контейнер запускается, эти слои объединяются (через union filesystem) и выглядят как одна файловая система.

Свойство слоёв: слой кешируется и переиспользуется, только если не изменились его входы — сама команда и файлы, которые в неё попадают через COPY/ADD. Если вход изменился — слой и все последующие пересоздаются заново. Это ключ ко всей оптимизации.

Главное правило порядка инструкций

Из свойства кеша следует правило: сначала то, что меняется редко, потом то, что меняется часто. Установка зависимостей — редко. Исходный код — на каждой сборке. Значит, зависимости ставим раньше.

Классическая ошибка — скопировать весь код раньше установки зависимостей:

# Плохо: копируем весь код перед установкой зависимостей
FROM node:20
WORKDIR /app
COPY . .                              # любой чих в любом файле
RUN npm ci                            # инвалидирует кеш этой строки
CMD ["node", "server.js"]

Любое изменение любого файла в репозитории инвалидирует слой COPY . ., а за ним — RUN npm ci. Зависимости переустанавливаются заново на каждой сборке. Сборка вместо 10 секунд занимает минуты.

Правильно — двухфазное копирование:

FROM node:20
WORKDIR /app

# Сначала — только манифесты зависимостей
COPY package.json package-lock.json ./
RUN npm ci                            # кешируется, пока не меняются версии

# Потом — остальной код
COPY . .
CMD ["node", "server.js"]

Теперь npm ci выполняется только когда меняются package.json или lock-файл. Изменения в src/ инвалидят только последний COPY, не трогая установки зависимостей.

Тот же паттерн для Python (requirements.txt), Java (pom.xml/build.gradle), Go (go.mod/go.sum), Rust (Cargo.toml/Cargo.lock).

Multi-stage build — главный инструмент сжатия

Часто образ тащит в себя инструменты, которые нужны только для сборки, но не для выполнения. Java-приложение собирается с JDK + Maven (гигабайты), а для выполнения нужна только JRE. Go компилируется в статический бинарник, но если собирать в golang-образе, в финальный образ попадают компилятор и исходники.

Multi-stage build решает это: в одном Dockerfile описывается несколько стадий, и в финальный образ копируется только то, что нужно.

Пример для Java:

# Стадия 1: сборка (тяжёлая, с JDK и Maven)
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml ./
RUN mvn dependency:go-offline                       # кеш зависимостей
COPY src ./src
RUN mvn package -DskipTests                         # собираем jar

# Стадия 2: runtime (лёгкая, только JRE)
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/app.jar app.jar
CMD ["java", "-jar", "app.jar"]

Первая стадия со всеми инструментами весит ~1.2 ГБ и в финальный образ не попадает. Финальный образ содержит только JRE (~200 МБ) и один jar-файл. Сжатие в 5–6 раз без потери функциональности.

Пример для Go — ещё радикальнее:

FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app server.go

FROM scratch                                          # пустой образ, 0 МБ
COPY --from=build /app /app
CMD ["/app"]

scratch — это совсем пустой образ. Итог: статический бинарник + certs, ~20 МБ против гигабайта golang-образа.

Ещё приёмы сжатия

Объединение RUN в один слой. Каждый RUN создаёт слой, и промежуточные файлы в одном слое остаются в образе, даже если следующий слой их удалит:

# Плохо: apt-кеш остаётся в первом слое, хоть и удалён во втором
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Хорошо: одна команда, чистый слой
RUN apt-get update \
    && apt-get install -y curl \
    && rm -rf /var/lib/apt/lists/*

Distroless-образы. Google distroless — образы без shell, без пакетного менеджера, без лишних утилит; только runtime и ваше приложение. Меньше размер, выше безопасность (меньше поверхность атаки). Цена — без shell нельзя зайти и подебажить exec, надо использовать другие инструменты.

Alpine. Linux-дистрибутив на musl libc, базовый образ ~5 МБ. Хорошо ужимает, но musl вместо glibc иногда ломает совместимость (некоторые библиотеки собраны под glibc и требуют пересборки). В Java/Python-мире обычно безопасно; в C/C++/Rust — надо проверять.

Как найти, где жир

docker image history <image> — показывает слои и их размер. Первый инструмент при борьбе с размером:

$ docker image history myapp:latest
IMAGE          CREATED        CREATED BY                                      SIZE
a3b4c5d6...    2 minutes ago  CMD ["node" "server.js"]                         0B
7f8e9a0b...    2 minutes ago  COPY . . # build                                340MB     ← жир
2c3d4e5f...    10 minutes ago   RUN npm ci                                    180MB
...

Видно, какой слой занимает сколько. Большой COPY — значит, копируете лишнее (.git, node_modules, тестовые данные). Добавьте .dockerignore:

.git
node_modules
__tests__
*.md
.env*

.dockerignore — как .gitignore, только для контекста сборки. Исключает файлы из передачи в демон Docker, уменьшает размер COPY . . и ускоряет сборку.

Короткое summary

Размер Docker-образа почти всегда — следствие непонимания слоёв. Каждая инструкция Dockerfile создаёт слой; слой кешируется, пока не изменились входы. Главное правило порядка: сначала редкое (установки зависимостей), потом частое (исходный код) — иначе кеш сбрасывается на каждом коммите. Multi-stage build копирует из тяжёлой сборочной стадии в лёгкую runtime-стадию только нужный артефакт, сжимая образ в разы. Дополнительные приёмы — объединение RUN в один слой с очисткой кеша, distroless/alpine-базы, .dockerignore. Первый инструмент диагностики — docker image history, который показывает размер каждого слоя.

Что почитать