Три простых приема для уменьшения Docker-образов

Автор оригинала: Daniele Polencic
  • Перевод
image

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


Но как контролировать размер, когда каждое выполнение оператора RUN создает новый слой? Плюс, еще нужны промежуточные артефакты до создания самого образа...


Возможно, вы знаете, что большинство Docker-файлов имеют свои, довольно странные, особенности, например:


FROM ubuntu
RUN apt-get update && apt-get install vim

Ну зачем тут &&? Разве не проще запустить два оператора RUN, как здесь?


FROM ubuntu
RUN apt-get update
RUN apt-get install vim

Начиная с версии Docker 1.10, операторы COPY, ADD и RUN добавляют новый слой к образу. В предыдущем примере были созданы два слоя вместо одного.


image


Слои как Git-коммиты.


Docker-слои сохраняют различия между предыдущей и текущей версией образа. И как Git-коммиты, они удобны, если вы делитесь ими с другими репозиториями или образами. Фактически, при запросе образа из реестра, загружаются только недостающие слои, что упрощает разделение образов между контейнерами.


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


1. Объединяем нескольких слоев в один с помощью поэтапной сборки Docker-образов


Когда Git-репозиторий разрастается, можно просто свести всю историю изменений в один commit и забыть о нем. Оказалось, что нечто подобное можно реализовать и в Docker — посредством поэтапной сборки.


Давайте создадим контейнер Node.js.


Начнем с index.js:


const express = require('express')
const app = express()
app.get('/', (req, res) => res.send('Hello World!'))
app.listen(3000, () => {
 console.log(`Example app listening on port 3000!`)
})

и package.json:


{
 "name": "hello-world",
 "version": "1.0.0",
 "main": "index.js",
 "dependencies": {
   "express": "^4.16.2"
 },
 "scripts": {
   "start": "node index.js"
 }
}

Упакуем приложение со следующим Dockerfile:


FROM node:8
EXPOSE 3000
WORKDIR /app
COPY package.json index.js ./
RUN npm install
CMD ["npm", "start"]

Создадим образ:


$ docker build -t node-vanilla .

Проверим, что все работает:


$ docker run -p 3000:3000 -ti --rm --init node-vanilla

Теперь можно пройти по ссылке: http://localhost:3000 и увидеть там «Hello World!».


В Dockerfile теперь у нас есть операторы COPY и RUN, так что фиксируем увеличение как минимум на два слоя, по сравнению с исходным образом:


$ docker history node-vanilla
IMAGE          CREATED BY                                      SIZE
075d229d3f48   /bin/sh -c #(nop)  CMD ["npm" "start"]          0B
bc8c3cc813ae   /bin/sh -c npm install                          2.91MB
bac31afb6f42   /bin/sh -c #(nop) COPY multi:3071ddd474429e1…   364B
500a9fbef90e   /bin/sh -c #(nop) WORKDIR /app                  0B
78b28027dfbf   /bin/sh -c #(nop)  EXPOSE 3000                  0B
b87c2ad8344d   /bin/sh -c #(nop)  CMD ["node"]                 0B
<missing>      /bin/sh -c set -ex   && for key in     6A010…   4.17MB
<missing>      /bin/sh -c #(nop)  ENV YARN_VERSION=1.3.2       0B
<missing>      /bin/sh -c ARCH= && dpkgArch="$(dpkg --print…   56.9MB
<missing>      /bin/sh -c #(nop)  ENV NODE_VERSION=8.9.4       0B
<missing>      /bin/sh -c set -ex   && for key in     94AE3…   129kB
<missing>      /bin/sh -c groupadd --gid 1000 node   && use…   335kB
<missing>      /bin/sh -c set -ex;  apt-get update;  apt-ge…   324MB
<missing>      /bin/sh -c apt-get update && apt-get install…   123MB
<missing>      /bin/sh -c set -ex;  if ! command -v gpg > /…   0B
<missing>      /bin/sh -c apt-get update && apt-get install…   44.6MB
<missing>      /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>      /bin/sh -c #(nop) ADD file:1dd78a123212328bd…   123MB

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


FROM node:8 as build
WORKDIR /app
COPY package.json index.js ./
RUN npm install
FROM node:8
COPY --from=build /app /
EXPOSE 3000
CMD ["index.js"]

Первая часть Dockerfile создает три слоя. Затем слои объединяются и копируются на второй и заключительный этапы. Сверху в образ добавляются еще два слоя. В итоге имеем три слоя.


image


Давайте пробовать. Сначала создаем контейнер:


$ docker build -t node-multi-stage .

Проверяем историю:


$ docker history node-multi-stage
IMAGE          CREATED BY                                      SIZE
331b81a245b1   /bin/sh -c #(nop)  CMD ["index.js"]             0B
bdfc932314af   /bin/sh -c #(nop)  EXPOSE 3000                  0B
f8992f6c62a6   /bin/sh -c #(nop) COPY dir:e2b57dff89be62f77…   1.62MB
b87c2ad8344d   /bin/sh -c #(nop)  CMD ["node"]                 0B
<missing>      /bin/sh -c set -ex   && for key in     6A010…   4.17MB
<missing>      /bin/sh -c #(nop)  ENV YARN_VERSION=1.3.2       0B
<missing>      /bin/sh -c ARCH= && dpkgArch="$(dpkg --print…   56.9MB
<missing>      /bin/sh -c #(nop)  ENV NODE_VERSION=8.9.4       0B
<missing>      /bin/sh -c set -ex   && for key in     94AE3…   129kB
<missing>      /bin/sh -c groupadd --gid 1000 node   && use…   335kB
<missing>      /bin/sh -c set -ex;  apt-get update;  apt-ge…   324MB
<missing>      /bin/sh -c apt-get update && apt-get install…   123MB
<missing>      /bin/sh -c set -ex;  if ! command -v gpg > /…   0B
<missing>      /bin/sh -c apt-get update && apt-get install…   44.6MB
<missing>      /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>      /bin/sh -c #(nop) ADD file:1dd78a123212328bd…   123MB

Смотрим, изменился ли размер файла:


$ docker images | grep node-
node-multi-stage   331b81a245b1   678MB
node-vanilla       075d229d3f48   679MB

Да, он стал меньше, но пока не значительно.


2. Сносим все лишнее из контейнера с помощью distroless


Текущий образ предоставляет нам Node.js, yarn, npm, bash и много других полезных бинарников. Также, он создан на базе Ubuntu. Таким образом, развернув его, мы получаем полноценную операционную систему со множеством полезных бинарников и утилит.


При этом они не нужны нам для запуска контейнера. Единственная нужная зависимость — это Node.js.


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


Таким образом, мы можем вынести из него все, кроме Node.js.


Но как?


В Google уже пришли к подобному решению — GoogleCloudPlatform/distroless.


Описание к репозиторию гласит:


Distroless-образы содержат только приложение и зависимости для его работы. Там нет менеджеров пакетов, shell'ов и других программ, которые обычно есть в стандартном дистрибутиве Linux.


Это то, что нужно!


Запускаем Dockerfile, чтобы получить новый образ:


FROM node:8 as build
WORKDIR /app
COPY package.json index.js ./
RUN npm install
FROM gcr.io/distroless/nodejs
COPY --from=build /app /
EXPOSE 3000
CMD ["index.js"]

Собираем образ как обычно:


$ docker build -t node-distroless .

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


$ docker run -p 3000:3000 -ti --rm --init node-distroless

И идем на http://localhost:3000. Стал ли образ легче без лишних бинарников?


$ docker images | grep node-distroless
node-distroless   7b4db3b7f1e5   76.7MB

Еще как! Теперь он весит всего 76,7 МБ, на целых 600 Мб меньше!


Все круто, но есть один важный момент. Когда контейнер запущен, и надо его проверить, подключиться можно с помощью:


$ docker exec -ti <insert_docker_id> bash

Подключение к работающему контейнеру и запуск bash очень похоже на создание SSH-сессии.


Но поскольку distroless — это урезанная версия исходной операционной системы, там нет ни дополнительных бинарников, ни, собственно, shell!


Как подключиться к запущенному контейнеру, если нет shell?


Самое интересное, что никак.


Это не очень хорошо, так как исполнять в контейнере можно только бинарники. И единственный, который можно запустить — это Node.js:


$ docker exec -ti <insert_docker_id> node

На самом деле в этом есть и плюс, ведь если вдруг какой-то злоумышленник сможет получить доступ к контейнеру, он причинит намного меньше вреда, чем если бы у него был доступ к shell. Иными словами, меньше бинарников — меньше вес и выше безопасность. Но, правда, ценой более сложной отладки.


Тут надо бы оговориться, что подключать и отлаживать контейнеры на prod-окружении не стоит. Лучше положиться на правильно настроенные системы логирования и мониторинга.


Но что, если нам-таки нужен дебаггинг, и при этом мы хотим, чтобы docker-образ имел наименьший размер?


3. Уменьшаем базовые образы с помощью Alpine


Можно заменить distroless на Alpine-образ.


Alpine Linux — это ориентированный на безопасность, легкий дистрибутив на основе musl libc и busybox. Но не будем верить на слово, а лучше проверим.


Запускаем Dockerfile с использованием node:8-alpine:


FROM node:8 as build
WORKDIR /app
COPY package.json index.js ./
RUN npm install
FROM node:8-alpine
COPY --from=build /app /
EXPOSE 3000
CMD ["npm", "start"]

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


$ docker build -t node-alpine .

Проверяем размер:


$ docker images | grep node-alpine
node-alpine   aa1f85f8e724   69.7MB

На выходе имеем 69.7MB — это даже меньше, чем distroless-образ.


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


Запускаем контейнер:


$ docker run -p 3000:3000 -ti --rm --init node-alpine
Example app listening on port 3000!

И подключаемся:


$ docker exec -ti 9d8e97e307d7 bash
OCI runtime exec failed: exec failed: container_linux.go:296: starting container process caused "exec: \"bash\": executable file not found in $PATH": unknown

Неудачно. Но, возможно, у контейнера есть sh'ell…:


$ docker exec -ti 9d8e97e307d7 sh / #

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


Alpine-образы основаны на muslc — альтернативной стандартной библиотеке для C. В то время, как большинство Linux-дистрибутивов, таких как Ubuntu, Debian и CentOS, основаны на glibc. Считается, что обе эти библиотеки предоставляют одинаковый интерфейс для работы с ядром.


Однако у них разные цели: glibc является наиболее распространенной и быстрой, muslc же занимает меньше места и написана с уклоном в безопасность. Когда приложение компилируется, как правило, оно компилируется под какую-то определенную библиотеку C. Если потребуется использовать его с другой библиотекой, придется перекомпилировать.


Другими словами, сборка контейнеров на Alpine-образах может привести к неожиданному развитию событий, поскольку используемая в ней стандартная библиотека C отличается. Разница будет заметна при работе с прекомпилируемыми бинарниками, такими как расширения Node.js для C++.


Например, готовый пакет PhantomJS не работает на Alpine.


Так какой же базовый образ выбрать?


Alpine, distroless или ванильный образ — решать, конечно, лучше по ситуации.


Если имеем дело с prod-ом и важна безопасность, возможно, наиболее уместным будет distroless.


Каждый бинарник, добавленный к Docker-образу, добавляет определенный риск к стабильности всего приложения. Этот риск можно уменьшить, имея только один бинарник, установленный в контейнере.


Например, если злоумышленник смог найти уязвимость в приложении, запущенном на базе distroless-образа, он не сможет запустить в контейнере shell, потому что его там нет!


Если же по каким-то причинам размер docker-образа для вас крайне важен, определенно стоит присмотреться к образам на основе Alpine.


Они реально маленькие, но, правда, ценой совместимости. Alpine использует немного другую стандартную библиотеку C — muslc, так что иногда будут всплывать какие-то проблемы. С примерами можно ознакомиться по ссылкам: https://github.com/grpc/grpc/issues/8528 и https://github.com/grpc/grpc/issues/6126.


Ванильные же образы идеально подойдут для тестирования и разработки.


Да, они большие, но зато максимально похожи на полноценную машину с установленной Ubuntu. Кроме того, доступны все бинарники в ОС.


Подытожим размер полученных Docker-образов:


node:8 681MB
node:8 с пошаговой сборкой 678MB
gcr.io/distroless/nodejs 76.7MB
node:8-alpine 69.7MB


Напутствие от переводчика


Читайте другие статьи в нашем блоге:


Бэкапы Stateful в Kubernetes


Резервное копирование большого количества разнородных web-проектов


Telegram-бот для Redmine. Как упростить жизнь себе и людям

Nixys
30,00
Компания
Поделиться публикацией

Похожие публикации

Комментарии 21

    +1
    На самом деле в этом есть и плюс, ведь если вдруг какой-то злоумышленник сможет получить доступ к контейнеру, он причинит намного меньше вреда, чем если бы у него был доступ к shell

    Замуровать свой дом, чтобы ничего не украли? Так себе плюс. Хотя экономия 500 МБ конечно существенна.
      0
      Согласен. Вообще статья выглядит как реклама distroless. Гуголь в каждый дом )

      На самом деле микроменеджмент докер-образов реально помогает. И тут есть больше возможностей, чем в статье.
      1. всегда существует squash. У него минус в том, что он схлопывает все слои и поэтому суммарный объем ВСЕХ докер-образов в системе может увеличиться (т.к. нет общих слоев).
      2. правильная расстановка requirements.txt, npm install и пр. в файле (лучше максимально в конец, но еще до внедрения пользовательского кода).
      3. чистить в том же блоке RUN зависимости, которые нужны для сборки образа (убираем кэши и пр.). Про мультистейдж, кстати, сказали — супер!
      и многое-многое другое.
      +1
      Alpine Linux — клёвая вещь! По возможности на нём всё делаю, но надо быть осторожнее. Допустим, тех же библиотек для PostGIS в репах нет, и приходится шаманить. Не скажу, что это проблема, но легко на создание образа вместо 20 минут можно потратить 3 часа.
        +1
        У alpine есть недостаток. На маленьких и простых образах — alpine впереди, образ реально меньше debian/ubuntu. Но если alpine накачать python + numpy + scikit + pandas и прочую дичь, то размер получается такой же как у соответствующего образа на ubuntu + в минусах более долгое время сборки на alpine.
          0
          Это уже после удаления зависимостей компиляции и очистки кеша? Имею ввиду вот это:
          RUN apk update \
          && apk add --no-cache --virtual .build-deps libffi-dev build-base zlib-dev jpeg-dev \
          && pip install -r /tmp/requirements.txt --no-cache-dir \
          && apk del .build-deps
            0

            я, по-моему, вполне конкретно высказался.
            Да, это после удаления зависимостей.
            А еще Вы в курсе, что как Вы делаете — не стоит так? Потому что в Вашем сниппете инструкция RUN будет каждый раз выполняться при изменении requirements.txt. Т.е. мы сильно проигрываем в двух вещах: в кэшировании последующих слоев. И во времени сборки.


            И еще подтвержу свою точку зрения ссылкой на статью https://habr.com/ru/post/415513/
            Вывод простой — чем больше пакетов — тем меньше выгода от alpine.

              0
              Про удаление кеша Вы не сказали, так что я предположил.
              Ну и про RUN спасибо, конечно, но знаю. Это кусок из базового образа для продукта, который обновляется вручную только при изменении зависимостей. В CI/CD у нас собирается другой, который основан на этом.
        +4
        Так какой же базовый образ выбрать?

        Здесь огромная ошибка. Нужно для dev и prod выбирать одно и то же. Они могут отличаться способом внедрения исходников, но базовый образ и все библиотеки должны быть одни и те же. Иначе у вас получаются разные окружения с соответствующими последствиями.
          0

          Я соглашусь. Но можно попробовать сделать как. dev — пускай разрабы делают что угодно и как им удобно. Но вот тогда нам понадобится еще одна pre-prod среда (stage? uat?), в которой образы будут уже собираться "по науке" и идентично prod'у.


          Иначе у вас получаются разные окружения с соответствующими последствиями.
          prod никогда не будет 100% такой же, как и тестовая среда. Всегда придется чем-то жертвовать. Тем же объемом вливаемых данных в среду. Иначе держать две полностью идентичные среды с зеркалированием трафика влетает в очень большую копеечку и попросту не нужно. Поэтому задача админов, программистов — выбрать те параметры, критерии, которые не очень существенны. И именно ими пожертвовать при моделировании тестовой среды.
            +2
            Верно, но можно перестать кататься на квадратных колесах и сразу сесть за круглые.
            У нас на сервисе, например, построено примерно так:
            1. Есть базовый образ с необходимым ЯП, модулями пр. зависимостями. Эти образы меняются крайне редко.
            2. dev работает именно на этих образах путем монтирования в них исходников с машины разработки.
            3. prod образы билдятся на CI/CD, но содержат только ADD исходников в образ. Больше никаких изменений не допускается.
            4. Все конфиги (nginx, mysql, php, webpack и т.д.) вынесены в docker config. Единственный минус, докер не может их обновлять, только менять имя или удалять/создавать.

            Каких-то проблем с такой конфигурацией не наблюдается. Размер образов может быть внушительный, но, честно говоря, не на столько, чтобы создавать этим проблемы.
        +1
        bash в Alpine «называется» ash.
          0

          ну-ну


          / # mbp-gaal:~ gaal$ docker run -it --rm alpine /bin/sh
          / # readlink /bin/sh
          /bin/busybox
          / # which ash
          /bin/ash
          / # readlink /bin/ash
          /bin/busybox```
            +1
            Пройдите этот квест дальше. Следующий этап: найдите информацию о командной оболочке, которая используется в busybox.
              0

              Можете продолжить за меня.
              И еще — если бы bash там был полноценный, то не пришлось делать apk add bash, чтобы работали стандартные баш-скрипты....

          +2

          Я новичок в docker и не понял из статьи 1 вещь. Зачем мы используем вначале node:8 (с Ubuntu, 700+ MiB) а потом во второй стадии node:alpine (50+ MiB), если… мы просто можем без всяких стадий сразу взять alpine и наш npm install сделать уже прямо там по месту.


          Я столкнулся с парой проблем, когда npm install в alphine не мог собрать ряд бинарников из third-party npm packages, но все они решились за счёт apk add. В итоге размеры nodejs-app образа всё равно копеечные.


          И сразу вопрос. Вот допустим мы решили остаться с Ubuntu и двумя стадиями. В итоге у нас результирующий образ мелкий, а промежуточный большой. Мы можем использовать мелкий-образ, удалив большой? Там ведь unionFS. Она не будет пытаться обратиться к "большому слою" за файлами из него? Или COPY --from копирует по настоящему и привязки нет?

            +1
            Мы можем использовать мелкий-образ, удалив большой?

            они абсолютно независимы. На целевой сервер уедет мелкий оьбраз.
            Или COPY --from копирует по настоящему и привязки нет?

            ответил выше
            Я столкнулся с парой проблем, когда npm install в alphine не мог собрать ряд бинарников из third-party npm packages

            верно, поэтому все стадии сборки должны быть сделаны на совместимых образах. Условно — можно на первой стадии скомпилировать некий проект в убунту, а на второй стадии запустить его в эльпайн, но придется добавить все необходимые библиотеки рантайма (ну, типа libc). Либо, что проще — на каждой стадии использовать эльпайн (к сожалению, не всегда возможно из-за зависимостей).
            +1
            > Как видим, итоговый образ возрос на пять новых слоев

            Вы наверное хотели сказать на шесть?
              +1
              > $ docker build -t node-vanilla

              Точку в конце пропустили.
                +1
                Спасибо, поправил!
                +1

                По теме минимизации размеров, забыли самое главное. Это scratch образы.
                И в этом деле лучше всего проявляются особеннности Golang, где размер образа равен размеру бинарника.


                FROM golang:alpine as go_builder
                COPY app  /app
                WORKDIR /app
                RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
                
                FROM scratch
                COPY --from=go_builder /app .
                ENTRYPOINT ["/app"]

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

                Самое читаемое