Образы Docker могуть быть очень большими. Многие превышают 1 Гб в размере. Как они становятся такими? Должны ли они быть такими? Можем ли мы сделать их меньше, не жертвуя функциональностью?
В CenturyLink Lab мы много работали над сборкой различных docker-образов в последнее время. Когда мы начали экспериментировать с их созданием, мы обнаружили, что наши сборки очень быстро раздуваются в объеме (обычным делом было собрать образ, который весит 1 Гб или больше). Размер, конечно, не столь важен, если мы говорим про образы по два гига, лежащие на локальной машине. Но это становится проблемой, когда вы начинаете постоянно скачивать/отправлять эти образы через интернет.
Я решил, что стоит копнуть поглубже и разобраться с тем, как работает процесс создания docker-образов, чтобы понять, что можно сделать для уменьшения размера наших сборок.
В качестве небольшого отступления, Адриан де Йонг (Adriaan de Jonge) недавно опубликовал статью «Создание самого маленького возможного контейнера Docker», в которой он описал как собрать образ, не содержащий ничего, кроме статически слинкованного бинарника Go, который запускается вместе с контейнером. Его образ поразительно мал — 3.6 Мб. Здесь я не буду рассматривать подобные крайности. Как человек, привыкший работать с языками вроде Python и Ruby мне нужен чуть больший уровень поддержки со стороны ОС, и я с радостью принесу в жертву сотню мегабайт свободного места, чтобы иметь возможность запускать Debian и
Прежде, чем мы перейдем к теме уменьшения ваших образов, нужно поговорить о слоях. Концепция слоев затрагивает различные низкоуровневые технические детали о вещах вроде корневой файловой системы (rootfs), механизма копирования при записи (copy-on-write) и каскадно-объединенного монтирования (union mount). К счастью, эта тема достаточно хорошо раскрыта в другом месте, поэтому я не буду пересказывать ее здесь. Для наших целей важным является понимание того, что каждая инструкция в Dockerfile приводит к созданию нового слоя образа.
Давайте взглянем на пример Dockerfile, чтобы увидеть это в действии:
Совершенно бесполезный образ, но он поможет нам продемонстрировать сказанное. Здесь мы используем
Соберем этот образ:
Если вы посмотрите на результат выполнения команды
Мы можем увидеть конечный результат запустив команду
Тут вы можете увидеть образ помеченный, как
Мы часто говорим о слоях и образах так, словно это разные вещи. Но, на самом деле, каждый слой — это уже образ, а слой образа — это просто коллекция других образов.
Так же, как мы выполним:
И тот и другой — образы, на основе которых могут быть запущены контейнеры. Разница только в том, что первый именован, а второй — нет. Такая возможность запуска контейнеров из любого слоя может оказаться весьма полезной при отладке вашего Dockerfile.
Зная, что образ — это не что иное, как коллекция других образов, можно прийти к очевидному выводу: размер образа равен сумме размеров образов, его составляющих.
Посмотрим на вывод команды
Мы можем увидеть все слои образа
Здесь только две инструкции, которые делают что-то значимое для нашего образа: инструкция
Давайте сохраним наш образ в tar-архив и посмотрим каков будет вес:
Когда образ сохраняется таким образом в tar-файл, туда также помещаются различные метаданные о каждом слое, поэтому конечный размер будет чуть больше суммы размеров всех слоев.
Добавим еще одну инструкцию в Dockerfile:
Новая инструкция удалит файл сразу же после его создания
Если мы выполним
Заметьте, что вызов
Если бы мы вызвали
Каждая дополнительная инструкция в вашем Dockerfile будет только увеличивать общий размер образа.
Конечно, данный пример притянут за уши. Но понимание того факта, что образы являются суммами слоев, из которых они состоят, важно при поиске путей их уменьшения. Ниже я опишу несколько способов, позволяющих это сделать.
Довольно очевидный совет. Однако, выбор основы может существенно повлиять на конечный размер образа. Вот, например, список популярных базовых образов и их размеры:
Мы в команде раньше использовали
Список полезных баз может быть разным и зависит от ваших нужд, но вам точно стоит его проверить. Если вы используете Ubuntu, когда хватило бы и BusyBox, то вы напрасно тратите кучу места.
Хотелось бы, чтобы размер образов отображался в хранилище Docker. Но сейчас, к сожалению, чтобы узнать размер, образ нужно скачать.
Одним из преимуществ слойного подхода является возможность повторного использования слоев между разными образами. В примере ниже показаны три образа, использующих
Каждый из них надстраивается над
Это означает, что один раз скачав
Так что вы можете сохранить немалое количество места и интернет-траффика, используя общую базу для разных образов.
В примере выше мы создаем файл, а затем сразу же его удаляем. Ситуация хоть и надуманная, но нечто похожее часто происходит при построении образов. Давайте посмотрим на что-то более реалистичное:
Мы скачиваем tar-архив, распаковываем его, кое-что перемещаем и подчищаем за собой.
Как мы убедились ранее, каждая из этих инструкций создает отдельный слой. Несмотря на то, что мы удаляем архив и извлеченные файлы, они все равно остаются частью образа.
Запуск
Мы можем исправить это, проведя небольшой рефакторинг нашего Dockerfile:
Вместо запуска каждой команды в отдельной инструкции
Вот, что получилось в результате:
Заметьте, что в итоге мы получили такой же образ, при этом избавившись от нескольких лишних слоев и сохранив 150 Мб свободного пространства.
Я бы не советовал вам срочно пойти и переписать все команды в ваших Dockerfile в одну строчку. Однако, если вы замечаете, что где-то есть похожая ситуация, когда вы создаете, а потом удаляете файлы, то совмещение нескольких инструкций в одну поможет вам держать размер образа минимальным.
Все вышеописаные стратегии исходят из предположения о том, что вы создаете свой собственный образ, или, хотя-бы, имеете доступ к Dockerfile. Однако, возможна ситуация, когда у вас есть образ, созданный кем-то другим, и вы хотите немного облегчить его.
В этом случае мы можем воспользоваться тем фактом, что создание контейнера приводит к слиянию всех слоев в один.
Давайте вернемся к нашему образу
Так как наш образ, по сути, ничего не делает, он сразу же завершает работу. Это дает нам остановленный контейнер, который представляет из себя результат слияния всех слоев образа (я использовал флаг
Если мы экспортируем этот контейнер, перенаправив вывод в команду
Обратите внимание, что история для нашего нового образа
И, хотя это довольной ловкий трюк, следует заметить, что у него есть существенные минусы:
Поэтому, я бы точно не советовал вам бросаться «схлопывать» все ваши образы. Но, иногда, это может быть полезным: в случае, если вы пытаетесь оптимизировать чужой образ, или просто хотите узнать насколько можно потеснить свои.
— Источник: Optimizing Docker Images
В CenturyLink Lab мы много работали над сборкой различных docker-образов в последнее время. Когда мы начали экспериментировать с их созданием, мы обнаружили, что наши сборки очень быстро раздуваются в объеме (обычным делом было собрать образ, который весит 1 Гб или больше). Размер, конечно, не столь важен, если мы говорим про образы по два гига, лежащие на локальной машине. Но это становится проблемой, когда вы начинаете постоянно скачивать/отправлять эти образы через интернет.
Я решил, что стоит копнуть поглубже и разобраться с тем, как работает процесс создания docker-образов, чтобы понять, что можно сделать для уменьшения размера наших сборок.
В качестве небольшого отступления, Адриан де Йонг (Adriaan de Jonge) недавно опубликовал статью «Создание самого маленького возможного контейнера Docker», в которой он описал как собрать образ, не содержащий ничего, кроме статически слинкованного бинарника Go, который запускается вместе с контейнером. Его образ поразительно мал — 3.6 Мб. Здесь я не буду рассматривать подобные крайности. Как человек, привыкший работать с языками вроде Python и Ruby мне нужен чуть больший уровень поддержки со стороны ОС, и я с радостью принесу в жертву сотню мегабайт свободного места, чтобы иметь возможность запускать Debian и
apt-get install
-ить свои зависимости. Поэтому, хоть я и завидую крошечному образу Адриана, но мне нужна поддержка более широкого круга приложений, что делает его подход непрактичным.Слои
Прежде, чем мы перейдем к теме уменьшения ваших образов, нужно поговорить о слоях. Концепция слоев затрагивает различные низкоуровневые технические детали о вещах вроде корневой файловой системы (rootfs), механизма копирования при записи (copy-on-write) и каскадно-объединенного монтирования (union mount). К счастью, эта тема достаточно хорошо раскрыта в другом месте, поэтому я не буду пересказывать ее здесь. Для наших целей важным является понимание того, что каждая инструкция в Dockerfile приводит к созданию нового слоя образа.
Давайте взглянем на пример Dockerfile, чтобы увидеть это в действии:
FROM debian:wheezy
RUN mkdir /tmp/foo
RUN fallocate -l 1G /tmp/foo/bar
Совершенно бесполезный образ, но он поможет нам продемонстрировать сказанное. Здесь мы используем
debian:wheezy
в качестве базового образа, создаем директорию /tmp/foo
, а в ней выделяем 1 Гб места под файл bar
.Соберем этот образ:
$ docker build -t sample .
Sending build context to Docker daemon 2.56 kB
Sending build context to Docker daemon
Step 0 : FROM debian:wheezy
---> e8d37d9e3476
Step 1 : RUN mkdir /tmp/foo
---> Running in 3d5d8b288cc2
---> 9876aa270471
Removing intermediate container 3d5d8b288cc2
Step 2 : RUN fallocate -l 1G /tmp/foo/bar
---> Running in 6c797329ee43
---> 3ebe08b36733
Removing intermediate container 6c797329ee43
Successfully built 3ebe08b36733
Если вы посмотрите на результат выполнения команды
docker build
, вы сможете увидеть что именно делает Docker, чтобы построить наш образ: - Используя значение инструкции
FROM
, Docker запускает контейнер на базеdebian:wheezy
образа (ID контейнера:3d5d8b288cc2
) - Внутри этого контейнера Docker выполняет команду
mkdir /tmp/foo
- Контейнер остановлен, закоммичен (в результате создан новый образ с ID
9876aa270471
) и затем удален - Docker запускает другой контейнер, на этот раз из образа, сохраненного на предыдущем шаге (у этого контейнера ID
6c797329ee43
) - Внутри запущенного контейнера Docker выполняет команду
fallocate -l 1G /tmp/foo/bar
- Контейнер остановлен, закоммичен (в результате создан новый образ с ID
3ebe08b36733
) и затем удален
Мы можем увидеть конечный результат запустив команду
docker images --tree
(к сожалению, флаг --tree
устарел и, скорее всего, будет удален в будущих релизах):$ docker images --tree
Warning: '--tree' is deprecated, it will be removed soon. See usage.
└─511136ea3c5a Virtual Size: 0 B Tags: scratch:latest
└─59e359cb35ef Virtual Size: 85.18 MB
└─e8d37d9e3476 Virtual Size: 85.18 MB Tags: debian:wheezy
└─9876aa270471 Virtual Size: 85.18 MB
└─3ebe08b36733 Virtual Size: 1.159 GB Tags: sample:latest
Тут вы можете увидеть образ помеченный, как
debian:wheezy
, после которого идут два контейнера, о которых говорилось ранее (по одному на каждую инструкцию в Dockerfile).Мы часто говорим о слоях и образах так, словно это разные вещи. Но, на самом деле, каждый слой — это уже образ, а слой образа — это просто коллекция других образов.
Так же, как мы выполним:
docker run -it sample:latest /bin/bash
Мы легко можем запустить один из неименованных слоев:docker run -it 9876aa270471 /bin/bash
И тот и другой — образы, на основе которых могут быть запущены контейнеры. Разница только в том, что первый именован, а второй — нет. Такая возможность запуска контейнеров из любого слоя может оказаться весьма полезной при отладке вашего Dockerfile.
Размер образа
Зная, что образ — это не что иное, как коллекция других образов, можно прийти к очевидному выводу: размер образа равен сумме размеров образов, его составляющих.
Посмотрим на вывод команды
docker history
:$ docker history sample
IMAGE CREATED CREATED BY SIZE
3ebe08b36733 3 minutes ago /bin/sh -c fallocate -l 1G /tmp/foo/bar 1.074 GB
9876aa270471 3 minutes ago /bin/sh -c mkdir /tmp/foo 0 B
e8d37d9e3476 4 days ago /bin/sh -c #(nop) CMD [/bin/bash] 0 B
59e359cb35ef 4 days ago /bin/sh -c #(nop) ADD file:1e2ba3d9379f 85.18 MB
511136ea3c5a 13 months ago 0 B
Мы можем увидеть все слои образа
sample
вместе с командами, которые привели к их созданию, и их размером (заметьте, что порядок слоев в docker history
обратен порядку, отображаемому в docker images --tree
).Здесь только две инструкции, которые делают что-то значимое для нашего образа: инструкция
ADD
(наследуемая из debian:wheezy
) и наша fallocate
команда.Давайте сохраним наш образ в tar-архив и посмотрим каков будет вес:
$ docker save sample > sample.tar
$ ls -lh sample.tar
-rw-r--r-- 1 core core 1.1G Jul 26 02:35 sample.tar
Когда образ сохраняется таким образом в tar-файл, туда также помещаются различные метаданные о каждом слое, поэтому конечный размер будет чуть больше суммы размеров всех слоев.
Добавим еще одну инструкцию в Dockerfile:
FROM debian:wheezy
RUN mkdir /tmp/foo
RUN fallocate -l 1G /tmp/foo/bar
RUN rm /tmp/foo/bar
Новая инструкция удалит файл сразу же после его создания
fallocate
.Если мы выполним
docker build
для обновленного Dockerfile и посмотрим на историю снова, мы увидим следующее:$ docker history sample
IMAGE CREATED CREATED BY SIZE
9d9bdb929b00 8 seconds ago /bin/sh -c rm /tmp/foo/bar 0 B
3ebe08b36733 24 minutes ago /bin/sh -c fallocate -l 1G /tmp/foo/bar 1.074 GB
9876aa270471 24 minutes ago /bin/sh -c mkdir /tmp/foo 0 B
e8d37d9e3476 4 days ago /bin/sh -c #(nop) CMD [/bin/bash] 0 B
59e359cb35ef 4 days ago /bin/sh -c #(nop) ADD file:1e2ba3d9379f 85.18 MB
511136ea3c5a 13 months ago 0 B
Заметьте, что вызов
rm
добавил новый слой (в 0 байт), но все остальное осталось по-прежнему. Если мы сохраним наш обновленный образ, то должны увидеть, что размер практически не изменился (будет небольшая разница из-за метаданных добавленного слоя):$ docker save sample > sample.tar
$ ls -lh sample.tar
-rw-r--r-- 1 core core 1.1G Jul 26 02:55 sample.tar
Если бы мы вызвали
docker run
для этого образа и заглянули в директорию /tmp/foo
, то обнаружили бы ее пустой (в конечном счете, файл был удален). Однако, так как наш Dockerfile сгенерировал слой, содержащий файл весом 1 Гб, тот стал неотъемлимой частью образа. Каждая дополнительная инструкция в вашем Dockerfile будет только увеличивать общий размер образа.
Конечно, данный пример притянут за уши. Но понимание того факта, что образы являются суммами слоев, из которых они состоят, важно при поиске путей их уменьшения. Ниже я опишу несколько способов, позволяющих это сделать.
Выберите вашу основу
Довольно очевидный совет. Однако, выбор основы может существенно повлиять на конечный размер образа. Вот, например, список популярных базовых образов и их размеры:
$ docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
scratch latest 511136ea3c5a 13 months ago 0 B
busybox latest a9eb17255234 7 weeks ago 2.433 MB
debian latest e8d37d9e3476 4 days ago 85.18 MB
ubuntu latest ba5877dc9bec 4 days ago 192.7 MB
centos latest 1a7dc42f78ba 2 weeks ago 236.4 MB
fedora latest 88b42ffd1f7c 10 days ago 373.7 MB
Мы в команде раньше использовали
ubuntu
в качестве основы — в основном, потому что большинство из нас уже были с ней знакомы. Однако, немного поиграв с debian
, мы пришли к выводу, что он полностью удовлетворяет нашим потребностям и сохраняет при этом 100+ Мб места.Список полезных баз может быть разным и зависит от ваших нужд, но вам точно стоит его проверить. Если вы используете Ubuntu, когда хватило бы и BusyBox, то вы напрасно тратите кучу места.
Хотелось бы, чтобы размер образов отображался в хранилище Docker. Но сейчас, к сожалению, чтобы узнать размер, образ нужно скачать.
Используйте вашу основу повторно
Одним из преимуществ слойного подхода является возможность повторного использования слоев между разными образами. В примере ниже показаны три образа, использующих
debian:wheezy
в качестве основы:$ docker images --tree
Warning: '--tree' is deprecated, it will be removed soon. See usage.
└─511136ea3c5a Virtual Size: 0 B Tags: scratch:latest
└─e8d37d9e3476 Virtual Size: 85.18 MB Tags: debian:wheezy
├─22a0de5ea279 Virtual Size: 85.18 MB
│ └─057ac524d834 Virtual Size: 85.18 MB
│ └─bd30825f7522 Virtual Size: 106.2 MB Tags: creeper:latest
├─d689af903018 Virtual Size: 85.18 MB
│ └─bcf6f6a90302 Virtual Size: 85.18 MB
│ └─ffab3863d257 Virtual Size: 95.67 MB Tags: enderman:latest
└─9876aa270471 Virtual Size: 85.18 MB
└─3ebe08b36733 Virtual Size: 1.159 GB
└─9d9bdb929b00 Virtual Size: 1.159 GB Tags: sample:latest
Каждый из них надстраивается над
debian:wheezy
, но это не три копии Debian. Вместо копирования каждый образ содержит ссылку на экземпляр Debian-слоя (одна из причин, по которой мне нравится docker images --tree
, в том, что она наглядно демонстрирует связи между различными слоями).Это означает, что один раз скачав
debian:wheezy
, вам больше не придется тянуть эти слои снова, и каждый его бит, используемый в образах, будет занимать место лишь единожды.Так что вы можете сохранить немалое количество места и интернет-траффика, используя общую базу для разных образов.
Группируйте ваши команды
В примере выше мы создаем файл, а затем сразу же его удаляем. Ситуация хоть и надуманная, но нечто похожее часто происходит при построении образов. Давайте посмотрим на что-то более реалистичное:
FROM debian:wheezy
WORKDIR /tmp
RUN wget -nv
RUN tar -xvf someutility-v1.0.0.tar.gz
RUN mv /tmp/someutility-v1.0.0/someutil /usr/bin/someutil
RUN rm -rf /tmp/someutility-v1.0.0
RUN rm /tmp/someutility-v1.0.0.tar.gz
Мы скачиваем tar-архив, распаковываем его, кое-что перемещаем и подчищаем за собой.
Как мы убедились ранее, каждая из этих инструкций создает отдельный слой. Несмотря на то, что мы удаляем архив и извлеченные файлы, они все равно остаются частью образа.
$ docker history some utility
IMAGE CREATED CREATED BY SIZE
33f4a99 16 seconds ago /bin/sh -c rm /tmp/someutility-v1.0.0.tar.gz 0 B
fec7b5e 17 seconds ago /bin/sh -c rm -rf /tmp/someutility-v1.0.0 0 B
0851974 18 seconds ago /bin/sh -c mv /tmp/someutility-v1.0.0/someuti 12.21 MB
5b6b996 19 seconds ago /bin/sh -c tar -xvf someutility-v1.0.0.tar.gz 99.91 MB
0eebad5 20 seconds ago /bin/sh -c wget -nv http://centurylinklabs.com 55.34 MB
d6798fc 8 minutes ago /bin/sh -c #(nop) WORKDIR /tmp 0 B
e8d37d9 5 days ago /bin/sh -c #(nop) CMD [/bin/bash] 0 B
59e359c 5 days ago /bin/sh -c #(nop) ADD file:1e2ba3d9379f7685a1 85.18 MB
511136e 13 months ago 0 B
Запуск
wget
приводит к появлению слоя размером 55 Мб, а распаковка архива к слою в 99 Мб. Нам не нужны эти файлы, а значит мы просто тратим 150+ Мб впустую.Мы можем исправить это, проведя небольшой рефакторинг нашего Dockerfile:
FROM debian:wheezy
WORKDIR /tmp
RUN wget -nv && \
tar -xvf someutility-v1.0.0.tar.gz && \
mv /tmp/someutility-v1.0.0/someutil /usr/bin/someutil && \
rm -rf /tmp/someutility-v1.0.0 && \
rm /tmp/someutility-v1.0.0.tar.gz
Вместо запуска каждой команды в отдельной инструкции
RUN
мы сгруппировали их с помощью оператора &&
. И хотя Dockerfile становится чуть менее читабельным, это позволяет нам удалить tar-архив и извлеченную директорию прежде, чем слой будет закоммичен.Вот, что получилось в результате:
$ docker history some utility
IMAGE CREATED CREATED BY SIZE
8216b5f 7 seconds ago /bin/sh -c wget -nv http://centurylinklabs.com 12.21 MB
d6798fc 17 minutes ago /bin/sh -c #(nop) WORKDIR /tmp 0 B
e8d37d9 5 days ago /bin/sh -c #(nop) CMD [/bin/bash] 0 B
59e359c 5 days ago /bin/sh -c #(nop) ADD file:1e2ba3d9379f7685a1 85.18 MB
511136e 13 months ago 0 B
Заметьте, что в итоге мы получили такой же образ, при этом избавившись от нескольких лишних слоев и сохранив 150 Мб свободного пространства.
Я бы не советовал вам срочно пойти и переписать все команды в ваших Dockerfile в одну строчку. Однако, если вы замечаете, что где-то есть похожая ситуация, когда вы создаете, а потом удаляете файлы, то совмещение нескольких инструкций в одну поможет вам держать размер образа минимальным.
«Схлопывайте» ваши образы
Все вышеописаные стратегии исходят из предположения о том, что вы создаете свой собственный образ, или, хотя-бы, имеете доступ к Dockerfile. Однако, возможна ситуация, когда у вас есть образ, созданный кем-то другим, и вы хотите немного облегчить его.
В этом случае мы можем воспользоваться тем фактом, что создание контейнера приводит к слиянию всех слоев в один.
Давайте вернемся к нашему образу
sample
(тому, который с fallocate
и rm
) и запустим его:$ docker run -d sample
7423d238b754e6a2c5294aab7b185f80be2457ee36de22795685b19ff1cf03ec
Так как наш образ, по сути, ничего не делает, он сразу же завершает работу. Это дает нам остановленный контейнер, который представляет из себя результат слияния всех слоев образа (я использовал флаг
-d
просто, чтобы отобразить ID контейнера).Если мы экспортируем этот контейнер, перенаправив вывод в команду
docker import
, мы сможем превратить его обратно в образ:$ docker export 7423d238b | docker import - sample:flat
3995a1f00b91efb016250ca6acc31aaf5d621c6adaf84664a66b7a4594f695eb
$ docker history sample:flat
IMAGE CREATED CREATED BY SIZE
3995a1f00b91 12 seconds ago 85.18 MB
Обратите внимание, что история для нашего нового образа
sample:flat
показывает только один слой весом 85 Мб, — слой, содержащий гигабайтный файл пропал. И, хотя это довольной ловкий трюк, следует заметить, что у него есть существенные минусы:
- Сливая все слои вместе, вы теряете описанное ранее преимущество совместного использования слоев разными образами. Наш
sample:flat
образ сейчас содержит встроенную копиюdebian:wheezy
. - Все метаданные, обычно, сохраняемые вместе с образом, теряются в процессе запуска/эскпорта/импорта. Открываемые порты, переменные окружения, команда по умолчанию — все, что может быть объявлено в оригинальном образе, теряется.
Поэтому, я бы точно не советовал вам бросаться «схлопывать» все ваши образы. Но, иногда, это может быть полезным: в случае, если вы пытаетесь оптимизировать чужой образ, или просто хотите узнать насколько можно потеснить свои.
— Источник: Optimizing Docker Images