nos_
ENRU

AX41: загрузка, которая не сказала ни слова

Ядро умерло раньше, чем появилась хоть какая-то консоль, на сервере, где консоль — только сеть. Два счётчика на дисках и копия в QEMU нашли причину.

8 мин чтения
ОтладкаЖелезоЗагрузкаQEMUx86-64
Read in English

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:

  1. Тот самый бинарник ядра из /nosenv, а не пересборку.
  2. Настоящие начала обоих NVMe-дисков, скопированные по ssh: первый мегабайт каждого (MBR и промежуток GRUB), область disk log, первые 32 МиБ nosenv — superblock, дескрипторы групп, битовые карты и таблицу inode первой группы, корневой каталог — и первые 64 КиБ каждого участника RAID.
  3. Два разреженных raw-образа размером с настоящие диски и с той же таблицей разделов; ext2 с геометрией, меткой и UUID раздела nosenv по смещению этого раздела; поверх — настоящие начала дисков. e2fsck результат принял.
  4. 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 нужен путь завершения запросов опросом. Пока его нет, молчаливую загрузку читают так же, как эту: по порядку загрузки, по двум счётчикам на дисках и по копии с последовательным портом.