AX41: загрузка, которая не сказала ни слова
Ядро умерло раньше, чем появилась хоть какая-то консоль, на сервере, где консоль — только сеть. Два счётчика на дисках и копия в QEMU нашли причину.
AX41 — одна из трёх реальных машин, на которых загружается nos: выделенный сервер Hetzner с AMD Ryzen 5 3600 (Zen 2, 6 ядер / 12 потоков), двумя NVMe-дисками, сетевой картой Intel I210 и Ubuntu рядом с nos в меню GRUB (Real hardware). Ни последовательного порта, ни IPMI. Всё, что nos хочет сказать на этой машине, она говорит по сети — через netconsole, UDP-шелл или SSH, — а для этого нужна сетевая карта с адресом. Ядро, которое умирает раньше, умирает молча.
10 сентября 2026 года записи GRUB на AX41 поменялись. Теперь nos загружалась с nosenv — обычного раздела ext2 — и монтировала этот же раздел корнем на чтение и запись, root=LABEL=nosenv, чтобы работающая nos могла сама скачать и поставить своё следующее ядро (6d3ffd5). Первая же загрузка по новым записям не дала ничего: ни ответа на ping, ни строчки в сборщике netconsole, ни новой загрузки в disk log.
На этой машине такой исход предусмотрен заранее. nosboot взводит ровно одну загрузку nos, GRUB сбрасывает флаг ещё до того, как что-либо загрузит, а аппаратный reset возвращает Ubuntu. Чего эта схема не даёт — так это причины.
Что всё-таки говорит тишина
«Ничего» — это утверждение о времени. Каждый канал открывается в известной точке загрузки, поэтому тишина на всех сразу ограничивает место, где умерло ядро. Порядок на тот момент, по kernel/main.cpp:
Main2
...
ParseMultiBootInfo the command line is read
Netconsole::Setup the netconsole ring starts capturing
...
BpStartup
SoftIrq::Init block requests can complete from here on
PartitionDevice::ProbeNew the disks' partition tables are read
MountRootFs a read-write ext2 mount bumps the superblock's mount count
DiskLog::Setup the disk log's boot number + 1; the log so far goes to disk
...
Netconsole::Start datagrams leave once eth0 has an address
boot: complete
Кольцо netconsole заполняется почти с самого начала, но отправка начинается, только когда у устройства есть адрес. А disk log держит в памяти все строки начиная с первой, но писать может лишь после появления слоя soft IRQ — через него завершается блочный запрос, — поэтому его настройка стоит сразу после монтирования корня. Тишина на обоих каналах означает, что ядро умерло где-то между GRUB и DiskLog::Setup. А это большая часть загрузки. Чтобы сузить диапазон, мы прочитали диски из Ubuntu — только на чтение.
Два счётчика на дисках
В заголовке disk log лежит номер загрузки, который ядро увеличивает в DiskLog::Setup. Его печатает scripts/disklog.py:
sudo ./scripts/disklog.py read /dev/nvme0n1p1 # read the last boot
Он не изменился. (Тогда лог писался при каждой загрузке машины с подготовленной областью; начиная с 0d70452 ему нужен disklog=on, и без этого параметра неизменный номер ничего не говорит — сначала проверьте командную строку записи GRUB.)
Второй счётчик — собственный счётчик ext2. При монтировании на запись драйвер ext2 в nos — тогда на C++, теперь на Rust — делает то же, что Linux (ext2.rs):
self.sb.state &= !STATE_VALID;
self.sb.mount_count = self.sb.mount_count.wrapping_add(1);
self.sb.mount_time = wall_clock_secs() as u32;
Ubuntu монтирует тот же раздел в /nosenv при каждой загрузке. dumpe2fs -h дал счётчик монтирований, журнал — число загрузок Ubuntu, которые этот раздел монтировали, и числа совпали в точности. Значит, nos ни разу не записала свой superblock: она умерла до монтирования корня.
Оставалось всё от GRUB до монтирования корня — карта памяти, ACPI, драйвер NVMe, разбор таблиц разделов, — и каждый из этих этапов читает что-то, присущее именно этой машине и её дискам.
Копия вместо ещё одной перезагрузки
Каждая догадка, проверенная на настоящей машине, стоит загрузки, аппаратного reset и загрузки обратно в Ubuntu. Поэтому мы перенесли машину в QEMU:
- Тот самый бинарник ядра из
/nosenv, а не пересборку. - Настоящие начала обоих NVMe-дисков, скопированные по ssh: первый мегабайт каждого (MBR и промежуток GRUB), область disk log, первые 32 МиБ nosenv — superblock, дескрипторы групп, битовые карты и таблицу inode первой группы, корневой каталог — и первые 64 КиБ каждого участника RAID.
- Два разреженных raw-образа размером с настоящие диски и с той же таблицей разделов; ext2 с геометрией, меткой и UUID раздела nosenv по смещению этого раздела; поверх — настоящие начала дисков.
e2fsckрезультат принял. - ISO из
grub-mkrescueс командной строкой машины, загруженный с двумя NVMe в том же порядке, что на машине, и QEMU-шнымigb— это 82576, ближайшая модель к I210.
mkfs.ext2 -q -F -b 4096 -I 256 -L nosenv -U <uuid of nosenv> -E offset=1048576 nvme1.img 8388608
e2fsck -fn "nvme1.img?offset=1048576"
qemu-system-x86_64 -smp 4 -cpu max -m 4096 -cdrom replica.iso \
-drive file=nvme0.img,format=raw,id=n0,if=none -device nvme,serial=n0,drive=n0 \
-drive file=nvme1.img,format=raw,id=n1,if=none -device nvme,serial=n1,drive=n1 \
-device igb,netdev=net0 -netdev user,id=net0 \
-serial file:replica.log
У копии есть то, чего нет у машины, — последовательный порт. Она умерла сразу, так и не прикоснувшись к диску:
Key netconsole value 10.0.2.2:6666
PANIC:ParseMultiBootInfo():grub.cpp,148: Can't parse command line
Два знака равенства
Parameters::ParseParameter делил токен на ключ и значение с помощью StrChrOnce(param, '=') — функции, которая возвращает nullptr, если разделитель встречается больше одного раза. В root=LABEL=nosenv их два. Не найдя разделителя, парсер принимал токен за одиночное слово, а единственное одиночное слово, которое он понимает, — ro; разбор проваливался, а на x86 проваленный разбор командной строки означал панику (grub.cpp):
Trace(0, "Cmdline %s", cmdLine->String);
if (!Kernel::Parameters::GetInstance().Parse(cmdLine->String)) {
Trace(0, "Can't parse command line");
Panic("Can't parse command line");
}
ParseMultiBootInfo выполняется в самом начале Main2: до того, как взведено кольцо netconsole, до появления disk log, до какой-либо сетевой карты. Адрес, на который следовало слать лог, сам был параметром в той строке, что убила загрузку, — в логе копии видно, как он разобран прямо перед паникой. root=LABEL= и root=UUID= были документированы с момента появления, то есть с предыдущего дня; smoke-тест загружается с root=auto и ни с одной из этих форм не встречался.
Исправление, 259bb5c, — один вызов:
- const char* sep = Stdlib::StrChrOnce(param, '=');
+ /* The key ends at the first '=': the value may hold one of its own, as
+ root=LABEL=<label> and root=UUID=<uuid> do */
+ const char* sep = Stdlib::StrChr(param, '=');
Самотест загрузки, написанный вместе с ним, — TestParameters разбирает root=LABEL=, root=UUID= вместе с ro и root=<device> в отдельные экземпляры — тут же нашёл второй случай того же рода: токены короче трёх символов отбрасывались ещё до разбора, а в ro два символа. Его ветка была недостижима. Теперь нижняя граница — два.
Ничто в командной строке не останавливает загрузку
Так починилась эта строка. Но остался вопрос, почему вообще какая-то строка могла убить загрузку. На x86 разбор проваливался из-за любого одного токена, который парсер не принимал: одиночного слова вроде quiet или splash — привычка из Linux; параметра без чего-либо до или после =; параметра длиннее 48 символов; строки длиннее 255, — и на этой машине каждый такой случай был тихой смертью. На arm64 ошибка была обратной: результат разбора игнорировался, и все параметры после первого неудачного молча терялись.
Тем же вечером ade11b8 научил Parse брать то, что получается, и называть то, что не получается, — как это делает Linux:
Cmdline: ignoring 'quiet'
grub.cpp больше не паникует, а предупреждения уходят в dmesg, который netconsole воспроизводит с начала загрузки. Строка длиннее буфера читается до последнего целого параметра, потому что половина параметра — это уже другой параметр: обрезанный root=LABEL=nosenv превращается в root=LABEL=nos. Копирование сделано через StrnCpy, потому что SnPrintf тогда при нехватке места не сохранял вообще ничего — по этой же причине старый парсер не читал ни кусочка слишком длинной строки. На копии машины 269-символьная строка мусора вокруг собственных параметров машины дала ровно то, что нужно: каждый плохой токен назван и пропущен, nosenv смонтирован на запись, загрузка дошла до boot: complete и получила аренду DHCP.
В конце месяца у командной строки появилась собственная цель фаззинга (Фаззинг ядра на хосте): строки из всех параметров ядра, незнакомые слова, строки, обрезанные где угодно, — и каждый геттер сверяется с читателем, написанным по parameters.h: параметр, который режет конец буфера, пропускается целиком и никогда не читается как более короткий. Заодно она нашла, что конструктор Parameters оставлял два поля неинициализированными — нули там были лишь потому, что объект лежит в статической памяти.
Консоли последней надежды
Что остаётся у такой машины:
- netconsole,
netconsole=ip:port(Netconsole). Каждая строка трассировки попадает в кольцевой буфер на 128 КиБ с того момента, как известна командная строка; накопленное уходит, как только у устройства появляется адрес, датаграммами с порядковыми номерами — так пропуск можно отличить от зависания;nctail=Nтратит короткую жизнь канала на самые свежие строки. Принимает егоscripts/netconsole.py. - disk log,
disklog=on. Каждая строка с первой — на сырой раздел, подготовленный из другой ОС командойscripts/disklog.py format, и оттуда же читается обратно. Начиная с 0d70452 он выключен, пока его не попросят, — принудительная запись стоила на реальном железе 25–33 мс на строку, — строка ставится в очередь без блокировки и пишется отдельной задачей, а паника выталкивает всё, что в очереди. panic=N(b32a3b5): reset через N секунд после того, как отчёт о панике ушёл, так что умершая одноразовая загрузка сама возвращает сервер в его собственную ОС. Системный образ передаётpanic=10(установка на сервер).
У обоих каналов логов теперь есть свои гейты — netconsole-test.py и disklog-test.py: каждый из них где-то оказывается единственной консолью (тесты и гейты).
А дыра, в которую провалилась эта загрузка, никуда не делась, и в main.cpp она названа в комментарии: до слоя soft IRQ и монтирования корня disk log не может записать ничего, так что машина, остановившаяся раньше, ничего на диске не оставит, а чтобы закрыть дыру, драйверу NVMe нужен путь завершения запросов опросом. Пока его нет, молчаливую загрузку читают так же, как эту: по порядку загрузки, по двум счётчикам на дисках и по копии с последовательным портом.