Маленькие «малинки» в крупном дата-центре (часть 2 — iPXE + Buildroot)

image-loader.svg


Привет всем, кто заинтересовался историей интеграции «малинок» в серверы! Многие хотели увидеть, как они выглядят в стойке, — вот они, представлены на заглавной иллюстрации.

Продолжим нашу историю о появлении одноплатников в выделенных серверах. В прошлой статье мы рассмотрели отличие процесса загрузки Raspberry Pi 4 от »‎обычных» серверов и подробно описали какие файлы необходимы для ее успешного завершения. Теперь нужно научиться менять этот процесс под наши нужды.
Остановились мы на загрузке по сети обратно в ОС, установленную на SD-карту. Напомню, что произошло это потому, что при загрузке ядра Linux kernel8.img мы дополнительно передали ему аргументы через файл cmdline.txt.

cat cmdline.txt
 
console=ttyAMA0,115200 console=tty1 root=/dev/mmcblk0p2 rootfstype=ext4 elevator=deadline rootwait dwc_otg.lpm_enable=0


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

Прежде чем развивать гипотезу, давайте рассмотрим проверенные варианты загрузки по сети для «малинок».

Загрузка в PXE + NFS


В сети можно найти достаточно инструкций, описывающих подготовку NFS-сервера для загрузки «малинки» в полностью автономном режиме, без участия SD-карты. Повторяться не буду, но обозначу ряд важных моментов:

  • Мы можем изменить аргумент root, чтобы он указывал на удаленный источник. Но все равно нужна корневая файловая система. Только »‎приготовленная» не локально, а на удаленном сервере.
  • Теперь /boot-раздел у нас загружается через TFTP-сервер, а корневая файловая система — через NFS-сервер. Процесс загрузки мы практически никак не поменяли — только источники. Значит, и управлять мы может только с уровня загруженной по NFS операционной системы, не ранее.
  • Непонятно, как решать проблему с доступом множества «малинок» к одному NFS-серверу. Создавать по одной корневой файловой системе на каждую ноду ­— тупиковый путь, запутаемся во множестве копий. Оставить одну в режиме только на чтение — возникнет проблема с изменяемыми данными (/tmp, /var, …).


Да, при должном усердии указанные проблемы можно решить через изменение скриптов инициализации при старте операционной системы. Тогда мы сможем на «малинке» загрузиться в какую-то среду, из которой будем запускать действия для дальнейшего развертывания сервера. Но результат все равно неоднозначен, а действий много.

piPXE


На этом месте стоит остановиться и сделать шаг назад. На «стандартных» серверах ведь уже есть среда, через которую мы управляем дальнейшей загрузкой сервера, — iPXE. Может, есть возможность перенести ее на «малинки»?

Недолгие поиски показали, что существует проект piPXE, предназначенный специально для запуска iPXE на одноплатниках. Стоит его протестировать и разобраться, как он работает.

Подготовка простая, достаточно раскатать образ на SD-карту.

curl https://github.com/ipxe/pipxe/releases/latest/download/sdcard.img | sudo dd of=/dev/sdX


После перезагрузки сервер успешно загрузится в iPXE, откуда уже можно загрузиться по сети. Рассмотрим, как именно это происходит и к каким файлам происходит обращение. Сверяться будем с официальной документацией.

  1. start4.elf и fixup4.dat — начинается процесс стандартно, с файлов, необходимых для инициализации видеоядра.
  2. сonfig.txt — с этого момента начинается самое интересное. В этот раз его содержимое отличается от того, что было в дистрибутиве Raspberry Pi OS.

    cat config.txt

    arm_64bit=1
    armstub=RPI_EFI.fd
    boot_delay=0
    enable_uart=1

  3. RPI_EFI.fd — ключевым является параметр armstub, который указывает на файл с ARM-кодом, выполняемым до запуска ядра. В данном случае запускается код EFI-среды, основанном на EDK II (во многом пересекается с проектом RPi4, также направленном на запуск EFI-окружения на Raspberry Pi).
  4. /efi/boot/bootaa64.efi — исполняемый файл EFI-приложения. Здесь он располагается в директории, откуда автоматически запускаются исполняемые файлы при запуске EFI-прошивки. В данном случае iPXE, скомпилированный в EFI.


Последний пункт позволяет нам скомпилировать собственную сборку iPXE, со встроенным скриптом под наши задачи. Для примера соберем iPXE-загрузчик с нашим кастомным скриптом rpi.ipxe

#!ipxe
dhcp
iseq ${platform} efi    	&& set uefi true ||
iseq ${platform} pcbios 	&& set uefi false ||

isset 224 || goto noparameter
chain --autofree ${224}&uefi=${uefi} || goto chain_error
:noparameter
echo ***************************************************************
echo * No 224 parameter was set: ${224}
echo ***************************************************************
goto exit0
:chain_error
echo ***************************************************************
echo * Error chaining to ${224}
echo ***************************************************************
goto exit0
:exit0
sleep 10
exit 0


Подробно останавливаться на работе этого скрипта не буду. Важно только отметить, что по нему iPXE получает сетевые настройки по DHCP. Проверяется наличие в DHCP-ответе опции 224 (первая из свободных опций для частного использования). Содержимое этой опции используется как адрес до образа, который запускается через chain. Для удобства добавлены диагностические сообщения, если опция 224 не задана или загрузка по ней невозможна.

Перед сборкой необходимо учесть, что мы собираем iPXE-загрузчик под архитектуру, отличную от x86_64. Поэтому сперва потребуется установка пакетов, нужных для кросс-компиляции (на примере Ubuntu 20.04). После чего уже клонировать репозиторий iPXE и собрать его утилитой make со встроенным скриптом.

apt install gcc-aarch64-linux-gnu
git clone https://github.com/ipxe/ipxe/ && cd ipxe
CROSS_COMPILE=aarch64-linux-gnu- make bin-arm64-efi/ipxe.efi EMBED=./rpi.ipxe


Убедиться, что файл собран правильно под нужную архитектуру, можно через утилиту file:

file bootaa64.efi

bootaa64.efi: MS-DOS executable PE32+ executable (DLL) (EFI application) Aarch64, for MS Windows


Затем достаточно скопировать готовый файл на SD-карту в /efi/boot/bootaa64.efi. После перезагрузки видим успешную загрузку в iPXE с нашим кастомным скриптом.

Казалось бы, успех?

Нет, такая схема оставляет нерешенными две проблемы:

  1. Зависимость от SD-карты, где располагается файл-заглушка RPI_EFI.fd. Да, его можно передать по TFTP, но в этом случае корректно он уже не запускается. Экспертизы в EDK II, чтобы это поправить, у нас нет.
  2. iPXE запускается через EFI. А это означает, что запускаемая установка дистрибутива должна поддерживать работу в этом режиме. Дистрибутивы под одноплатники обычно лишены этой способности за ненадобностью. Из-за этого могут не работать отдельные компоненты (например, сетевая карта), что потребует дополнительной адаптации дистрибутивов.


Опыт с запуском iPXE через EFI интересен и даже открывает некоторые перспективы (например, так обеспечивается запуск VMware ESXi на «малинках»), но, увы, для нас не подходит.

Образ Buildroot


Остановимся еще раз и оценим полученный опыт. Напрямую изменить последовательность загрузки «малинки» мы не можем. Список типов файлов, которые мы можем передавать по TFTP, определен. Возможность запуска arm stub файла до ядра нам не помогает, так как при передаче по сети корректно запускать его мы не умеем.

На что действительно можно повлиять — файл ядра Linux (и связанный с ним образ initramfs), который запускается на последнем этапе загрузки одноплатника. Нужно только собственное ядро с минимальным окружением в initramfs, которое бы позволило воспроизвести поведение iPXE. Фактически требуется собственный мини-дистрибутив, запускаемый из памяти, чтобы не зависеть от локальных носителей.

При загрузке Linux используется схема, когда сперва загружается initramfs-образ с минимально необходимой корневой файловой системой rootfs (прежде всего модули ядра). На втором этапе rootfs меняется и он перемонтируется на полноценную корневую файловую систему (локально на SD-карте или по NFS). Взять файлы ядра и initramfs-образа из существующего дистрибутива без дополнительной их адаптации мы не можем.

Можно собрать ядро Linux напрямую из исходных файлов. Инструкцию, как это сделать под Raspberry Pi 4, можно найти в официальной документации. Но к ядру еще требуется минимальное рабочее окружение из командной оболочки (shell) и базовых утилит, которые и будут образовывать корневую файловую систему, упакованную в initramfs и загружаемую в память.

Чтобы упростить эту задачу, мы обратимся к Buildroot — инструменту для сборки собственного мини-дистрибутива. О нем уже писали на Хабре. Мы же пойдем чуть дальше и воспользуемся его механизмом еxternal toolchain, чтобы отделить наши изменения от основного кода.

Предварительно создадим buildroot-окружение и установим необходимые пакеты.

# on ubuntu 20.04
export BUILDROOT="$HOME/buildroot_pi4"

sudo apt -y install \
    python-is-python3 \
    expect-dev \
    git \
    bc rsync wget cpio unzip
git clone --single-branch --branch 2021.08.x https://github.com/buildroot/buildroot.git ${BUILDROOT}


После подготовки Buildroot-окружения мы можем сразу же запустить сборку образа по Raspbery Pi 4, используя готовый профиль.

cd ${BUILDROOT}
make raspberrypi4_64_defconfig
make


После запуска мы можем отойти попить чаю (может, даже не одну кружку), так как скачивание зависимостей, необходимых утилит и кросс-компиляция займут значительное время. После завершения в директории ./output/images мы обнаружим необходимый нам образ sdcard.img, пригодный для записи на SD-карту.

Убедиться в этом мы можем, просматривая передаваемые ядру Linux аргументы. Видно, что корневая файловая система (root) ожидается на втором разделе SD.

cat output/images/rpi-firmware/cmdline.txt

root=/dev/mmcblk0p2 rootwait console=tty1 console=ttyAMA0,115200


Нам нужно, чтобы вся корневая файловая система располагалась в памяти и не была привязана к локальным носителям. Для этого мы создадим внешнее окружение buildroot, где уже на основе существующего профиля raspberrypi4_64_defconfig создадим собственный.

EXT_BUILDROOT="$HOME/pi4_pxe_buildroot"
mkdir -p ${EXT_BUILDROOT}
cd ${EXT_BUILDROOT}


Для начала создадим описание нового профиля через файл external.desc

name: raspberrypi4_64_pxe
desc: raspberrypi4_64_pxe: builds special image to boot raspberry Pi4 over pxe


Далее создадим наш новый профиль. Самый простой способ для этого — скопировать существующий профиль и внести изменения.

mkdir -p ${EXT_BUILDROOT}/configs
cp ${BUILDROOT}/configs/raspberrypi4_64_defconfig "$_/raspberrypi4_64_pxe_defconfig"


Поскольку нам нужно, чтобы корневая файловая система (root) после запуска ядра Linux располагалась в оперативной памяти (RAM), указываем опцию в профиле. Для этого добавим в конец файла две дополнительные строки.

BR2_TARGET_ROOTFS_CPIO=y
BR2_TARGET_ROOTFS_CPIO_GZIP=y


В целом, этого достаточно, чтобы после пересборки полученные файлы Image и rootfs.cpio.gz можно было использовать для загрузки по PXE. Но пойдем чуть дальше и изменим систему инициализации с busybox на systemd. Для этого нам потребуется изменить системную библиотеку с uclibc на glibc, указав это в профиле и включив дополнительные пакеты.

# Systemd
BR2_TOOLCHAIN_BUILDROOT_GLIBC=y
BR2_TOOLCHAIN_BUILDROOT_LIBC="glibc"
BR2_TOOLCHAIN_USES_GLIBC=y
BR2_INIT_SYSTEMD=y
BR2_PACKAGE_SYSTEMD_INITRD=y
BR2_PACKAGE_SYSTEMD_FIRSTBOOT=y
BR2_PACKAGE_SYSTEMD_HOSTNAMED=y
BR2_PACKAGE_SYSTEMD_HWDB=y
BR2_PACKAGE_SYSTEMD_LOCALED=y
BR2_PACKAGE_SYSTEMD_LOGIND=y
BR2_PACKAGE_SYSTEMD_MACHINED=y
BR2_PACKAGE_SYSTEMD_MYHOSTNAME=y
BR2_PACKAGE_SYSTEMD_NETWORKD=y
BR2_PACKAGE_SYSTEMD_RESOLVED=y
BR2_PACKAGE_SYSTEMD_TIMEDATED=y
BR2_PACKAGE_SYSTEMD_TIMESYNCD=y
BR2_PACKAGE_SYSTEMD_TMPFILES=y
BR2_PACKAGE_SYSTEMD_VCONSOLE=y


Так как busybox больше не используется, желательно также изменить командную оболочку, например, на bash. Образ загружается по сети, поэтому неплохо иметь еще и мультиплексор tmux. Также добавим его в профиль.

BR2_SYSTEM_BIN_SH_BASH=y
BR2_PACKAGE_BASH=y
BR2_PACKAGE_BASH_COMPLETION=y
BR2_PACKAGE_TMUX=y


Для добавления в образ наших собственных файлов и скриптов следует воспользоваться механизмом root filesystem overlay, задаваемым через опцию BR2_ROOTFS_OVERLAY.

BR2_ROOTFS_OVERLAY="$(BR2_EXTERNAL_raspberrypi4_64_pxe_PATH)/files"


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

Так, для примера создадим файл (относительно директории профиля) с содержимым, меняющим приглашение bash на красный цвет:

echo 'export PS1="\e[0;31m[\u@\h \W]# \e[m "' > ./files/root/.bashrc


В собранном образе он окажется в /root/.bashrc, т.е. в домашней папке пользователя root.

Итоговое содержимое профиля raspberrypi4_64_pxe_defconfig:
BR2_aarch64=y
BR2_cortex_a72=y
BR2_ARM_FPU_VFPV4=y

BR2_TOOLCHAIN_BUILDROOT_CXX=y
BR2_TOOLCHAIN_BUILDROOT_GLIBC=y
BR2_TOOLCHAIN_BUILDROOT_LIBC="glibc"
BR2_TOOLCHAIN_USES_GLIBC=y

BR2_SYSTEM_DHCP="eth0"

# Linux headers same as kernel, a 5.10 series
BR2_PACKAGE_HOST_LINUX_HEADERS_CUSTOM_5_10=y

BR2_LINUX_KERNEL=y
BR2_LINUX_KERNEL_CUSTOM_TARBALL=y
BR2_LINUX_KERNEL_CUSTOM_TARBALL_LOCATION="$(call github,raspberrypi,linux,4afd064509b23882268922824edc5b391a1ea55d)/linux-4afd06459b23882268922824edc5b391a1ea55d.tar.gz"
BR2_LINUX_KERNEL_DEFCONFIG="bcm2711"

# Build the DTB from the kernel sources
BR2_LINUX_KERNEL_DTS_SUPPORT=y
BR2_LINUX_KERNEL_INTREE_DTS_NAME="broadcom/bcm2711-rpi-4-b"

BR2_LINUX_KERNEL_NEEDS_HOST_OPENSSL=y

BR2_PACKAGE_RPI_FIRMWARE=y
BR2_PACKAGE_RPI_FIRMWARE_VARIANT_PI4=y

# Required tools to create the SD image
BR2_PACKAGE_HOST_DOSFSTOOLS=y
BR2_PACKAGE_HOST_GENIMAGE=y
BR2_PACKAGE_HOST_MTOOLS=y

# Filesystem / imageBR2_TARGET_ROOTFS_EXT2=y
BR2_TARGET_ROOTFS_EXT2_4=y
BR2_TARGET_ROOTFS_EXT2_SIZE="120M"
# BR2_TARGET_ROOTFS_TAR is not set
BR2_ROOTFS_POST_BUILD_SCRIPT="board/raspberrypi4-64/post-build.sh"
BR2_ROOTFS_POST_IMAGE_SCRIPT="board/raspberrypi4-64/post-image.sh"
BR2_ROOTFS_POST_SCRIPT_ARGS="--add-miniuart-bt-overlay --aarch64"
BR2_ROOTFS_OVERLAY="$(BR2_EXTERNAL_raspberrypi4_64_pxe_PATH)/files"
BR2_TARGET_ROOTFS_CPIO=y
BR2_TARGET_ROOTFS_CPIO_GZIP=y

BR2_INIT_SYSTEMD=y
BR2_PACKAGE_SYSTEMD_INITRD=y
BR2_PACKAGE_SYSTEMD_FIRSTBOOT=y
BR2_PACKAGE_SYSTEMD_HOSTNAMED=y
BR2_PACKAGE_SYSTEMD_HWDB=y
BR2_PACKAGE_SYSTEMD_LOCALED=y
BR2_PACKAGE_SYSTEMD_LOGIND=y
BR2_PACKAGE_SYSTEMD_MACHINED=y
BR2_PACKAGE_SYSTEMD_MYHOSTNAME=y
BR2_PACKAGE_SYSTEMD_NETWORKD=y
BR2_PACKAGE_SYSTEMD_RESOLVED=y
BR2_PACKAGE_SYSTEMD_TIMEDATED=y
BR2_PACKAGE_SYSTEMD_TIMESYNCD=y
BR2_PACKAGE_SYSTEMD_TMPFILES=y
BR2_PACKAGE_SYSTEMD_VCONSOLE=y

BR2_SYSTEM_BIN_SH_BASH=y
BR2_PACKAGE_BASH=y
BR2_PACKAGE_BASH_COMPLETION=y
BR2_PACKAGE_TMUX=y


Несмотря на то, что новый профиль располагается в отдельной директории, его сборка все равно происходит из основного buildroot-окружения. Нужно только указать его расположение через переменную окружения BR2_EXTERNAL. Для ускорения пересборки buildroot использует кэш. Так как мы используем измененный профиль и заменили системную библиотеку uclibc на glibc, то пересобрать лучше с нуля.

cd ${BUILDROOT}
make clean
make BR2_EXTERNAL=${EXT_BUILDROOT} raspberrypi4_64_pxe_defconfig
make


После завершения сборки нам достаточно скопировать на удаленный TFTP-сервер файлы Image и rootfs.cpio.gz из директории output/images.

Чтобы «малинка» знала, какие файлы ей необходимо запрашивать по TFTP, необходимо указать их имена в файле config.txt:

kernel=Image
initramfs rootfs.cpio.gz


Изменим также файл cmdline.txt, чтобы при загрузке дополнительно передать аргументы ядру, указывающие на расположение корневой файловой системы (root) в оперативной памяти.

cat cmdline.txt
root=/dev/ram0 rootwait console=tty1 console=ttyAMA0,115200


Итоги и планы


Итак, мы добились нужного нам результата. С помощью buildroot мы создали собственный мини-дистрибутив. После загрузки по TFTP он полностью располагается в оперативной памяти и больше никак не зависит от локальных носителей (прежде всего от SD-карты, но в потенциале и от usb-дисков). И мы можем достаточно гибко модифицировать полученный образ, добавлять в него собственные скрипты с нужным функционалом.

Осталось только воспроизвести описанное поведение iPXE с получением опции 224. Но что это за опция, зачем она нужна и как она передается? Для ответа потребуется предварительно рассказать о Kea DHCP сервере и его системе hook-модулей. Этим и займемся в следующей статье.

image-loader.svg

© Habrahabr.ru