Некоторые языки программирования (например, 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 и системами с повышенными требованиями к безопасности.
Комментарии (0)
M_AJ
21.09.2025 21:43Следующий шаг – отказаться от ядра и можно будет с чистой совестью сказать, что история сделала круг :)
Хотя мне в общих чертах импонирует подобный подход, когда у нас есть какая-то минимальная система без всего лишнего (желательно "атомарная" как FedorаCore), а на ней все упаковано в контейнеры с самодостаточными приложениями, но на практике это не всегда рационально особенно, когда нужно взаимодействовать с каким-то сторонним ПО.
aakumykov
Очень интересно, спасибо за статью.