MAATRIX / Блог / Docker-образ: как уменьшить размер

Docker-образ: как уменьшить размер

Docker-образ: как уменьшить размер

MAATRIX

Если образ приложения весит гигабайт с лишним, а на сервере с диском 20-40 ГБ каждый пуш в реестр и каждый деплой ощущаются как испытание — дело почти всегда не в самом коде, а в том, как собран Dockerfile. Лишний базовый образ, случайно затащенный контекст сборки, неправильно расположенная чистка кэша — и вес растёт незаметно, слой за слоем. Ниже — конкретные приёмы, которые реально уменьшают образ, и почему часть «очевидных» способов на деле не работает.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Проверьте текущий размер и слои, прежде чем оптимизировать

Оптимизация вслепую — плохая идея: сначала нужно понять, откуда вес берётся. Базовые команды:

docker images myapp
docker history myapp:latest --no-trunc

docker images покажет итоговый размер образа, docker history — размер каждого слоя по отдельности. Часто уже здесь видно проблему: один RUN apt-get install весит в разы больше, чем сам код приложения, или в истории виден слой с COPY . ., который тащит что-то явно лишнее.

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

Прежде чем что-то менять, сохраните текущий размер как точку отсчёта — иначе трудно будет оценить, дал ли эффект конкретный приём.

Базовый образ: alpine или slim вместо full

Самый быстрый выигрыш обычно даёт замена базового образа. У большинства официальных образов на Docker Hub есть несколько вариантов:

ТегОсноваОсобенности
python:3.12 / node:20полный Debianвсе системные утилиты, компиляторы, документация — удобно для разработки, избыточно для продакшена
python:3.12-slim / node:20-slimурезанный Debianglibc, минимум утилит, совместимость почти как у full-образа
python:3.12-alpine / node:20-alpineAlpine Linuxmusl вместо glibc, минимальный набор пакетов, заметно легче обоих вариантов выше

alpine даёт наибольший выигрыш в весе, но у него есть нюанс: библиотека musl — не то же самое, что glibc, и часть скомпилированных бинарных зависимостей (особенно нативные модули Node.js или Python-пакеты с C-расширениями, собранные под glibc) может не заработать без пересборки под musl или вовсе не собраться из-за отсутствующих в Alpine dev-пакетов. Иногда экономия в весе образа компенсируется временем, потраченным на отладку рантайм-ошибок вида Error loading shared library — и это стоит учитывать до, а не после перехода на alpine.

slim-варианты — как правило, безопасный средний вариант: заметно легче полного образа, но остаются на glibc, поэтому совместимость почти не отличается от full. Если алpine ломает сборку, а тратить время на разбор нет возможности, slim часто разумный компромисс.

Точные цифры веса зависят от версии образа и того, что вы в него доставите поверх — не буду называть конкретные мегабайты, они устаревают быстрее, чем статья. Проверяйте актуальный размер тегов на Docker Hub или в реестре перед выбором, и сравнивайте docker images до/после на своём проекте.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

.dockerignore: не тащите лишнее в контекст сборки

Перед тем как Docker вообще начнёт собирать образ, он упаковывает *весь контекст сборки* (обычно — содержимое директории рядом с Dockerfile) и отправляет демону. Без .dockerignore в этот контекст попадает всё: .git с полной историей репозитория, node_modules с хост-машины, локальные .env с секретами, логи, кэши IDE. Часть этого может случайно оказаться и в самом образе, если Dockerfile делает широкий COPY . ..

Минимальный .dockerignore для типичного проекта:

.git
.gitignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
.env
.env.*
*.md
.vscode
.idea
dist
coverage
__pycache__
*.pyc
.pytest_cache

Список стоит подогнать под конкретный стек: для Python добавить venv/ и *.egg-info, для Go — vendor/, если зависимости не кэшируются намеренно.

Эффект здесь двойной. Во-первых, быстрее и легче сама сборка — меньше данных гонять демону, особенно заметно при сборке на удалённом Docker-хосте или в CI. Во-вторых, снижается риск случайно замуровать в образ секреты или мусор, если Dockerfile где-то использует широкий COPY. Это не главный рычаг уменьшения итогового размера образа (тот зависит в первую очередь от того, что реально попадает в COPY/RUN), но это первое, что стоит проверить — и это дёшево.

Объединяйте RUN-команды в один слой

Каждая инструкция RUN, COPY, ADD в Dockerfile создаёт новый слой. Слои иммутабельны: то, что записано в одном слое, физически остаётся в образе навсегда, даже если следующий слой этот файл «удаляет» — с точки зрения файловой системы образа он просто помечается удалённым, но занятое место в предыдущем слое не освобождается.

Отсюда вытекает конкретное правило: связанные по смыслу команды — установка пакетов и чистка за ними — должны быть одной инструкцией RUN, а не несколькими подряд.

Неправильно (часто встречается в старых Dockerfile или скопированных примерах):

RUN apt-get update
RUN apt-get install -y curl git build-essential
RUN rm -rf /var/lib/apt/lists/*

Здесь три слоя. Первые два создают слой с полным списком пакетов apt и установленными файлами, третий слой удаляет список пакетов — но в образе всё равно физически лежат оба предыдущих слоя целиком, включая уже удалённые из финального состояния файлы. Итоговый видимый размер файловой системы при docker run действительно не покажет эти файлы, но вес самого образа (то, что качается при docker pull, хранится в реестре и занимает место на диске) не уменьшится ни на байт.

Правильно — одна команда через && с переносами \:

RUN apt-get update && \
    apt-get install -y --no-install-recommends curl git build-essential && \
    rm -rf /var/lib/apt/lists/*

Теперь установка и чистка происходят в рамках одного слоя, и итоговый слой содержит только то, что осталось после rm -rf — файлы, удалённые внутри той же команды RUN, никогда не попадают в готовый слой вообще. Заодно --no-install-recommends не даёт apt тащить рекомендованные, но не обязательные пакеты — часто это ощутимая доля лишнего веса.

Это же правило работает не только для apt: для любой связки «поставили — которую собираемся тут же почистить» нужно держать обе операции в одном RUN.

Кэш пакетного менеджера: чистите в том же слое, где он создан

Это частный случай предыдущего правила, но настолько частая ошибка, что стоит разобрать её отдельно — потому что интуитивно кажется логичным вынести чистку кэша в отдельный шаг для читаемости Dockerfile, а по факту это сводит эффект чистки к нулю.

apt. Самый надёжный вариант — вообще не создавать индекс пакетов отдельным слоем и удалить его в той же команде:

RUN apt-get update && \
    apt-get install -y --no-install-recommends <пакеты> && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

apk (Alpine). У apk есть встроенный флаг, который вообще не сохраняет локальный индекс пакетов на диске — тогда чистить нечего:

RUN apk add --no-cache curl git

pip. Аналогично, флаг отключает кэширование скачанных wheel-файлов сразу, без отдельного шага чистки:

RUN pip install --no-cache-dir -r requirements.txt

npm. Здесь чуть тоньше: npm cache clean --force в отдельном RUN после npm install — классический пример бесполезной команды по тем же причинам, что и с apt выше. Если чистка кэша нужна, делайте её в одном RUN с установкой:

RUN npm ci --omit=dev && npm cache clean --force

Но чаще для продакшн-образа лучше вообще не тащить кэш npm в промежуточный слой — эта проблема решается multi-stage build (ниже), где кэш и dev-зависимости остаются в стадии сборки и физически не попадают в финальный образ вообще, а не удаляются постфактум.

Общий принцип один: если что-то создано и удалено в *разных* инструкциях RUN, оно всё равно занимает место в образе. Если создано и удалено в *одной* — не занимает.

Multi-stage build закрывает то, что не решают слои

Все приёмы выше уменьшают вес одной стадии сборки, но не решают более фундаментальную проблему: инструментам, которые нужны только для *сборки* приложения (компиляторы, dev-зависимости, полный тулчейн языка), вообще не место в образе, который потом *запускает* готовое приложение. Даже идеально почищенный однослойный Dockerfile всё равно тащит в продакшн весь node:20 с npm и инструментами сборки нативных модулей, если сборка и запуск идут в одном образе.

Решается это через multi-stage build — несколько инструкций FROM в одном Dockerfile, где промежуточные стадии (с компилятором, dev-зависимостями, исходниками) вообще не входят в финальный образ, а в него копируются COPY --from=<стадия> только готовые артефакты. Подробный разбор с примерами Dockerfile для Node.js и Go до/после — в отдельной статье Docker multi-stage build: оптимизация образа, здесь не повторяю.

Если совмещать multi-stage build с alpine-базой для финальной стадии, .dockerignore и правильно сгруппированными RUN, эффект накопительный: каждый приём режет вес по своей причине, и вместе они дают заметно более компактный образ, чем любой из них по отдельности.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

С чего начать, если образ уже большой и непонятно почему?

С docker history myapp:latest --no-trunc — команда покажет размер каждого слоя, и обычно сразу видно, какая инструкция даёт основной вес.

Alpine всегда лучше slim?

Не всегда. Alpine даёт больший выигрыш в размере, но на musl вместо glibc, и часть нативных зависимостей может потребовать пересборки или не заработать без дополнительных dev-пакетов. Если нужна гарантированная совместимость без экспериментов — slim безопаснее.

Достаточно ли одного .dockerignore, чтобы образ стал заметно легче?

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

Почему rm -rf в отдельном RUN после установки пакетов не уменьшает размер образа?

Потому что слои Docker иммутабельны: файл, добавленный на одном слое, физически остаётся в образе, даже если следующий слой помечает его удалённым. Экономия появляется только если установка и удаление происходят в одной инструкции RUN.

Нужен ли multi-stage build, если приложение и так на интерпретируемом языке без шага компиляции?

Эффект меньше, чем для компилируемых языков, но не нулевой — можно вынести установку пакетов с C-расширениями (которая тянет компилятор) в отдельную стадию и скопировать в финальную только готовые файлы, без тулчейна сборки.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →