Глубокая оптимизация образов: distroless, scratch, кэш

Рейтинг: 64.6% · 12 голосов
Практический курс по Docker: образы, контейнеры, тома, сети, Compose и продакшен. Уроки по главам с обсуждением.
Ответить
Аватара пользователя
Marina_DevOps
Сообщения: 54
Зарегистрирован: 11 май 2026, 05:31

Глубокая оптимизация образов: distroless, scratch, кэш

Сообщение Marina_DevOps »

Оглавление курса (46)
  1. Что такое Docker и какие задачи он решает
  2. Установка Docker и запуск первого контейнера
  3. Образы: слои, теги и реестр Docker Hub
  4. Пишем свой Dockerfile
  5. Тома и хранение данных: volumes и bind mounts
  6. Сети в Docker: связываем контейнеры между собой
  7. Переменные окружения и конфигурация контейнеров
  8. Docker Compose: поднимаем многоконтейнерное приложение
  9. Оптимизация образов: multi-stage сборка, размер и кэш слоёв
  10. Логи, отладка и мониторинг контейнеров
  11. Базовая безопасность контейнеров
  12. Подготовка к продакшену: что важно учесть
  13. Dockerfile глубже: ENTRYPOINT и CMD, HEALTHCHECK, .dockerignore, запуск не от root
  14. Реестры образов: приватные registry, push и pull, теги и digest, imagePullSecrets
  15. BuildKit и buildx: multi-arch сборки, секреты сборки, экспорт кэша
  16. Docker в CI/CD: автосборка, сканирование образов (Trivy, Docker Scout), публикация
  17. Итоговый проект и куда расти: от Dockerfile до прода, обзор оркестрации (Kubernetes, Podman, OCI)
  18. Архитектура Docker: dockerd, containerd, runc и OCI
  19. Как работает изоляция: namespaces и cgroups
  20. Образы изнутри: слои, OverlayFS и storage driver
  21. Жизненный цикл контейнера и политики перезапуска
  22. Отладка контейнеров: exec, attach, nsenter, debug
  23. Сети Docker глубже: bridge, overlay, macvlan и iptables
  24. Docker Compose глубже: profiles, healthcheck-зависимости, override
  25. Данные и тома глубже: драйверы, бэкап, права
  26. Секреты и конфигурация: как не светить пароли
  27. Ограничение ресурсов: CPU, память, OOM и cgroups
  28. Здоровье и автозапуск: HEALTHCHECK, init и сигналы
  29. Логирование контейнеров: драйверы, ротация, сбор
  30. Мониторинг контейнеров: stats, cAdvisor, Prometheus
  31. Безопасность глубже: rootless, user namespaces, seccomp, capabilities
  32. Supply chain: сканирование, SBOM и подпись образов
  33. Глубокая оптимизация образов: distroless, scratch, кэш (вы здесь)
  34. Multi-arch и сборка под ARM: buildx и эмуляция
  35. Реестры в продакшене: Harbor и облачные registry
  36. Docker без Docker: Podman, containerd, nerdctl, Buildah
  37. От Compose к оркестрации: когда контейнеров становится много
  38. Docker Swarm: встроенная оркестрация
  39. Сборка образов в CI без демона: kaniko, BuildKit, Buildah
  40. Траблшутинг Docker: типичные ошибки и как чинить
  41. Производительность и эксплуатация Docker-хоста
  42. Docker Desktop, WSL2 и альтернативы на Windows и Mac
  43. Контейнеризация приложений: бэкенд, фронтенд, БД
  44. Docker и Kubernetes: containerd, миграция, kompose
  45. Лучшие практики и антипаттерны Docker
  46. Сквозной проект: production-ready стек и путь дальше
Ты собрал рабочий образ, запушил, а коллега в ревью пишет: "1.2 ГБ на хелловорлд, серьезно?". Знакомо. Раздутый образ - это не только обида за диск. Это медленный pull на каждом деплое, лишние секунды на старте подов, и главное - гигантская поверхность атаки. Внутри типового образа на базе ubuntu лежат bash, coreutils, apt, perl, питон по случайности - сотни пакетов, в каждом потенциально своя CVE. А твоему скомпилированному бинарю из всего этого не нужно вообще ничего.

Этот урок про то, как из жирного образа сделать минимальный и быстрый: какие базы бывают (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.
Правило выбора: статический бинарь -> scratch или distroless/static. Нужен рантайм языка, но не нужен shell -> distroless. Нужен apk/shell и язык дружит с musl -> alpine. Нужен glibc и пакетный менеджер -> slim.
Multi-stage: оставляем в финале только артефакт

Главный инструмент похудения - 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"]
Разбор. Флаг CGO_ENABLED=0 убирает динамическую линковку с C-библиотеками - бинарь становится полностью статическим, ему не нужен libc в финале, поэтому distroless/static (или scratch) подходит. Линкер-флаги -s -w выкидывают таблицу символов и отладочную информацию - минус несколько МБ. COPY --from=build берет только /app. Стадия golang:1.23 весит под 800 МБ, но в финал не попадает ни байта из нее. Итог - образ в районе 10-20 МБ, где живой код - твой бинарь.

Дополнительный приемчик: можно копировать --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 ...
Сразу видно виновника: слой с build-essential на 245 МБ, который в рантайме не нужен - сигнал, что пора в multi-stage.

dive - интерактивный анализатор, который показывает не только размеры слоев, но и что именно изменилось в каждом, плюс "wasted space" - файлы, которые ты добавил в одном слое и перезаписал/удалил в другом. dive docker - первый инструмент, который стоит запускать, когда образ необъяснимо толстый.

Код: Выделить всё

dive myapp:latest
Сверху - список слоев с размерами, снизу - дерево файлов выбранного слоя (добавленное подсвечено). Внизу метрика Image efficiency score и Potential wasted space. Если efficiency 78% и wasted space 60 МБ - значит где-то ты добавил данные и затер их следующим слоем. В CI dive умеет работать в режиме проверки через переменную CI=true с порогом в .dive-ci - билд падает, если эффективность ниже заданной. Это защищает от деградации размера со временем.

Чистка мусора, объединение слоев, кэш пакетов

Классический антипаттерн и его лечение:

Код: Выделить всё

# Плохо: три слоя, кэш 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/*
Почему так. Объединение в один RUN означает один слой - и rm удаляет списки apt до того, как слой "запечатается", поэтому они не попадут в образ. Флаг --no-install-recommends отрубает необязательные зависимости (часто десятки лишних МБ). rm -rf /var/lib/apt/lists/ выкидывает скачанные индексы пакетов. Для alpine аналог - apk add --no-cache, который вообще не сохраняет индекс apk на диск.

Кэш-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
Параметр target - куда монтировать кэш. sharing=locked важен для apt: при параллельных сборках одного Dockerfile берется блокировка, чтобы два билда не портили кэш одновременно (альтернативы - shared, дефолт, и private). Чтобы apt не удалял пакеты после установки, в debslim надо снять штатную очистку - убрать /etc/apt/apt.conf.d/docker-clean.

То же для языков. Go:

Код: Выделить всё

RUN --mount=type=cache,target=/root/.cache/go-build \
    --mount=type=cache,target=/go/pkg/mod \
    go build -o /app ./...
Для Node - target=/root/.npm, для Python pip - target=/root/.cache/pip, для Rust cargo - target=/usr/local/cargo/registry. Эффект: первая сборка качает зависимости, последующие при изменении только кода берут их из кэша мгновенно. Уменьшается и время сборки, и нагрузка на сеть (актуально с RU-реалиями, где Docker Hub и зеркала бывают капризны - меньше дергаешь registry, реже ловишь rate limit; разумно держать pull-зеркало в Yandex или VK Container Registry).

Важно не путать два кэша. Слойный кэш 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 - копировать и устанавливать строго по ним.
Для полной воспроизводимости BuildKit умеет SOURCE_DATE_EPOCH - фиксирует timestamps файлов в слоях, чтобы одинаковый вход давал бит-в-бит одинаковый образ. Воспроизводимость - фундамент supply chain security: если сборка детерминирована, ты можешь проверить, что образ в реестре собран именно из этого кода. Сверху уже навешивают SBOM (buildx умеет --sbom=true), сканеры Trivy/Scout и подпись cosign - но без pin все это стоит на песке.

Типичные грабли и антипаттерны
  • 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 оптимизация на уровне прода.
👍3 ❤️2 🔥 😄 🤔1
Аватара пользователя
nika123
Сообщения: 1
Зарегистрирован: 26 май 2026, 07:05

Re: Глубокая оптимизация образов: distroless, scratch, кэш

Сообщение nika123 »

Собрал свой Go-сервис, было 940 МБ от golang, стало 14 МБ на distroless/static. dive показал что 90% веса был тулчейн. До сих пор не верю что так просто.
👍 ❤️1 🔥 😄 🤔
Аватара пользователя
tcpfan
Сообщения: 1
Зарегистрирован: 18 май 2026, 01:53

Re: Глубокая оптимизация образов: distroless, scratch, кэш

Сообщение tcpfan »

А как тогда отлаживать distroless в проде если shell нет? Нашел тег debug с busybox, но он же только для разладки. Или kubectl debug с ephemeral-контейнером цеплять?
👍 ❤️ 🔥 😄 🤔
Ответить
← Предыдущая глава
Supply chain: сканирование, SBOM и подпись образов
Следующая глава →
Multi-arch и сборка под ARM: buildx и эмуляция

Все главы курса «Docker: контейнеризация от основ до продакшена»

Поделиться темой: ✈ Telegram VK
Похожие запросы: docker no space left on device как очиститьmulti-stage сборка docker как уменьшить образ и почистить кэшкак поднять приватный docker registry push pull и digestharbor реестр образов что это и чем отличается от registry

Вернуться в «Docker с нуля»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 1 гость