Некоторые языки программирования (например, Go и Zig) позволяют собрать приложение без каких-либо зависимостей, в том числе отвязаться от libc, тем самым создание distroless-контейнера на Go становится тривиальной задачей. Но эта же особенность может быть применена не только для создания контейнера, но и для запуска такого приложения в VM или на реальном хосте не используя какой-либо дистрибутив Linux, а используя только ядро Linux и само приложение, построенное с помощью Go (или, например, Zig). Такая возможность позволяет избавиться от дополнительных зависимостей, которые добавляют потенциальные риски с точки зрения атаки на цепочку поставок (supply chain attack).
Рассмотрим простейший пример веб-сервера на Go:
package main import ( "net/http" ) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hello-world")) }) println("Server started at port 8080") if err := http.ListenAndServe(":8080", nil); err != nil { panic(err) } }
Далее в качестве хоста для подготовки образа используется Ubuntu 24.04.3 x86_64.
Сборка и запуск kernel + initramfs
Сборка с отвязкой от libc и подготовка initramfs-образа:
#CGO_ENABLED=0 убирает зависимость от libc, ldflags убирают отладочную информацию CGO_ENABLED=0 go build -ldflags='-w -s' server.go mkdir rootfs cp ./server rootfs/init # ядро Linux ищет файл /init в rootfs # сборка initramfs cd rootfs find . | cpio -H newc -o | gzip -9 > ../initramfs.cpio.gz cd ..
Ядра из generic-дистрибутивов (Debian/Ubuntu, RHEL-based, Alpine) не подойдут для запуска конкретно этого приложения потому что они собраны таким образом, что драйвера сетевых карт (включая virtio-net) не включены в ядро (не являются built-in), а собраны модулями, а чтобы загрузить модуль нужно, чтобы init (из initramfs) умел это делать, в нашем же случае init - это тривиальный веб-сервер на Go, который не умеет грузить модули. Также нужно, чтобы ядро было собрано с CONFIG_IP_PNP, чтобы задачу назначения IPv4/IPv6-адреса можно было возложить на само ядро, в противном случае надо делать то, что делают тулы типа ip/ifconfig.
# получаем исходники ядра Linux любым удобным способом git clone https://github.com/torvalds/linux -b v6.16 --depth=1 # ставим компилятор и прочие инструменты для сборки ядра Linux sudo apt install gcc-x86-64-linux-gnu gcc make flex bison libelf-dev bc # задаем целевую архитектуру и префикс поиска компилятора (можно не делать если не планируется компилировать под другие платформы) export ARCH=x86_64 export CROSS_COMPILE=x86_64-linux-gnu- cd linux make kvm_guest.config # базовый конфиг для работы как гость kvm ./scripts/config --enable CONFIG_PRINTK_TIME # добавление времени в логи ядра, для отладки # для работы initramfs(initrd) ./scripts/config --enable CONFIG_SHMEM ./scripts/config --enable CONFIG_TMPFS ./scripts/config --enable CONFIG_DEVTMPFS ./scripts/config --enable CONFIG_BLK_DEV_INITRD # для использования устройства с ФС FAT вместо initramfs ./scripts/config --enable CONFIG_NLS_CODEPAGE_437 ./scripts/config --enable CONFIG_NLS_ISO8859_1 ./scripts/config --enable CONFIG_VFAT_FS # для возможности работы в UEFI-среде, не нужно если не планируется загрузка как EFI-приложение ./scripts/config --enable CONFIG_EFI ./scripts/config --enable CONFIG_EFI_STUB make olddefconfig # устанавливает незаданные значения по умолчанию # пример отключения фич ядра, которые не нужны для указанного выше примера веб-сервера ./scripts/config --disable CONFIG_WIRELESS ./scripts/config --disable CONFIG_WLAN ./scripts/config --disable CONFIG_NAMESPACES ./scripts/config --disable CONFIG_ETHERNET ./scripts/config --disable CONFIG_IPV6 make -j$(nproc) # на выходе получаем образ ядра arch/x86_64/boot/bzImage
Предложенный .config ядра не претендует на минималистичность (в нем можно довольно много отключить), но вполне пригоден для экспериментов в qemu с использованием kvm
qemu-system-x86_64 -nographic -enable-kvm -cpu host -m 64 \ -kernel arch/x86_64/boot/bzImage \ -initrd initramfs.cpio.gz \ -netdev user,id=net0,hostfwd=tcp::8080-:8080 -device virtio-net,netdev=net0 \ -append "console=ttyS0 ip=10.0.2.15::10.0.2.2:255.255.255.0:::none:"
Здесь происходит следующее: qemu использует одно ядро (с помощью kvm) и 64МБ памяти, делает примерно то же самое, что загрузчики типа grub/u-boot (загружает initrd по нужному адресу и запускает ядро), также в ядро передается, что консоль будет в виртуальном серийном порту и что ядро должно настроить ip-адрес 10.0.2.15/24 на (единственном) virtio-net интерфейсе. Примерно за одну секунду ядро загрузится и запустит приложение (тривиальный web-сервер)
[ 0.606419] IP-Config: Complete: [ 0.606927] device=eth0, hwaddr=52:54:00:12:34:56, ipaddr=10.0.2.15, mask=255.255.255.0, gw=10.0.2.2 [ 0.607437] host=10.0.2.15, domain=, nis-domain=(none) [ 0.607729] bootserver=255.255.255.255, rootserver=255.255.255.255, rootpath= [ 0.608310] Freeing unused kernel image (initmem) memory: 1548K [ 0.608806] Write protecting the kernel read-only data: 14336k [ 0.609333] Freeing unused kernel image (text/rodata gap) memory: 1760K [ 0.609806] Freeing unused kernel image (rodata/data gap) memory: 1912K [ 0.610064] Run /init as init process Server started at port 8080
Проверка веб-сервера (с хост-машины):
user@host:~$ curl http://localhost:8080 -D - HTTP/1.1 200 OK Date: Thu, 18 Sep 2025 20:24:49 GMT Content-Length: 11 Content-Type: text/plain; charset=utf-8 hello-world
Сборка kernel + initramfs в EFI-приложение
Если планируется работа на реальном хосте и хост поддерживает UEFI (что вполне актуально не только для x86_64, но и для современных arm64 и riscv64), то собрать EFI-приложение можно следующим образом:
sudo apt install systemd-boot-efi systemd-ukify # для сборки EFI-приложения sudo apt install ovmf # поддержка запуска EFI-приложений в qemu (x86_64) # построение EFI-образа ukify build \ --linux=arch/x86_64/boot/bzImage \ --initrd=initramfs.cpio.gz \ --cmdline="console=ttyS0 ip=10.0.2.15::10.0.2.2:255.255.255.0:::none:" \ --os-release="distroless golang app" \ --output=golang-web.efi # создание структуры каталогов для загрузки EFI-приложения mkdir -p esp/EFI/BOOT cp golang-web.efi esp/EFI/BOOT/BOOTX64.EFI # запуск EFI-образа (диск с файловой системой FAT эмулируется с помощью qemu) qemu-system-x86_64 -nographic -enable-kvm -cpu host -m 128 \ -netdev user,id=net0,hostfwd=tcp::8080-:8080 -device virtio-net,netdev=net0 \ -bios /usr/share/qemu/OVMF.fd \ -drive file=fat:rw:esp/,format=raw
Вопросы подписи EFI-приложений выходят за рамки данной статьи
Загрузка приложения с файловой системы вместо initramfs
initramfs занимает память, от него можно избавиться, если загрузка осуществляется с накопителя (например с файловой системой FAT)
cp ./server fatfs/sbin/init # копируем собранное Go-приложение как /sbin/init qemu-system-x86_64 -nographic -enable-kvm -cpu host -m 64 \ -kernel arch/x86_64/boot/bzImage \ -netdev user,id=net0,hostfwd=tcp::8080-:8080 -device virtio-net,netdev=net0 \ -drive file=fat:rw:fatfs/,format=raw,if=none,id=disk -device virtio-blk-pci,drive=disk \ -append "console=ttyS0 ip=10.0.2.15::10.0.2.2:255.255.255.0:::none: root=/dev/vda1"
Аналогично можно поступить с EFI-приложением, т.е. не добавлять initramfs при сборке EFI-приложения и добавить в cmdline ядра root=/dev/vda1
Пример подготовки distroless на реальном устройстве Orange Pi RV2 (riscv64)
Уходя из мира qemu в реальный мир железа, особенно за пределы типовых x86_64 устройств, начинаются нюансы. В качестве примера distroless-системы возьмём плату Orange Pi RV2 на базе SoC SpaceMIT X-60. Как это часто бывает в мире микрокомпьютеров, наработки по коду ядра не заапстримлены, как и u-boot и toolchain (компилятор), в дополнение к этому еще накладываются прошивки (блобы), которые тоже надо добавлять в distroless-систему. Вендор этой платы предоставляет образ Ubuntu 24.04 (riscv64), в котором ядро и initramfs собраны из репозитория вендора.
Беглый осмотр файла /boot/config-6.6.63-ky и вывода lsmod говорит о том, что большая часть драйверов встроена (built-in) в ядро, т.е. можно попробовать использовать ядро вендора без пересборки самому (хотя рабочая инструкция имеется). Попытка это сделать, подложив собранное под riscv64 Go-приложение в качестве /init внутри initramfs (или в качестве /sbin/init отключив initramfs) привела к такой ошибке
[ 5.158776] remoteproc remoteproc0: powering up rcpu_rproc [ 5.164404] remoteproc remoteproc0: Direct firmware load for esos.elf failed with error -2 [ 5.172729] remoteproc remoteproc0: request_firmware failed: -2 [ 5.178700] ky-rproc c088c000.rcpu_rproc: rproc_boot failed
Ядро пытается загрузить прошивку процессора esos.elf, не может найти файл, после чего дальше процесс загрузки не идет и возникает множество ошибок. Данная проблема решается тем, что нужно положить файл esos.elf в /lib/firmware внутри initramfs (сам файл лежит в /lib/firmware/esos.elf в образе от вендора), после чего проблема с инициализацией CPU решается и ядро грузится дальше.
# записываем образ ubuntu24.04 от вендора платы на SD-карту и монтируем его в /path/to/vendor_fs mkdir -p initramfs/lib/firmware # готовим структуру каталогов initramfs cp /path/to/vendor_fs/lib/firmware/esos.elf initramfs/lib/firmware/ # копирование прошивки cd initramfs find . | cpio -H newc -o | gzip -9 > ../initramfs-fw.cpio.gz # создание initramfs в формате cpio.gz # поскольку использует u-boot и initrd в формате u-boot initramfs, то создаем uInitrd инструментом mkimage из пакета u-boot-tools mkimage -A riscv -O linux -T ramdisk -C gzip -d ../initramfs-fw.cpio.gz ../uInitrd cp ../uInitrd /path/to/vendor_fs/boot/uInitrd # заменяем uInitrd от вендора платы на свой # Поскольку ядро Linux не умеет монтировать по UUID (этим занимается /init из initramfs, которого теперь нет), то прописываем rootdev явным образом (SD карта это /dev/mmcblk0) # diff boot/orangepiEnv.txt.old boot/orangepiEnv.txt 5c5 < rootdev=UUID=b615f740-5087-40b8-af5a-0a7ffa0b83f7 --- > rootdev=/dev/mmcblk0p1
Также добавляем дополнительные аргументы cmdline ядра, чтобы назначить ip-адрес на сетевой интерфейс и явным образом задать init (поскольку в initramfs его не будет)
echo "extraargs=ip=192.168.1.10::192.168.1.4:255.255.255.0::eth0:none: init=/fbapp" >> /path/to/vendor_fs/boot/orangepiEnv.txt
В качестве приложения рассмотрим пример веб-сервиса, который принимает png-файл в http-запросе и выводит его на экран (через интерфейс framebuffer)
Исходник
package main import ( "fmt" "image" "image/png" "net/http" "sync" "syscall" "github.com/d21d3q/framebuffer" "golang.org/x/image/draw" ) func main() { fbpath := "/dev/fb0" // Создание устройства fb0, нужно если что /dev не примонтирован (зависит CONFIG_DEVTMPFS_MOUNT и опции ядра devtmpfs.mount) mode := uint32(syscall.S_IFCHR | 0600) major := 29 // см. https://www.kernel.org/doc/Documentation/fb/framebuffer.txt minor := 0 // первый фреймбуфер dev := int((major << 8) | minor) err := syscall.Mknod(fbpath, mode, dev) if err != nil { // Игнорируем ошибку если fb0 уже есть fmt.Println(fbpath, err) } else { println(fbpath, "created (mknod)") } // Открываем фреймбуфер как FrameBuffer для прямого доступа fb, err := framebuffer.OpenFrameBuffer(fbpath, syscall.O_RDWR) if err != nil { panic(err) } // Получаем информацию о экране varInfo, err := fb.VarScreenInfo() if err != nil { panic(err) } // Получаем размеры фреймбуфера fbWidth := int(varInfo.XRes) fbHeight := int(varInfo.YRes) // Проверяем формат пикселя, последующий предполагает именно RGBA if varInfo.BitsPerPixel != 32 { panic("Framebuffer не в формате RGBA (32 bpp)") } // Создаем промежуточное изображение для масштабирования один раз dst := image.NewRGBA(image.Rect(0, 0, fbWidth, fbHeight)) // Получаем прямой доступ к пикселям фреймбуфера pixels, _ := fb.Pixels() // под капотом это mmap // Мьютекс для последовательной обработки запросов var mu sync.Mutex // Настраиваем HTTP сервер http.HandleFunc("/upload", func(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPut { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } defer req.Body.Close() // Блокируем мьютекс для последовательной обработки mu.Lock() defer mu.Unlock() // Декодируем PNG из тела запроса img, err := png.Decode(req.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Масштабируем изображение с помощью стандартной библиотеки draw.NearestNeighbor.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil) // Считаем что padding отсутствует, копируем картинку во framebuffer copy(pixels, dst.Pix) // единственный нормальный способ (copy) быстро вывести картинку, чтобы ее появление не было заметно w.Write([]byte("OK")) }) fmt.Println("Starting http server on :8080") // Запускаем сервер на порту 8080 if err := http.ListenAndServe(":8080", nil); err != nil { panic(err) } }
Сборка приложения и удаление лишнего
# сборка приложения go mod init fbapp go mod tidy CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 GORISCV64=rva22u64 go build -ldflags='-w -s' # копируем его на загрузочную sd-карту cp fbapp /path/to/vendor_fs/fbapp # удаляем всё остальное кроме boot cd /path/to/vendor_fs rm -rf bin etc home lib media mnt opt root sbin selinux srv usr var
Результат: получаем условно-полезное distroless-приложение работающее с сетью и выводом на видеоадаптер, где в userspace нет кода ни на C, ни на C++, готовое после включения игрушечного мини-ПК через 16 секунд (что весьма плохой показатель по скорости загрузки, но Ubuntu 24.04 без GUI грузится около минуты). Из этих 16 секунд 8 уходит на u-boot (две на чтение ядра с SD-карты) и 8 на запуск ядра и приложения (из них две секунды уходят на поднятие 1GE-линка). Можно пересобрать u-boot (отключив в нём кучу всего) и ядро (тоже исключив многое) и значительно ускорить загрузку
Существующие distroless-проекты
Существует ряд известных проектов, где в userspace полностью (или почти) избавились от традиционного подхода "обычных" дистрибутивов с libc, systemd и прочим. Например, Talos (дистрибутив для запуска нод kubernetes), где почти всё написано на Go (включая init). На текущий момент, в нём все же присутствует libc (musl) для ряда классических утилит для работы с блочными устройствами и файловой системой.
Также есть проект u-root, позволяющий создавать базовое окружение (а-ля busybox) на Go. Из исходных кодов Talos и u-root можно брать код (или черпать идеи) на тему того, как назначить ip-адрес (в условиях отсутствия ip/ifconfig), dhcp/ntp-клиент и т.д., т.е. те утилиты, которые нужны, но в угоду distroless-подхода удалены.
Еще один интересный проект firecracker-containerd - очень быстрый запуск контейнера на microvm, где vm обеспечивает дополнительный слой изоляции по сравнению с "обычными" контейнерами, когда они используют общее ядро.
Отдельно стоит отметить подход unikernel (например, unikraft), где не просто запускают ядро + приложение, а где их совмещают, тем самым убирая даже разделение на kernel space и user space. Данный подход интересен в первую очередь с точки зрения оптимизации ресурсов CPU, RAM и занимаемого места.
Почему не Rust и что еще сложного в distroless-подходе
К сожалению, приложения на Rust нельзя скомпилировать без зависимости от libc (точнее это можно сделать, отказавшись от стандартной библиотеки Rust и, тем самым, потеряв возможность использовать почти все библиотеки). Статическая линковка с musl libc создаст единый образ приложения, но не избавит от кода на C с бесконечными out-of-bounds и use-after-free. Существует несколько реализаций libc на Rust, которые находятся в стадии активной разработки, если эти проекты будут доведены до хорошего состояния, то можно будет спокойно использовать Rust для целей построения userspace без C-кода.
На текущий момент, Go обладает хорошей стандартной библиотекой и огромным количеством Pure Go библиотек, что делает его наиболее интересным с точки зрения distroless-строения. С другой стороны, нельзя забывать, что Go - это всё-таки управление памятью с помощью сборки мусора со всеми широкоизвестными проблемами такого подхода, особенно когда идёт речь о приложениях, где время отклика критично.
Новый язык Zig (как и Rust) можно использовать как замену C и C++ в приложениях, где время реакции критично, при этом можно собрать без libc (по-настоящему, а не статически слинковав с musl). Но пока не ясно, выдержит ли этот язык испытания временем, а его стандартная библиотека довольно скудная (по сравнению с Go).
Ещё одна проблема, с которой можно столкнуться в коммерческой разработке это сертификация подобного решения и бюрократические проблемы. Например, может быть предъявлено требование к использованию ОС прошедших те или иные сертификации. Также могут быть предъявлены требования, связанные с использованием криптографических алгоритмов, что усложнит применение distroless-подхода или сделает его вовсе невозможным.
Но самая большая проблема в distroless на pure Go/Zig это GUI и взаимодействие с различными устройствами и их первоначальная настройка (типа задание точки доступа wifi для wifi-адаптера). Большинство библиотек для взаимодействия с периферией написаны на C или C++. Даже настройка IP-адреса на distroless-хосте - это нетривиально, хотя ядро Linux и умеет статическую настройку и даже dhcp-клиент, но dhcp-клиент там не полноценный, не шлет периодические dhcp-request'ы (кстати, с IPv6 SLAAC с этим куда лучше). Однако, несмотря на это, есть большое количество возможных сценариев применения такого подхода - начиная от embedded (на SoC где запуск ядра Linux считается нормальным) и pet-проектов и заканчивая кровавым энтерпрайзом с microvm и системами с повышенными требованиями к безопасности.
