ВМ по ссылке: облачные образы на nos и ВМ в папках
hv create берёт ссылку на облачный образ и делает из него ВМ силами одного nos: загрузка через HTTPS-клиент самого ядра, конвертер qcow2 со своим декодером DEFLATE, сид для cloud-init и папка, которая переживает перезагрузку.
Когда вы просите у облака ВМ, оно делает четыре вещи. Берёт облачный образ дистрибутива и превращает его в диск ВМ, увеличивает диск, передаёт cloud-init сид с вашим SSH-ключом и загружает ядро, которое загрузил бы собственный GRUB образа. С 25 октября nos делает то же самое одной командой, сам, и ничего не нужно готовить на другой машине:
$ hv create debian https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2 size=8G password=nos-howto-1 forward=2201:22
hv: https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2 -- into /vms/debian/debian-13-genericcloud-amd64.qcow2
download: 5 MiB of 326 MiB, 1%, 2.6 MiB/s
...
download: 342294528 bytes in 21.3 s, 15.2 MiB/s
hv: /vms/debian/debian-13-genericcloud-amd64.qcow2: qcow2 version 3, a disk of 3072 MiB in clusters of 64 KiB -- into /vms/debian/disk.raw
hv: 266 of 3072 MiB of the disk, 2 s
...
hv: /vms/debian/disk.raw: 907 MiB written of 3072 MiB, the rest holes -- 14487 clusters inflated, 788 copied, 0 zeros, 33877 not there -- in 62.0 s
hv: /vms/debian/vm.toml written -- hv start debian boots it: hvboot, a 8192 MiB disk, cloud-init given 1 key and a password
$ hv start debian
hv: vm 0 (debian) started on cpu 3 -- /lib/hvboot, 1024 MiB, 1 cpu, cmdline "console=ttyS0 nolapic ip=10.0.100.2::10.0.100.1:255.255.255.0:::off:10.0.2.3", restarted when it resets
hv: port 2201 forwarded to vm 0, 10.0.100.2:22
Этот nos работал под TCG из QEMU на Mac. Через 48,6 с после hv start ssh с Mac дошёл до собственного sshd гостя, сначала через проброс порта QEMU, потом через проброс nos, и вошёл с ключом, который принимает sshd самого nos. Свои данные cloud-init нашёл на сиде, который записал nos: DataSourceNoCloud [seed=/dev/vdb].
Двумя днями раньше ничего этого не было. Гость жил, пока жила строка hv start, которая его запустила, и при следующем запуске все слова приходилось набирать заново. Ядро и initrd надо было доставать из образа на другой машине через debugfs или xorriso, поэтому ядро, которое apt upgrade ставил внутри гостя, так и не запускалось. А диск на ext2 у nos не мог вырасти больше 4 ГиБ. Всё это изменили вот эти коммиты:
| Коммит | Что добавил |
|---|---|
| 36a77e7 | ВМ в своей папке: vm.toml, hv create, hv vms, консоль в console.log, а ещё параметры модулей, чтобы insmod мог сказать, где лежат папки |
| fe893b5 | файлы ext2 больше 4 ГиБ, чтобы диск гостя мог быть таким, какой ему нужен |
| 96c4663 | PVH и hvboot: гость загружает ядро, которое загрузил бы GRUB его собственного диска |
| 8d5df60 | hv convert: облачный образ qcow2 становится сырым диском прямо на nos, а декодер на каждый поток отвечает так же, как zlib |
| 8c525ce | hv create <name> <url>: загрузка, диск, сид для cloud-init и vm.toml одной командой |
| ac09bba | облачный образ Debian 13, созданный и загруженный таким способом: 12 проверок из 12 |
ВМ — это папка
ВМ, которая хранится в папке, живёт в /vms/<name>/. В папке лежат vm.toml, где сказано, как загружать ВМ, файлы, которые в нём названы (ядро, initrd, диски), и журнал консоли ВМ. hv start web загружает её, а каждая команда, которая принимает номер ВМ, принимает и её имя (A VM in a folder). hv create web <words> записывает файл из тех же слов, что принимает hv start. Она проверяет их так же, как проверил бы запуск, и никогда не перезаписывает уже существующий vm.toml. hv vms перечисляет папки: что загружает каждая и какие ВМ работают. vm.toml выглядит так:
kernel = "/boot/bzImage"
initrd = "/boot/initrd"
cmdline = "console=ttyS0 root=/dev/vda rw"
memory = 1024
cpus = 2
disks = ["root.img"]
net = true
port = 3
forward = ["2222:22"]
restart = true
Формат — строгое подмножество TOML, и читает его парсер, в котором нет ядра (config.rs). Всё, что он не может прочитать, он отвергает и называет строку. Это касается и незнакомого ключа, и ключа, заданного дважды, а причину объясняет комментарий в парсере: иначе memroy = 4096 молча загрузил бы гостя с памятью по умолчанию. port= закрепляет за гостем один порт коммутатора, так что адрес и MAC у него при каждом запуске одни и те же. forward= пробрасывает гостю порты nos, пока он работает. restart загружает его снова, когда он перезагружается сам.
Журнал консоли пишет отдельная задача ВМ, раз в секунду, из кольцевого буфера консоли (vmlog.rs). CPU гостя его никогда не пишут: иначе выход из ВМ ждал бы диска. Строки самого hv идут между строками гостя, там, где это случилось: --- hv: <date> UTC vm 0 started on cpus 2,3 -- ... ---, а потом booting again, stopped или off the list. На 8 МиБ файл переезжает в console.log.1. Если гость печатает больше 64 КиБ в секунду, кольцо теряет то, что уже не вмещает, и журнал пишет, сколько потеряно.
Где лежат папки — единственный параметр hv, а до этого коммита у модулей параметров не было вовсе. Теперь insmod /hv.ko vms=/data/vms передаёт остаток строки в init модуля. Заголовок модуля перешёл на версию 2, и модуль, собранный под старую, отвергается. Параметр, которого у hv нет, проваливает загрузку, так же как опечатка в ключе проваливает vm.toml. Корень nos — это диск, поэтому папка переживает перезагрузку nos, а rc add hv start web запускает ВМ при каждой загрузке.
Диски больше 4 ГиБ
Диск гостя — это файл на ext2 у nos. До fe893b5 ext2 в nos отображал файл только через двойной косвенный блок и хранил размер файла в 32 битах. На блоках по 4 КиБ файл упирался в 4 ГиБ, и облачный диск Ubuntu в 3,5 ГиБ едва помещался. Коммит добавил тройной косвенный блок, старшее слово размера и флаг large_file. Как и в Linux, флаг ставит первый файл размером от 2 ГиБ, и на диск флаг попадает раньше, чем inode, которому он нужен. Теперь файл может быть таким большим, как позволяет ext2 в Linux: почти 2 ТиБ на блоках по 4 КиБ. truncate /vms/web/root.img 20G делает пустой диск в 20 ГиБ, который ничего не стоит, пока гость в него не пишет. Усечение перешагивает дыры целыми поддеревьями, поэтому обрезать разреженный файл в терабайты стоит ровно столько, сколько в него записано.
Фаззер файловых систем тоже научился делать такие большие файлы (Фаззинг ядра на хосте). Вместе фаззер и чтение кода нашли три бага, два из них старше этого изменения. Вот один из этих двух. Запись, которой не хватило места на блоке после последнего, писала хвост последнего блока файла за конец файла, а раз ничего не выделила, то ничего и не вернула. Файл, позже выросший поверх этого хвоста, читал там байты неудачной записи вместо нулей.
Собственное ядро гостя: hvboot
До 96c4663 hv загружал то ядро, которое ему дали. Ядро и initrd облачного образа сначала приходилось из него доставать, а собственный /boot гостя никто не читал. Теперь ВМ может загружаться через hvboot — программу в 164 КиБ, которая работает внутри гостя и загружает то, что загрузил бы GRUB гостя (A guest's own kernel). Она читает диски virtio гостя, их GPT или MBR и файловые системы ext2, ext3 или ext4 на них. Затем читает grub.cfg и grubenv, выбирает пункт, который загрузил бы GRUB, загружает ядро и initrd этого пункта и входит в ядро по протоколу загрузки Linux. После apt upgrade и update-grub следующая загрузка запускает новое ядро, в том числе при перезапуске, потому что ВМ каждый раз собирается заново из своих файлов.
hv запускает hvboot по PVH — протоколу прямой загрузки, который определил Xen. Его понимают vmlinux из Linux, Cloud Hypervisor и прошивки, написанные для них. Сегменты ELF ложатся по своим физическим адресам, структура start info несёт командную строку, карту памяти и адрес таблиц ACPI, а первый CPU стартует в 32-битном защищённом режиме с выключенной страничной адресацией. На стороне хоста это всего лишь загрузчик ELF, и каждое поле заголовка он проверяет, прежде чем им воспользоваться. У hv по-прежнему четыре места с unsafe.
Зачем читать grub.cfg внутри гостя, а не в nos? Потому что всё, что там читается, принадлежит гостю: таблица разделов, файловая система, скрипт GRUB. Тогда баг в hvboot — проблема гостя, какой был бы баг в его собственном GRUB, а не проблема nos. Парсеры hvboot — обычный Rust поверх срезов байтов, без unsafe. 29 мест с unsafe, которые у него всё же есть, принадлежат платформе: порты ввода-вывода, физическая память, куча, кольца virtio, таблица прерываний и прыжок в ядро. Это не интерпретатор GRUB. Слова скрипта он читает так же, как GRUB, но условий не выполняет, поэтому читает все ветви if, и побеждает последняя строка linux в пункте. Облачные образы Ubuntu 24.04 и Debian 13 загружаются так в том виде, в каком поставляются, и под TCG hvboot доходит до ядра через 0,6 с после старта ВМ. XFS, btrfs, LVM и LUKS он читать не умеет, так что /boot семейства RHEL пока недоступен. BLS-записи он тоже не читает, а EFI нет вовсе: ядро запускается по его протоколу загрузки.
Облачный образ qcow2 становится сырым диском на nos
Облака раздают образы в формате qcow2 из QEMU, а диски hv — сырые файлы. Серверный образ Ubuntu 24.04 — это qcow2 версии 3 с кластерами по 64 КиБ, и 27 971 из 28 663 кластеров с данными сжаты zlib. Его таблицы L2, которые сопоставляют кластеры диска местам в файле, разбросаны по файлу, а часть данных идёт раньше таблицы, которая их описывает. Поэтому образ нельзя конвертировать на лету, пока он скачивается: сначала он скачивается, а потом конвертируется.
hv convert — команда модуля поверх крейта diskimage, в котором нет ни ядра, ни зависимостей, зато есть forbid(unsafe_code) (A disk from a cloud's image). Он читает qcow2 версий 2 и 3, кластеры от 512 байт до 2 МиБ — хранимые целиком, сжатые, нулевые и отсутствующие, — а также расширенные записи L2 с их подкластерами. Всё, что он не может сконвертировать честно, он отвергает и объясняет почему: файл-подложку (backing file), шифрование, внешний файл данных, сжатие zstd, образ, который QEMU пометил повреждённым, незнакомую возможность формата.
Самое интересное здесь — декодер DEFLATE. Сжатый кластер — это сырой DEFLATE, и QEMU читает его через zlib. Значит, хороший поток — тот, который хорошим считает zlib, включая потоки со странностями на краях, и заголовок декодера говорит об этом прямо (inflate.rs):
//! Raw DEFLATE (RFC 1951) -- what a compressed qcow2 cluster holds --
//! decoded as zlib's inflate decodes it. QEMU reads those clusters through
//! zlib, so a cluster this accepts must be one QEMU reads, to the byte, and
//! one QEMU refuses must be refused: every rule here is zlib's, checked in
//! zlib's order (its inflate.c and inftrees.c), and
//! scripts/diskimage-test.py holds the two to the same answer over streams
//! made and broken at random.
Вот одно из таких правил. QEMU просит у zlib ровно кластер байтов. Когда буфер zlib уже заполнен, тот всё равно читает дальше, пока следующий символ не выдал бы байт, и если по пути находит ошибку, поток не проходит. Поэтому и этот декодер читает дальше «всухую». Гейт diskimage-test.py сверяет его с zlib на 6634 потоках: собственные потоки zlib, они же испорченные, потоки, записанные блок за блоком, поток каждого правила, обрезанный на каждом байте, и шум. Каждый вердикт и каждый байт должны совпасть с zlib. Когда мы нарочно внесли ошибку, которая пропускала неполный код длин кодов, случайные потоки её не заметили, и поймали её только потоки, обрезанные на каждом байте. Поэтому они и стоят в гейте. Тот же гейт конвертирует образы, которые пишут qemu-img и qemu-io (все версии, размеры и виды кластеров), и побайтно сравнивает результат с тем, что читает qemu-img. А ещё он фаззит метаданные образов.
Образ — это загрузка, поэтому конвертер ничему в нём не доверяет. Каждое смещение проверяется на выравнивание формата и на размер файла. Ничто в образе не может заставить конвертер читать, писать или держать больше 64 КиБ за раз, какой бы ни был размер кластера, так что кластер в 2 МиБ никогда не становится одним выделением памяти. Одно правило строже, чем у QEMU. QEMU читает всё, что за концом файла, как нули, а здесь таблица или кластер, которые начинаются за концом, отвергаются:
const WHOLE: &str = "past the image's end -- is the image whole?";
Такой файл — это оборванная загрузка. Превратить его в диск, полный нулей там, где должны быть данные, — ровно тот сбой, о котором никто бы не узнал.
Диск сохраняет дыры. Это новый файл, выращенный до размера образа дырой (File::set_len, новое в интерфейсе модулей), и записываются только те блоки по 4 КиБ, которые не целиком из нулей. Диск Ubuntu в 3,5 ГиБ занимает те 1,7 ГиБ, что в нём на самом деле есть. nos сконвертировал его сам, на своём корне ext2 на arm64 под HVF, за 25,8 с, записав 1785 МиБ из 3584, и SHA-256 результата, посчитанный в nos, совпал с хостовым. На хосте qemu-img compare признал ту же конвертацию идентичной.
Одна команда
hv create <name> <url> собирает всё вместе (A VM of a cloud's image, cloud.rs):
- Загрузка. Модуль не может слинковаться с сетевым уровнем, а у TCP-соединения, которое модуль может открыть сам, маленькое окно приёма и нет TLS. Поэтому ядро теперь экспортирует модулям свой HTTP-клиент,
kernel_http_fetch. Это тот же код, что уwgetв шелле (HTTPS прямо из ядра): TLS с проверкой сертификата, переходы по редиректам и окно приёма, которого хватает на загрузку в гигабайты. Телом считается только ответ 2xx, уже существующий файл никогда не перезаписывается, а неудачная загрузка не оставляет после себя файла. - Диск. Образ qcow2 конвертируется, а загрузка удаляется. Сырой образ берётся как есть. Всё остальное отвергается, и в сообщении сказано, что это: ISO, поток gzip или xz, VMDK или VHD, и для каждого подсказка, что с ним делать.
size=увеличивает диск дырой, но не меньше, чем занимает образ, а при первой загрузке growpart из cloud-init растягивает на неё корень. - Сид. Источник данных NoCloud в cloud-init ищет диск с меткой
cidata, и nos пишет такой диск сам:seed.iso, образ ISO 9660 с именами Rock Ridge, который делает тот же крейт, что и конвертер. Егоmeta-dataназывает машину именем ВМ и даёт ей собственный instance ID (nos-<name>-<time>), чтобы cloud-init считал её новой машиной. Егоuser-dataдаёт пользователю образа по умолчанию (debianилиubuntu) ключи, которые принимает sshd самого nos, из/etc/ssh/authorized_keys, а сpassword=ещё и пароль для консоли. Каждая строка попадает туда как строка YAML в двойных кавычках, так что комментарий ключа никогда не прочитается как YAML. libarchive читает 24 сектора, прежде чем признать файл образом ISO 9660, поэтому сид дополнен до 64 КиБ. vm.tomlпишется последним. Ядро в нём — hvboot (/lib/hvboot, где его держит системный образ), дальше идут диск и сид только для чтения, сетевая карта на коммутаторе иrestart, а памяти дан 1 ГиБ, еслиmem=не говорит иного. Остальные словаhv startпопадают туда как есть:cpus=,port=,forward=2201:22для собственного sshd гостя. Если какой-то шаг не удался, команда удаляет всё, что успела сделать (загрузку, диск, сид, а если она создала и папку, то и папку), и объясняет почему.
Гейт для команды — hv-test.py на x86-64. Он поднимает собственный веб-сервер и делает ВМ из того, что тот отдаёт. Сжатый образ qcow2 скачивается, конвертируется и увеличивается, а диск сверяется по SHA-256. Сырой образ берётся байт в байт. Ответ 404, файл, который не образ, и size= меньше образа отвергаются, и после каждого отказа ничего не остаётся.
Debian 13 по ссылке
hv-distro-test.py --create <url> проделывает всё это в nos под TCG из QEMU на Mac, а потом проверяет результат изнутри гостя. Мы дали ему облачный образ Debian 13 genericcloud. nos скачал его по HTTPS через редирект 302 у Debian, 342 294 528 байт за 35,5 с (9,1 МиБ/с), и сконвертировал за 51,5 с, записав 907 МиБ из 3072. hv start загрузил его через hvboot с собственного /boot образа. cloud-init закончил через 49,9 с после старта ВМ, взяв данные из DataSourceNoCloud [seed=/dev/vdb]. Пользователь debian вошёл в консоль с паролем из сида, ключ nos оказался в его authorized_keys, у машины было имя ВМ, корень вырос на весь диск в 4 ГиБ, и sudo работал. Наконец hv stop нажал кнопку питания, и гость выключился сам. Это 12 проверок из 12.
Первый прогон потерял вход, и консоль показала почему. Getty на последовательной консоли Debian печатает первое приглашение после последней строки cloud-init, а agetty выбрасывает всё, что набрали до того, как он открыл порт. Тест набирал имя пользователя, как только cloud-init сообщал, что закончил. Имя выбрасывалось, а пароль, набранный следом, становился именем пользователя в следующем приглашении. Теперь тест сначала ждёт login: и только потом набирает. Прогон перед этим остановился на пределе hv wait в 600 с на одно ожидание, поэтому теперь тест ждёт по частям.
После перезагрузки nos
Папка задумана так, чтобы пережить nos. Чтобы проверить это так, как это увидит пользователь, мы прогнали шаги из руководства скриптом. nos работал под TCG из QEMU на Mac с корнем ext2 на 8 ГиБ, а команды шли в шелл nos через его собственный sshd. Сессия в начале поста — из этого прогона. Когда гость поднялся, внутри него записали файл, а rc add добавил в /etc/rc строки insmod, hv on и hv start debian. hv stop нажал кнопку питания, через 7,4 с гость выключился, а затем выключился и nos.
При следующей загрузке ничего не набирали: ВМ запустил /etc/rc. Через 34 с после того, как поднялся sshd у nos, ответил sshd гостя с тем же ключом хоста, что и раньше, а ssh было велено никакого другого не принимать. cloud-init принял ВМ за машину, которую уже настроил, файл был на месте, а console.log хранил обе загрузки, каждую между строками hv:
--- hv: 2026-10-25 12:45:38 UTC vm 0 started on cpu 3 -- /lib/hvboot, 1024 MiB, 1 cpu, cmdline "console=ttyS0 nolapic ip=10.0.100.2::10.0.100.1:255.255.255.0:::off:10.0.2.3", restarted when it resets ---
--- hv: 2026-10-25 12:46:37 UTC vm 0 stopped -- the guest powered itself off, S5 by its ACPI PM1 control register, at 0xffffffff96e1d0a3 ---
--- hv: 2026-10-25 12:46:37 UTC vm 0 off the list ---
--- hv: 2026-10-25 12:46:44 UTC vm 0 started on cpu 3 -- /lib/hvboot, 1024 MiB, 1 cpu, cmdline "console=ttyS0 nolapic ip=10.0.100.2::10.0.100.1:255.255.255.0:::off:10.0.2.3", restarted when it resets ---
Обе загрузки вместе со скачиванием заняли три с половиной минуты.
Чего пока нет
- Образ не сверяется с
SHA256SUMSдистрибутива. Пока за байты ручается сертификат TLS. - Оборвавшаяся загрузка не возобновляется. Клиент сдаётся после десяти секунд тишины, и команда ничего после себя не оставляет.
- Пока образ конвертируется, корень nos должен вмещать и загрузку, и диск одновременно.
- ВМ, созданные таким способом, пока работали только под TCG из QEMU на Mac, а он эмулирует AMD-V. PVH ещё не запускался под VT-x, и ни один дистрибутив ещё не загружался через hvboot на голом железе. Следующие на очереди — серверы.
Этап 4 дорожной карты переносит жизненный цикл ВМ за HTTP API. Папка — это уже то определение ВМ, которое понадобится этому API, и она переживает перезагрузки. Пошагово всё описано в руководстве ВМ из облачного образа, а остальное о гипервизоре — на его странице.