Когда дело доходит до создания Docker-контейнеров, лучше всегда стремиться к минимизации размера образов. Образы, которые используют одни и те же слои и весят меньше — быстрее переносятся и деплоятся.  Но как контролировать размер, когда каждое выполнение оператора RUN создает новый слой? Плюс, еще нужны промежуточные артефакты до создания самого образа... Возможно, вы знаете, что большинство Docker-файлов имеют свои, довольно странные, особенности, например: Ну зачем тут &&? Разве не проще запустить два оператора RUN, как здесь? Начиная с версии Docker 1.10, операторы COPY, ADD и RUN добавляют новый слой к образу. В предыдущем примере были созданы два слоя вместо одного.  Docker-слои сохраняют различия между предыдущей и текущей версией образа. И как Git-коммиты, они удобны, если вы делитесь ими с другими репозиториями или образами. Фактически, при запросе образа из реестра, загружаются только недостающие слои, что упрощает разделение образов между контейнерами. Но при этом, каждый слой занимает место, и чем их больше, тем тяжелее итоговый образ. Git-репозитории в этом отношении схожи: размер репозитория растет вместе с количеством слоев, потому что должен хранить все изменения между коммитами. Раньше была хорошая практика объединять несколько операторов RUN в одной строке, как в первом примере. Но теперь, увы, нет. Когда Git-репозиторий разрастается, можно просто свести всю историю изменений в один commit и забыть о нем. Оказалось, что нечто подобное можно реализовать и в Docker — посредством поэтапной сборки. Давайте создадим контейнер Node.js. Начнем с index.js: и package.json: Упакуем приложение со следующим Dockerfile: Создадим образ: Проверим, что все работает: Теперь можно пройти по ссылке: http://localhost:3000 и увидеть там «Hello World!». В Dockerfile теперь у нас есть операторы COPY и RUN, так что фиксируем увеличение как минимум на два слоя, по сравнению с исходным образом: Как видим, итоговый образ возрос на пять новых слоев: по одному для каждого оператора в нашем Dockerfile. Давайте теперь опробуем поэтапную Docker-сборку. Используем тот же самый Dockerfile, состоящий из двух частей: Первая часть Dockerfile создает три слоя. Затем слои объединяются и копируются на второй и заключительный этапы. Сверху в образ добавляются еще два слоя. В итоге имеем три слоя.  Давайте пробовать. Сначала создаем контейнер: Проверяем историю: Смотрим, изменился ли размер файла: Да, он стал меньше, но пока не значительно. Текущий образ предоставляет нам Node.js, yarn, npm, bash и много других полезных бинарников. Также, он создан на базе Ubuntu. Таким образом, развернув его, мы получаем полноценную операционную систему со множеством полезных бинарников и утилит. При этом они не нужны нам для запуска контейнера. Единственная нужная зависимость — это Node.js.  Docker-контейнеры должны обеспечивать работу одного процесса и содержать минимально необходимый набор инструментов для его запуска. Целая операционная система для этого не требуется. Таким образом, мы можем вынести из него все, кроме Node.js.  Но как?  В Google уже пришли к подобному решению — GoogleCloudPlatform/distroless. Описание к репозиторию гласит: Distroless-образы содержат только приложение и зависимости для его работы. Там нет менеджеров пакетов, shell'ов и других программ, которые обычно есть в стандартном дистрибутиве Linux.  Это то, что нужно!  Запускаем Dockerfile, чтобы получить новый образ: Собираем образ как обычно: Приложение должно нормально заработать. Чтобы проверить, запускаем контейнер: И идем на http://localhost:3000. Стал ли образ легче без лишних бинарников? Еще как! Теперь он весит всего 76,7 МБ, на целых 600 Мб меньше! Все круто, но есть один важный момент. Когда контейнер запущен, и надо его проверить, подключиться можно с помощью: Подключение к работающему контейнеру и запуск bash очень похоже на создание SSH-сессии.  Но поскольку distroless — это урезанная версия исходной операционной системы, там нет ни дополнительных бинарников, ни, собственно, shell! Как подключиться к запущенному контейнеру, если нет shell?  Самое интересное, что никак.  Это не очень хорошо, так как исполнять в контейнере можно только бинарники. И единственный, который можно запустить — это Node.js: На самом деле в этом есть и плюс, ведь если вдруг какой-то злоумышленник сможет получить доступ к контейнеру, он причинит намного меньше вреда, чем если бы у него был доступ к shell. Иными словами, меньше бинарников — меньше вес и выше безопасность. Но, правда, ценой более сложной отладки. Тут надо бы оговориться, что подключать и отлаживать контейнеры на prod-окружении не стоит. Лучше положиться на правильно настроенные системы логирования и мониторинга. Но что, если нам-таки нужен дебаггинг, и при этом мы хотим, чтобы docker-образ имел наименьший размер? Можно заменить distroless на Alpine-образ. Alpine Linux — это ориентированный на безопасность, легкий дистрибутив на основе musl libc и busybox. Но не будем верить на слово, а лучше проверим. Запускаем Dockerfile с использованием node:8-alpine: Создаем образ: Проверяем размер: На выходе имеем 69.7MB — это даже меньше, чем distroless-образ. Проверим, можно ли подключиться к работающему контейнеру (в случае с distrolles-образом мы не могли этого сделать). Запускаем контейнер: И подключаемся: Неудачно. Но, возможно, у контейнера есть sh'ell…: Отлично! У нас получилось подключиться к контейнеру, и при этом его образ имеет ещё и меньший размер. Но и тут не обошлось без нюансов. 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. Как упростить жизнь себе и людям Директор по развитию