↓ Перейти к основному содержимому

Собирая лучшие образы Docker: способы оптимизации времени и размера сборки

·1769 слов·9 минут

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

Ожидается, что вы знакомы с основами Docker и Dockerfile.

Приведенные в статье способы проверены на Docker 27.5.1 в связке с Buildkit 0.20.0!

Оптимизация контекста сборки
#

Перед началом сборки Docker собирает контекст сборки - файлы, которые могут быть использованы при сборке контейнера. По умолчанию, Docker может копировать любые данные из директории, указанной в команде docker build. Попадание в контекст файлов разработки, таких как __pycache__ директории в Python, node_modules в NodeJS и подобных может серьезно замедлить старт сборки.

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

Для ограничения контекста сборки используется файл .dockerignore, имеющий формат аналогичный .gitignore. Файлы, указанные в нем не попадают в контекст сборки. Он может располагаться в корне директории проекта, который вы собираете или быть привязан к определенному Dockerfile. Для проекта на Python, использующего файл .env для конфигурации он может выглядеть следующим образом:

.env
__pycache__
*.pyc
С подробной документацией об использовании .dockerignore вы можете ознакомиться здесь

Как работает сборка образа
#

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

Docker layers
Источник: dockerdocs

Однако, если результат какого-либо из слоев изменится, кеш будет инвалидирован в измененном слое и слоях после него. Из примера выше, если файл Makefile или main.c будет изменен, то кеш инвалидируется на этапе COPY и сборка на этапе make build будет произведена заново.

Как понять, что попало в образ?
#

При помощи сторонних утилит вы можете изучить, что попало (или не попало) в ваш образ в случае возникновения проблем. Для таких целей я рекомендую использование утилиты dive. Она доступна под многие системы (Mac, Windows, DEB/RPM-based дистрибутивы и др.), а также вы можете собрать ее из исходного кода или запустить при помощи Docker.

Далее, при помощи команды dive <название образа> вы можете изучить его слои, а также посмотреть, что изменилось при выполнении каждого слоя

Dive UI
Интерфейс утилиты Dive при изучении образа

Приемы оптимизации
#

Выбор оптимальных образов
#

Коротко: большое количество образов имеют slim или alpine версии, которые могут отвечать вашим запросам при меньшем весе. Однако используйте их с осторожностью.

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

  1. Использование musl вместо glibc - чаще всего используется при работе с компилируемыми языками, например Rust. Может быть частично или полностью несовместим с рядом инструментов и библиотек
  2. Меньший набор встроенных библиотек (например, python:<version> и python:<version>-slim)
  3. Базовый дистрибутив (например, nginx:alpine использует Alpine Linux вместо Debian)

По различных причинам, эти образы могут вам не подойти. Например, библиотека uwsgi для Python требует дополнительные библиотеки при установке и без дополнительных вмешательств установится только на “полноценный” Python-образ, который тяжелее. Однако в таком случае вы можете использовать мультиэтапную сборку для сборки библиотек на более тяжелом образе и их перемещении в более легкий. Далее в статье вы увидите подобные примеры.

Пример мультиэтапной сборки для Python-проекта из файла requirements.txt

 1FROM python:3.11 AS builder
 2WORKDIR /app
 3COPY requirements.txt .
 4RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
 5
 6FROM python:3.11-slim
 7WORKDIR /usr/src/app
 8COPY . .
 9COPY --from=builder /app/wheels /wheels
10RUN pip install --no-cache /wheels/*
11CMD "<ваша команда>"

А разработчики образа NodeJS вообще не рекомендуют использовать slim-образ, если у вас нет проблем с дисковым пространством - поскольку в этой редакции существует только минимальное окружение для запуска node.

Кроме того, alpine-версии образов также используют musl, что может серьезно повлиять на производительность или даже усложнить оптимизацию веса образа (источник 1, источник 2)

При копировании файлов
#

Коротко: копируйте мало и по делу. Избегайте “тяжелых” действий под командами, которые копируют большое количество файлов. Ограничивайте контекст сборки при помощи .dockerignore

Рассмотрим пример с Python приложением из двух файлов: main.py с кодом и requirements.txt для установки зависимостей. Исходный Dockerfile выглядит так:

1FROM python:3.12
2WORKDIR /app
3COPY . .
4RUN pip install -r requirements.txt
5CMD ["python", "main.py"]

Что не так?

  • Не ясно, что вообще передается. Без корректного .dockerignore (и даже с ним) в контейнер могут попасть “лишние” данные
  • Любые правки в код, которые попадут на 3 строке файла инвалидируют все действия после - в том числе установку зависимостей

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

1FROM python:3.12
2WORKDIR /app
3COPY requirements.txt .
4RUN pip install -r requirements.txt
5COPY main.py .
6CMD ["python", "main.py"]

Теперь мы можем спокойно редактировать код и быстро пересобирать образ!

При установке пакетов
#

Коротко: по необходимости делите действия на составные и зачищайте кеш пакетных менеджеров или используйте cache mounts

Допустим, вы собираете образ с графическим интерфейсом внутри (например, вы RDP поднимаете в Docker), рассмотрим такой пример:

1FROM debian:bookworm
2RUN apt-get update && apt-get install -y xfce4 xfce4-goodies

Что не так?

  • В общем, этап рабочий - и будет кешироваться, но при модификации списка пакетов будут повторно загружаться все остальные (а у нас их тут весом под гигабайт), что не всегда приятно
  • В образе контейнера остаются ненужные файлы, например, в нашем случае, кеш пакетного менеджера APT

С загрузкой тяжеловесных пакетов можно разобраться разбив загрузки на несколько этапов, однако проблема избыточного веса сложнее - в более старых версиях Docker мы могли бы удалять директории APT:

1FROM debian:bookworm
2RUN apt-get update
3RUN apt-get install -y xfce4 xfce4-goodies
4RUN apt clean all && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*deb

Но при использовании новых версий Buildkit мы можем воспользоваться таким нововведением, как монтирование кеша (cache mounts). Идея заключается во временном монтировании директорий - благодаря чему не остается лишних файлов в контейнере. Монтированный кеш сохраняется между разными сборками, что ускоряет время сборки. Перепишем наш пример следующим образом:

1FROM debian:bookworm
2RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
3    --mount=type=cache,target=/var/lib/apt,sharing=locked apt-get update
4RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
5    --mount=type=cache,target=/var/lib/apt,sharing=locked apt-get install -y xfce4 xfce4-goodies

Обратите внимание: от инвалидации кеша это не спасет. А также, монтировать директории придется на каждом этапе где это необходимо. То есть такой файл не будет успешно отработан:

1FROM debian:bookworm
2RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
3    --mount=type=cache,target=/var/lib/apt,sharing=locked apt-get update
4RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
5    --mount=type=cache,target=/var/lib/apt,sharing=locked apt-get install -y xfce4 xfce4-goodies
6RUN apt-get install -y sudo
7# команда не будет отработана, т.к данные apt-get update не сохранены и пакет не будет найден

Как использовать монтирование кеша в своем сценарии?

Минимальная нотация: --mount=type=cache,target=<директория, которую кешируете>. Флаг sharing=locked нужен, если использование кеша между параллельными сборками единовременно может быть опасно и вы хотите этого избежать

При сборке программ/переносимых файлов
#

Коротко: используйте мультиэтапную сборку с наименьшим допустимым образом

Рассмотрим пример со сборкой проекта на Haskell (да, несколько необычно, но поможет показать комбинацию подходов):

1FROM haskell:9.4.7-buster
2WORKDIR /project
3COPY . .
4RUN stack install
5CMD ["/root/.local/bin/my-app-exe"]

Что не так?

  • Титанический вес образа (3.4GB!) при сборке бинарного файла со статическими зависимостями
  • В окружении остается большое количество артефактов разработки (например, исходный код), которые больше не нужны

В случае, если контейнер, используемый для сборки не нужен для запуска полученных файлов можно применить мультиэтапную сборку, в процессе которой выполняются действия в рамках одного базового образа (или нескольких, если этапов много) - а далее полученные файлы можно перенести в итоговый контейнер, который и будет сохранен. Перенесем наш собранный бинарный файл в контейнер с Debian для последующего запуска

 1# присваиваем контейнеру псевдоним builder для более простого обращения
 2FROM haskell:9.4.7-buster AS builder
 3WORKDIR /project
 4COPY . .
 5# вызываем сборку проекта через Stack (пакетный менеджер)
 6RUN stack install
 7
 8FROM debian:bookworm
 9WORKDIR /project
10COPY --from=builder /root/.local/bin/my-app-exe .
11CMD ["my-app-exe"]

Вуаля! Итоговый размер контейнера - 122 мегабайта!

Дополнительное действие: Stack нуждается в сборке индекса библиотек перед тем, как их загрузить - поэтому вызов stack install может не кешироваться. Для этого, добавим вызов команды stack update для обновления индекса до копирования любых файлов (что позволит сохранить этап в кеше). Можете добавить подобные действия в свои сценарии сборки, если есть такая необходимость.

 1FROM haskell:9.4.7-buster AS builder
 2RUN stack update
 3WORKDIR /project
 4COPY . .
 5RUN stack install
 6
 7FROM debian:bookworm
 8WORKDIR /project
 9COPY --from=builder /root/.local/bin/my-app-exe .
10CMD ["my-app-exe"]

Похожим образом, например, можно оптимизировать сборку фронтенда на NodeJS и ее помещение в образ NGINX

 1FROM node:lts-slim AS builder
 2WORKDIR /project
 3COPY package.json package-lock.json ./
 4RUN npm install
 5COPY . .
 6RUN npm build
 7
 8FROM nginx:stable-alpine
 9# копируем конфиг NGINX, написанный нами
10COPY nginx.conf /etc/nginx/conf.d/site.conf
11# копируем результаты сборки
12COPY --from=builder /project/dist /frontend

Если вы собираете много однотипных образов
#

Помимо этого, вы можете собирать промежуточный образ, из которого будут собираться остальные. Из рабочего примера - у меня есть проект с рядом микросервисов на Haskell, большая часть которых использует схожий технологический стек и ряд общих самописных библиотек. При сборке их образов, в первую очередь я собираю “родительский” образ в котором компилирую библиотеки и зависимости (благодаря чему они помещаются в кеш пакетного менеджера Stack). После этого, я запускаю мультиэтапную сборку каждого микросервиса, в которой остается собрать сам проект (поскольку зависимости уже собраны и берутся из кеша) и переместить его в легковесный образ Debian. Экспериментируйте и находите общие рутинные действия, которые можно объединить!

Единовременное копирование файлов при помощи монтирования директорий
#

Помимо монтирования директорий для их кeширования, Buildkit поддерживает такую возможность как монтирование директории в рамках одного этапа сборки. Рассмотрим на примере приложения на Rust:

 1FROM rust AS builder
 2WORKDIR /project
 3COPY . .
 4# собираем проект
 5RUN cargo install --path .
 6
 7FROM debian:bookworm
 8WORKDIR /project
 9# копируем бинарный файл
10COPY --from=builder /project/target/release/app .
11CMD ["./app"]

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

1FROM rust AS builder
2WORKDIR /project
3RUN --mount=type=bind,target=.,src=.,rw cargo install --path . && cp /project/target/release/app /
4
5FROM debian:bookworm
6WORKDIR /project
7COPY --from=builder /app .
8CMD ["./app"]

Синтаксис монтирования директорий

Минимальная нотация: --mount=type=bind,target=., где target - целевая директория монтирования в контейнере

Дополнительно можно указать доступность для записи (по умолчанию директория read-only) при помощи флага rw, а также указать источник монтирования помощи флага src.

Заключение
#

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