Alpine + Docker: поваренная книга разработчика

Автор: Евгений Голышев

Статья была опубликована в 237-м выпуске журнала Linux Format. По истечении полугода с момента публикации права на статью переходят ее автору, поэтому материал публикуется в нашем блоге.

Docker – одна из немногих технологий, которая, наделав несколько лет назад много шума, до сих пор остается с нами. Чтобы помочь разработчикам взглянуть на любимый Docker под другим углом, я достал свою поваренную книгу, смахнул с нее пыль и приготовился к тому, чтобы в рамках этой статьи поделиться своими рецептами.

Два из четырех фигурирующих здесь рецептов призваны уменьшить размер результирующих образов. Эта задача всегда была и остается актуальной для Docker’а, поэтому я начну свой небольшой сборник рецептов с рассказа об Alpine Linux, а потом перейду непосредственно к самим рецептам. Во-первых, именно Alpine является первым шагом на пути к компактным образам, а во-вторых, я хочу воспользоваться случаем и рассказать немного об этом дистрибутиве, чтобы исправить недостаток информации об Alpine на страницах этого журнала (по какой-то причине журнал до сих пор обходил этот дистрибутив стороной).

Немного об Alpine

Alpine Linux применяется в качестве основы для официальных Docker-образов; в свое время он вытеснил с этого места Ubuntu. Более того, в начале 2016-го стало известно, что Натанаэ́ль Копа [Natanael Copa], создатель дистрибутива, присоединился к команде Docker’а. При этом Alpine остается независимым дистрибутивом, подконтрольным только своему сообществу.

Вот список наиболее интересных, на мой взгляд, фактов:

  • Последняя на момент написания статьи версия дистрибутива – 3.8 – вышла 26 июня 2018 г., и ее официальный образ весит чуть больше 4 МБ.
  • Разработчики стараются придерживаться предсказуемого графика выпуска новых версий дистрибутива. Как правило, новые версии Alpine’а выходят два раза в год – в мае и декабре, но возможны и небольшие отклонения от графика. К примеру, версия 3.8 вышла с опозданием на пару месяцев.
  • Каждая версия дистрибутива поддерживается на протяжении двух лет. Так как новые версии Alpine’а выходят два раза в год, то одновременно поддерживается 3-4 версии дистрибутива.
  • В качестве системы инициализации используется OpenRC. Напомню, что стандартом де-факто сейчас является systemd.
  • Поддерживается 6 портов: x86 и x86_64, armhf и aarch64, ppc64le, s390x. Это меньше, чем у Debian, но больше, чем у Ubuntu.
Docker-образы на базе текущей и предыдущей версий Alpine’а весят чуть больше 4 МБ.

Статическая компоновка

Не вдаваясь в лишние подробности, сборку программы можно разделить на два этапа: получение объектных файлов (на каждую единицу компиляции – файл с расширением .c или .cpp – приходится по одному объектному файлу) и компоновку (или линковку, от англ. linkage). Суть компоновки заключается в создании исполняемого файла (или разделяемой библиотеки) из полученных ранее объектных файлов. Если у исполняемого файла есть зависимости в виде библиотек, то в задачи компоновщика также входит связывание [linking] исполняемого файла с этими библиотеками одним из двух способов: статически или динамически. Для статической компоновки необходимы статические библиотеки [static libraries], а для динамической – разделяемые [shared libraries].

Статическая библиотека представляет собой архив объектных файлов, который создается программой ar. Когда исполняемый файл компонуется со статической библиотекой, то составляющие данную библиотеку объектные файлы становятся частью этого исполняемого файла. Для сравнения, разделяемая библиотека представляет собой объектный файл. Когда исполняемый файл компонуется с разделяемой библиотекой, то в исполняемый файл попадает только информация об этой библиотеке. В таком случае, процесс компоновки будет осуществляться во время запуска программы динамически. (В силу ограниченного объема нашего урока, описание процесса компоновки пришлось сильно упростить, поэтому я настоятельно рекомендую обратиться к книгам «Linux API. Исчерпывающее руководство» Майкла Керриска и «Linux. Руководство программиста» Джона Фуско за более детальными подробностями по этому вопросу.)

С использованием разделяемых библиотек связан ряд преимуществ.

  • Использование разделяемых библиотек уменьшает расход дискового пространства и памяти: на диске хранится и в память загружается только одна копия разделяемой библиотеки, независимо от количества программ, которые ее используют.
  • Устранение ошибок и уязвимостей в разделяемой библиотеке не требует пересборки зависимых от нее программ (конечно, при условии, что исправления не затронули двоичную совместимость).

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

Всё указывает на то, что статические библиотеки подходят для Docker-образов с идеологической точки зрения. Теперь осталось разобраться, есть ли в этом какая-то практическая польза.

Использование разделяемых библиотек негативно сказывается на размере Docker-образа. В основном это связано с особенностью дистрибутивов. В дистрибутивах разделяемые библиотеки распространяются в отдельных пакетах, а с установкой любых пакетов связаны определенные накладные расходы, и чем меньше остается пакетов в образе после его сборки, тем меньше он в итоге занимает места. Таким образом, если компактность результирующего образа для вас не пустой звук, то польза от использования статических библиотек определенно есть.

К примеру, у Memcached есть две основные зависимости – пакет libevent-dev с библиотекой для ассинхронного неблокируещего ввода/вывода, и cyrus-sasl-dev с библиотекой, реализующей вторую версию SASL API. libevent-dev зачем-то тащит за собой пакет python2, который в установленном виде занимает 39 МБ. Это именно те накладные расходы, о которых я говорил выше. Таким образом, официальный Docker-образ с Memcached’ом 1.5.10, последней на момент написания статьи версией демона, весит почти 59 МБ, т. к. постоянно таскает за собой Python 2, о котором даже никто не вспоминает, когда запускает Memcached. Docker-образ со статически скомпонованным с этими библиотеками Memcached’ом, весит чуть больше 10 МБ. Предлагаю в этом убедиться на примере образа Memcached’а из проекта MMB. Для этого сначала получите исходники всего проекта, а затем (предпочтительнее) соберите образ –

$ git clone https://github.com/tolstoyevsky/mmb.git
$ cd mmb/memcached
$ IMAGE_NAME=$(grep "image: " docker-compose.yml | awk -F': ' '{print $2}')
$ docker build -t ${IMAGE_NAME} .

или вытяните его с Docker Hub’а:

$ docker pull cusdeb/memcached:1.5.10-amd64

(На самом деле, в этом образе используется еще одна нехитрая оптимизация, о которой будет рассказано сразу в следующем рецепте. Без нее, конечно, размер образа был бы чуть больше.)

Образ со статически скомпонованным Memcached’ом весит почти в 6 раз меньше официального образа.

Но за эту оптимизацию приходится платить. Дело в том, что при использовании Alpine в качестве базового образа статическая компоновка осложняется тем, что у этого дистрибутива туговато со статическими библиотеками. К примеру, как libevent-dev, так и cyrus-sasl-dev содержат только разделяемые варианты библиотек. Наличие обоих вариантов библиотек в Alpine отдается на откуп сопровождающим этих библиотек, и если сопровождающий решил, что библиотека, от которой зависит докеризуируемая вами программа, должна быть доступна только в разделяемом виде, то придется самостоятельно готовить ее статический вариант на этапе сборки самого образа, что потребует дополнительных сил на написание Dockerfile’а.

Поэтому для начала необходимо решить, что для вас имеет больший приоритет – быстрая докеризация или небольшой объем результирующего образа. Если образ предназначается для широкого круга пользователей, то лично я бы вложился в уменьшение его объема.

Докеризация только серверных частей

При докеризации клиент-серверного программного обеспечения не оставляйте в образе клиентов. В противном случае образ будет включать компоненты, которые примутся всюду за ним таскаться, и более того, о которых никто не вспомнит при запуске целевого сервера (немного напоминает историю с Python 2 и Memcached из предыдущего рецепта).

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

Тем не менее, если вам достаточно сделать docker exec для того, чтобы подключиться к серверу посредством клиента, запущенного в том же контейнере, что и сам сервер, то речь идет об учебной или просто несерьезной задаче, и волноваться вам не о чем. Во всех остальных случаях клиенты в Docker-образах с серверами являются лишними.

Сборка образов под ARM на машине x86

Благодаря широкому распространению одноплатных компьютеров (Raspberry Pi, Orange Pi, Banana Pi и т. д.), устройства на базе 32- и 64-битного процессора ARM, на которых можно запустить полноценную операционную систему, сейчас доступны как никогда. Более того, Docker поддерживает ARM уже достаточно давно: начиная с версии 1.10, вышедшей 4 февраля 2016 г., появилась возможность собрать клиентскую и серверную части под ARM, а с версией 1.12.1, вышедшей 18 августа того же года, официальная поддержка пришла на ARM-машины под управлением Debian Jessie, Ubuntu Trusty и Raspbian Jessie.

Тем не менее, Docker-образы не всегда удобно собирать непосредственно на одноплатниках – для экономии времени сборку лучше производить на рабочей станции или ноутбуке, которые, как правило, представляют собой машины на базе процессора x86. Один из способов этого добиться – поместить в базовый образ [base image] целевого Docker-образа двоичные файлы средства эмуляции в режиме пользователя [user mode emulation binaries].

Как в производных от Debian дистрибутивах, так и в Fedora двоичные файлы средства эмуляции в режиме пользователя содержатся в пакете qemu-user-static. Модуль ядра binfmt_misc, доступный в Linux начиная с версии 2.1.43 (которая, кстати, вышла в июне далекого теперь 1997 г.), позволяет распознавать различные форматы исполняемых файлов и ассоциировать их с произвольными приложениями. Другими словами, для определенного формата исполняемого файла можно зарегистрировать эмулятор, и при каждой попытке запустить исполняемый файл этого формата передавать его эмулятору, а не запускать на текущем процессоре. (В производных от Debian дистрибутивах также потребуется установить пакет binfmt-support, в который вынесена функция регистрации эмуляторов.) Таким образом, если предназначенный для запуска на одноплатнике образ включает эти бинарники, то на машине на базе процессора, отличного от ARM, сборка образа и запуск контейнера на его основе будут осуществляться с привлечением эмулятора, а на ARM-машинах всё будет выполняться на реальном процессоре.

Возможность прозрачного запуска собранных под ARM программ на x86-машинах доступна в ядре более 20 лет.

Предлагаю рассмотреть скрипт создания базового Docker-образа для ARM, который будет включать двоичные файлы средства эмуляции в режиме пользователя, чтобы любой образ на его основе мог быть без проблем собран и запущен на x86-машинах. Чтобы скрипт корректно отработал, в системе, где он будет запущен, должны находиться эти бинарники. В том случае, если этой системой является Fedora, установите пакет qemu-user-static, а если речь идет о Debian или Ubuntu, то qemu-user-static и binfmt-support.

А теперь перейдем к самому скрипту:

#!/bin/sh

set -e

if [ "$(id -u)" -ne "0" ]; then
  >&2 echo "This script must be run as root"
  exit 1
fi

mirror=http://dl-cdn.alpinelinux.org/alpine
alpine_ver=3.8
apk_ver=2.10.0-r3
chroot_dir=./alpine-baseimage

wget ${mirror}/v${alpine_ver}/main/armhf/apk-tools-static-${apk_ver}.apk

tar xzf apk-tools-static-${apk_ver}.apk

mkdir -p ${chroot_dir}/usr/bin

cp /usr/bin/qemu-arm-static ${chroot_dir}/usr/bin

./sbin/apk.static -X ${mirror}/v${alpine_ver}/main -U --allow-untrusted --root ${chroot_dir} --initdb add alpine-base
mknod -m 666 ${chroot_dir}/dev/full c 1 7
mknod -m 666 ${chroot_dir}/dev/ptmx c 5 2
mknod -m 644 ${chroot_dir}/dev/random c 1 8
mknod -m 644 ${chroot_dir}/dev/urandom c 1 9
mknod -m 666 ${chroot_dir}/dev/zero c 1 5
mknod -m 666 ${chroot_dir}/dev/tty c 5 0

IMAGE=$(sh -c "tar -C ${chroot_dir} -c . | docker import -")

docker tag ${IMAGE} alpine:${alpine_ver}-armhf

Обратите внимание на то, что версия утилиты apk-tools-static (в данном случае 2.10.0-r3) является плавающей – почти наверняка к выходу данной статьи в свет она изменится. Скрипту определенно следовало бы быть немного сложнее, чтобы учитывать эту особенность, но здесь он должен оставаться как можно более простым. Прежде чем переходить к его модернизации, проделайте следующее:

  • откройте http://dl-cdn.alpinelinux.org/alpine/v3.8/main/armhf/, чтобы подсмотреть текущую версию apk-tools-static;
  • обновите версию apk-tools-static в тексте скрипта;
  • запустите скрипт с правами суперпользователя (т.к. скрипту необходимо производить различные манипуляции с chroot-окружением).

В результате будет создан базовый Docker-образ alpine:3.8-armhf.

Напоследок стоит сказать, что эмулятор позволяет решить подавляющее большинство задач, но, к сожалению, не всё через него запускается или собирается. Тем не менее, случаи отказов являются большой редкостью и сильно зависят от того, насколько экзотическими являются те вещи, для запуска которых эмулятор привлекается. По моему опыту, запущенные через эмулятор FTP-сервер vsftpd и сборка watchtower ведут себя нестабильно. К счастью, на этом список известных мне проблем заканчивается.

Накладывание патчей при сборке образа

Между пакетами исходных текстов, на базе которых строятся двоичные пакеты дистрибутивов GNU/Linux, и Docker-образами есть много общего. Нередки случаи, когда докеризуемая программа собирается из исходников. Как и в случае с пакетами исходных текстов, в рамках Docker-образа, возможно, придется заняться полировкой исходных текстов целевой программы – из-за того, что разработчики ее оригинала не учитывали особенности той системы, в которой программу в данный момент хотят заставить работать. Этот рецепт посвящен распространению патчей в составе исходников Docker-образов, чтобы, как и в случае пакетов исходных текстов, внесенные в тот или иной кусок (известного) кода изменения находились у всех на виду и могли быть проанализированы и переиспользованы другими разработчиками.

В качестве примера хочу привести Docker-образ QEMU из проекта MMB: https://github.com/tolstoyevsky/mmb/tree/master/qemu. Одной из главных задач для MMB в один прекрасный момент стало предоставление пользователям компактного образа с эмулятором (а по совместительству и системой виртуализации) QEMU, поэтому в качестве основы для образа был выбран Alpine. Затем, чтобы не зависеть от версии QEMU, которая в тот или иной момент времени находится в репозитории дистрибутива, было принято решение собирать эмулятор из исходников. После этого выяснилось, что некоторые зависимости QEMU отсутствуют в Alpine, и возникла необходимость их тоже собирать из исходных текстов.

Библиотека numactl, одна из зависимостей QEMU, до версии 2.0.12 не собиралась в Alpine из-за того, что Musl, стандартная библиотека языка C, которую использует дистрибутив, нигдене объявляет макрос __GLIBC_PREREQ, фигурирующий в одном из модулей библиотеки. Таким образом, numactl оказалась зависима от Glibc. Чтобы исправить эту ситуацию, был взят на вооружение менеджер патчей quilt, тот самый, который используется в пакетах исходных текстов Debian.

К сожалению, quilt никак не может стать частью дистрибутива, вечно находясь в edge, тестируемой версии Alpine, поэтому менеджер патчей придется тоже собрать из исходников или написать примитивную альтернативу на базе программы patch и цикла for. Преимуществами использования quilt для управления патчами являются в первую очередь легкость отключения того или иного патча в серии или, при необходимости, изменение порядка их внесения.

Чтобы исправить сложившуюся с numactl ситуацию, можно воспользоваться самим quilt’ом, но для начала надо получить исходники библиотеки.

$ git clone https://github.com/numactl/numactl.git
$ cd numactl

Затем необходимо сообщить quilt’у о намерении создать новый патч:

$ quilt new adapt_to_musl.patch
$ quilt add syscall.c

syscall.c – тот самый модуль, в котором используется макрос __GLIBC_PREREQ. Теперь в него можно внести изменение, устраняющее проблему.

Моим решением на скорую руку было просто-напросто удалить __GLIBC_PREREQ из условия

#if defined(__GLIBC__) && __GLIBC_PREREQ(2, 11)

Это решает проблему и никак не влияет на логику – в случае использования Musl остаток выражения всегда будет ложным. Тем не менее, более красивым решением является добавление перед этим условием заглушки, которая выглядит следующим образом.

#ifndef __GLIBC_PREREQ
# define __GLIBC_PREREQ(x,y) 0
#endif

И наконец, надо обновить патч, чтобы он отражал сделанные только что изменения.

$ quilt refresh

В результате всех этих манипуляций в корне дерева исходных текстов библиотеки будет создана директория patches с двумя файлами – adapt_to_musl.patch и series. Второй файл является так называемой серией патчей и представляет собой список, который на данный момент состоит всего из одного-единственного патча. Посредством редактирования можно повлиять на поведение quilt.

Теперь, чтобы созданный патч был применен к numactl во время сборки образа, необходимо позаботиться о том, чтобы директория patches находилась корне исходных текстов библиотеки. Затем, вызов quilt push -a инициирует накладывание всех патчей, указанных в серии.

GNU или не GNU/Linux

Такие дистрибутивы, как Debian или Ubuntu (и многие другие), используют GNU Coreutils и GNU C Library (Glibc) в качестве Unix-подобного окружения и стандартной библиотеки языка C соответственно. Таким образом, Проект GNU, как разработчик этих компонентов, играет важную роль в дистростроении, особенно если учесть тот факт, что для сборки вышеперечисленных частей используется компиляторы C/C++ из состава коллекции компиляторов от GNU (GNU Compiler Collection, GCC). В связи с этим Debian и Ubuntu следует называть дистрибутивами GNU/Linux, т.е. дистрибутивами, в которых различные компоненты Unix-подобной операционной системы от GNU в сочетании с ядром Linux образуют прочную основу, на которой строится всё остальное.

Было даже время, когда полнофункциональную Linux-подобную операционную систему не представлялось возможным создать без привлечения арсенала GNU. Несмотря на то, что достойная альтернатива Coreutils в лице Busybox существовала с незапамятных времен, свободные компиляторы C/C++ и альтернативные стандартные библиотеки языка C не всегда дотягивали до функциональности своих GNU’шных собратьев. Но времена изменились, и сейчас Musl является достойной альтернативой Glibc, а Clang не только научился конкурировать с компиляторами C/C++ из состава GCC, но и начал в чем-то их превосходить. Таким образом, в новых операционных системах на базе ядра Linux появилась возможность без значительного ущерба для функциональности свести к минимуму использование кода от GNU, компенсировав его аналогами, распространяющимися под пермессивной лицензией (Apache 2.0, все виды BSD, MIT и т.д.) или лучше «заточенными» под встраиваемые устройства. К числу таких операционных систем принадлежит Alpine. (Однако, справедливости ради, стоит заметить, что значительная часть дистрибутива собирается не чем иным, как GCC.) Таким образом, в данном конкретном случае от GNU/Linux остается только Linux, в результате чего полное название дистрибутива – Alpine Linux. Данный подход является семантическим именованием: взглянув на одно лишь имя операционной системы, возможно не только понять, что она построена на базе ядра Linux, но еще и то, что в ней, к примеру, используется нечто отличное от Glibc. Для сравнения, полным названием Debian является Debian GNU/Linux. Глядя на это название, можно с уверенностью сказать, что концентрация распространяющегося под лицензией GPLv3 кода в базовой системе очень высока. К сожалению, не все проекты по разработке Linux-подобных операционных используют семантиче­ское именование. Отличным примером является Gentoo Linux.

Leave a Reply

Your email address will not be published. Required fields are marked *