Этот урок про то, как из жирного образа сделать минимальный и быстрый: какие базы бывают (scratch, distroless, alpine, slim), как multi-stage оставляет в финале только артефакт, как читать слои через dive и docker history, и как кэш-mount BuildKit режет время сборки в разы. docker оптимизация - это не магия, а понимание того, из чего реально состоит образ.
Из чего состоит образ и почему он толстый
Образ - это не один файл, а стопка слоев (layers), каждый из которых - tar-архив с разницей файловой системы относительно предыдущего. Каждая инструкция Dockerfile, меняющая файлы (RUN, COPY, ADD), порождает новый слой. Слои иммутабельны и кэшируются по хэшу. Когда ты делаешь pull, Docker тянет только те слои, которых нет локально - вот почему общий базовый слой между образами скачивается один раз.
Ключевой момент, на котором горят почти все: слои аддитивны, удаление в следующем слое не уменьшает размер. Если в одном RUN ты скачал 300 МБ исходников, а в следующем RUN их удалил - оба слоя останутся в образе. Первый слой все еще содержит эти 300 МБ, удаление просто добавляет поверх "белый маркер" (whiteout-файл). Итоговый размer docker образа = сумма всех слоев, а не состояние финальной файловой системы. Отсюда два правила: чистить мусор в том же RUN, где его создал, и выбирать базу поменьше.

Базовые образы: scratch, distroless, alpine, slim
scratch - это абсолютный ноль. Псевдо-образ, в котором нет вообще ничего: ни shell, ни libc, ни /etc, ни /tmp. Размер базы - 0 байт. Подходит для полностью статических бинарей: Go с CGO_ENABLED=0, Rust с musl-таргетом, C со статической линковкой. Если бинарь самодостаточен - docker scratch даст образ, где лежит ровно твой файл и больше ничего. Грабли: нет CA-сертификатов (HTTPS к внешним API сломается), нет /etc/passwd, нет временной зоны, нет shell - значит docker exec внутрь не зайдешь, отлаживать нечем. Сертификаты и пользователя приходится копировать руками.
distroless от Google - золотая середина для тех, кому scratch жесток. Это "язык-ориентированные образы минус операционная система": внутри есть рантайм (libc, для java - JRE, для python - интерпретатор), CA-сертификаты, /etc/passwd, таймзоны - но нет пакетного менеджера и нет shell. distroless существует в нескольких типах: static (для статики, аналог scratch но с сертификатами), base (есть glibc и openssl), cc (есть libgcc для C++), плюс языковые java, python3, nodejs. Образы базируются на Debian 12 (bookworm), есть тег static-debian12. Важные варианты тегов: nonroot (запуск не от root, UID 65532) и debug (добавлен busybox-shell - только для разладки, не для прода). Например gcr.io/distroless/static-debian12:nonroot. Отсутствие shell - это не неудобство, а фича безопасности: даже если атакующий получит RCE, у него нет /bin/sh, чтобы развернуть атаку.
alpine - крошечный (~5-8 МБ) дистрибутив на musl libc и busybox. Есть пакетный менеджер apk, есть shell - удобно. Но musl вместо glibc - это источник коварных багов. Классика: DNS-резолвинг в musl исторически вел себя иначе (не читал все записи, проблемы с параллельными A/AAAA-запросами, игнор /etc/resolv.conf опций), из-за чего в кластере периодически "теряются" сервисы. Питоновские wheel-пакеты под glibc (manylinux) на alpine не ставятся - musl несовместим, приходится компилировать из исходников, и сборка раздувается компиляторами. Для Go/Rust alpine отличен, для Python/Node с нативными зависимостями - часто боль.
slim (например python:3.12-slim, debian:12-slim) - это полноценный glibc-дистрибутив, но вычищенный: убраны doc, man, лишние пакеты, apt-кэш. Совместимость полная (glibc, manylinux работают), размер заметно меньше обычного. Прагматичный дефолт, когда нужен apt и нет времени воевать с musl.
Multi-stage: оставляем в финале только артефактПравило выбора: статический бинарь -> scratch или distroless/static. Нужен рантайм языка, но не нужен shell -> distroless. Нужен apk/shell и язык дружит с musl -> alpine. Нужен glibc и пакетный менеджер -> slim.
Главный инструмент похудения - multi-stage build. Идея: в одном Dockerfile несколько стадий FROM. В первой (build) ты тащишь весь тяжелый инструментарий - компилятор, dev-заголовки, кэши пакетов. В финальной стадии берешь чистую минимальную базу и копируешь из build только готовый артефакт через COPY --from. Все промежуточное в финал не попадает.
Код: Выделить всё
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
USER nonroot
ENTRYPOINT ["/app"]
Дополнительный приемчик: можно копировать --from по имени внешнего образа, не только из своей стадии. Например COPY --from=gcr.io/distroless/static-debian12 /etc/ssl/certs /etc/ssl/certs - так в scratch-образ подкладывают CA-сертификаты, не разворачивая весь дистрибутив.
Анализ слоев: dive и docker history
Прежде чем оптимизировать, измерь. Два инструмента.
docker history показывает слои образа и их размер - встроен, ничего ставить не надо.
Код: Выделить всё
docker history --no-trunc --format "{{.Size}}\t{{.CreatedBy}}" myapp:latest
Код: Выделить всё
245MB RUN /bin/sh -c apt-get update && apt-get install -y build-essential ...
0B COPY . . # buildkit
12.8MB RUN /bin/sh -c go build ...
dive - интерактивный анализатор, который показывает не только размеры слоев, но и что именно изменилось в каждом, плюс "wasted space" - файлы, которые ты добавил в одном слое и перезаписал/удалил в другом. dive docker - первый инструмент, который стоит запускать, когда образ необъяснимо толстый.
Код: Выделить всё
dive myapp:latest
Чистка мусора, объединение слоев, кэш пакетов
Классический антипаттерн и его лечение:
Код: Выделить всё
# Плохо: три слоя, кэш apt остался в первом слое навсегда
RUN apt-get update
RUN apt-get install -y curl nginx
RUN apt-get clean
# Хорошо: один слой, чистка в том же RUN
RUN apt-get update && \
apt-get install -y --no-install-recommends curl nginx && \
rm -rf /var/lib/apt/lists/*
Кэш-mount: ускоряем сборку, не раздувая образ
Раньше выбор был жестоким: либо кэшировать скачанные зависимости в слое (и тащить их в финальный образ), либо чистить (и качать заново каждую сборку). BuildKit решил это через RUN --mount=type=cache. Это монтирование, которое живет только во время сборки, переживает между билдами, но в слой образа не попадает.
Код: Выделить всё
# syntax=docker/dockerfile:1
FROM debian:12-slim
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends curl
То же для языков. Go:
Код: Выделить всё
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go build -o /app ./...
Важно не путать два кэша. Слойный кэш Docker - переиспользование готовых слоев, ломается при изменении инструкции выше. Кэш-mount - постоянное хранилище для данных внутри RUN, переживает инвалидацию слоя. Они работают вместе.
Reproducible builds и pin версий
Минимальный образ должен быть еще и воспроизводимым - чтобы сборка месяц спустя дала то же самое. Что пинить:
- Базовый образ по дайджесту, а не по тегу: FROM debian:12-slim@sha256:... Тег bookworm-slim перезаписывается, дайджест - нет.
- Версии пакетов: apt-get install -y curl=7.88.1-10+deb12u5. Без пина apt поставит "последнюю", и сборка вчера и сегодня разойдутся.
- Лок-файлы зависимостей: go.sum, package-lock.json, poetry.lock, Cargo.lock - копировать и устанавливать строго по ним.
Типичные грабли и антипаттерны
- apt-get clean в отдельном RUN - бесполезен, кэш уже в предыдущем слое. Чистить только в том же RUN.
- COPY . . без .dockerignore - утащишь .git, node_modules, локальные .env в build-контекст и в слой. Заведи .dockerignore.
- scratch без CA-сертификатов - HTTPS-запросы падают с x509: certificate signed by unknown authority. Копируй /etc/ssl/certs.
- latest вместо дайджеста - невоспроизводимо, в один день получишь другую базу.
- alpine под Python/Node с нативными модулями - musl ломает manylinux-wheels, ловишь часовую компиляцию вместо готового бинаря. Бери slim.
- Запуск от root в финале - даже минимальный образ должен иметь USER nonroot. distroless дает готовый nonroot-вариант.
- Кэш зависимостей в слое вместо --mount=type=cache - либо раздуваешь образ, либо качаешь каждый билд.
- Возьми простой Go-сервис (или hello-world на Go). Собери "наивно" от golang:1.23 без multi-stage, посмотри размер: docker images.
- Запусти dive на этом образе, найди слой с тулчейном и оцени wasted space.
- Перепиши в multi-stage с финалом gcr.io/distroless/static-debian12:nonroot, добавь CGO_ENABLED=0 и -ldflags="-s -w". Сравни размер - должно упасть с сотен МБ до десятков.
- Добавь RUN --mount=type=cache на /go/pkg/mod и /root/.cache/go-build. Собери дважды, меняя одну строку кода, замерь время второй сборки.
- Проверь docker history --no-trunc - убедись, что в финале только слой с бинарем, без компилятора.
- Почему удаление файлов в отдельном RUN не уменьшает размер образа, и как это лечится?
- Чем distroless безопаснее alpine при том же сценарии RCE, и за счет чего?
- В чем разница между слойным кэшем Docker и RUN --mount=type=cache, и почему второй не раздувает образ?
- Какие три вещи нужно запинить ради воспроизводимой сборки и при чем тут supply chain?
Минимальный образ - это дисциплина, а не один трюк. Выбираешь правильную базу под тип бинаря (scratch/distroless/slim, осторожно с alpine и musl), через multi-stage оставляешь в финале только артефакт, чистишь мусор в том же RUN, читаешь слои через dive и docker history, ускоряешь сборку кэш-mount без раздувания образа и пинишь версии ради воспроизводимости. На выходе - образ в десятки МБ, который быстро пуллится, быстро стартует и почти нечего ломать. Это и есть docker оптимизация на уровне прода.