Некоторые языки программирования (например, 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)


  1. aakumykov
    21.09.2025 21:43

    Очень интересно, спасибо за статью.


  1. M_AJ
    21.09.2025 21:43

    Следующий шаг – отказаться от ядра и можно будет с чистой совестью сказать, что история сделала круг :)

    Хотя мне в общих чертах импонирует подобный подход, когда у нас есть какая-то минимальная система без всего лишнего (желательно "атомарная" как FedorаCore), а на ней все упаковано в контейнеры с самодостаточными приложениями, но на практике это не всегда рационально особенно, когда нужно взаимодействовать с каким-то сторонним ПО.