Уменьшение размера Docker-образов: практическое руководство по ускорению сборки

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

Уменьшение размера Docker-образов: практическое руководство по ускорению сборки

Уменьшение размера Docker-образа — это не погоня за наименьшим числом в docker image ls. Полезная цель — сборка, которая быстра, воспроизводима, легко поддается исправлению и достаточно мала, чтобы не замедлять CI, развертывание или локальную разработку.

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

Начните с измерения текущего образа:

docker image ls my-app
docker history --no-trunc my-app:latest

docker history показывает, какие инструкции Dockerfile добавили размер. Если один шаг RUN добавляет большой объем, проверьте этот шаг, прежде чем менять что-либо еще. Для более глубокого изучения такие инструменты, как dive, могут показать файлы, добавленные и удаленные в каждом слое, но встроенных команд достаточно для первого прохода.

Выбирайте базовый образ в первую очередь по совместимости, а не по размеру

Выбор базового образа важен, но самый маленький образ не всегда лучший. Alpine мал, потому что использует musl libc и минимальное пользовательское пространство. Это хорошо работает для многих образов Go, Node, Python и утилит, но может создавать проблемы с нативными расширениями, предварительно собранными бинарными файлами, поведением DNS или пакетами, которые ожидают glibc.

Практический порядок:

  1. Используйте официальный образ для вашей среды выполнения.
  2. Предпочитайте версионированный тег, такой как python:3.12-slim, вместо python:latest.
  3. Попробуйте slim, прежде чем переходить на Alpine, если у вашего приложения есть нативные зависимости.
  4. Используйте distroless или scratch, когда вы уже точно знаете, что нужно среде выполнения.

Например, сервис Python с psycopg, библиотеками изображений или пакетами криптографии может быть проще поддерживать на python:3.12-slim, чем на python:3.12-alpine. Образ Alpine может быть меньше изначально, но если вы добавите компиляторы и пакеты совместимости для сборки зависимостей, конечный результат может не быть проще или намного меньше.

Для сервисов Go scratch или distroless могут быть отличными, потому что статически скомпилированный бинарный файл может требовать очень мало во время выполнения. Но даже в этом случае помните о TLS-сертификатах, данных часового пояса и пользователях, не являющихся root. Крошечный образ, который не может выполнять HTTPS-запросы, не является полезным производственным образом.

Используйте многоэтапные сборки, чтобы оставить инструменты сборки вне продакшена

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

Пример на Go:

FROM golang:1.22 AS builder
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=builder /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

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

Приложение Node имеет похожую форму, но вам нужно отделить зависимости разработки от производственных зависимостей:

FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build

FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

Для ресурсов фронтенда, обслуживаемых nginx, финальному образу Node вообще не нужен:

FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html

Этот шаблон удаляет node_modules, дерево исходников и инструменты сборки из рабочего образа.

Заставьте кэш Docker работать на вас

Аннулирование кэша Docker просто, но неумолимо. Если инструкция изменяется, Docker перестраивает этот слой и каждый слой после него. Если вы копируете весь проект перед установкой зависимостей, каждое изменение кода может вызвать переустановку зависимостей.

Это медленно:

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

Обычно это быстрее:

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

Слой зависимостей перестраивается только при изменении requirements.txt. Изменения исходного кода перестраивают только более поздний слой COPY . ..

То же правило применяется к Maven, Gradle, npm, pnpm, Cargo и модулям Go. Сначала копируйте манифесты зависимостей, устанавливайте или загружайте зависимости, затем копируйте остальной исходный код.

Держите контекст сборки маленьким с помощью .dockerignore

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

Базовый .dockerignore может выглядеть так:

.git
.gitignore
.env
.env.*
node_modules
coverage
dist
build
target
.pytest_cache
__pycache__
*.log
.DS_Store

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

Вы можете увидеть размер контекста в начале классического вывода сборки Docker, а вывод BuildKit также делает видимыми большие передачи контекста. Если первый шаг занимает много времени до выполнения какой-либо инструкции Dockerfile, проверьте .dockerignore.

Очищайте кэши менеджеров пакетов в том же слое

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

Для образов Debian и Ubuntu:

RUN apt-get update &&     apt-get install -y --no-install-recommends ca-certificates curl &&     rm -rf /var/lib/apt/lists/*

Для Alpine:

RUN apk add --no-cache ca-certificates curl

Для Python:

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

Для npm:

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

Не копируйте слепо команды очистки. Используйте очистку, соответствующую менеджеру пакетов и базовому образу. Также избегайте apt-get upgrade в большинстве Dockerfile приложений. Это делает сборки менее предсказуемыми и может привнести больше изменений, чем вы планировали. Применяйте исправления, регулярно пересобирая из поддерживаемых базовых образов и фиксируя ожидаемое семейство образов.

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

COPY копирует файлы из контекста сборки. ADD имеет дополнительное поведение: в некоторых случаях он может распаковывать локальные tar-архивы и загружать удаленные URL-адреса. Это дополнительное поведение может удивить сопровождающих и усложнить понимание поведения кэша.

Используйте COPY для обычных файлов приложения:

COPY ./src ./src

Используйте ADD только тогда, когда вы намеренно хотите его поведение извлечения архива. Для удаленных загрузок предпочитайте curl или wget в шаге RUN, где вы можете проверить контрольные суммы и четко сообщить об ошибке.

Уменьшайте то, что вы устанавливаете, а не только то, что удаляете

Лучшая очистка кэша — не устанавливать ненужные файлы в первую очередь. Используйте --no-install-recommends с apt-get. Избегайте установки редакторов, оболочек, менеджеров пакетов и инструментов отладки в производственные образы, если у вас нет на то причин.

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

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

Используйте кэш-монтирования BuildKit для загрузки зависимостей

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

Для npm:

# syntax=docker/dockerfile:1.7
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .

Для apt кэш-монтирования могут помочь в средах CI, где повторные сборки происходят на одном и том же сборщике, хотя многие системы CI используют свежие рабочие машины, если вы не настроите постоянный кэш. Функции BuildKit мощны, но сохраняйте Dockerfile читаемым для команды, которая должна его поддерживать.

Проверьте содержимое финального образа

После изменений докажите, что улучшилось:

docker build -t my-app:optimized .
docker image ls my-app
docker history my-app:optimized

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

Полезные дымовые тесты включают:

docker run --rm my-app:optimized --version
docker run --rm -p 8080:8080 my-app:optimized
curl -f http://localhost:8080/health

Если приложение записывает файлы, протестируйте этот путь. Если оно вызывает HTTPS-сервисы, протестируйте TLS. Если оно работает не от root, протестируйте разрешения.

Практический порядок оптимизации

Когда я проверяю слишком большой Docker-образ, я использую этот порядок:

  1. Добавьте или исправьте .dockerignore.
  2. Переупорядочьте Dockerfile, чтобы установка зависимостей могла кэшироваться.
  3. Внедрите многоэтапные сборки.
  4. Переключитесь с полных базовых образов на slim, Alpine, distroless или scratch, где это совместимо.
  5. Удалите кэши менеджеров пакетов и зависимости разработки в том же слое, где они созданы.
  6. Проверьте с помощью docker history или dive и повторяйте только там, где есть доказательства.

Этот порядок позволяет избежать преждевременной хитрости. Он также сохраняет Dockerfile понятным, что важнее, чем выжать последние несколько мегабайт.