Многие Dockerfile включают в себя как зависимости для сборки приложения, так и зависимости для его выполнения в продакшене. Это приводит к тому, что в финальные Docker-образы попадает куда больше компонентов, чем необходимо для запуска приложения. А ведь большие образы с ненужными зависимостями не только занимают лишнее место, но и повышают вероятность появления уязвимостей.
Почему образы получаются такими большими
У приложений есть зависимости двух типов:
- Зависимости для сборки (build-time) – библиотеки и инструменты, необходимые для компиляции и подготовки к запуску.
- Зависимости для выполнения (run-time) – то, что нужно только в продакшене.
Когда мы используем один и тот же образ для сборки и запуска, то в продакшн попадают лишние инструменты – интерпретаторы, компиляторы, линтеры и т.д. Избежать этого можно только с помощью разделения этапов сборки и выполнения.
Примеры неправильных Dockerfile
Рассмотрим Dockerfile для Go и Node.js приложений, где допущены ошибки, приводящие к ненужному раздуванию образов.
Неправильный Dockerfile для Go-приложения
Здесь использован образ golang:1.23, который включает не только скомпилированное приложение, но и весь инструментарий Go вместе с зависимостями – больше 800 Мб, множество из которых становятся уязвимостями в продакшене:
Структура этого раздутого образа выглядит так:
Неправильный Dockerfile для Node.js-приложения
Здесь используются команды npm ci
и npm run build
. Первая команда устанавливает зависимости и для разработки, и для продакшена. Но при попытке убрать девелоперские зависимости команда npm run build
не сможет завершиться, так как для сборки нужны оба типа зависимостей:
Такой докерфайл создает образ с 500 Мб лишних компонентов:
Как работает многоэтапная сборка
Концепция многоэтапной сборки основана на паттерне проектирования «Строитель». Чтобы понять, как этот подход работает в Docker, сначала нужно познакомиться с двумя мощными возможностями Dockerfile – копированием файлов из другого образа и определением нескольких образов в одном докерфайле.
Копирование файлов из другого образа
Одна из самых распространенных инструкций в Dockerfile – это COPY
. Обычно команда используется для копирования файлов с хоста в образ:
Однако файлы можно также копировать напрямую из других Docker-образов. Например, можно скопировать файл конфигурации nginx.conf
из официального образа nginx:latest прямо в свой текущий образ:
Именно фича COPY --from=
помогает реализовать паттерн «Строитель» – например, при создании образа для Node.js:
Определение нескольких образов в одном Dockerfile
С 2018 года Docker поддерживает многоцелевые Dockerfile: в одном файле можно указать несколько инструкций FROM
, каждая из которых создает отдельный целевой образ. Здесь определены три разных образа с их собственными настройками:
Эта особенность позволяет выбрать цель сборки с помощью параметра --target
в команде docker build
, давая возможность одному Dockerfile создавать разные образы в зависимости от выбранной цели:
Собрать образ на основе первого FROM
:
Собрать образ на основе второго FROM
:
Собрать образ на основе третьего FROM
:
Как использовать эти две возможности для многоэтапной сборки
Dockerfile для сборки и Dockerfile для выполнения можно объединить в один файл с несколькими инструкциями FROM
. Здесь на первом этапе происходит сборка приложения с помощью npm run build
, а второй этап копирует результат сборки и запускает приложение:
Важно помнить:
- Порядок этапов имеет значение. Нельзя выполнить
COPY --from
из этапа, который определен после текущего. Этапы должны быть описаны в логической последовательности. - Алиасы этапов. Использование AS (например, AS build) позволяет дать этапу понятное имя, но их применение опционально: если имя не указано, на этапы можно ссылаться по их порядковому номеру (например,
COPY --from=0
). - По умолчанию собирается последний этап. Если не указать флаг
--target
, командаdocker build
соберет последний этап и все этапы, от которых он зависит.
Несколько примеров многоэтапной сборки
Приложение на Go
Go-приложения всегда компилируются на этапе сборки. Итоговый бинарник может быть двух типов:
- Статически связанный (собран с
CGO_ENABLED=0
) – все необходимые зависимости включены внутрь самого бинарника. Такой бинарник можно запускать даже на минималистичных базовых образах, например,gcr.io/distroless/static
илиscratch
. Последний – это вообще пустой образ, поэтому нужно быть очень осторожным при его использовании. - Динамически связанный (собран с
CGO_ENABLED=1
) – он требует внешних библиотек, таких как стандартные C-библиотеки. Для него нужен базовый образ, в котором эти библиотеки уже есть. Например, это может бытьgcr.io/distroless/cc
, alpine или даже debian.
В большинстве случаев выбор базового образа для этапа выполнения не меняет структуру многоэтапного Dockerfile – вы просто выбираете подходящий образ в зависимости от нужного типа бинарника:
Приложение на Rust
Rust-приложения обычно компилируются из исходного кода с помощью утилиты cargo. Официальный Docker-образ для Rust включает cargo, rustc (компилятор) и другие инструменты для разработки и сборки. Из-за этого размер образа получается довольно большим – почти 2 Гб. Поэтому для Rust-приложений обязательно используют многоэтапную сборку, чтобы итоговый образ для выполнения приложения был как можно более компактным и не содержал лишних инструментов. Финальный выбор базового образа для этапа выполнения будет зависеть от того, какие библиотеки нужны вашему Rust-приложению:
Приложение на Java
Java-приложения компилируются из исходного кода с помощью Maven или Gradle, и для их выполнения нужна Java Runtime Environment (JRE). Когда Java-приложение запускается в контейнере, обычно используют разные базовые образы для этапов сборки и выполнения. На этапе сборки нужен Java Development Kit (JDK), который включает инструменты для компиляции и упаковки кода. А вот для этапа выполнения достаточно более легкой Java Runtime Environment (JRE), так как она содержит только необходимое для запуска приложения. Dockerfile выглядит намного сложнее, чем сценарии для приложений на Go и Rust, поскольку файл для Java включает дополнительный этап тестирования, да и сам процесс сборки включает больше действий:
А какие хитрости оптимизации Docker-образов используете вы? Поделитесь своим опытом в комментариях!
Комментарии