Сборка образов для Docker на основе базового образа, как правило, предполагает вызов команд в окружении этого базового образа. Например — вызов команды apt-get, которая есть в базовом образе, для установки новых пакетов.
Часто возникает необходимость доустановить в базовую систему некоторый набор утилит, с помощью которых происходит установка или сборка некоторых файлов, которые требуются в итоговом образе. Например, чтобы собрать Go-приложение, надо установить компилятор Go, положить все исходные коды приложения в базовом образе, скомпилировать требуемую программу. Однако в итоговом образе требуется лишь скомпилированная программа без всего набора утилит, который использовался для компиляции этой программы.
Проблема известная: одним из путей её решения может быть сборка вспомогательного образа и перенос файлов из вспомогательного образа в результирующий. Для этого появились Docker multi-stage builds или образы-артефакты в dapp. И данный подход идеально решает проблему подобную переносу результатов компиляции исходных кодов в итоговый образ. Однако он не решает все возможные проблемы…
Вот другой пример: для сборки образа используется Chef в локальном режиме. Для этого в базовый образ ставится chefdk, монтируются или добавляются рецепты, запускаются эти рецепты, которые настраивают образ, устанавливают новые компоненты, пакеты, файлы-конфиги и прочее. Аналогично может быть использована другая система управления конфигурациями — например, Ansible. Однако установленный chefdk занимает около 500 Мб и существенно увеличивает размеры итогового образа — оставлять его там нет смысла.
Но multi-stage builds в Docker уже не решат эту проблему. Что, если пользователю не хочется знать о том, каков побочный эффект работы программы — в частности, какие файлы она создает? Например, чтобы не держать лишние явные описания всех экспортируемых путей из образа. Хочется просто запустить программу, получить какой-то результат в образе, но чтобы программа и все окружение, нужное для ее работы, осталось вне итогового образа.
В случае с chefdk можно было бы монтировать директорию с этим chefdk в сборочный образ на время сборки. Но с этим решением есть проблемы:
- Не любая программа, нужная для сборки, устанавливается в отдельный каталог, который легко примонтировать в сборочный образ. В случае с Ansible надо монтировать Python в нестандартное место, чтобы не конфликтовать с системным Python, что уже может вызывать проблемы.
- Примонтированная программа будет зависеть от используемого базового образа. Если программа собрана для Ubuntu, то она может не запуститься в не предусмотренном для нее окружении — например, в Alpine. Даже chefdk, который является omnibus-пакетом со всеми своими зависимостями, все равно зависит от системного glibc и не будет работать в Alpine, где используется musl libc.
А что, если мы сможем подготовить некий статичный неизменный набор всех возможных полезных утилит, который будет так хитро слинкован, что будет работать в любом базовом образе, даже scratch? После подключения такого/таких образов в базовый, в итоговом образе останется лишь пустая директория mount-point, в которую были подключены эти утилиты.
В поисках приключений
Теория
Необходимо получить образ, в котором содержится набор программ в некоторой статически определенной нестандартной директории — например, /myutils
. Любая программа в /myutils
должна зависеть только от библиотек в /myutils
.
Динамически скомпилированная программа в Linux зависит от местоположения линкера ld-linux в системе. Например, бинарник bash
в ubuntu:16.04
скомпилирован так, что зависит от линкера /lib64/ld-linux-x86-64.so.2
:
$ ldd /bin/bash
linux-vdso.so.1 => (0x00007ffca67d8000)
libtinfo.so.5 => /lib/x86_64-linux-gnu/libtinfo.so.5 (0x00007fd8505a6000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007fd8503a2000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd84ffd8000)
/lib64/ld-linux-x86-64.so.2 (0x00007fd8507cf000)
Причем эта зависимость является статической и вкомпилирована в сам бинарник:
$ grep "/lib64/ld-linux-x86-64.so.2" /bin/bash
Binary file /bin/bash matches
Таким образом, надо: а) скомпилировать условный /myutils/bin/bash
так, чтобы он использовал линкер /myutils/lib64/ld-linux-x86-64.so.2
; б) чтобы линкер /myutils/lib64/ld-linux-x86-64.so.2
был настроен на динамическую линковку библиотек из /myutils/{lib64,lib}
.
Первым шагом будет сборка образа toolchain
, который будет содержать всё, что необходимо для сборки и последующей работы других программ в нестандартной root-директории. Для этого нам как нельзя кстати придутся инструкции проекта Linux From Scratch.
Собираем дистрибутив dappdeps
Почему набор образов нашего «дистрибутива» называется dappdeps? Потому что эти образы использует сборщик dapp — они собираются под нужды этого проекта.
Итак, наша конечная цель:
- Образ dappdeps/toolchain с компилятором GCC для сборки других приложений и библиотекой glibc.
- Образ dappdeps/base с набором программ и всех зависимых библиотек: bash, gtar, sudo, coreutils, findutils, diffutils, sed, rsync, shadow, termcap.
- Образ dappdeps/gitartifact с утилитой Git и всеми зависимостями.
- Образ dappdeps/chefdk с omnibus-пакетом chefdk, который содержит все зависимости Chef, в т.ч. интерпретатор Ruby.
- Образ dappdeps/ansible с утилитой Ansible, который содержит все зависимости, в т.ч. интерпретатор Python.
Образы dappdeps могут зависеть друг от друга. Например, при сборке dappdeps/base требуется toolchain и glibc из образа dappdeps/toolchain. После компиляции всех утилит в dappdeps/base для их работы в runtime будут требоваться файлы из dappdeps/toolchain.
Главное условие заключается в том, чтобы утилиты из этих образов располагались в нестандартном месте, а именно — в /.dapp/deps/
, и не зависели ни от каких утилит или библиотек в стандартных системных путях. Также в dappdeps-образах не должно быть никаких других файлов, кроме /.dapp/deps
.
Такие образы позволят создавать на их основе контейнеры с томами, где содержатся утилиты, и монтировать их в другие контейнеры с использованием опции --volumes-from
для Docker.
Собираем dappdeps/toolchain
Глава 5 «Constructing a Temporary System» руководства Linux From Scratch как раз описывает процесс построения временного chroot-окружения в /tools
с некоторым набором утилит, которым затем собирается главный целевой дистрибутив.
В нашем случае немного переиначим директорию chroot-окружения. В параметре --prefix
при компиляции будем указывать /.dapp/deps/toolchain/0.1.1
. Это та директория, которая будет появляться в сборочном контейнере, при монтировании в него dappdeps/toolchain — в ней содержатся все нужные утилиты и библиотеки. Нам требуются лишь GNU binutils, GCC и glibc.
Собирается образ с использованием Docker multi-stage builds. В образе на основе ubuntu:16.04
подготавливается все окружение и производится компиляция и установка программ в /.dapp/deps/toolchain/0.1.1
. Затем эта директория копируется в scratch-образ dappdeps/toolchain:0.1.1. Dockerfile можно найти здесь.
Итоговый образ dappdeps/toolchain — это и есть «temporary system» в терминологии LFS. GCC в данной системе все еще завязан на системные пути к библиотекам, однако мы не будем добиваться того, чтобы GCC работал в любом базовом образе. Образ dappdeps/toolchain — вспомогательный, он будет использоваться далее, в т.ч. для сборки уже реально независимых от общих системных библиотек программ.
Используем Omnibus вместе с dappdeps/toolchain
Для сборки таких проектов, как chefdk или GitLab, используется Omnibus. Он позволяет создать самодостаточные наборы (self-contained bundle) с программой и всеми зависимыми библиотеками, кроме системного линкера и libc. Все инструкции описываются читаемыми удобными Ruby-рецептами. Также у проекта Omnibus есть библиотека уже написанных рецептов omnibus-software.
Итак, попробуем описать сборку остальных dappdeps-дистрибутивов с использованием Omnibus. Однако, чтобы избавиться от зависимости от системного линкера и libc, будем собирать все программы в Omnibus с использованием компилятора из dappdeps/toolchain. В этом случае программы окажутся завязаны на glibc, который тоже есть в dappdeps/toolchain.
Для этого сохраним содержимое dappdeps/toolchain как архив:
$ docker pull dappdeps/toolchain:0.1.1
$ docker save dappdeps/toolchain:0.1.1 -o dappdeps-toolchain.tar
Добавим этот архив через директиву Dockerfile ADD
и распакуем содержимое архива в корень сборочного контейнера:
ADD ./dappdeps-toolchain.tar /dappdeps-toolchain
RUN tar xf /dappdeps-toolchain/**/layer.tar -C /
Перед запуском сборки через omnibus добавляем в переменную PATH
путь /.dapp/deps/toolchain/0.1.1/bin
в качестве приоритетного, чтобы использовался GCC из dappdeps/toolchain.
Результат работы Omnibus — это пакет (в нашем случае — DEB), содержимое которого распаковывается и переносится в /.dapp/deps/{base|gitartifact|...}
с помощью Docker multi-stage builds аналогично dappdeps/toolchain.
Собираем dappdeps/base
Проект для Omnibus описывается с помощью файла проекта dapp/dappdeps/base/omnibus/config/projects/dappdeps-base.rb
:
name 'dappdeps-base'
license 'MIT'
license_file 'LICENSE.txt'
DOCKER_IMAGE_VERSION = "0.2.3"
install_dir "/.dapp/deps/base/#{DOCKER_IMAGE_VERSION}"
build_version DOCKER_IMAGE_VERSION
build_iteration 1
dependency "dappdeps-base"
В этом файле указаны все зависимости Omnibus-пакета dappdeps-base и целевая директория для установки. Зависимости могут располагаться либо в отдельном репозитории (например, omnibus-software), либо в директории omnibus/config/software
. Каждый файл в этой директории описывает инструкции по установке какого-то пакета/компонента. Для dappdeps-base в Omnibus написаны software-рецепты, отсутствующие в стандартном репозитории omnibus-software: acl
, attr
, coreutils
, diffutils
, findutils
, gtar
, rsync
, sed
, shadow
, sudo
, termcap
.
Рассмотрим на примере rsync
, как выглядит software-рецепт для Omnibus:
name 'rsync'
default_version '3.1.2'
license 'GPL-3.0'
license_file 'COPYING'
version('3.1.2') { source md5: '0f758d7e000c0f7f7d3792610fad70cb' }
source url: "https://download.samba.org/pub/rsync/src/rsync-#{version}.tar.gz"
dependency 'attr'
dependency 'acl'
dependency 'popt'
relative_path "rsync-#{version}"
build do
env = with_standard_compiler_flags(with_embedded_path)
command "./configure --prefix=#{install_dir}/embedded", env: env
command "make -j #{workers}", env: env
command 'make install', env: env
end
Директивой source
указывается URL, откуда надо скачать исходные коды. Зависимости от других компонентов указаны директивой dependency
по имени. Имя собираемого компонента задано директивой name
. Каждый software-рецепт в свою очередь может указывать зависимости от других компонентов. Внутри блока build
указаны стандартные команды сборки из исходных кодов.
Проект Omnibus и Dockerfile для dappdeps/base можно найти здесь.
Собираем dappdeps/gitartifact
В случае с dappdeps-gitartifact необходим лишь рецепт сборки Git, а он уже есть в omnibus-software — остается только подключить его в текущий Omnibus. В остальном все аналогично.
Проект Omnibus и Dockerfile для dappdeps/gitartifact можно найти здесь.
Собираем dappdeps/chefdk
Для chefdk тоже уже есть готовый проект Omnibus. Остается лишь добавить его в сборочный контейнер через Dockerfile и заменить стандартные пути установки chefdk /opt/chefdk
на /.dapp/deps/chefdk/2.3.17-2
(наш путь установки будет включать в себя версию Chef).
Dockerfile для сборки dappdeps/chefdk можно найти здесь.
Собираем dappdeps/ansible
Для сборки Ansible также заводим Omnibus-проект, в котором устанавливаем интерпретатор Python, pip и описываем software-рецепт для Ansible:
name "ansible"
ANSIBLE_GIT_TAG = "v2.4.4.0+dapp-6"
dependency "python"
dependency "pip"
build do
command "#{install_dir}/embedded/bin/pip install https://github.com/flant/ansible/archive/#{ANSIBLE_GIT_TAG}.tar.gz"
command "#{install_dir}/embedded/bin/pip install pyopenssl"
end
Как видно, образ с Ansible представляет собой встроенный Python, pip и установленный через pip Ansible с зависимостями.
Проект Omnibus и Dockerfile для dappdeps/ansible можно найти здесь.
Как использовать дистрибутив dappdeps?
Чтобы пользоваться образами dappdeps через монтирование томов, предварительно для каждого образа необходимо создать контейнер и указать, какой том хранится в этом контейнере. Этого требует Docker на данный момент.
$ docker create --name dappdeps-toolchain --volume /.dapp/deps/toolchain/0.1.1 dappdeps/toolchain:0.1.1 no-such-cmd
13edda732176a44d7d822202d8327565b78f4a2190368bb1df46cdad1e127b6e
$ docker ps -a | grep dappdeps-toolchain
13edda732176 dappdeps/toolchain:0.1.1 "no-such-cmd" About a minute ago Created dappdeps-toolchain
Контейнер называется dappdeps-toolchain
: по этому имени все объявленные томы этого контейнера можно использовать для монтирования в другие контейнеры с помощью --volumes-from
. Параметр-команду с произвольным текстом no-such-cmd
требуется указать для Docker, но данный контейнер никогда не будет запущен — он так и останется в состоянии Created
.
Создаем остальные контейнеры:
$ docker create --name dappdeps-base --volume /.dapp/deps/base/0.2.3 dappdeps/base:0.2.3 no-such-cmd
20f524c5b8b4a59112b4b7cb85e47eee660c7906fb72a4935a767a215c89964e
$ docker create --name dappdeps-ansible --volume /.dapp/deps/ansible/2.4.4.0-10 dappdeps/ansible:2.4.4.0-10 no-such-cmd
cd01ae8b69cd68e0611bb6c323040ce202e8e7e6456a3f03a4d0a3ffbbf2c510
$ docker create --name dappdeps-gitartifact --volume /.dapp/deps/gitartifact/0.2.1 dappdeps/gitartifact:0.2.1 no-such-cmd
2c12a8743c2b238d90debaf066e29685b41b138c10f2b893a815931df866576d
$ docker create --name dappdeps-chefdk --volume /.dapp/deps/chefdk/2.3.17-2 dappdeps/chefdk:2.3.17-2 no-such-cmd
4dffe74c49c8e4cdf9d749177ae9efec3bdae6e37c8b6df41b6eb527a5c1d891
Вот мы и дошли до кульминации, ради которой задумывался весь этот сыр-бор. Итак, в качестве демонстрации возможностей установим в образ Alpine пакеты nginx
и tree
, запустив Ansible из dappdeps/ansible через Bash из dappdeps/base:
$ docker run -ti --name mycontainer --volumes-from dappdeps-toolchain --volumes-from dappdeps-base --volumes-from dappdeps-gitartifact --volumes-from dappdeps-ansible --volumes-from dappdeps-chefdk alpine:latest /.dapp/deps/base/0.2.3/embedded/bin/bash -lc '/.dapp/deps/ansible/2.4.4.0-10/embedded/bin/ansible localhost -m apk -a "name=nginx,tree update_cache=yes"'
[WARNING]: Unable to parse /etc/ansible/hosts as an inventory source
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'
localhost | SUCCESS => {
"changed": true,
"failed": false,
"msg": "installed nginx tree package(s)",
"packages": [
"pcre",
"nginx",
"tree"
],
"stderr": "",
"stderr_lines": [],
"stdout": "(1/3) Installing pcre (8.41-r1)n(2/3) Installing nginx (1.12.2-r3)nExecuting nginx-1.12.2-r3.pre-installn(3/3) Installing tree (1.7.0-r1)nExecuting busybox-1.27.2-r7.triggernOK: 6 MiB in 14 packagesn",
"stdout_lines": [
"(1/3) Installing pcre (8.41-r1)",
"(2/3) Installing nginx (1.12.2-r3)",
"Executing nginx-1.12.2-r3.pre-install",
"(3/3) Installing tree (1.7.0-r1)",
"Executing busybox-1.27.2-r7.trigger",
"OK: 6 MiB in 14 packages"
]
}
Финальный аккорд — создаем образ из получившегося контейнера и… видим, что от dappdeps в нем остались лишь пустые директории mount-point'ов!
$ docker commit mycontainer myimage
sha256:9646be723b91daeaf538b7d92bb8844578abc7acd3028394f543e883eeb382bb
$ docker run -ti --rm myimage tree /.dapp
/.dapp
└── deps
├── ansible
│ └── 2.4.4.0-10
├── base
│ └── 0.2.3
├── chefdk
│ └── 2.3.17-2
├── gitartifact
│ └── 0.2.1
└── toolchain
└── 0.1.1
11 directories, 0 files
Казалось бы, о чем еще можно мечтать?..
Дальнейшие работы и проблемы
Какие проблемы с dappdeps?
Необходимо провести работу по уменьшению размеров dappdeps/toolchain. Для этого надо разделить toolchain на 2 части: часть, необходимая для сборки новых утилит в dappdeps, и часть с базовыми библиотеками типа glibc, которые необходимо монтировать в runtime уже для запуска этих утилит.
Для работы модуля Ansible apt в dappdeps/ansible пришлось добавить содержимое пакета python-apt в Ubuntu прямо в образ без пересборки. В этом случае модуль apt работает без проблем в базовых образах на основе DEB, но требуется наличие glibc определенной версии. Поскольку сам apt — это дистрибутиво-специфичный модуль, то такое допустимо.
Чего не хватает в Dockerfile?
Для использования тома из образа dappdeps/toolchain приходится сначала создавать архив этого образа, а затем добавлять его в другой образ через директиву Dockerfile ADD
(см. раздел «Используем Omnibus вместе с dappdeps/toolchain»). Со стороны Dockerfile не хватает функционала, который бы позволял просто подключать директорию другого образа на время сборки как VOLUME
, т.е. аналог опции --volumes-from
для Dockerfile.
Выводы
Мы убедились, что идея работает и позволяет использовать в сборочных инструкциях GNU- и другие CLI-утилиты, запускать интерпретатор Python или Ruby, запускать даже Ansible или Chef в Alpine или scratch-образах. При этом писателю сборочных инструкций не требуется знать побочный эффект выполнения запускаемых команд и явно перечислять, какие файлы необходимо импортировать, как в случае с Docker multi-stage builds.
Результаты данной работы применяются и на практике: dapp использует dappdeps-образы в сборочных контейнерах. Например, Git из dappdeps/gitartifact используется для работы с патчами, и утилита Git с некоторой гарантией ведет себя одинаково во всех базовых образах. Однако то, как dapp использует dappdeps, выходит за рамки данной статьи (ссылки на код для самых любопытных: dapp/deps, dapp/dimg/builder/chef.rb, dapp/dimg/builder/ansible.rb).
Целью данной статьи было донести саму идею и показать на реальном практическом примере возможность ее применения.
P.S. Все описанные dappdeps-образы доступны на hub.docker.com: dappdeps/toolchain:0.1.1
, dappdeps/base:0.2.3
, dappdeps/gitartifact0.2.1
, dappdeps/ansible:2.4.4.0-10
, dappdeps/chefdk:2.3.17-2
— ими можно пользоваться.
Автор: tkir