Минимальные образы: alpine, distroless, .dockerignore

Размер образа — это не косметика. От него зависит скорость деплоя, расходы на реестр и количество уязвимостей, которые вы тащите в продакшен вместе с лишними пакетами. В этом уроке разбираемся, как выбор базового образа и пара продвинутых приёмов уменьшают образ с сотен мегабайт до десятков.

Минимальный базовый образ — это образ, в котором есть ровно то, что нужно вашему приложению для работы в рантайме, и ничего сверх того. Принцип простой: всё, чего нет в образе, нельзя сломать, нельзя проэксплуатировать и не нужно скачивать при каждом деплое.

Зачем за это вообще бороться на практике? Во-первых, скорость: образ на 1 ГБ тянется при каждом pull на каждой ноде кластера, при каждом холодном старте раннера CI. Урезали до 80 МБ — деплой и автоскейлинг ускорились в разы. Во-вторых, поверхность атаки: каждый лишний пакет в образе — это потенциальный CVE. Полный debian или ubuntu тащит сотни системных библиотек, утилит и интерпретаторов, которые ваше приложение никогда не вызывает, но которые исправно светятся в отчётах сканеров безопасности. Меньше пакетов — меньше уязвимостей и меньше работы по их латанию.

Выбор базового образа

Главное решение принимается в первой строке Dockerfile — в инструкции FROM. Сравним четыре типовых варианта на примере Python-приложения.

alpine

Дистрибутив, построенный вокруг минимализма: базовый образ весит около 5 МБ. Вместо привычной библиотеки glibc здесь используется musl libc, а вместо apt — пакетный менеджер apk. Это даёт крошечный размер, но именно из-за musl возможны проблемы совместимости с пакетами, которые рассчитывают на glibc (об этом ниже в «частых ошибках»).

FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]

slim (debian-slim)

Это обычный debian, из которого вырезали документацию, локали и редко нужные пакеты, но оставили родную glibc и менеджер apt. Золотая середина: образ заметно меньше полного debian (десятки МБ против сотен), но сохраняет полную бинарную совместимость. Если alpine что-то ломает — почти всегда спасает переход на slim.

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]

distroless

Образы от Google, в которых нет ничего лишнего: ни командной оболочки (shell), ни пакетного менеджера, ни даже ls и cat. Только сам рантайм (например, Python или JVM) и системные библиотеки. Это максимальная безопасность — взломщику, попавшему внутрь, буквально нечем работать, — но и максимальная сложность отладки: внутрь нельзя зайти через docker exec ... sh, потому что оболочки там просто нет. Обычно используется как финальный слой в multi-stage сборке.

FROM gcr.io/distroless/python3-debian12
WORKDIR /app
COPY . .
CMD ["app.py"]
ОбразТипичный размерЕсть shell?Пакетный менеджерКогда брать
debian / ubuntu (полный)120–300+ МБдаaptотладка, инструменты сборки
slim (debian-slim)50–120 МБдаaptвыбор по умолчанию, нужна glibc
alpine10–60 МБда (ash)apkчистые приложения без нативных зависимостей
distroless20–80 МБнетнетпрод-рантайм, максимум безопасности

.dockerignore

Файл .dockerignore вы уже встречали в азах — напомним одной фразой: он исключает файлы из контекста сборки, то есть из того набора файлов, который CLI отправляет демону Docker перед стартом сборки. Здесь это лишь один из приёмов минимизации, но важный.

.git
.gitignore
node_modules
__pycache__
*.pyc
.venv
Dockerfile
.dockerignore
*.md
tests/
.env
.vscode

Эффект двойной. Во-первых, контекст не раздувается: без этого файла команда docker build . упакует и отправит демону весь каталог целиком, включая .git и node_modules на сотни мегабайт — а это лишние секунды (или минуты) на каждую сборку. Во-вторых, ускоряется и удешевляется COPY . .: копируется меньше файлов, а главное — мусорные файлы не попадают внутрь образа. Бонусом вы не утащите в образ .env с секретами.

Объединение RUN и очистка

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

# ПЛОХО: лишние слои и мусор остаётся в образе
FROM debian:12-slim
RUN apt-get update
RUN apt-get install -y curl git
RUN apt-get install -y build-essential

Здесь три проблемы: каждый RUN создаёт отдельный слой; индекс пакетов из apt-get update остаётся внутри образа навсегда; кэш так и не очищен. Правильный вариант — одна инструкция RUN, склеенная через &&, с очисткой кэша в той же команде:

# ХОРОШО: один слой, кэш вычищен тут же
FROM debian:12-slim
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        curl git build-essential \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

Для разных экосистем есть свои флаги, отключающие кэш прямо на лету, чтобы не чистить его вручную:

# Alpine: --no-cache не сохраняет индекс пакетов
apk add --no-cache curl git

# Python: не складывать колёса в кэш pip
pip install --no-cache-dir -r requirements.txt

# Debian/Ubuntu: чистим список пакетов в том же слое
apt-get clean && rm -rf /var/lib/apt/lists/*

Как это работает под капотом

Чтобы понять, почему очистку нужно делать именно «в той же команде», вспомним устройство образа. Образ — это стопка неизменяемых слоёв; каждая инструкция RUN, COPY и ADD добавляет сверху новый слой, а итоговый размер образа равен сумме всех слоёв.

Ключевой момент: слой нельзя «похудеть» задним числом. Если в одном слое вы скачали 200 МБ кэша, а в следующем RUN удалили его командой rm, то верхний слой лишь запишет пометку «этих файлов больше нет». Но сами 200 МБ физически остаются в нижнем слое и продолжают занимать место в образе и в реестре. Файл удалён логически, но не исчез. Именно поэтому установка и очистка должны жить в одном RUN: тогда мусорные файлы вообще не попадают в финальный слой.

Отдельная техническая деталь про alpine — замена glibc на musl libc. Эти библиотеки реализуют стандарт C по-разному, и заранее скомпилированные бинарники, собранные под glibc, в alpine могут просто не запуститься. Поэтому в alpine такие пакеты нередко приходится собирать из исходников прямо при сборке образа — что съедает выигрыш по размеру и времени.

Частые ошибки

  • Брать alpine «по умолчанию». На нативных модулях (node-gyp, бинарные пакеты Python вроде numpy/pandas, драйверы БД) из-за musl сборка падает или тянет тяжёлый toolchain. Часто slim в итоге и меньше, и быстрее.
  • Чистить кэш в новом слое. rm -rf в отдельной инструкции RUN не уменьшает образ — удалённые файлы остаются в нижнем слое. Очистка обязана быть в том же RUN, что и установка.
  • Брать distroless без отладочного образа. Когда в проде что-то падает, без оболочки внутрь не зайти. У distroless есть теги :debug с busybox — держите их для диагностики, а в основном образе оставляйте «чистый» вариант.
  • Игнорировать .dockerignore. Раздутый контекст сборки замедляет каждый build и тащит в образ .git, node_modules и секреты из .env.

Вывод: разница между подходами хорошо видна в выводе docker images для одного и того же приложения.

REPOSITORY   TAG               SIZE
myapp        debian-full       412MB
myapp        slim               95MB
myapp        alpine             58MB
myapp        distroless         52MB

Итоги

  • Выбор образа начинается с FROM: slim — разумный дефолт, alpine — для чистых приложений, distroless — для безопасного прод-рантайма.
  • alpine крошечный за счёт musl libc, но musl вместо glibc ломает часть нативных пакетов.
  • distroless безопаснее всех, потому что в нём нет ни shell, ни пакетного менеджера — но и отлаживать его труднее.
  • .dockerignore уменьшает контекст сборки, ускоряет COPY и не пускает мусор и секреты в образ.
  • Объединяйте установку и очистку в один RUN через &&: apt-get clean && rm -rf /var/lib/apt/lists/*, apk add --no-cache, pip install --no-cache-dir.
  • Очистка работает, только если она в том же слое, что и установка: удаление в новом слое размер образа не уменьшает.
Проверьте себя
1. Почему команда rm -rf /var/lib/apt/lists/*, вынесенная в отдельную инструкцию RUN (после установки пакетов), не уменьшает итоговый размер образа?
AПотому что apt-get автоматически восстанавливает удалённые списки при следующей сборке
BПотому что удалённые файлы остаются в нижнем слое, а верхний слой лишь помечает их как удалённые
CПотому что rm не работает с правами root внутри контейнера
DПотому что /var/lib/apt/lists/ — это символическая ссылка, и rm её не затрагивает
2. Какое утверждение о выборе базового образа корректно?
Aalpine всегда лучший выбор, потому что он самый маленький по размеру
Bdistroless содержит shell и пакетный менеджер, поэтому его удобно отлаживать
Calpine использует musl libc вместо glibc, из-за чего возможны проблемы совместимости с нативными пакетами
Dslim — это образ на базе alpine с добавленным менеджером apt