Всем привет. Я Backend разработчик, в основном на Python и немного Go. Хотел бы рассказать про свой опыт оптимизации docker образов и написать некий «туториал». Он скорее будет полезен для разработчиков или начинающим DevOps. Для опытных DevOps инженеров, возможно будет мало интересного и полезного
Не претендую на правильность во всем, полноту. Мое основное ремесло – эффективно делать эффективный бекенд. Направление инфраструктуры мне интересно, стараюсь активно изучать и в ходе работы, так или иначе приходилось и приходится с этим работать.
Цели:
-
собрать свои наработки и структурировать их. Помочь сэкономить время тем, кто занимается тем же
-
получить фидбек и улучшить показатели оптимизации
Статья может показаться не супер-дружной и легко-читаемой. Есть профессиональный лексикон, объяснений в данной версии статьи может быть маловато.
Допущения этой статьи
Для управления зависимостями используется poetry. Да, есть супер быстрый uv. Исторически сложилось, что poetry - мой инструмент для управления зависимостями и кто-то сочтет poetryустаревшим, но предлагаю в рамках этой статьи обсуждать именно оптимизацию образа, а не пакетные менеджеры. В целом, возможно перейду на использование uv в скором времени.
Теория
Для чего оптимизировать?
-
Увеличить скорость разворачивания подов в кубах (kubernates), чтобы в моменты повышения нагрузки деградация сервиса была ниже. Логика т��кая: под поднимается, когда нагрузка увеличилась, и чем быстрее запустится новый под, тем меньше времени пользователи будут получать задержки. А для разворачивания сервиса, нужно его образ перенести внутрь пода. И, собственно, чем меньше размер, тем быстрее копируется
-
Экономия ресурсов хранилища
-
Экономия сетевого ресурса
Уровень: Базовый. Справятся все
1) Использовать .dockerignore
Обратим внимание на то, что директория tests/ включена в список исключений, для того чтобы в runtimeобразе, был только исполняемый код. А так как тесты нужны только на стадии тестирования, то из финального образа их убираем
2) Использовать slim версию питона
Даже не измерял размер образа с не-slim версией python. Это будет на сотни мегабайтов, а то и гигабайт больше
3) Отключение кеша для pip или poetry
Внутри runtime образа кеши для быстрой установки зависимостей с помощью pip/poetry не нужны, так как все зависимости уже собраны на этапе билда
Поэтому их можно не хранить в кеше.
Использовать флаги
Для pip:
pip install -r requriments.txt –no-cache-dir
Для poetry
4) Многоэтапная сборка
Заметил, что не все компании используют многоэтапную сборку, хотя, казалось бы, все туториалы говорят об этом.
Результаты:
Время первого билда, с нуля сильно увеличилось, но нужно понимать, что при правильном запуске сборки образа, все слой билдера (builder) будут переиспользоваться в будущем и все последующие обновления будут занимать значительно меньше времени, и будут даже быстрее чем, при одноэтапной сборке
|
|
Время сборки с нуля |
Время сборки после изменения кода |
Размер образа, Мб |
|
Было |
58 |
56 |
894.84 |
|
Стало |
315 |
16 |
508 |
|
Изменение |
+600% |
-71% |
-43% |
Финальный Dockerfile:
Скрытый текст
# ───────────────────────────────────────────────────────────────
# Builder stage — здесь ставим всё для сборки и тестов
# ───────────────────────────────────────────────────────────────
FROM python:3.13-slim-bookworm AS builder
ENV PYTHONUNBUFFERED=1 LANG=C.UTF-8 PYTHONDONTWRITEBYTECODE=1
# Устанавливаем Poetry + необходимые системные пакеты, если нужны для сборки некоторых пакетов
RUN apt-get update -qq && apt-get install -y --no-install-recommends curl gcc libc-dev && pip install --no-cache-dir 'poetry==2.3.2' && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
WORKDIR /code
# Копируем только файлы зависимостей, для оптимизации скорости повторной сборки
COPY pyproject.toml poetry.lock* ./
# Настраиваем Poetry и ставим зависимости
RUN poetry config virtualenvs.create false && poetry install --no-root --no-dev --no-interaction --no-ansi && poetry cache clear --all pypi --no-interaction || true && rm -rf ~/.cache/pip
# Копируем весь проект и запускаем тесты
COPY . .
# Тесты — выполняем в builder
RUN pytest
# ───────────────────────────────────────────────────────────────
# Runtime stage — максимально лёгкий финальный образ
# ───────────────────────────────────────────────────────────────
FROM python:3.13-slim-bookworm
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 LANG=C.UTF-8
WORKDIR /code
# Копируем только установленные пакеты и бинарники
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/gunicorn
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/uvicorn
# Если есть другие бинарники, которые ставит poetry их нужно добавить сюда
# Копируем код приложения
COPY --from=builder /code /code
Уровень: посложнее. Нужно подумать стоит ли это делать
Методы оптимизации ниже, уже не базовые и стоит оценить плюсы и минусы. Готовы ли бизнес и разработчики платить за получаемый "профит", усложнением кода, более сложной отладкой.
5) Использование Chainguard образа
Идея: использовать chainguard образы, в которых нет установщиков пакетов (apk, apt), оболочек (shell) или лишних библиотек.
Плюсы:
-
Нет shell, apt (apk) и соответственно меньше поверхность атаки и лучше безопасность. В некоторых образах заявляется полное отсутствие CVE уязвимостей
-
Меньше размер образа
Минусы:
-
Сложность отладки из-за отсутствия shell и библиотек
-
Насколько мне известно, бесплатные образы имеют только latest теги, соответственно нельзя зафиксировать версию.
-
Платные образы стоят достаточно дорого
Экономию размера лично я ни разу не вычислял. Говорят что экономит от десятков до сотни мегабайт.
6) Ручной анализ файлов в образе
Это более душно, тяжелее и с меньшим шансом на успех. Тщательно подумайте перед тем, как тратить на это часы времени.
Идея: посмотреть размер слоев. Посмотреть какие размер директорий, которые добавляются на каждом этапе сборки и попытаться найти директории и файлы, которые не нужны для функционирования сервиса.
Нужен опыт linux систем для анализа.
Честно говоря, мне это�� метод не подошел. Экономия памяти невелика, вероятность успеха не высокая, но требует много времени на анализ и отладку. После взвешивания "за" и "против" практически всегда отказываюсь от этого метода, но держу в арсенале.
Итак, процесс:
- В терминале посмотреть размер слоев
docker history --format "{{.CreatedBy}}: {{.Size}}" ваш_образ:tag
Или в Docker desktop нажать на образ во вкладке "Images"
-
Будет отображаться размер каждого слоя. Начинаем анализ со слоев с наибольшим размеров и наболее близким к Runtime слою. Это и будет направлением куда смотреть
-
Запускаем dive командой
docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive:latest <ваш_образ:tag>
-
И смотрим на каждый слой какой размер, и начиная со слоя, с самым большим размеров, смотрим справа (переключение по Tab) какие директории и файлы занимают много места
На Shift + Пробел можно скрывать содержимое папки (Collapse)
-
Удаляем ненужные библиотеки из билда
Уровень: Экстрим! - скорее всего это вам не нужно
Идея: Использование scratch образа для runtime этапа
scratch - это образ, который представляет из себя практический полное отсутствие всего в операционной системе. Никаких системных библиотек, пакетов. Конечно же в нем нет shell
Так как нет системных библиотек, то их нужно копировать при сборке. Причем точечно только те, которые нужны для работы вашего сервиса.
Минусы:
-
После добавления новых зависимостей, может отказаться что недостаточно системных библиотек и придется потратить час, а то и более на добавление нужной библиотеки и отладку. Следственно, это дороже с точки зрения бизнеса
Результат: Мне это позволило уменьшить размер того же образа до 384 Мб.
Сравнение результата
|
|
Размер было, Мб |
Размер стало, Мб |
% |
|
БЕЗ многоэтапной сборки |
894
|
384
|
- 57%
|
|
С многоэтапной сборки |
508 |
384 |
-24% |
Этот вариант часто используется в Docker для других языков, например Golang, где это не создает столько трудностей как в Python, так как в процессе компиляции можно забрать все зависимости с бинарник.
Возможно, это стоит того с точки зрения бизнеса и разработки и окупается, в некоторых очень нагруженных проектах, где поды должны быть очень быстро разворачиваемыми и в больших количествах. Но перед тем, как заниматься этим, возможно будет потребность в смене языка Python на что-то более быстрое. Поэтому допускаю, что возможно в рамках Python Backend разработки, данный этап не целесообразен, но не всегда и не везде.
Возможно, целесообразно когда тысячи подов или при Serverless / Knative / Cloud Run / Fargate, чтобы ROI этой работы был достаточным.
TODO: Что я не пробовал и предстоит сделать
1) Отказ от poetry в runtime этапе
2) Wolfi образы
Бонус: Оптимизация времени установки
Пока не собрал достаточно наработок для отдельной статьи про уменьшения времени сборки, напишу некоторые тут
Порядок слоев
Идея: Сначала копировать requirements.txt/pyproject.toml, и устанавливать зависимости перед копированием всего кода.
В таком случае при изменении кода не нужно переустанавливать все зависимости.
Например было:
RUN pip install --no-cache-dir 'poetry==2.3.2'
RUN mkdir /code
COPY . /code/ # <- Тут копируется весь код
WORKDIR /code
RUN poetry install # <- Тут устанавливаются зависимости
RUN pytest
В моем случае в docker запускаются pytest в самом конце.
После изменения кода билд составлял 58 секунд. Так как каждое изменение кода -> билд почти с самого начала, с переустановкой зависимостей. Используется кэш только тех слоев, которые идут до копирования кода COPY . . или COPY . ./code
Стало
RUN pip install --no-cache-dir 'poetry==2.3.2'
RUN mkdir /code
COPY pyproject.toml poetry.lock /code/ # <- Тут копируется только список зависимостей
WORKDIR /code
RUN poetry install # <- Установка зависимостей
COPY . /code/ # <- Тут копируется весь код
RUN pytest
B после изменения кода время билда после правок кода сократилось до 14 секунд. Время необходимо только на копирование кода и тесты. Зависимости копируются из кэша
Ускорение в 4 раза!
Буду очень рад конструктивной критике, идеям и предложениям по улучшению показателей.
Спасибо за внимание!
Автор: abdullin-rail
