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. Как упростить жизнь себе и людям

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


  1. Akuma
    26.01.2019 10:25

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

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


    1. gecube
      26.01.2019 11:48

      Согласен. Вообще статья выглядит как реклама distroless. Гуголь в каждый дом )

      На самом деле микроменеджмент докер-образов реально помогает. И тут есть больше возможностей, чем в статье.
      1. всегда существует squash. У него минус в том, что он схлопывает все слои и поэтому суммарный объем ВСЕХ докер-образов в системе может увеличиться (т.к. нет общих слоев).
      2. правильная расстановка requirements.txt, npm install и пр. в файле (лучше максимально в конец, но еще до внедрения пользовательского кода).
      3. чистить в том же блоке RUN зависимости, которые нужны для сборки образа (убираем кэши и пр.). Про мультистейдж, кстати, сказали — супер!
      и многое-многое другое.


  1. TyVik
    26.01.2019 11:20

    Alpine Linux — клёвая вещь! По возможности на нём всё делаю, но надо быть осторожнее. Допустим, тех же библиотек для PostGIS в репах нет, и приходится шаманить. Не скажу, что это проблема, но легко на создание образа вместо 20 минут можно потратить 3 часа.


    1. gecube
      26.01.2019 11:45

      У alpine есть недостаток. На маленьких и простых образах — alpine впереди, образ реально меньше debian/ubuntu. Но если alpine накачать python + numpy + scikit + pandas и прочую дичь, то размер получается такой же как у соответствующего образа на ubuntu + в минусах более долгое время сборки на alpine.


      1. TyVik
        26.01.2019 12:36

        Это уже после удаления зависимостей компиляции и очистки кеша? Имею ввиду вот это:
        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


        1. gecube
          26.01.2019 12:39

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


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


          1. TyVik
            26.01.2019 12:45

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


  1. Akuma
    26.01.2019 11:50
    +4

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

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


    1. gecube
      26.01.2019 12:47

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


      Иначе у вас получаются разные окружения с соответствующими последствиями.
      prod никогда не будет 100% такой же, как и тестовая среда. Всегда придется чем-то жертвовать. Тем же объемом вливаемых данных в среду. Иначе держать две полностью идентичные среды с зеркалированием трафика влетает в очень большую копеечку и попросту не нужно. Поэтому задача админов, программистов — выбрать те параметры, критерии, которые не очень существенны. И именно ими пожертвовать при моделировании тестовой среды.


      1. Akuma
        26.01.2019 14:06
        +2

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

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


        1. gecube
          26.01.2019 15:45

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

          Соглашусь, что это не является проблемой, если речь не идет про образы по 5ГиБ, но ведь есть и такие кейсы.
          И легко можно потратиться на трафик, если будет куча скачиваний.
          https://www.brianchristner.io/docker-image-base-os-size-comparison/
          https://nickjanetakis.com/blog/the-3-biggest-wins-when-using-alpine-as-a-base-docker-image


  1. neenik
    26.01.2019 12:13
    +1

    bash в Alpine «называется» ash.


    1. gecube
      26.01.2019 12:43

      ну-ну


      / # 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. neenik
        26.01.2019 13:30
        +1

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


        1. gecube
          26.01.2019 15:42

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


  1. faiwer
    26.01.2019 12:57
    +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. gecube
      26.01.2019 15:42
      +1

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

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

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

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


  1. ivanych
    26.01.2019 00:32

    > Как видим, итоговый образ возрос на пять новых слоев

    Вы наверное хотели сказать на шесть?


  1. ivanych
    26.01.2019 00:59

    > $ docker build -t node-vanilla

    Точку в конце пропустили.


    1. mikesidd Автор
      26.01.2019 12:23

      Спасибо, поправил!