Pull to refresh

Оптимизация образов Docker

Reading time 9 min
Views 65K
Образы Docker могуть быть очень большими. Многие превышают 1 Гб в размере. Как они становятся такими? Должны ли они быть такими? Можем ли мы сделать их меньше, не жертвуя функциональностью?

В 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, чтобы построить наш образ:

  1. Используя значение инструкции FROM, Docker запускает контейнер на базе debian:wheezy образа (ID контейнера: 3d5d8b288cc2)
  2. Внутри этого контейнера Docker выполняет команду mkdir /tmp/foo
  3. Контейнер остановлен, закоммичен (в результате создан новый образ с ID 9876aa270471) и затем удален
  4. Docker запускает другой контейнер, на этот раз из образа, сохраненного на предыдущем шаге (у этого контейнера ID 6c797329ee43)
  5. Внутри запущенного контейнера Docker выполняет команду fallocate -l 1G /tmp/foo/bar
  6. Контейнер остановлен, закоммичен (в результате создан новый образ с 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
Tags:
Hubs:
+41
Comments 18
Comments Comments 18

Articles