У каждого образа Docker есть свой размер, который он занимает на жёстком диске. Порой бывает так, что контейнер с запущенным приложением на языке программирования Go, который содержит в себе всего лишь одну строчку с выводом фразы «Hello, world!» может занимать сотни Мб, в то время как существуют образы содержащие легковесные ОС весом всего лишь 5 Мб (alpine).

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

Способ 1. Использование правильного базового образа

Базовый образ (его также называют родительским образом) – это образ, из которого собирается текущий (новый) образ. Все последующие слои «накладываются» поверх родительского образа, тем самым образуя подобие слоеного пирога. Базовый образ всегда задается в самой первой инструкции Dockerfile в инструкции – FROM.

В реестре Docker Hub присутствуют базовые образы операционных систем (Ubuntu, Debian, CentOS, Fedora), однако необходимо использовать конкретные образы, которые специально собраны под конкретные задачи.

Приведем пример. Предположим, у нас есть 3 приложения, написанные на 3 разных языках программирования – JavaScript (с использованием платформы Node.js), Python и Java. Для каждого из этих языков программирования существует свой базовый образ, который содержит все самое необходимое для запуска написанных программ – среду разработки, интерпретатор, компилятор и т. д. Для Node.js образ называется node, для Python —python, для Java —openjdk.

Такие образы существуют не только для языков программирования, но также и для различных инфраструктурных компонентов, таких как Nginx, MySQL, Apache Tomcat, HAProxy и других. При использовании специфического образа отпадает необходимость в установке дополнительных пакетов и, как следствие, образ не заполняется ненужными пакетами, библиотеками и файлами, которые приводят к увеличению размера образа.

Способ 2. Использование образов slim и alpine

Отдельно стоит выделить, что существуют slim и alpine образы, которые занимают еще меньше места на жестком диске. На скриншоте ниже приведен пример образа, содержащего OpenJDK 8 версии, который представляет собой среду разработки Java. Как можно заметить, базовый образ openjdk 8 занимает больше всего места на жестком диске – 526 Мб, в то время как образы slim и alpine занимают, соответственно, 194 Мб и 84.9 мб:

Slim образы – это образы, в которых присутствует минимальное количество пакетов и, в первую очередь, такие образы предназначены для запуска написанных программ. В качестве примера можно привести образ node:slim, в котором отсутствует компилятор.

Alpine образ содержит в себе одноименную операционную систему, разработанную специально для запуска внутри контейнера. Легковесность Alpine объясняется тем, что в данном дистрибутиве не используются привычные функции, которые доступны в других дистрибутивах Linux, такие как пакетные менеджеры apt/yum/dnf, система инициализации systemd, а также существенно сокращён список используемых стандартных утилит.

Прежде чем использовать образы с тегом slim и alpine, необходимо тщательно протестировать ваши приложения на этих образах, чтобы убедиться, что написанная программа работает без сбоев.

Способ 3. Использование файла dockerignore

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

Для решения этой задачи необходимо использовать специальный файл под названием .dockerignore (имя файла начинается с символа точки, так как этот файл является скрытым). В данном файле можно указывать, какие файлы и директории не нужно включать в итоговую сборку образа. Файл .dockerignore является аналогом файла .gitignore, который используется в git. Так же, как и gitignore, он представляет собой специальный тип файла — в нем перечисляются те файлы, которые должны быть исключены из сборки образа.

Рассмотрим несколько примеров.

# игнорировать директории .git и .cache
.git
.cache
 
# игнорировать все файлы, которые имеют разрешение class *.class во всех директориях, включая корневой каталог сборки
**/*.class
 
# игнорировать все файлы в формате markdown (md) кроме всех файлов README*.md, исключая README-secret.md
*.md
!README*.md
README-secret.md

Файл .dockerignore помещается в корневую папку с проектом и располагается там же, где и файл Dockerfile.

Способ 4. Минимизация количества слоев образа

При использовании таких команд, как RUN, COPY, ADD Docker создает слои. Каждый слой увеличивает размер образа, так как слои кэшируются.

Чтобы уменьшить количество слоев, необходимо объединять (комбинировать) команды в цепочки для того, чтобы исключить проблемы, связанные с неправильным использованием кэша. Рассмотрим эти рекомендации на конкретных примерах. Предположим, нам необходимо выполнить следующие 2 команды:

RUN apt update
RUN apt -y install tree

Если вы используете apt, необходимо комбинировать в одной инструкции RUN команды apt update и apt install. Команды выше необходимо скомбинировать в одну команду следующим методом:

RUN apt update && apt -y install tree

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

RUN apt update && apt install -y \
	htop \
	tree \
	mc

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

Способ 5. Удаление кэшей и временных файлов

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

Для удаления кэша в конец команды по установке (например, apt install) необходимо добавить одну из нижеперечисленных строк — в зависимости от используемого пакетного менеджера:

APT: ... && rm -rf /var/cache/apt
APK: ... && rm -rf /etc/apk/cache
YUM: ... && rm -rf /var/cache/yum
DNF: ... && rm -rf /var/cache/dnf

Способ 6. Использование многоэтапных (multi-stage) сборок

В версии Docker 17.05 был добавлен функционал многоэтапных сборок или как ее часто называют multi-stage сборка. Для чего нужна многоэтапная сборка? Предположим, что в нашем проекте есть такие файлы, как инструменты разработки, файлы библиотек и т. д., которые необходимы для создания образа, но они не нужны в финальном образе. Если включить эти файлы в финальную сборку, то это приведет к увеличению размера образа. Для решения данной проблемы необходимо отделить стадию сборки от стадии выполнения или, говоря простыми словами, исключить лишние зависимости сборки из образа, при этом оставив их доступными во время процесса сборки образа.

Мulti stage сборка позволяет использовать несколько инструкций FROM, и как итог используется сразу 2 базовых образа. При использовании muti-stage сборки появляется главное преимущество — возможность копирования необходимых артефактов из одной стадии в другую.

Разберем этот способ на конкретном примере. В качестве теста возьмем простое приложение, написанное на языке программирования Go и представляющее собой HTTP-сервер, который выводит фразу «Hello, world!». Код приложения указан ниже:

package main;
import (
	"fmt"
	"log"
    "net/http"
)
func main() {
    http.HandleFunc("/helloworld", func(w http.ResponseWriter, r *http.Request){
        fmt.Fprintf(w, "Hello, World!")
	})
    fmt.Printf("Server running (port=8080), route: http://localhost:8080/helloworld\n")
	if err := http.ListenAndServe(":8080", nil); err != nil {
    	log.Fatal(err)
	}
}

Содержимое Dockerfile выглядит следующим образом:

FROM golang:1.16-buster AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY *.go ./
RUN go build -o /hello_go_http
EXPOSE 8080
ENTRYPOINT ["/hello_go_http"]

Соберем образ:

docker build -t hello_go_http .

Далее проверим размер созданного образа, выполнив команду:

docker images

Образ занимает 869 Мб. Для вывода одной фразы “Hello World” это довольно много. В данном случае стоит воспользоваться multi-stage сборкой и собрать образ только из исполняемых файлов. Переделаем вышеописанный Dockerfile под multi-stage сборку:

FROM golang:1.16-buster AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY *.go ./
RUN go build -o /hello_go_http
 
FROM gcr.io/distroless/base-debian10
WORKDIR /
COPY --from=builder /hello_go_http /hello_go_http
EXPOSE 8080
ENTRYPOINT ["/hello_go_http"]

Соберем образ еще раз:

docker build -t hello_go_http_multistage .

И проверяем размер образа, созданного при помощи многоэтапной сборки:

docker images

Как можно увидеть, размер существенно сократился – 25.4 Мб вместо прошлых 869 Мб!

Способ 7. Использование пустых образов

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

В таком случае можно отказаться от использования базового образа. Но инструкция FROM является обязательным условием и должна присутствовать в Dockerfile, без базового образа не получится собрать итоговый образ.

Для решения проблемы можно использовать специальный образ — scratch. Он представляет собой пустой образ, в котором нет никаких файлов. Scratch часто используют для построения других образов, например, для Debian или Alpine. После сборки образа при помощи scratch, в готовом образе не создается дополнительный слой, и как итог не увеличивается объем образа.

Итог

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


НЛО прилетело и оставило здесь промокод для читателей нашего блога:

— 15% на все тарифы VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.

Комментарии (15)


  1. QtRoS
    29.11.2022 13:52

    Немного оффтоп, но:

    Мulti stage сборка позволяет использовать несколько инструкций FROM, и как итог используется сразу 2 базовых образа

    Насколько я помню, данный подход не позволяет полноценно совместить два образа, только использовать один как первый этап другого. А если действительно нужен образ, в котором, например, одновременно и python, и node.js? Есть какой-то способ добиться этого без лишних усилий?


    1. 1shaman Автор
      29.11.2022 14:25

      Как вариант, можно использовать готовый образ, где уже предустановлен и python, и nodejs https://hub.docker.com/r/nikolaik/python-nodejs


      1. alexxxnf
        29.11.2022 21:10

        Использовать готовый образ может быть опасно. Почему вы должны доверять автору?

        Я обычно беру официальный образ Python и сам установливаю в него Node.js пакетным менеждером ОС. Или наоборот, в официальный образ с Node.js устанавливаю Python.


        1. pOmelchenko
          29.11.2022 22:21
          +1

          Тогда надо брать базовый и собирать свой с нужными зависимостями


  1. noRoman
    29.11.2022 16:54

    Здесь

    FROM golang:1.16-buster AS builder
    WORKDIR /app
    COPY go.* ./
    RUN go mod download
    COPY *.go ./
    RUN go build -o /hello_go_http
    ...

    не будет лучше сделать так?

    FROM golang:1.16-buster AS builder
    WORKDIR /app
    COPY . .
    RUN go build -o /hello_go_http

    при build всё подтянется само.
    Или я просто не понимаю тонкостей (


    1. Iv38
      29.11.2022 17:06
      +1

      Если выполняется команда RUN, зависящая от какого-то набора файлов, то лучше предварительно скопировать только этот набор файлов. Если при последующих сборках эти файлы не будут меняться, команда RUN не будет выполняться, возьмётся готовый слой. Копируя всё сразу, вы рискуете скопировать какие-то файлы, которые были изменены, но на ход сборки невлияют, однако команда RUN всё равно будет выполнена — лишнее время и сброс всех слоёв дальше.


    1. Vitaly48
      30.11.2022 01:22
      +1

      Тогда при каждом изменении кода пакеты каждый раз будут подтягиваться заново, а в первом примере они будут кешироваться


  1. amarkevich
    29.11.2022 19:14

    1. openjdk deprecated, из списка альтернатив личто я выбрал eclipse-temurin

    2. для работы Java приложению обычно достаточно JRE, образ ещё меньше

    3. dockerignore никак не влияет на размер итогового образа, только на время сборки, т.к. демону по умолчнию отправляется всё содержимое директории. Косвенно может влиять, если ненужные пути попадают в инструкции ADD/COPY


    1. Teapot
      30.11.2022 15:36

      А сам список был выбран с той же страницы? Просто в нём только образы с отметкой official, что означает ровно те же грабли, когда размещением образов занимается кто-то из Docker Hub-а, а не непосредственно разработчики.


  1. alexxxnf
    29.11.2022 21:05
    +1

    Хорошая статья, спасибо!

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

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

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


    1. Iv38
      30.11.2022 02:32

      Да, не совсем правильно минимизировать число слоёв. При сложной сборке большее число слоёв позволяет оптимизировать процесс сборки, сократив время и объём пересобранных слоёв. Но тут важно с умом подойти к последовательности операций, собирая те слои, что меняются реже раньше тех, что меняются чаще.

      Если про новые фичи говорить, то Buildkit — бомба. Он не только значительно быстрее зачастую, но ещё и имеет более информативный лог. Ну и в плане новых фич можно ещё отметить docker-bake — некий аналог docker-compose для сборки.


  1. Teapot
    30.11.2022 00:52

    По Java небольшой комментарий. Образ openjdk уже длительное время является deplrecated. На странице даже приведён список "official" образов на замену. Помимо размера стоит обращать пристальное внимание на безопасность. Вот например статья со сравнением размеров и результатов работы сканеров
    https://thecattlecrew.net/2022/11/07/preparing-for-spring-boot-3-choose-the-right-java-17-base-image/
    Помимо Alpine теперь есть и Alpaquita (https://hub.docker.com/r/bellsoft/alpaquita-linux-base), в ней кстати примечательно отсутствие уязвимостей при примерно том же размере. Ну и соответствующие минималистичные базовые образы вроде bellsoft/liberica-runtime-container:jdk-17-musl.


    1. ggo
      30.11.2022 09:59

      в ней кстати примечательно отсутствие уязвимостей

      просто интересно, как это достигается?


      1. Teapot
        30.11.2022 15:31

        В Alpine например по умолчанию libssl / libcrypto более низких мажорных версий. Кстати алпайн алпайну рознь. В базовых образах Либерики на alpine эти библиотеки были обновлены на более актуальную минорную версию. Ничего подобного в других образах не наблюдается. Из интересного, довольно неплохо в плане закрытия уязвимостей слоя ОС себя показывает Ubuntu, но обновления JDK они выпускают не слишком оперативно, и размер образа далеко не самый маленький.


  1. JPEGEC
    01.12.2022 01:19

    Перечислять пакеты необходимо на нескольких строках, разделяя список символами .

    В смысле "необходимо"?