Когда Docker-образ backend-приложения начинает весить 1,5 GB, это уже хороший повод хотя бы посмотреть, что вообще лежит внутри. Пока все работает, мало кто задумывается, сколько мусора, dev-зависимостей и ненужных файлов уезжает в production вместе с приложением. Но на самом деле от «лишнего веса» нужно избавляться, потому что каждый лишний мегабайт — это более долгие сборки и дополнительные сложности.  

Привет! Меня зовут Дмитрий, я руководитель группы разработки в YADRO. В этой статье поделюсь своим опытом оптимизации и покажу на примере, как уменьшить размер production-образа Django-приложения почти на треть.

Основные принципы оптимизации Docker-образов

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

  • Проверить .dockerignore и убедиться, что в образ не попадает лишнее.

  • Использовать Multi-stage build в Dockerfile.

  • Разделить зависимости на группы: основные зависимости и dev-зависимости, используемые только во время разработки.

  • Оставить в конечном образе только минимально необходимый набор пакетов.

  • Убедиться, что все указанные зависимости действительно используются приложением.

  • Oчистить кеши после установки зависимостей.

  • Минимизировать количество слоев при сборке образа.

  • Использовать более компактный базовый образ.

  • Исключить тестовые файлы из итогового образа — даже не ради экономии места, а как часть good practices.

В качестве «подопытного» буду рассматривать один legacy-проект, с которым мне довелось работать. В примере будет не конкретный код проекта, а данные о размере Docker-образа и проделанных шагах по оптимизации.

План оптимизации Docker-образов по шагам

Для начала я запустил приложение в контейнере как есть, чтобы получить точку отсчета. Итоговый Total Image Size составил 1.5 GB.

Для анализа содержимого образа я использовал утилиту dive:

dive {image}

Пойдем по пунктам из чек-листа.

Проверка .dockerignore

В моем случае файл .dockerignore выглядел примерно так:

.idea/
 .coverage
 .git/
 .gitlab/
 .gitlab-ci.yml
 .gitignore
 .dockerignore
 .DS_Store
 *.md
 Dockerfile*
 docker-compose*
 .editorconfig
 .python-version
 docs/
 data/
 media/
 conf/
 static/
 pyrightconfig.json
 celerybeat-schedule*
 env/
 venv/
 .venv/
 __pycache__/

В целом все выглядело вполне адекватно: из контекста сборки уже было исключено практически все лишнее. Каких-то дополнительных оптимизаций на этом этапе придумать не удалось.

Переход на Multi-stage build

Следующим кандидатом на оптимизацию стал Dockerfile. Изначально он был одностадийным, поэтому первым делом захотелось разбить сборку на несколько стейджей. Забегая вперед, скажу, что это действительно дало заметный эффект.

После перехода на multi-stage build размер образа удалось сократить с 1.5 GB до 1.2 GB.

Финальную версию Dockerfile я приведу в конце статьи, ее вполне можно использовать как основу для подобных кейсов.

Оптимизация зависимостей

Дальше я посмотрел на зависимости проекта. Они хранились в обычном requirements.txt и устанавливались простым способом: pip install -r requirements.txt.

На этом этапе я решил перейти на более современный пакетный менеджер. Лично мне нравится poetry, хотя против uv я тоже ничего не имею. Оба инструмента поддерживают pyproject.tomlи умеют работать с группами зависимостей, а это как раз то, что нужно для разделения production- и dev-пакетов.

Пример конфигурации:

dependencies = [
 	"celery (>=5.6.2,<6.0.0)",
 	"django-celery-results (>=2.6.0,<3.0.0)",
 	"django-cleanup (>=9.0.0,<10.0.0)",
 	"dotenv (>=0.9.9,<0.10.0)",
 	"envparse (>=0.2.0,<0.3.0)",
 	"gunicorn (>=25.0.1,<26.0.0)",
 	"psycopg2-binary (>=2.9.11,<3.0.0)",
 	"redis (>=7.1.0,<8.0.0)",
 	"whitenoise (>=6.11.0,<7.0.0)",
 	"django (>=6.0.2,<7.0.0)",
 	"django-stubs (>=5.2.9,<6.0.0)",
 	"django-simple-captcha (>=0.6.3,<0.7.0)",
 	"requests (>=2.33.1,<3.0.0)",
 	"django-tinymce (>=5.0.0,<6.0.0)",
 	"django-admin-sortable2 (>=2.3.1,<3.0.0)",
 ]

 [dependency-groups]
 dev = [
 	"black (>=26.1.0,<27.0.0)",
 	"flake8 (>=7.3.0,<8.0.0)",
 	"isort (>=7.0.0,<8.0.0)",
 	"ruff (>=0.15.0,<0.16.0)",
 	"pytest (>=9.0.3,<10.0.0)"
 ]

После разделения на группы я пересобрал образ без dev-зависимостей.

Для poetry команда выглядит так: poetry install --without=dev --no-root --no-ansi --no-interaction.

Главный параметр здесь — --without=dev.

Для uv аналогичная команда: uv sync --no-dev --frozen.

После этих изменений размер образа уменьшился еще немного — до 1.1 GB. Небольшая, но победа.

Анализ слоев образа

После очередного анализа через dive стало видно два особенно жирных слоя — примерно по 400 MB каждый:

  • слой с копированием зависимостей из build-stage,

  • слой с установкой системных пакетов внутри контейнера.

Проверка используемых зависимостей

На следующем этапе нужно было понять, действительно ли все перечисленные зависимости нужны проекту. Для этого я использую утилиту deptry. Запускаем ее в корне проекта: deptry.

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

Scanning 938 files...

Success! No dependency issues found.

Сократить слой с Python-зависимостями здесь не удалось — все пакеты действительно использовались приложением. Зато внимание привлекла другая часть Dockerfile:

RUN set -e; \
 	apt update; \
 	apt install -y --no-install-recommends \
         curl \
         ffmpeg \
         libcairo2 \
         libpango-1.0-0;

Как минимум curl показался мне явно лишним. С остальными пакетами ситуация была менее очевидной: в проекте использовалось более 50 Python-зависимостей, и вручную проверять, кому именно нужны системные библиотеки, совсем не хотелось. Поэтому я пошел немного ленивым, но вполне рабочим путем — задал вопрос LLM. Использовал Qwen 3.6 Plus и спросил примерно следующее:

Для каких пакетов из этого списка Python-зависимостей нужны установленные в системе библиотеки: ffmpeg, libcairo2, libpango-1.0-0?

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

Очистка кэшей и мусора

На этом же этапе я решил добавить очистку кэшей и ненужных файлов после установки системных пакетов. В итоге инструкция стала выглядеть так:

RUN set -e; \
 	apt update; \
 	apt install -y --no-install-recommends \
         ffmpeg \
         libcairo2 \
         libpango-1.0-0; \
 	# Очистка мусора
 	apt clean; \
 	rm -rf /var/lib/apt/lists/* \
         /var/cache/apt/archives/* \
         /usr/share/doc/* \
         /usr/share/man/* \
         /usr/share/locale/* \
         /usr/share/groff/* \
         /usr/share/info/* \
         /usr/share/lintian/* \
         /var/cache/debconf/* \
         /var/log/*.log

И здесь уже появился заметный эффект: после очистки мусора размер образа уменьшился до 1.0 GB.

К этому моменту удалось сократить исходный размер примерно на треть.

Попытка смены базового образа

Дальше напрашивался следующий шаг — попробовать заменить базовый образ. На тот момент использовался python:3.13.1-slim-bookworm.

Теоретически можно было попробовать перейти на что-то еще более компактное — например, Alpine-based-образ. В подобных проектах это часто приводит к дополнительной боли: проблемам со сборкой бинарных зависимостей, отсутствию нужных системных библиотек и сложности с совместимостью Python-пакетов. Поэтому в данном случае я решил не тратить время на эксперименты: потенциальная выгода выглядела сомнительной относительно возможных проблем.

Исключение тестов из production-образа

Сразу поясню, зачем вообще исключать тесты из production-образа:

  • уменьшение поверхности атаки,

  • более чистое окружение,

  • снижение риска случайного выполнения тестового кода,

  • соблюдение принципа minimal viable runtime.

Да, иногда это еще и экономит место, особенно если тесты содержат тяжелые фикстуры или тестовые данные. Здесь я рассматривал несколько вариантов:

  1. Добавить tests в .dockerignore, а в docker-compose.local.yml монтировать проект через папку volume.

  2. Использовать ARG и условное копирование тестов при необходимости.

  3. Использовать разные Dockerfile для production-среды.

В итоге я остановился на третьем варианте. В проекте уже использовались разные Dockerfile для разных окружений, поэтому решение выглядело наиболее естественным.

Особенности Django-проектов

У Django есть одна особенность: тесты часто лежат внутри каждого приложения (app/tests/), а не вынесены в отдельный пакет.

В моем случае было сразу два варианта: тесты внутри Django apps и отдельный пакет с тестами на уровне проекта. С точки зрения архитектуры было бы правильно все унифицировать, но это уже история про полноценный рефакторинг. Для текущей задачи я решил оставить структуру как есть.

Для production-сборки тесты были исключены прямо на этапе копирования файлов приложения.

Сделать это можно, применив ключ --exclude в инструкции COPY. Единственное, тут важно понимать помнить, что ключ доступен при использовании BuildKit, поэтому в начало Dockerfile нужно добавить: # syntax=docker/dockerfile:1.4.

Это актуально для старых версий Docker Engine (< 23.0), где BuildKit может быть не включен по умолчанию. В моем случае тесты занимали всего около 7 MB, поэтому серьезной оптимизацией это назвать сложно. Но зато production-образ стал заметно аккуратнее и чище.

Финал финалов

Ниже — итоговая версия Dockerfile.prod, которая получилась после всех оптимизаций:

# syntax=docker/dockerfile:1.4

 FROM python:3.13.1-slim-bookworm AS base

 LABEL maintainer="shoytov@gmail.com"
 LABEL vendor="Dmitry Shoytov"

 ENV \
     PYTHONDONTWRITEBYTECODE=1 \
     POETRY_VIRTUALENVS_CREATE=false \
     PYTHONUNBUFFERED=1 \
     PYTHONFAULTHANDLER=1 \
     PYTHONHASHSEED=random \
     POETRY_VERSION=2.0.0 \
     DEBUG=False

 RUN set -e; \
 	apt update; \
 	apt install -y --no-install-recommends \
         ffmpeg \
         libcairo2 \
         libpango-1.0-0; \
 	# Очистка мусора
 	apt clean; \
 	rm -rf /var/lib/apt/lists/* \
         /var/cache/apt/archives/* \
         /usr/share/doc/* \
         /usr/share/man/* \
         /usr/share/locale/* \
         /usr/share/groff/* \
         /usr/share/info/* \
         /usr/share/lintian/* \
         /var/cache/debconf/* \
         /var/log/*.log

 WORKDIR /app

 FROM base AS builder
 COPY poetry.lock pyproject.toml /app/

 RUN pip install --upgrade pip && \
 	pip install poetry==${POETRY_VERSION} && \
     poetry install --without=dev --no-root --no-ansi --no-interaction


 FROM base AS app
 COPY --link --from=builder /usr/local/bin /usr/local/bin
 COPY --link --from=builder /usr/local/lib/python3.13/site-packages/ /usr/local/lib/python3.13/site-packages/
 COPY --exclude=tests/ \
      --exclude=**/tests/ \
      --exclude=**/tests.py \
      --exclude=**/test_*.py \
      --exclude=**/conftest.py \
  	. /app

В итоге за несколько относительно простых шагов удалось сократить размер Docker-образа с 1.5 GB до примерно 1.0 GB, то есть почти на треть. При этом большая часть оптимизаций не потребовала каких-то радикальных изменений в проекте или сложного рефакторинга.

А какие способы «похудения» production-образов используете вы? И занимаетесь ли этим вообще? Пишите в комментариях!