<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Блог ALDO TECH</title>
    <link>https://aldo.tech/ru/blog/</link>
    <atom:link href="https://aldo.tech/ru/blog/feed.xml" rel="self" type="application/rss+xml"/>
    <description>Заметки с мест: общественный транспорт, оплата проезда, железо и софт, которые должны работать в дороге.</description>
    <language>ru</language>
    <item>
      <title>urMQTT после VPS: SQLite в пользовательском режиме, сравнение с Mosquitto, конфигурация как данные и сборщик в браузере</title>
      <link>https://aldo.tech/ru/blog/urmqtt-config-builder-plaintext-mosquitto/</link>
      <guid isPermaLink="true">https://aldo.tech/ru/blog/urmqtt-config-builder-plaintext-mosquitto/</guid>
      <pubDate>Sun, 01 Nov 2026 08:00:00 +0000</pubDate>
      <description>Что произошло после второго текста об urMQTT: SQLite, вынесенный за пределы ядра, измерения против Mosquitto на том же VPS (и то, что оказалось не в нашу пользу), найденные причины медленного одиночного потока, конфигурация как данные с веб-сборщиком ISO и эмулятором в браузере, необязательный обычный MQTT и то, что еще не доказано.</description>
      <content:encoded><![CDATA[<p>Второй текст, <a href="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/">urMQTT на настоящем VPS</a>, закончился списком «Что дальше»: H6 (SQLite вне ядра), H7 (безопасность времени), X11 (сравнение с Mosquitto на том же VPS) и первый релиз. Этот текст проходит по этому списку в том же порядке, а затем добавляет две вещи, которых в списке не было, потому что они появились по ходу дела: конфигурацию как данные, с веб-сборщиком ISO, и необязательный обычный MQTT без TLS. Как и раньше: что мы планировали, что построили, что нас удивило и что остается открытым. В этом тексте есть и несколько результатов, которые не в пользу urMQTT; мы их оставили, потому что они часть правды.</p>
<p>Предупреждение заранее прежнее: проект находится на стадии пре-альфа, первый релиз только для VPS с KVM, а цифры измеряют код, тесты и определенные эксперименты, а не зрелость в продакшене.</p>
<h2 id="_1">Как это выглядело во времени</h2>
<p>С предыдущего текста (31.10. в 23:00) до сегодняшнего дня (1.11. в 20:41) в репозитории 59 новых коммитов, всего 178. Все они появились в течение одного календарного дня, поэтому действует то же предупреждение, что и раньше: проект строится с Claude Code (коммиты несут <code>Co-Authored-By: Claude</code>), план, код, тесты и ADR создаются короткими замкнутыми циклами, а цифры ниже говорят об объеме кода и проверок, а не о том, сколько в них человеческого труда.</p>
<figure><img loading="lazy" alt="Рост с предыдущего текста: строк Rust с 89 790 до 112 408, функций #[test] с 739 до 926, ADR с 104 до 126 и коммитов с 119 до 178. У каждой строки своя шкала." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/growth-since.svg" /><figcaption>Рост с предыдущего текста: строк Rust с 89 790 до 112 408, функций #[test] с 739 до 926, ADR с 104 до 126 и коммитов с 119 до 178. У каждой строки своя шкала.</figcaption></figure>
<p>Цифры вычислены тем же методом, что и в прошлом тексте: все файлы <code>.rs</code> вне <code>vendor/</code> и <code>fuzz/</code>, функции <code>#[test]</code>, заголовки <code>## ADR-</code> в <code>docs/DECISIONS.md</code>.</p>
<table>
<thead>
<tr>
<th>Момент</th>
<th>Коммит</th>
<th>Время</th>
<th style="text-align: right;">Строк Rust</th>
<th style="text-align: right;">Тестов</th>
<th style="text-align: right;">ADR</th>
</tr>
</thead>
<tbody>
<tr>
<td>прошлый текст</td>
<td>794f607</td>
<td>31.10. 23:00</td>
<td style="text-align: right;">89 790</td>
<td style="text-align: right;">739</td>
<td style="text-align: right;">104</td>
</tr>
<tr>
<td>H6 готов</td>
<td>a0423cc</td>
<td>1.11. 03:59</td>
<td style="text-align: right;">95 078</td>
<td style="text-align: right;">754</td>
<td style="text-align: right;">107</td>
</tr>
<tr>
<td>X11</td>
<td>a29f49b</td>
<td>1.11. 07:56</td>
<td style="text-align: right;">95 579</td>
<td style="text-align: right;">754</td>
<td style="text-align: right;">108</td>
</tr>
<tr>
<td>веб-сборщик и зеркало релизов</td>
<td>582783f</td>
<td>1.11. 16:49</td>
<td style="text-align: right;">105 645</td>
<td style="text-align: right;">848</td>
<td style="text-align: right;">122</td>
</tr>
<tr>
<td>обычный MQTT, документация</td>
<td>8f959f1</td>
<td>1.11. 19:05</td>
<td style="text-align: right;">112 408</td>
<td style="text-align: right;">926</td>
<td style="text-align: right;">125</td>
</tr>
<tr>
<td>сегодня</td>
<td>d21e3ec</td>
<td>1.11. 20:41</td>
<td style="text-align: right;">112 408</td>
<td style="text-align: right;">926</td>
<td style="text-align: right;">126</td>
</tr>
</tbody>
</table>
<p>Работа шла параллельно в нескольких ветках, поэтому порядок коммитов не строго совпадает с порядком работы. ADR пронумерованы до 232, но в нумерации есть пропуски (часть была переименована при слиянии веток); мы считаем заголовки, а не наибольший номер. Релизы: v0.2.0 (1.11., 18:45 UTC) и v0.2.1 (1.11., 20:22 UTC), оба помечены как пре-релиз.</p>
<h2 id="h6-sqlite">H6: SQLite в пользовательском режиме</h2>
<p><strong>Что мы планировали.</strong> SQLite это 254 268 строк C, которые разбирают базу, WAL и журнал с диска, а диск может быть подделан или изменен. До этого он работал в ядре, с правами ядра. <strong>Что мы построили</strong> (ADR-210 до ADR-212): SQLite, его части libc и куча перенесены в отдельную программу, <code>dbworker</code>, которая работает на EL0 (aarch64) или в ring 3 (x86_64), в собственном адресном пространстве. Ядро больше не линкует SQLite. Воркер общается с ядром через общее окно памяти и двенадцать системных вызовов, а каждая возвращаемая длина и смещение проверяется перед использованием. Ошибка в воркере (недопустимый доступ, неопределенная инструкция, превышенный временной бюджет в 30 с) завершает вызов, воркер перезапускается, и база открывается из WAL, как при загрузке; после 5 перезапусков за минуту хранилище объявляется неисправным, а не перезапускается бесконечно.</p>
<p>Цена, измеренная на aarch64 под HVF, на той же VM, ядро со встроенным SQLite против ядра с воркером, по шесть чередующихся запусков каждого:</p>
<table>
<thead>
<tr>
<th>Измерение</th>
<th>До (в ядре)</th>
<th>После (воркер)</th>
<th>Разница</th>
</tr>
</thead>
<tbody>
<tr>
<td>вход в пользовательский режим и выход из него</td>
<td>-</td>
<td>1,1 us за круг</td>
<td></td>
</tr>
<tr>
<td><code>db-bench</code>, 5 000 вставок в 50 транзакциях, лучшее / медиана</td>
<td>61,9k / 58,4k op/s</td>
<td>54,7k / 53,3k op/s</td>
<td>-11,6 % / -8,7 %</td>
</tr>
<tr>
<td>одна вставка на транзакцию, лучшее / медиана</td>
<td>2 319 / 2 148 op/s</td>
<td>2 307 / 2 126 op/s</td>
<td>-0,5 % / -1 %</td>
</tr>
<tr>
<td>вся резервная копия одним вызовом (23 MiB)</td>
<td>514 ms</td>
<td>635 ms</td>
<td>+23 %</td>
</tr>
</tbody>
</table>
<p>Записи на диск (1 572) и сбросы (319) идентичны, как и должно быть. Не изолировано вот что: <strong>ядро по-прежнему доверяет тому, что вернет воркер</strong>, после проверки формы. Скомпрометированный воркер может лгать (например, вернуть пустой список отозванных сертификатов и тем самым «снять отзыв» с них), но не может читать память ядра, ключи TLS и ключи тикетов. KPTI нет, поэтому утечки типа Spectre/Meltdown не предотвращены, и мы ничего о них не утверждаем; ключ PAC разделяют EL0 и EL1. На x86_64 цену переключения мы на KVM не измеряли (под TCG число было бы бессмысленным), а фаззинг самого воркера и протокола не закончен. Набор тестов хранилища (79 сценариев, каждый гоняет SQLite в воркере) проходит на aarch64 HVF и x86_64 TCG, на aarch64 TCG 76 (один пропускается, потому что у TCG нет MSI-X).</p>
<p><strong>H7 закрыт, но не так, как мы объявляли.</strong> Нижняя граница времени (ADR-050) и SNTP с консенсусом (T1) покрывают объем H7; NTS (RFC 8915) остается вне объема и записан как оставшийся риск, а не как то, что работает. Мы не считаем, что безопасность времени «решена», а считаем, что готова ее доказуемая часть.</p>
<p><strong>Новая сетевая опция.</strong> Contabo не выдает DHCP пользовательскому ISO, поэтому VPS до сих пор требовал другой образ, чем лабораторная VM. Теперь <code>network.mode = "auto"</code> (ADR-214) сначала спрашивает DHCP, а если аренды нет в заданный срок (<code>dhcp_timeout_s</code>, по умолчанию 15 s) или он ее теряет, применяет заданный статический адрес. Проверено вручную на QEMU (с DHCP использует аренду, без сети через 5 s переходит на статический адрес); smoke-сценария для этого пока нет.</p>
<h2 id="x11-mosquitto-vps">X11: сравнение с Mosquitto на том же VPS</h2>
<p>Тот же тип машины, что и в прошлом тексте (Contabo, 4 vCPU AMD EPYC, 8 GiB), нагрузка с Linux-хоста на расстоянии 15 ms, взаимный TLS 1.3 с одними и теми же сертификатами с обеих сторон, Mosquitto 2.0.21 на Debian 13 без персистентности. Метод и решения в ADR-213, сырые данные в <code>docs/perf-results/</code>. Медиана трех запусков.</p>
<figure><img loading="lazy" alt="Полные рукопожатия в секунду (TLS 1.3 + клиентский сертификат + CONNECT, без возобновления сессии) при 32, 64 и 128 клиентах: urMQTT 169, 278, 413; Mosquitto 82, 140, 184. Это нижние границы: генератор нагрузки был близок к собственному пределу." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/mosquitto.svg" /><figcaption>Полные рукопожатия в секунду (TLS 1.3 + клиентский сертификат + CONNECT, без возобновления сессии) при 32, 64 и 128 клиентах: urMQTT 169, 278, 413; Mosquitto 82, 140, 184. Это нижние границы: генератор нагрузки был близок к собственному пределу.</figcaption></figure>
<table>
<thead>
<tr>
<th></th>
<th>urMQTT</th>
<th>Mosquitto 2.0.21</th>
</tr>
</thead>
<tbody>
<tr>
<td>рукопожатий/с, 32 / 64 / 128 клиентов (профиль bench)</td>
<td>169 / 278 / 413</td>
<td>82 / 140 / 184</td>
</tr>
<tr>
<td>то же, с ограничениями, как в поставляемой версии</td>
<td>185 / 262 / 333 (при 128: от 202 до 286 ошибок, сбросы рукопожатия)</td>
<td>-</td>
</tr>
<tr>
<td>память на неактивное соединение (20 000 соединений)</td>
<td>0,50 KB кучи ядра (0,66 KB при 5 000)</td>
<td>26,8 KB роста процесса</td>
</tr>
<tr>
<td>задержка p50, QoS 0 / QoS 1, 800 сообщений/с, 64 B</td>
<td>14,0 / 14,8 ms</td>
<td>21,4 / 39,5 ms</td>
</tr>
<tr>
<td>QoS 1, 4 800 сообщений/с, доставлено/с (p50)</td>
<td>4 209 (77 ms)</td>
<td>4 235 (197 ms)</td>
</tr>
<tr>
<td>100 подписчиков, 50 сообщений/с: p50</td>
<td>16,6 ms</td>
<td>99 ms</td>
</tr>
<tr>
<td>один поток, QoS 0, 64 B, опубликовано/с</td>
<td>2 962 (от 0 до 7 733)</td>
<td>22 749</td>
</tr>
</tbody>
</table>
<p>Что это значит, а что нет. Рукопожатия это <strong>нижние границы для обоих серверов</strong>: генератор нагрузки это разделяемая VM с 60 до 70 % украденного процессорного времени, и при 128 потоках он был близок к собственному пределу, а рабочие ядра urMQTT были заняты лишь на 12 до 13 %. Отношение при 128 клиентах (около 2,2 раза) наиболее осмысленно; при 32 и 64 оно в основном измеряет ожидание клиентов. Цифры памяти считают не одно и то же (куча ядра против RSS процесса), поэтому «примерно в 50 раз меньше» это порядок величины, а не точное отношение. Задержка в 14 ms это один круг сети (около 15 ms), то есть «измеримого времени брокера нет»; хвосты (p99 около 100 до 200 ms) у обоих брокеров определяются разделяемыми хостами. Различия ниже примерно 20 % мы не утверждаем.</p>
<p><strong>Что оказалось не в нашу пользу</strong> и осталось в таблицах: один TCP-поток через urMQTT нес в 2 до 8 раз меньше, чем через Mosquitto, с колебаниями от нуля до 7,7 тысячи сообщений в секунду между запусками, а 20-миллисекундный watchdog обработчика <strong>обрывал живые соединения</strong>, когда гипервизор останавливал vCPU (в продакшен-настройке <code>action = "drop"</code>, от 6 до 8 соединений за запуск при затоплении QoS 0). Обе проблемы описаны в следующем разделе, потому что их причины были в нашем коде.</p>
<h2 id="_2">Почему один поток был медленным: причины, а не отговорки</h2>
<p>Проще всего было бы сказать «разделяемый VPS». Мы этого не сказали, а на том же VPS и тем же методом искали причины. Найдено пять причин, четыре на пути данных и одна в watchdog (ADR-221 до ADR-224):</p>
<table>
<thead>
<tr>
<th>Симптом в X11</th>
<th>Причина в urMQTT</th>
<th>Изменение</th>
</tr>
</thead>
<tbody>
<tr>
<td>один поток 1,9k сообщений/с, часто <strong>0 доставлено</strong></td>
<td><strong>Потерянное пробуждение:</strong> цикл вывода возвращал список готовых соединений поверх только что записанного; подписчик, у которого выходной буфер однажды заполнился, больше не обслуживался, пока его не будил PINGRESP</td>
<td>исправлено (ADR-223)</td>
</tr>
<tr>
<td>несколько процентов потерь превращают 48k в 14k сообщений/с</td>
<td><strong>каждый сегмент после потерянного отбрасывался</strong> (прием только по порядку), поэтому восстановление стоило круг сети на сегмент</td>
<td>очередь сегментов вне порядка, SACK (ADR-222)</td>
</tr>
<tr>
<td>потолок 4,4 MB/s при 15 ms</td>
<td>16-битное окно, очередь отправки 64 KiB и медленный старт, считавший ACK, а не байты</td>
<td>масштабирование окна, метки времени, подсчет байтов (ADR-222)</td>
</tr>
<tr>
<td>каждый круг потока, который помещается, ждет от 0 до 10 ms</td>
<td><strong>диспетчер спал до следующего тика в 10 ms</strong> (у virtio-net нет прерывания на прием)</td>
<td>опрос NIC каждые 200 us, пока есть соединения (ADR-224)</td>
</tr>
<tr>
<td>от 1 до 8 оборванных соединений за запуск</td>
<td><strong>watchdog начислял обработчику прошедшее время</strong>, а VPS останавливает vCPU часто и надолго</td>
<td>бюджет это время, которое ядро действительно работало (ADR-221)</td>
</tr>
</tbody>
</table>
<p>Остановки настоящие: на этом VPS у ядер 0 до 2 была медиана от 36 до 80 опоздавших тиков за 5 секунд, а худшая остановка в окне имела медиану около 70 ms и доходила до 0,9 s (ядро 3, база и журнал: до 1,55 s), при том что хост не сообщает украденное время (<code>host steal 0 us</code>). Поэтому watchdog нужен был другой источник времени, а не больший бюджет.</p>
<figure><img loading="lazy" alt="До и после, на том же VPS тем же методом, медиана трех до шести запусков: один поток QoS 0, 64 B: опубликовано 1,9k, 23,7k (Mosquitto 29,3k), доставлено 0 (медиана), 12,6k (29,2k). 16 пар, затопление, 64 B: опубликовано 24,7k, 43,2k (31,4k), доставлено 20,0k, 37,4k (30,7k)." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/before-after.svg" /><figcaption>До и после, на том же VPS тем же методом, медиана трех до шести запусков: один поток QoS 0, 64 B: опубликовано 1,9k, 23,7k (Mosquitto 29,3k), доставлено 0 (медиана), 12,6k (29,2k). 16 пар, затопление, 64 B: опубликовано 24,7k, 43,2k (31,4k), доставлено 20,0k, 37,4k (30,7k).</figcaption></figure>
<table>
<thead>
<tr>
<th></th>
<th>код X11 (7f9517e)</th>
<th>сейчас (86a71ff)</th>
<th>Mosquitto</th>
</tr>
</thead>
<tbody>
<tr>
<td>один поток, QoS 0, 64 B, опубликовано / доставлено в с</td>
<td>1,9k / 0</td>
<td>23,7k / 12,6k</td>
<td>29,3k / 29,2k</td>
</tr>
<tr>
<td>один поток, QoS 0, 1 KiB</td>
<td>0 / 0</td>
<td>3,9k / 2,3k</td>
<td>5,4k / 5,4k</td>
</tr>
<tr>
<td>16 пар, затопление, QoS 0, 64 B</td>
<td>24,7k / 20,0k</td>
<td>43,2k / 37,4k</td>
<td>31,4k / 30,7k</td>
</tr>
<tr>
<td>16 пар, затопление, QoS 0, 1 KiB</td>
<td>3,7k / 2,9k</td>
<td>5,0k / 4,5k</td>
<td>5,9k / 5,7k</td>
</tr>
<tr>
<td>потерянные соединения за запуск (16 пар, 64 B)</td>
<td>13 (от 7 до 16)</td>
<td>0 (от 0 до 0)</td>
<td>0</td>
</tr>
</tbody>
</table>
<p>Watchdog: 148 оборванных соединений в 30 нагруженных запусках до, 0 в 24 запусках после; на локальной установке, которая задерживает и теряет кадры, при потере 0,1 % один поток в 64 B с 14,3k сообщений/с (ядро X11) теперь имеет 41,0k, а при 0,5 % с 5,0k до 17,7k.</p>
<p><strong>Чего это не говорит.</strong> urMQTT <strong>по-прежнему не быстрее Mosquitto на одном потоке</strong>: при 64 B он публикует около 80 % его скорости, а доставляет около 40 %, потому что отбрасывает QoS 0, когда поток подписчика медленнее потока издателя (Mosquitto без границы ставит в очередь). При 1 KiB в 16-парном затоплении Mosquitto впереди примерно на 15 %, что в пределах шума. Генератор снова был разделяемой VM с 58 до 77 % украденного времени. Второй раунд (блокировка кучи вне пути данных, SACK на стороне отправки и другое) находится в неинтегрированной ветке <code>perf-single-flow-wip</code>; его цифры еще не утверждения, и здесь мы их не приводим.</p>
<h2 id="_3">Обещание, которого мы не давали, но выполнили: конфигурация это данные</h2>
<p>До сих пор каждая конфигурация была новой сборкой: ключи и ограничения были вшиты в образ. C1 переворачивает это (ADR-215 до ADR-219): ядро собирается один раз, а конфигурация это данные.</p>
<ul>
<li><strong>Блоб <code>urmqtt-cfg</code></strong> (C1a): версионированная, каноническая запись (поля по возрастающим меткам, отпечаток SHA-256, необязательная отдельная подпись Ed25519), 70 полей со значениями по умолчанию и границами, декодер, который отвергает все неправильное.</li>
<li><strong>Ядро читает его как модуль Limine</strong> (C1b), раньше всего остального, на aarch64 и x86_64. Существует <strong>одно выпускаемое ядро для каждого <code>ENV</code>, кроме <code>dev</code></strong> (ELF aarch64 для <code>release</code>, <code>prod</code> и <code>bench</code> имеют один и тот же SHA-256; проверено только на aarch64). Выпускаемое ядро отвергает конфигурацию для разработки, а без конфигурации пишет «refusing to serve» и ничего не обслуживает.</li>
<li><strong>Установленный диск несет конфигурацию</strong>, и обновление ее сохраняет (C1c).</li>
<li><strong><code>urmqtt-config</code> и шаблон ISO</strong> (C1d): <code>pack</code>, <code>show</code>, <code>verify</code>, а <code>pack --iso</code> заменяет оба экземпляра места для конфигурации и 24 дайджеста модулей в готовом ISO. Результат байт в байт совпадает с ISO, который собирает <code>mkiso.sh --config</code>. Шаблон релиза v0.2.0 имеет 63 111 168 байт.</li>
<li><strong>Заявление о доверии</strong> (ADR-219): сконфигурированный образ при загрузке выводит отпечаток конфигурации (<code>config: fingerprint ...</code>), а подпись необязательна и <strong>по умолчанию выключена</strong>. У измененного ISO нет официального дайджеста; отпечаток это способ увидеть, что в нем находится.</li>
</ul>
<h2 id="aldotech">Сборщик на aldo.tech</h2>
<p>То, что делает <code>urmqtt-config pack --iso</code>, делает и веб-страница, тем же кодом: библиотека инструмента, скомпилированная в WebAssembly (1,06 MB, без импортов и без блока <code>unsafe</code>). Страница находится по адресу <a href="https://aldo.tech/ru/produkty/urmqtt-builder/">aldo.tech/ru/produkty/urmqtt-builder/</a> и существует на всех 15 языках сайта (например, <a href="https://aldo.tech/proizvodi/urmqtt-builder/">hr</a>).</p>
<ul>
<li><strong>Ничто не покидает браузер.</strong> Сертификаты, закрытые ключи и готовый ISO остаются на компьютере; страница скачивает только файлы релиза с этого сайта, без отслеживания и без сторонних скриптов. В живом тесте ни один запрос не нес ключевой материал.</li>
<li><strong>Зеркало релизов</strong> (ADR-227): каждый час (таймер systemd, низкий приоритет) копирует релизы с GitHub, каждый проверяет перед публикацией, а страница в браузере проверяет подпись <code>SHA256SUMS</code> ключом, встроенным в страницу, и только потом записывает конфигурацию в шаблон. Неудачная проверка блокирует скачивание.</li>
<li><strong>Просто по умолчанию, расширенно по необходимости</strong> (ADR-232): первый экран это три шага (где будет работать, ваши сертификаты, имя образа). Одно поле для перетаскивания файлов раскладывает сертификат, ключ и CA по содержимому. Расширенный вид открывает каждое из 70 полей, поиск, импорт и экспорт TOML; оба вида редактируют одно и то же состояние. Шаблон (63 MB) скачивается, пока пользователь заполняет форму. В живой пробе, с пробным зеркалом и пробным ключом, сборка ISO в браузере заняла от 5 до 18 s.</li>
</ul>
<figure><img loading="lazy" alt="Простой вид сборщика: три шага, «Где будет работать?», «Ваши сертификаты» и «Имя образа», с одним следующим действием в боковой панели." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/builder-simple.png" /><figcaption>Простой вид сборщика: три шага, «Где будет работать?», «Ваши сертификаты» и «Имя образа», с одним следующим действием в боковой панели.</figcaption></figure>
<figure><img loading="lazy" alt="Расширенный вид, снятый на пробном зеркале с пробным ключом (дата релиза и ключ подписи не настоящие): релиз с проверенной подписью, шаблоны, каждое поле конфигурации." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/builder-advanced.png" /><figcaption>Расширенный вид, снятый на пробном зеркале с пробным ключом (дата релиза и ключ подписи не настоящие): релиз с проверенной подписью, шаблоны, каждое поле конфигурации.</figcaption></figure>
<p><strong>Эмулятор, необязательно.</strong> Рядом с «Скачать ISO» стоит «Попробовать в эмуляторе»: QEMU, скомпилированный в WebAssembly, запускает только что собранный образ во вкладке браузера. Ничего из него не скачивается до клика (около 3 MB в первый раз). Эмулятор работает только с BIOS, <strong>без сети</strong> (чтобы трафик пользователя не прошел через какого-либо посредника и не нарушил «ничто не покидает браузер»), требует около 1,5 GB памяти и компьютерный браузер (протестирован Chrome; Firefox и Safari нет). В нем urMQTT доходит до <code>URMQTT READY</code> примерно за 8 s (с пропущенным обратным отсчетом) и выводит тот же отпечаток конфигурации, что и страница, что доказывает, что поднята именно эта конфигурация. Загрузка примерно в 2 раза медленнее исходного QEMU на том же Mac (0,82 s против 1,9 s), а криптография примерно в 58 раз медленнее, потому что аппаратные инструкции эмулируются. Поэтому <strong>из эмулятора мы не делаем никаких утверждений о скорости</strong>, и страница говорит об этом до запуска. При сборке пришлось исправить четыре байта в <code>.wasm</code> из-за ошибки в трансляции инструкции POPCNT в исходном эмуляторе (мы о ней еще не сообщили); сам эмулятор распространяется под GPL, с предложением исходного кода.</p>
<figure><img loading="lazy" alt="Диагностический вход в эмуляторе вживую на aldo.tech: ядро вывело тот же отпечаток конфигурации, что и страница, а экран показывает диагностику (в эмуляторе она завершается примерно за 20 s; ее цифры описывают эмулятор, а не urMQTT)." src="https://aldo.tech/blog/urmqtt-config-builder-plaintext-mosquitto/img/emulator-diagnostic.png" /><figcaption>Диагностический вход в эмуляторе вживую на aldo.tech: ядро вывело тот же отпечаток конфигурации, что и страница, а экран показывает диагностику (в эмуляторе она завершается примерно за 20 s; ее цифры описывают эмулятор, а не urMQTT).</figcaption></figure>
<h2 id="mqtt">Обычный MQTT, только для сетей, которым вы доверяете</h2>
<p>Обычный MQTT (TCP 1883, без TLS) очень распространен в IoT: он по умолчанию в Mosquitto, в большинстве прошивок для Arduino и ESP, в Tasmota, Zigbee2MQTT и Home Assistant, а используется он и внутри сегментированных промышленных сетей, за VPN и в частных LAN, в том числе на устройствах без часов (сертификатам нужна дата), без достаточного объема flash или процессора для TLS. Через недоверенную сеть он неприемлем: кто находится на пути, читает и изменяет трафик. Поэтому P1 (ADR-229 до ADR-231) это явный, ограниченный выбор, <strong>по умолчанию выключенный</strong>, а порт TLS остается; TLS этим не отключается, обычный порт появляется рядом с ним.</p>
<ul>
<li><strong>Список разрешенных сетей обязателен</strong> (<code>allow_from</code>, CIDR) и проверяется при SYN: вне списка нет ни SYN-ACK, ни состояния. Весь интернет (<code>0.0.0.0/0</code>) требует отдельной явной опции.</li>
<li><strong>Три способа входа:</strong> пароль (по умолчанию, когда включено; PBKDF2-HMAC-SHA-256, соль, 10 000 итераций, сравнение за постоянное время, блокировка по адресу), анонимно (одна настроенная идентичность со своим ACL) и по адресу (<code>ip</code>). Сборщик хэширует пароль в браузере и хранит в памяти только дайджест.</li>
<li><strong>Меньше прав, чем при mTLS:</strong> обычный клиент никогда не получает служебных, операторских и управляющих прав, у него нет <code>$SYS</code> и консоли, у него свои сессии (<code>$plain/&lt;идентичность&gt;/&lt;id клиента&gt;</code>, только чистые), собственные ограничения, и реестр допустимых по-прежнему его обязывает.</li>
<li><strong>Что теряется, записано в <code>SECURITY.md</code>:</strong> конфиденциальность, целостность, аутентичность брокера и повтор записанного входа. Это нельзя исправить конфигурацией; так бывает, когда нет TLS.</li>
</ul>
<p>Измерено на ноутбуке (Apple silicon, HVF, пользовательская сеть QEMU), 16 пар, 64 B, три запуска по 10 s: при 8 000 сообщений в секунду TLS и обычный путь доставляют все сообщения с одинаковой задержкой (p50 0,17 ms против 0,13 до 0,18 ms), новых соединений в секунду: TLS около 2 600 до 2 750, анонимный обычный около 4 900 до 7 800, а <strong>вход по паролю это дорогая часть</strong>: один PBKDF2 стоит около 2,5 ms, поэтому число новых входов ограничено примерно 200 на ядро данных в секунду (измерено 308 до 342 на двух ядрах). Затопление на этой машине не является стабильным инструментом (от 20k до 440k доставленных в секунду между запусками), и <strong>из него мы не делаем вывода</strong>. Сравнение обычного пути с Mosquitto на VPS еще не сделано. Тесты: smoke-группа <code>plaintext</code> 7 из 7 на aarch64 HVF и на x86_64 TCG, новая цель фаззинга <code>plain_input</code> (2,3 миллиона запусков без находок).</p>
<h2 id="_4">Ошибки, которые нашла работа вживую</h2>
<p>Две вещи поймали не тесты, а первое включение на настоящем сайте:</p>
<ul>
<li>Страница искала файлы релиза <strong>на один каталог выше</strong>, поэтому каждая проверка подписи отвечала 404 (исправление 13af301). Тест использовал пробное зеркало по другому пути.</li>
<li><strong>Клавиатура эмулятора никогда не доходила до гостя</strong>: причиной был не плохой перевод клавиш, а то, что диалог вообще не слушал клавиши (исправление в ADR-232, с входом для загрузки без клавиш).</li>
</ul>
<h2 id="_5">Уроки</h2>
<ul>
<li><strong>Ищите причину, не обвиняйте окружение.</strong> Большинство слабостей одного потока и все оборванные соединения были нашими; гипервизор настоящий, но его нужно было измерять, а не предполагать.</li>
<li><strong>Сравнение с другим продуктом должно включать и потери.</strong> Таблица, в которой только наши победы, не была бы сравнением.</li>
<li><strong>У измерительного инструмента есть границы.</strong> Разделяемый генератор с 60 до 70 % украденного времени меняет то, что означают цифры; поэтому мы пишем «нижняя граница» и не утверждаем различий ниже 20 %.</li>
<li><strong>Изоляция это не доказательство.</strong> SQLite в EL0 уменьшает то, что может натворить ошибка в нем, но не то, о чем он может лгать.</li>
<li><strong>Настоящая установка находит то, чего тесты не ищут</strong>: неправильно составленный путь и клавиши, которые никто не слушает.</li>
</ul>
<h2 id="_6">Что еще не доказано</h2>
<ul>
<li>Ключ подписи релиза пока пробный; v0.2.0 и v0.2.1 это пре-релизы, а образы к ним для разработки с пробными сертификатами (кроме шаблона, который без конфигурации).</li>
<li>13 переводов страницы сборщика (и этого текста) написал ИИ, и проверили их тесты и выборочные проверки, а не носители языка.</li>
<li>Сравнение с Mosquitto это одна сессия, один путь и один разделяемый генератор, на разделяемом VPS; различия ниже 20 % не являются утверждениями.</li>
<li>Обычный путь не измерен на VPS и не против Mosquitto.</li>
<li>Lighthouse на новом простом виде не подтвержден (старый вид: доступность 95 или выше).</li>
<li>Bare metal, цена воркера на x86_64 на KVM, Firefox и Safari для эмулятора: не измерено.</li>
</ul>
<h2 id="_7">Что дальше</h2>
<ul>
<li><strong>D1: публичный демо-брокер.</strong> Эмулятор из браузера мы не можем сделать публичным: он живет во вкладке одного посетителя, в 20 до 100 раз медленнее KVM, у него нет входящей сети, и он исчезает при закрытии вкладки; перенаправление интернета в чужой браузер было бы к тому же злоупотреблением. Возможен настоящий демо-брокер: urMQTT на VPS за именем на aldo.tech, с опубликованным пробным CA, строгим ACL и небольшими ограничениями. Пока это только идея, не разработка, и ей нужно явное согласие на публичное размещение.</li>
<li><strong>Второй раунд для одного потока</strong> (ветка <code>perf-single-flow-wip</code>): только когда он будет измерен на VPS и слит.</li>
<li><strong>Обычный путь против Mosquitto на VPS</strong>, вход и отзыв учетных записей для обычного MQTT через подписанный управляющий канал.</li>
<li><strong>После релиза:</strong> bare metal (X12 до X16), IOMMU и Secure Boot, шифрование диска.</li>
</ul>
<p>Код находится на <a href="https://github.com/zeljan-alduk/urMQTT">github.com/zeljan-alduk/urMQTT</a>, а <code>docs/PROGRESS.md</code> по-прежнему единственный источник истины о том, что действительно работает, <code>docs/PERFORMANCE.md</code> о том, что измерено. Пре-альфа, как и раньше; сообщения об ошибках и о безопасности по инструкциям в <code>SECURITY.md</code> приветствуются.</p>]]></content:encoded>
      <category>rust</category>
      <category>mqtt</category>
      <category>iot</category>
      <category>unikernel</category>
      <category>open source</category>
    </item>
    <item>
      <title>urMQTT на настоящем VPS: ISO, рукопожатие за 0,24 мс, собственный диск и усиленное ядро</title>
      <link>https://aldo.tech/ru/blog/urmqtt-vps-handshake-disk-hardening/</link>
      <guid isPermaLink="true">https://aldo.tech/ru/blog/urmqtt-vps-handshake-disk-hardening/</guid>
      <pubDate>Sat, 31 Oct 2026 08:00:00 +0000</pubDate>
      <description>Что стало с тремя обещаниями из первого текста об urMQTT (один ISO, сырой диск без файловой системы, доказуемая безопасность) за первые четыре дня разработки: с измерениями, таблицами и списком того, что еще не готово.</description>
      <content:encoded><![CDATA[<p>В <a href="https://aldo.tech/blog/urmqtt-unikernel-mqtt-broker/">первом тексте об urMQTT</a> мы описали unikernel, который <em>является</em> MQTT-брокером, до вехи M17 и закончили тремя обещаниями в разделе «Что дальше»: один ISO, который загружается на нескольких платформах, сырой диск без файловой системы и безопасность, которую можно доказать, а не просто заявить. Тогда ничего из этого еще не было построено. Этот текст прослеживает, что произошло с каждым обещанием, в том же порядке, что и раньше: что мы планировали, что построили, что нас удивило и что остается открытым. Заодно покажем, сколько времени это заняло, потому что это тоже часть истории.</p>
<p>Одно замечание заранее: проект по-прежнему находится на стадии пре-альфа. Цифры в тексте измеряют код и тесты, а не зрелость в продакшене, а первый релиз намеренно ограничен VPS с KVM. Bare metal остается на потом.</p>
<h2 id="_1">Как это выглядело во времени</h2>
<p>Первый коммит появился 28 октября 2026 года в 23:48 (M0). К 23:00 31 октября в репозитории уже 119 коммитов, распределенных по календарным дням как 3, 39, 38 и 39. Сразу скажем, как это возможно: проект строится с Claude Code, а коммиты несут трейлеры <code>Co-Authored-By: Claude</code>. Четыре календарных дня истории не означают, что один человек за четыре дня написал 90 000 строк; они означают, что план, реализация, тесты и ADR пишутся короткими замкнутыми циклами, и каждый шаг заканчивается тем же ритуалом проверки, что и раньше. Цифры ниже говорят об объеме кода и тестов, а не о том, сколько всего этого было проделано в реальной работе.</p>
<figure><img loading="lazy" alt="Рост проекта за четыре дня с вехами от M0 до сегодняшнего дня: тысячи строк Rust (от 1,6 до 89,8), число функций #[test] (от 27 до 739) и число ADR (от 13 до 104), а также столбцы коммитов по дням (39, 38 и 39 за 29, 30 и 31 октября)." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/growth.svg" /><figcaption>Рост проекта за четыре дня с вехами от M0 до сегодняшнего дня: тысячи строк Rust (от 1,6 до 89,8), число функций #[test] (от 27 до 739) и число ADR (от 13 до 104), а также столбцы коммитов по дням (39, 38 и 39 за 29, 30 и 31 октября).</figcaption></figure>
<p>На графике три кривые, которые растут почти вместе, и это намеренно: тесты и записанные решения идут вровень с кодом, а не отстают от него. Наклон круче всего 29 октября, когда создается весь путь от ядра до брокера. Дальше строки добавляются медленнее, но тесты растут быстрее кода (27 тестов на 1,6 тысячи строк в начале, 739 на 89,8 тысячи сегодня).</p>
<table>
<thead>
<tr>
<th>Веха</th>
<th>Коммит</th>
<th>Время</th>
<th style="text-align: right;">Строк Rust</th>
<th style="text-align: right;">Тестов</th>
<th style="text-align: right;">ADR</th>
</tr>
</thead>
<tbody>
<tr>
<td>M0</td>
<td>9412a9c</td>
<td>28.10. 23:48</td>
<td style="text-align: right;">1 592</td>
<td style="text-align: right;">27</td>
<td style="text-align: right;">13</td>
</tr>
<tr>
<td>M5</td>
<td>a61083b</td>
<td>29.10. 01:32</td>
<td style="text-align: right;">9 314</td>
<td style="text-align: right;">88</td>
<td style="text-align: right;">24</td>
</tr>
<tr>
<td>M9</td>
<td>4be8cc9</td>
<td>29.10. 02:21</td>
<td style="text-align: right;">15 729</td>
<td style="text-align: right;">149</td>
<td style="text-align: right;">28</td>
</tr>
<tr>
<td>M11</td>
<td>4800904</td>
<td>29.10. 03:43</td>
<td style="text-align: right;">22 737</td>
<td style="text-align: right;">180</td>
<td style="text-align: right;">30</td>
</tr>
<tr>
<td>M13</td>
<td>a558d5e</td>
<td>29.10. 05:22</td>
<td style="text-align: right;">26 556</td>
<td style="text-align: right;">193</td>
<td style="text-align: right;">32</td>
</tr>
<tr>
<td>M17</td>
<td>7df4f98</td>
<td>29.10. 08:48</td>
<td style="text-align: right;">33 504</td>
<td style="text-align: right;">232</td>
<td style="text-align: right;">36</td>
</tr>
<tr>
<td>S1</td>
<td>ecf72ec</td>
<td>30.10. 00:46</td>
<td style="text-align: right;">41 910</td>
<td style="text-align: right;">299</td>
<td style="text-align: right;">45</td>
</tr>
<tr>
<td>X8</td>
<td>fb1504a</td>
<td>30.10. 19:08</td>
<td style="text-align: right;">56 341</td>
<td style="text-align: right;">456</td>
<td style="text-align: right;">59</td>
</tr>
<tr>
<td>PERF</td>
<td>76f7c36</td>
<td>31.10. 14:19</td>
<td style="text-align: right;">72 511</td>
<td style="text-align: right;">618</td>
<td style="text-align: right;">76</td>
</tr>
<tr>
<td>сегодня</td>
<td>HEAD</td>
<td>31.10. 23:00</td>
<td style="text-align: right;">89 790</td>
<td style="text-align: right;">739</td>
<td style="text-align: right;">104</td>
</tr>
</tbody>
</table>
<p>Строки это все файлы <code>.rs</code> вне <code>vendor/</code> и <code>fuzz/</code>, тесты это функции <code>#[test]</code>. Что добавлялось и когда, по датам из истории git:</p>
<table>
<thead>
<tr>
<th>Дата</th>
<th>Вехи</th>
<th>Что добавлено</th>
</tr>
</thead>
<tbody>
<tr>
<td>29.10.</td>
<td>M0 до M17</td>
<td>ядро, сеть, TLS, брокер, SQLite, несколько ядер, собственный TCP, усиление, воспроизводимый релиз</td>
</tr>
<tr>
<td>вечер 29.10. и 30.10.</td>
<td>X0 до X10, S1, H2, H3 (X0 начинается 29.10. в 20:54)</td>
<td>порт на x86_64, универсальный ISO, один диск и самоустанавливающийся ISO, проверки TLS, атакующие клиенты</td>
</tr>
<tr>
<td>31.10.</td>
<td>O1 до O4, T1, PERF, H4, H5, H9, S2 до S6</td>
<td>наблюдаемость и консоль через MQTT, живой режим и диагностика, SNTP, ускорения, усиление, верификация, цепочка поставок, хранение</td>
</tr>
</tbody>
</table>
<h2 id="1-iso">Обещание 1: один ISO для нескольких платформ</h2>
<p><strong>Что мы планировали.</strong> Один файл, который загружается на aarch64 UEFI, x86_64 UEFI и x86_64 BIOS. <strong>Что мы построили.</strong> Порт на x86_64 (X0 до X10) и универсальный ISO с загрузчиком Limine, один гибридный ISO, с которого загружаются все эти комбинации; смоук-тесты поднимают его на четырех виртуальных машинах. В меню три пункта: urMQTT (живой режим, работает из RAM, на диск ничего не пишет), установка на диск машины, на которой он запущен (установка записывает GPT и данные, а затем выключает машину) и Диагностика (определение оборудования и измерения).</p>
<figure><img loading="lazy" alt="Меню загрузки ISO, снятое на VPS: пункты «urMQTT», «Install urMQTT on this machine's disk» и «Diagnostic: hardware detection and benchmarks» с обратным отсчетом до автоматической загрузки. Снимок сделан с тестового ISO с более длинным отсчетом; по умолчанию 10 секунд." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/boot-menu.png" /><figcaption>Меню загрузки ISO, снятое на VPS: пункты «urMQTT», «Install urMQTT on this machine's disk» и «Diagnostic: hardware detection and benchmarks» с обратным отсчетом до автоматической загрузки. Снимок сделан с тестового ISO с более длинным отсчетом; по умолчанию 10 секунд.</figcaption></figure>
<p>Первой настоящей целью стал VPS, а не физический компьютер: решено, что первый релиз поддерживает только VPS (KVM), а bare metal идет после него. Мы тестировали на VPS у Contabo с процессором AMD EPYC (семейство 23, Zen 1), 4 vCPU и 8 GiB RAM, с сетью virtio-net и диском virtio-scsi. Первая попытка нас удивила: ISO, подключенный как виртуальный CD, загрузил пункт по умолчанию, продакшен-образ отказался работать с носителя только для чтения и перезагрузился, и мы получили бесконечный цикл меню, ядро, сброс без единой читаемой строки. Сегодня фатальная ошибка в продакшен-образе оставляет сообщение на консоли на 60 секунд перед перезагрузкой, а с оптического носителя выводит, что загрузка произошла с носителя только для чтения, и останавливается. Живой режим в RAM (O3) позже обошел эту проблему для обычного случая.</p>
<p>Пункт диагностики родился из этого опыта: машину, которую мы не видим, нужно уметь спросить, что у нее есть. Он выводит CPU, RAM, сеть, диски и PCI-устройства, измеряет пропускную способность памяти, диск и криптографию, а те же измерения выводит в виде JSON на последовательный порт. Запись на диск разрешена только в нераспределенном пространстве и только после ввода <code>WIPE</code>; на снимке ниже это пропущено.</p>
<figure><img loading="lazy" alt="Экран диагностики на VPS у Contabo (AMD EPYC, KVM, 4 vCPU): система, память, диск, PCI-устройства, пропускная способность и задержка RAM, а также криптоизмерения, включая 244 us на mTLS-рукопожатие, X25519 48 us, подпись ECDSA 35,7 us и проверку 107 us." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/diag-vps.png" /><figcaption>Экран диагностики на VPS у Contabo (AMD EPYC, KVM, 4 vCPU): система, память, диск, PCI-устройства, пропускная способность и задержка RAM, а также криптоизмерения, включая 244 us на mTLS-рукопожатие, X25519 48 us, подпись ECDSA 35,7 us и проверку 107 us.</figcaption></figure>
<p><strong>Что остается открытым.</strong> Настоящее железо. После первого релиза идет bare metal (загрузка с USB, NVMe и AHCI, сетевая карта реального компьютера) вместе с IOMMU и Secure Boot, потому что без IOMMU вредоносное или неисправное устройство может писать по всей памяти. В этой версии мы ничего об этом не утверждаем.</p>
<h2 id="12-024">Рукопожатие: от 1,2 мс до 0,24 мс</h2>
<p>Первое измерение диагностики на VPS дало неприятную цифру: mTLS-рукопожатие, только криптография на стороне сервера, занимало 1,2 мс. На aarch64 мы уже использовали аппаратные инструкции AES и SHA, но для открытого ключа на x86_64 ничего подобного не было: RustCrypto переносим и работает за постоянное время, а значит, и медленно. Три изменения это исправили.</p>
<ul>
<li>Для P-256 мы взяли ассемблер <code>nistz256</code> из OpenSSL и ring (ADR-130), с таблицами с фиксированным основанием для подписи и проверки подписи CA (ADR-110, ADR-111).</li>
<li>X25519 переписан на четыре 64-битные ветви с инструкциями MULX/ADX (ADR-131).</li>
<li>После этого мы профилировали и остальное (ADR-132, ADR-200).</li>
</ul>
<figure><img loading="lazy" alt="Рукопожатие на VPS: 1200 us до, 237 us после ассемблера и таблиц; A/B-сравнение memcpy в одном запуске, 278,8 us со старым, 229,4 us с новым; а также до и после для X25519 (146 до 48 us), подписи ECDSA (673 до 34 us) и проверки (309 до 90 us)." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/handshake.svg" /><figcaption>Рукопожатие на VPS: 1200 us до, 237 us после ассемблера и таблиц; A/B-сравнение memcpy в одном запуске, 278,8 us со старым, 229,4 us с новым; а также до и после для X25519 (146 до 48 us), подписи ECDSA (673 до 34 us) и проверки (309 до 90 us).</figcaption></figure>
<table>
<thead>
<tr>
<th>Измерение на VPS (сервер, микросекунды)</th>
<th style="text-align: right;">До</th>
<th style="text-align: right;">После</th>
</tr>
</thead>
<tbody>
<tr>
<td>mTLS-рукопожатие, вся криптография</td>
<td style="text-align: right;">1200</td>
<td style="text-align: right;">около 240 (237 в измерении для графика, 244 на снимке)</td>
</tr>
<tr>
<td>X25519</td>
<td style="text-align: right;">146</td>
<td style="text-align: right;">48</td>
</tr>
<tr>
<td>Подпись ECDSA</td>
<td style="text-align: right;">673</td>
<td style="text-align: right;">34</td>
</tr>
<tr>
<td>Проверка ECDSA</td>
<td style="text-align: right;">309</td>
<td style="text-align: right;">90 (107 на снимке)</td>
</tr>
<tr>
<td>рукопожатие, старый memcpy, та же загрузка</td>
<td style="text-align: right;">278,8</td>
<td style="text-align: right;">229,4 (-17,7%)</td>
</tr>
</tbody>
</table>
<p>Цифры отличаются от запуска к запуску примерно на 10%, поэтому мы записали диапазон вместо одного значения. В сумме это примерно в пять раз меньше процессорного времени на рукопожатие.</p>
<p>Что мы узнали о memcpy. Когда криптография стала быстрой, профиль показал неожиданное: функции <code>mem*</code> (memcpy, memmove, memset) занимали 7% рукопожатия, от 13 до 39% пути TLS-записи (39% для 64 B, 13% для 4 KiB) и от 17 до 34% разбора PUBLISH. Мы заменили их ассемблером SSE2 для x86_64 (ADR-200) с дифференциальными тестами против эталонной реализации для всех размеров, выравниваний и перекрытий. На один вызов это 55-75 циклов против 7-12 для 1-64 байт.</p>
<figure><img loading="lazy" alt="Слева: доля времени сервера в функциях mem* до переписывания (рукопожатие 7%, TLS-запись 64 B 39%, 256 B 37%, 1 KiB 24%, разбор 17-34%). Справа: насколько быстрее каждый путь после (рукопожатие 17,7%, 64 B 26,4%, 256 B 19,6%, 1 KiB 16,1%, 4 KiB 6,6%, разбор 7-34%)." src="https://aldo.tech/blog/urmqtt-vps-handshake-disk-hardening/img/hotpaths.svg" /><figcaption>Слева: доля времени сервера в функциях mem* до переписывания (рукопожатие 7%, TLS-запись 64 B 39%, 256 B 37%, 1 KiB 24%, разбор 17-34%). Справа: насколько быстрее каждый путь после (рукопожатие 17,7%, 64 B 26,4%, 256 B 19,6%, 1 KiB 16,1%, 4 KiB 6,6%, разбор 7-34%).</figcaption></figure>
<table>
<thead>
<tr>
<th>Путь</th>
<th style="text-align: right;">Доля mem* до</th>
<th style="text-align: right;">Быстрее после</th>
</tr>
</thead>
<tbody>
<tr>
<td>рукопожатие</td>
<td style="text-align: right;">7%</td>
<td style="text-align: right;">17,7%</td>
</tr>
<tr>
<td>TLS-запись 64 B</td>
<td style="text-align: right;">39%</td>
<td style="text-align: right;">26,4%</td>
</tr>
<tr>
<td>TLS-запись 256 B</td>
<td style="text-align: right;">37%</td>
<td style="text-align: right;">19,6%</td>
</tr>
<tr>
<td>TLS-запись 1 KiB</td>
<td style="text-align: right;">24%</td>
<td style="text-align: right;">16,1%</td>
</tr>
<tr>
<td>TLS-запись 4 KiB</td>
<td style="text-align: right;">13%</td>
<td style="text-align: right;">6,6%</td>
</tr>
<tr>
<td>разбор сообщений</td>
<td style="text-align: right;">17 до 34%</td>
<td style="text-align: right;">7 до 34%</td>
</tr>
</tbody>
</table>
<p>Как читать график: чем меньше запись, тем большая часть времени уходит на копирование, а значит, тем больше выигрыш. Для маленьких сообщений, которых большинство в MQTT-трафике, это и есть то, что имеет значение.</p>
<p>Что это не означает. На нативном x86_64 мы измеряли OpenSSL 3.5.5 (ADR-130) только как эталон для ассемблера, а не как сравнение продуктов. Сравнение Linux плюс Mosquitto на том же VPS (задача X11) мы еще не сделали. Поэтому мы не утверждаем, что urMQTT быстрее Linux: это не измерено. Цифры выше сравнивают urMQTT с самим собой.</p>
<p>Перегрузка стала вторым результатом. На VPS, с одного Mac (RTT 35 мс), 128 клиентов дали 618 рукопожатий в секунду, 256 клиентов 393, а 512 клиентов 67, с тысячами отклоненных соединений. Это не было плато, это был обвал. Причины мы нашли в коде (две подтверждены в QEMU/HVF, на VPS воспроизвести их не удалось), и их было несколько сразу: отклоненное соединение возвращало клиента через следующий сегмент, несущий действительную SYN-куки, отказ через RST говорил клиенту немедленно пробовать снова, а прием был O(очередь). Исправление (ADR-080 до ADR-082) отбрасывает SYN, который не был бы принят, запоминает отклоненные потоки, держит прием в O(1) и считает рукопожатия по ядрам. Возможно (гипотеза, не проверена), часть причины была и на стороне клиента: инструмент нагрузки закрывал соединение первым, и один Mac с 16 384 эфемерными портами и TIME_WAIT в 30 секунд был ограничен примерно 550 соединениями в секунду. Поэтому настоящий потолок сервера мы еще не измерили.</p>
<h2 id="2">Обещание 2: сырой диск, но аккуратный</h2>
<p><strong>Что мы планировали.</strong> Сохранить собственную раскладку без файловой системы и добавить все то, что файловая система обычно дает: выравнивание, рост, discard, резервные копии и проверки. <strong>Почему не файловая система.</strong> Мы это обсуждали, и решение подтверждено: urMQTT пишет одну базу, один журнал телеметрии и собственные метаданные, ему не нужны каталоги, права доступа и имена файлов, а каждый дополнительный слой был бы еще одним кодом в доверенной части и еще одним способом, которым запись тихо исчезнет. Диск это GPT с разделом ESP и одним разделом данных urMQTT, внутри которого собственная раскладка.</p>
<p><strong>Что мы построили (S2 до S6).</strong></p>
<ul>
<li><strong>Раскладка v2 (S2).</strong> Топология диска, выравнивание на 1 MiB или оптимальный размер ввода-вывода, целые физические блоки, A/B-пара суперблоков с поколением (ADR-140). Раскладку v1 ядро отклоняет с понятным сообщением, а мигрирует ее офлайн-инструмент <code>urmqtt-disk migrate</code> (ADR-141): по умолчанию пробный прогон, сначала копирование, потом переключение, с откатом и внедрением ошибок на каждой записи.</li>
<li><strong>Рост (S3).</strong> При загрузке диск может вырасти (никогда не уменьшается), а на virtio-blk и во время работы, через прерывание об изменении конфигурации (ADR-142, ADR-143).</li>
<li><strong>Discard и write-zeroes (S4).</strong> Тонко выделенные диски возвращают место хосту. В тесте qcow2-файл на хосте уменьшился с 62 до 16 MiB после истечения сегментов телеметрии, а восстановление никогда не доверяет содержимому, которое было отброшено.</li>
<li><strong>Резервная копия базы на диске (S5).</strong> A/B-копия с помощью онлайн-API резервного копирования SQLite шагами по 16 страниц: самый длинный шаг 4,6 мс против 445 мс при копировании одним вызовом. Коммиты во время копирования имели максимум 2,4 мс против 2,2 мс без нее. Если <code>quick_check</code> при загрузке не проходит, база восстанавливается из копии.</li>
<li><strong>Очистка и ограниченная загрузка (S6).</strong> Scrubber читает диск с ограниченным бюджетом ввода-вывода и сообщает о поврежденных записях, а загрузка читает только самый новый сегмент каждого раздела, так что время загрузки не растет вместе с размером региона телеметрии. Так мы закрыли проблему, которую рассчитали заранее: на большом диске полное восстановление заняло бы от минут до десятков минут.</li>
</ul>
<p>Вся группа смоук-тестов хранения проходит на обеих архитектурах: 79 успешных, 0 неуспешных, на aarch64 под HVF и на x86_64 под TCG. Мы также измерили, что S4 не замедлил базу: 53,3 тысячи вставок в секунду пакетами до и после, без измеримой разницы.</p>
<p><strong>Что удивило.</strong> Сколько в «простой» раскладке уходит на крайние случаи: virtio-scsi и virtio-blk сообщают топологию по-разному, а неполная запись посреди миграции всегда должна оставлять хотя бы одну действительную версию. Поэтому каждая запись на пути миграции и роста прошла внедрение ошибок.</p>
<p><strong>Что остается открытым.</strong> У страниц SQLite нет контрольной суммы, поэтому изменившийся бит внутри значения в строке база не обнаружит; записи телеметрии и содержимое копий имеют CRC и обнаруживаются. Шифрование диска (S7) не готово.</p>
<h2 id="3">Обещание 3: доказуемо безопасно, насколько получится</h2>
<p><strong>Усиление (H4).</strong> Аллокатор стирает освобожденную память и проверяет ее при повторном использовании, обнаруживает двойное освобождение, связывает указатели свободного списка через XOR и обнуляет ключи при освобождении (ADR-170). Защита стека (<code>-Zstack-protector=strong</code>, 1315 функций, а SQLite с <code>-fstack-protector-strong</code>, ADR-171). Отчеты об ошибках в продакшене не содержат регистров и адресов (ADR-172). KASLR на обеих архитектурах: ядро является PIE, и Limine сдвигает его примерно на 19 бит, а куча, стеки и окно MMIO размещаются случайно (ADR-173). На aarch64 MTE помечает кучу (ADR-174), но только под TCG: в HVF и на x86 его нет. kCFI мы оценили и не приняли, потому что компилятор на закрепленном nightly падает с внутренней ошибкой; IOMMU отложен вместе с bare metal (ADR-175, ADR-176).</p>
<p><strong>Цена измерена, а не предположена.</strong> На пути рукопожатия (<code>dp-bench</code>, aarch64 HVF, полные рукопожатия TLS 1.3 + mTLS + CONNECT):</p>
<table>
<thead>
<tr>
<th>Шаг</th>
<th style="text-align: right;">1 ядро</th>
<th style="text-align: right;">4 ядра</th>
<th>Изменение</th>
</tr>
</thead>
<tbody>
<tr>
<td>без усиления</td>
<td style="text-align: right;">1941</td>
<td style="text-align: right;">7620</td>
<td></td>
</tr>
<tr>
<td>+ аллокатор</td>
<td style="text-align: right;">1898</td>
<td style="text-align: right;">7429</td>
<td>-2,2% до -2,5%</td>
</tr>
<tr>
<td>+ защита стека</td>
<td style="text-align: right;">1845</td>
<td style="text-align: right;">7243</td>
<td>вместе -4,9% до -5%</td>
</tr>
<tr>
<td>+ PIE и метки</td>
<td style="text-align: right;">около 1800</td>
<td style="text-align: right;">около 6750</td>
<td>на загруженном компьютере, с шумом</td>
</tr>
</tbody>
</table>
<p>В целом цена на пути рукопожатия составляет примерно 5% для частей, измеренных чисто, а с PIE и метками выходит около 7% на одном и около 11% на четырех ядрах, но это измерение шумное. Мы считаем ее приемлемой, но не повторяли ее на x86_64 VPS.</p>
<p>Стоит сравнить с цифрой из первого текста (862 рукопожатия в секунду на одном ядре). Тот же инструмент сегодня, с драйвером аппаратного шифрования на aarch64 (X0b) и рядом ускорений, пришедших с тех пор, дает 1941 до усиления. Вклад каждого изменения мы не выделяли, поэтому о причине ничего не утверждаем.</p>
<p><strong>Как проверено, и что нет.</strong> H5 добавил Miri на подмножество хост-крейтов, доказательства Kani, санитайзеры, модельный фаззер для ACL и отчеты о покрытии. Kani доказал <code>record_len</code> в TLS, <code>parse</code> в TCP и две арифметики колеса таймеров. <strong>Не доказано</strong> (истекло время): оба MQTT-декодера, оба разбора конфигурации и поведение колеса таймеров; CI-задача для Kani поэтому пока не блокирует. Miri не умеет выполнять ассемблер. Цепочка поставок (H9): CycloneDX SBOM с 215 компонентами, <code>cargo-vet</code> проходит с 39 полностью проверенными и 140 исключениями, а cargo-deny настроен так, чтобы рассматривать советы об уязвимостях как падение CI (эти CI-задачи пока проверены только локально, не на GitHub, а негативный тест не запускался), плюс <code>VULNERABILITY-HANDLING.md</code>. Честное положение дел: из 67 версий сторонних крейтов, линкуемых в ядро, 53 это исключения, а не аудиты. Vet доказывает, что набор зависимостей нельзя изменить без видимой разницы, а не то, что TCB проверен. Собственный аудит ни одного крейта мы не писали.</p>
<p>SNTP (T1) в этом смысле маленькая история о безопасности: он опционален, только к частным IPv4-серверам, с максимальным скачком и при условии, что источники согласны (медиана, большинство в пределах одной секунды). Часы, которые кто угодно на пути может сдвинуть, это не часы, которым мы можем доверять при проверке срока действия сертификата.</p>
<h2 id="mac-aarch64-x86_64">На Mac: aarch64 нативно и x86_64 эмуляцией</h2>
<p>Мы разрабатываем на Mac, а у него два способа запускать один и тот же код, и они очень разные.</p>
<table>
<thead>
<tr>
<th></th>
<th>aarch64 под HVF</th>
<th>x86_64 под TCG</th>
</tr>
</thead>
<tbody>
<tr>
<td>Что это</td>
<td>нативное выполнение, скорость близка к реальному процессору</td>
<td>QEMU эмулирует инструкции</td>
</tr>
<tr>
<td>Рукопожатие в госте</td>
<td>около 0,58 мс (впервые измерено около M13); <code>dp-bench</code> сегодня 1941/с на ядро</td>
<td>около 3,3 мс</td>
</tr>
<tr>
<td>Сценарии TLS-смоука</td>
<td>быстрые</td>
<td>около 15 минут на сценарий</td>
</tr>
<tr>
<td>MTE</td>
<td>нет (в HVF его нет)</td>
<td>на x86 его нет; разметка кучи работает только для гостя aarch64 под TCG</td>
</tr>
<tr>
<td>Для чего служит</td>
<td>реальные измерения</td>
<td>корректность: x86-путь проверяется, цифры не являются производительностью</td>
</tr>
</tbody>
</table>
<p>Правило простое: цифры под TCG это эмуляция, и мы не приводим их как производительность. Производительность x86 мы измеряем только на настоящем VPS. Поэтому криптографические цифры выше взяты с VPS у Contabo, а не с Mac.</p>
<h2 id="_2">Уроки</h2>
<ul>
<li><strong>Измеряйте на настоящей машине, прежде чем оптимизировать.</strong> Первый подход состоял в оптимизации того, что мы считали медленным; только диагностика на VPS показала 1,2 мс и где именно уходит время.</li>
<li><strong>Перегрузка это отдельная характеристика.</strong> Система, которая быстра под нагрузкой, может обвалиться выше нее; отказ должен быть дешевле принятия.</li>
<li><strong>Профиль удивляет.</strong> Самый большой отдельный выигрыш для маленьких сообщений оказался не в криптографии, а в копировании памяти.</li>
<li><strong>Диск без файловой системы требует больше работы, а не меньше.</strong> То, что файловая система обычно дает (выравнивание, рост, discard, копии, проверки), мы должны написать и проверить сами, каждый шаг с внедрением ошибок.</li>
<li><strong>Честность в цифрах.</strong> Измерения памяти на общем VPS колеблются до 10 раз между запусками и ничего не говорят о коде, поэтому мы их не приводим. Криптографические цифры стабильны примерно до 10%.</li>
<li><strong>Скорость разработки это не зрелость.</strong> Тесты и ADR растут вместе с кодом, но четыре дня это не настоящая эксплуатация.</li>
</ul>
<h2 id="_3">Что дальше</h2>
<ul>
<li><strong>H6:</strong> SQLite, изолированный на уровне EL0, чтобы ошибка в базе не открывала ядро.</li>
<li><strong>H7:</strong> остальная безопасность времени (NTS и обработка скачков); SNTP это лишь первый шаг.</li>
<li><strong>X11:</strong> сравнение с Linux и Mosquitto на том же VPS, только тогда имеет смысл говорить об относительной скорости.</li>
<li><strong>Релиз:</strong> первая публичная версия для VPS (KVM), с подписанными артефактами и инструкциями.</li>
<li><strong>После релиза:</strong> bare metal (X12 до X16), IOMMU и Secure Boot, а также шифрование диска.</li>
</ul>
<p>Код находится на <a href="https://github.com/zeljan-alduk/urMQTT">github.com/zeljan-alduk/urMQTT</a>, а <code>docs/PROGRESS.md</code> по-прежнему единственный источник истины о том, что действительно работает. Пре-альфа, как и раньше; сообщения об ошибках и о безопасности по инструкции в <code>SECURITY.md</code> приветствуются.</p>]]></content:encoded>
      <category>rust</category>
      <category>mqtt</category>
      <category>iot</category>
      <category>unikernel</category>
      <category>open source</category>
    </item>
    <item>
      <title>urMQTT: строим MQTT-брокер, который одновременно является операционной системой</title>
      <link>https://aldo.tech/ru/blog/urmqtt-unikernel-mqtt-broker/</link>
      <guid isPermaLink="true">https://aldo.tech/ru/blog/urmqtt-unikernel-mqtt-broker/</guid>
      <pubDate>Thu, 29 Oct 2026 08:00:00 +0000</pubDate>
      <description>Заметки о разработке urMQTT, unikernel на Rust, который сам является MQTT-брокером: один ELF с собственным TCP/IP, TLS 1.3, брокером и SQLite, загружаемый с одного ISO.</description>
      <content:encoded><![CDATA[<p>У устройств в автомобилях, на парковках и в шкафах с электроникой есть общая черта: они отправляют небольшие сообщения, нечасто, но их очень много, и они постоянно на связи. Годами мы подключаем их к MQTT-брокерам и годами нас мучает один и тот же вопрос. Зачем для чего-то столь простого нам нужны целый Linux, OpenSSL, среда выполнения какого-нибудь языка, SSH, менеджер пакетов и только потом брокер? Каждый из этих слоев приносит свои уязвимости, расходует свою память и добавляет еще одно место, которое может сломаться, а парку устройств от сервера нужно всего три вещи: держать много почти неактивных соединений, надежно аутентифицировать каждое устройство и передавать сообщения без потерь.</p>
<p>Так появился <a href="https://github.com/zeljan-alduk/urMQTT">urMQTT</a>: unikernel, который <em>является</em> MQTT-брокером. Ядро, TCP/IP, TLS 1.3, MQTT и встроенная база данных в одном бинарном файле на Rust, запускаемом с одного ISO. Нет процессов, нет системных вызовов, нет оболочки. Первая платформа, на которой он собирается и тестируется, это aarch64, а тот же код движется к x86_64, через UEFI и классический BIOS, и к настоящему железу: цель в том, чтобы один ISO загружался на всем этом. Проект с открытым исходным кодом под лицензией MIT или Apache 2.0, и в этом тексте мы хотим записать, до чего мы дошли, как он устроен изнутри и чему мы попутно научились.</p>
<h2 id="unikernel">Что именно означает «unikernel»</h2>
<p>Обычный брокер — это программа, работающая в операционной системе. У urMQTT под собой нет операционной системы: он сам загружается через UEFI firmware и загрузчик Limine, настраивает собственные таблицы страниц, программирует контроллер прерываний и таймер, находит virtio-устройства через PCIe, получает адрес по DHCP и слушает на одном порту. Всё, что обычно делали бы ядро, драйверы, сетевой стек и приложение, здесь собрано в один ELF, работающий на уровне EL1.</p>
<p>Сравнение с типовой установкой говорит само за себя:</p>
<table>
<thead>
<tr>
<th></th>
<th>Типичный брокер на Linux</th>
<th>urMQTT</th>
</tr>
</thead>
<tbody>
<tr>
<td>Открытые сервисы</td>
<td>SSH, брокер, часто и порт для метрик или администрирования</td>
<td><strong>только TCP 8883</strong> (MQTT поверх TLS 1.3 с mTLS)</td>
</tr>
<tr>
<td>Код в образе</td>
<td>ядро, userland, libc, OpenSSL, среда выполнения</td>
<td><strong>один Rust ELF</strong> (плюс вендорный SQLite)</td>
</tr>
<tr>
<td>Удаленное управление</td>
<td>оболочка, конфигурационные файлы, обновления пакетов</td>
<td><strong>никакого.</strong> Новая версия — это новый ISO</td>
</tr>
<tr>
<td>Память на неактивное соединение</td>
<td>килобайты (буферы сокетов, объекты TLS, куча)</td>
<td><strong>цель ≤ 512 Б</strong></td>
</tr>
<tr>
<td>Многоядерная модель</td>
<td>потоки и блокировки или один цикл событий</td>
<td><strong>один поток на ядро, без общего состояния, обработка до конца</strong></td>
</tr>
<tr>
<td>Целостность кода</td>
<td>возможны отображения, которые одновременно доступны для записи и исполнения</td>
<td><strong>W^X, заблокированные таблицы страниц, PAC/BTI</strong></td>
</tr>
</tbody>
</table>
<p>Приоритеты, в этом порядке: безопасность, масштабируемость, многоядерная производительность, малый объем и неизменяемая установка. Когда две цели вступают в конфликт, побеждает та, что выше в этом списке.</p>
<h2 id="_1">Где мы сейчас</h2>
<p>Проект находится на стадии пре-альфа, но закрыто семнадцать этапов разработки, от M0 до M17. Сегодня urMQTT обслуживает MQTT 3.1.1 поверх TLS 1.3 с обязательными клиентскими сертификатами, а состояние хранит в SQLite на сыром диске. Коротко, что уже работает:</p>
<ul>
<li><strong>Ядро:</strong> собственные векторы исключений с декодированными отчетами, собственные таблицы страниц с принудительным W^X, защитные страницы, PAC и BTI там, где они есть у процессора, GICv3 и GICv2, универсальный таймер, SMP до 64 ядер.</li>
<li><strong>Обнаружение оборудования</strong> из DTB или ACPI в единое описание платформы, с идентичным результатом из обоих источников.</li>
<li><strong>Драйверы:</strong> PCIe ECAM, MSI-X, virtio-net, virtio-blk, virtio-scsi и virtio-rng, а также CSPRNG, инициализируемый аппаратной энтропией.</li>
<li><strong>Сеть:</strong> собственные DHCPv4, ARP, ICMP и строгий IPv4, затем собственный TCP только для серверной стороны: SYN-куки, иерархическое колесо таймеров, NewReno, объединение ACK, без состояния TIME_WAIT.</li>
<li><strong>TLS:</strong> только TLS 1.3, обязательный mTLS, идентификатор устройства из CN или SAN URI; рукопожатие выполняет rustls, а после него работает собственный слой записей с AES-128-GCM и ChaCha20-Poly1305. Возобновление сессии по PSK-билетам, список отзыва, контроль допуска уже при рукопожатии.</li>
<li><strong>MQTT:</strong> кодек без копирования, проверенный фаззингом и тестами с настоящими клиентами mosquitto; QoS 0 и 1, подстановочные символы, сохраненные сообщения, завещание (LWT), постоянные сессии, ACL с ограничением на <code>devices/{id}/#</code>, ограничения скорости по устройству.</li>
<li><strong>Хранение:</strong> SQLite внутри ядра через VFS поверх сырого диска, в режиме WAL, с проверкой целостности при загрузке; реестр устройств, отзывы, сохраненные сообщения, постоянные сессии с очередями сообщений для устройств, находящихся офлайн; на каждое ядро отдельный журнал телеметрии с CRC на запись и восстановлением после сбоя.</li>
<li><strong>Эксплуатация и надзор:</strong> сторожевой таймер для обработчиков, проверка работоспособности, ограниченное логирование, syslog и statsd по UDP, допуск, переживающий шторма переподключений.</li>
<li><strong>Поставка:</strong> побитово идентичные повторные сборки, проверяемые в CI, ISO и сырой образ диска GPT, подписи minisign, инструкции для KVM, Proxmox и Hetzner.</li>
</ul>
<p>Каждый шаг отмечен этапом, который его принес, а файл <code>docs/PROGRESS.md</code> в репозитории — единственный источник истины о том, что действительно работает.</p>
<h2 id="_2">Архитектура в трех картинках</h2>
<p>Весь дизайн описан в <code>docs/ARCHITECTURE.md</code>, а причины каждого важного решения записаны как ADR в <code>docs/DECISIONS.md</code>. Здесь приведен обзор.</p>
<h3 id="_3">Слои</h3>
<figure><img loading="lazy" alt="Слои urMQTT: от брокера через кодек MQTT, TLS, TCP и сеть до драйверов virtio и архитектурного слоя, всё в одном ELF на уровне EL1." src="https://aldo.tech/blog/urmqtt/img/layers.svg" /><figcaption>Слои urMQTT: от брокера через кодек MQTT, TLS, TCP и сеть до драйверов virtio и архитектурного слоя, всё в одном ELF на уровне EL1.</figcaption></figure>
<p>Сверху вниз: брокер (сессии, дерево тем, QoS, сохраненные сообщения, завещания, ACL, ограничения), кодек MQTT, TLS, TCP, сетевой слой, затем драйверы. Рядом с этим основным путем стоит хранилище (SQLite и журнал телеметрии), а под всем этим управление памятью, обнаружение платформы и конфигурация, преобразованная на этапе сборки в константы, вместе со встроенным сертификатом CA, серверным сертификатом и ключом. Гипервизор с virtio-устройствами — единственное, что находится вне образа.</p>
<h3 id="_4">Поток на ядро, без общего состояния</h3>
<figure><img loading="lazy" alt="Модель выполнения: каждое ядро данных имеет свой цикл и свою часть брокера, а старшее ядро запускает только SQLite. Ядра общаются исключительно через кольца SPSC." src="https://aldo.tech/blog/urmqtt/img/cores.svg" /><figcaption>Модель выполнения: каждое ядро данных имеет свой цикл и свою часть брокера, а старшее ядро запускает только SQLite. Ядра общаются исключительно через кольца SPSC.</figcaption></figure>
<p>Каждое ядро данных крутит собственный цикл со своим стеком TCP, состояниями TLS, частью брокера (сессии и дерево тем), кэшем кучи и разделом телеметрии. Ядра не разделяют никакого изменяемого состояния, только ограниченные кольца без блокировок, по одному для каждой упорядоченной пары ядер. Старшее ядро запускает SQLite и больше ничего.</p>
<p>У сетевой карты сегодня одна пара очередей, которой владеет ядро 0. Оно направляет каждый поток MQTT по хешу Тёплица его четверки на одно из рабочих ядер и отправляет то, что возвращают рабочие ядра. Сессия живет на своем домашнем ядре, определяемом хешем идентификатора клиента, поэтому перехват, завещания и устойчивость никогда не пересекают границы ядер. Соединение, попавшее на другое ядро, сохраняет там свои TCP и TLS, а обычный MQTT пересылает домашнему ядру с управлением потоком на основе кредитов. Каждое маршрутизируемое сообщение также рассылается остальным частям брокера, каждая из которых держит реплику сохраненных сообщений.</p>
<p>Эту модель держат вместе три правила. <strong>Обработка до конца:</strong> принятый пакет полностью обрабатывается на одном ядре, от TCP через расшифровку TLS на месте, разбор MQTT и маршрутизацию до зашифрованного ответа, без очередей задач и без копирования в буферы приложения. <strong>Адаптивный опрос:</strong> под нагрузкой очереди приема опрашиваются активно, а в покое ядро возвращается к прерываниям и <code>WFI</code>; обработчики прерываний лишь подтверждают прерывание и отмечают работу. <strong>Сторожевой таймер:</strong> тик в 10 мс на ядро ловит обработчики, превысившие свой бюджет.</p>
<h3 id="publish">Жизнь одного PUBLISH</h3>
<figure><img loading="lazy" alt="Путь одного сообщения PUBLISH с QoS 1: от записи TLS на сетевой карте через расшифровку, проверку ACL и маршрутизацию до PUBACK, объединенного с TCP ACK в одном сегменте." src="https://aldo.tech/blog/urmqtt/img/publish.svg" /><figcaption>Путь одного сообщения PUBLISH с QoS 1: от записи TLS на сетевой карте через расшифровку, проверку ACL и маршрутизацию до PUBACK, объединенного с TCP ACK в одном сегменте.</figcaption></figure>
<p>Устройство отправляет запись TLS. Сетевая карта передает ее циклу ядра дескриптором, без копирования. Цикл проверяет TCP, расшифровывает TLS на месте и разбирает MQTT из заимствованного буфера, затем в памяти проверяет ACL, ограничение скорости и путь по дереву тем. Если другое ядро интересуется этой темой, оно получает сообщение для маршрутизации со ссылкой на содержимое и идентификатором темы; база получает запись телеметрии или обновление сохраненного сообщения, пакетом. Ответ PUBACK шифруется прямо в исходящий буфер и объединяется с TCP ACK, так что к устройству уходит один сегмент. Другому ядру сообщение стоит одного шифрования на подписчика в момент отправки.</p>
<h2 id="_5">Сколько стоит одно соединение</h2>
<p>Цель — <strong>не более 512 байт на неактивное TLS-соединение</strong>, со стремлением к 256. Пока соединение простаивает, для него в буфере ничего не держится: принятые данные обрабатываются прямо из сетевого кадра, а буферы для отправки, записей и MQTT существуют только пока что-то не завершено или не подтверждено. Ядро измеряет это само: точные счетчики кучи в запрошенных байтах и в блоках, которые они занимают, записываются каждые десять секунд как куча сверх базового уровня на одно открытое соединение.</p>
<table>
<thead>
<tr>
<th>Часть</th>
<th style="text-align: right;">Размер</th>
<th>Как</th>
</tr>
</thead>
<tbody>
<tr>
<td>Блок управления TCP</td>
<td style="text-align: right;">96 Б</td>
<td>упакованные флаги, 16-битные RTT/RTO, слот в колесе таймеров, без буферов в покое</td>
</tr>
<tr>
<td>Слот в таблице TCP</td>
<td style="text-align: right;">4–8 Б</td>
<td>только индекс соединения, таблица заполнена не более чем наполовину</td>
</tr>
<tr>
<td>Соединение и состояние записей TLS 1.3</td>
<td style="text-align: right;">152 Б</td>
<td>секрет и порядковый номер по направлению (40 Б); ключ и IV выводятся для каждой записи</td>
</tr>
<tr>
<td>Соединение MQTT</td>
<td style="text-align: right;">48 Б</td>
<td>идентификатор и завещание в куче только пока существуют</td>
</tr>
<tr>
<td>Сессия MQTT, горячая форма</td>
<td style="text-align: right;">64 Б</td>
<td>одна подписка в строке, интернированные идентификаторы узлов</td>
</tr>
<tr>
<td>Подписка <code>devices/&lt;id&gt;/cmd/#</code></td>
<td style="text-align: right;">0 узлов дерева</td>
<td>хранится относительно идентификатора сессии, общая для всех устройств</td>
</tr>
</tbody>
</table>
<p>Измерено под HVF с четырьмя виртуальными ядрами, из них три рабочих, при этом каждое устройство держит соединение mTLS и одну подписку с QoS 1, включая округление аллокатора:</p>
<table>
<thead>
<tr>
<th style="text-align: right;">Соединения</th>
<th style="text-align: right;">Байт на соединение</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: right;">960</td>
<td style="text-align: right;">531 Б</td>
</tr>
<tr>
<td style="text-align: right;">9 922</td>
<td style="text-align: right;">494 Б</td>
</tr>
<tr>
<td style="text-align: right;">10 197</td>
<td style="text-align: right;">474 Б</td>
</tr>
</tbody>
</table>
<p>Пул сетевых кадров фиксирован и составляет около 2 МиБ, то есть примерно 200 Б на соединение при десяти тысячах соединений и около 2 Б при миллионе. Больше измерить мы не смогли: пользовательская сеть QEMU на macOS перестала принимать соединения на 10 197, примерно на границе в 10 240 дескрипторов процесса QEMU. Показатель снижается по мере распределения фиксированных частей на большее число соединений, поэтому он не растет вместе с их числом.</p>
<h2 id="_6">Модель безопасности</h2>
<p>Безопасность — первый приоритет, и она выигрывает все конфликты с остальными целями. В модели несколько слоев:</p>
<ul>
<li><strong>Один порт.</strong> TCP 8883, только TLS 1.3, обязательные клиентские сертификаты, подписанные CA, встроенным в образ. Идентификатор клиента MQTT должен совпадать с идентификатором устройства из сертификата.</li>
<li><strong>Нет входа.</strong> Нет оболочки, консольного ввода, SSH, API администрирования, динамического кода, SQL, доступного по сети, и файловой системы.</li>
<li><strong>Безопасность памяти.</strong> Rust, с <code>forbid(unsafe_code)</code> в протокольных пакетах; у каждого блока <code>unsafe</code> есть комментарий <code>// SAFETY:</code>, который проверяет линтер, а на внешний ввод нет паники: clippy в пакетах на пути данных запрещает <code>unwrap</code>, <code>expect</code>, индексирование и <code>panic!</code>.</li>
<li><strong>Смягчение эксплуатации.</strong> W^X обеспечивается кодированием дескрипторов и битом <code>SCTLR_EL1.WXN</code> и проверяется при каждой загрузке. Таблицы страниц после загрузки доступны только для чтения, без записываемого алиаса. Нижняя половина адресного пространства исключена. У каждого стека есть защитные страницы, а исключения выполняются на отдельном стеке. PAC и BTI включаются, когда процессор их поддерживает, и это проверяют дымовые тесты.</li>
<li><strong>Устойчивость к злоупотреблениям.</strong> SYN-куки; допуск отклоняет новые соединения, до какой-либо криптографии, когда куча мала, когда база отстает, когда слишком много рукопожатий выполняется на ядро или на исходный адрес, или когда скорость рукопожатий слишком высока. Ограничения скорости по устройству, ограниченные очереди, окно TCP, привязанное к противодавлению брокера, ограниченное логирование, сторожевой таймер и проверка работоспособности.</li>
<li><strong>Неизменяемость.</strong> Конфигурация, CA и серверный ключ встроены в образ. Изменение чего-либо означает новый образ.</li>
</ul>
<p>Модель угроз записана в <code>docs/SECURITY.md</code>.</p>
<h2 id="_7">Хранение без файловой системы</h2>
<p>Данные живут на отдельном диске, virtio-blk или virtio-scsi, на котором нет файловой системы. Раскладка минимальна и собственная:</p>
<pre><code>LBA 0      [ суперблок: магическое число · версия раскладки · uuid · таблица регионов · CRC-32C ]
LBA 8, 16  [ таблица размеров файлов, слоты A/B (побеждает последний валидный) ]
LBA 64     [ основная база SQLite ½ ][ SQLite WAL ⅛ ][ журнал 1 MiB ][ журнал телеметрии: 8 разделов … ]
последние 4 KiB [ область для самопроверки блочного устройства при загрузке ]
</code></pre>
<p>SQLite (вендорная версия 3.54.1, с проверкой хеша при сборке) компилируется в ядро с помощью clang, без стандартной библиотеки, и использует фиксированную кучу <code>memsys5</code>. Его VFS отображает три фиксированных файла на регионы диска. Он работает в режиме WAL с <code>locking_mode=EXCLUSIVE</code>, поэтому ему не нужен индекс в разделяемой памяти, и с <code>synchronous=FULL</code>; если <code>quick_check</code> при загрузке находит ошибку, система безопасно останавливается. В базе находятся реестр устройств, отзывы, сохраненные сообщения и постоянные сессии с очередями сообщений QoS 1 для устройств, которые не подключены.</p>
<p>Ядро для базы, то есть с наибольшим номером, запускает SQLite и больше ничего. Плоскость данных отправляет ему изменения состояния через кольцо SPSC без блокировок; оно собирает их в одну транзакцию каждые 5 мс или каждые 512 операций и возвращает подтверждения другим кольцом. Пустой диск форматируется при первой загрузке; всё остальное никогда не форматируется поверх.</p>
<p>Телеметрия идет другим путем. Каждый принятый PUBLISH по темам из <code>storage.telemetry_topics</code> попадает в раздел журнала телеметрии своего ядра: кольцо сегментов с CRC на запись, посоленное порядковым номером сегмента, чтобы старые проходы кольца никогда не появлялись снова. Ядра передают записи базе собственным кольцом, без копирования, а она сбрасывает их на диск каждые 5 мс или каждые 256 KiB. При загрузке восстановление сохраняет каждую сброшенную запись, отсекает незавершенный хвост и продолжает в новом сегменте. Хранение ограничено по размеру, через кольцо, и по возрасту, через <code>retention_days</code>. Мы проверили это на миллионе записей и с <code>kill -9</code> посреди записи.</p>
<h2 id="_8">Цифры</h2>
<p>Инструмент нагрузки в <code>tools/loadgen</code> делает три вещи: шторм рукопожатий TLS 1.3 с mTLS и CONNECT, долгоживущие соединения с PINGREQ, публикующие телеметрию каждую секунду, и шторма переподключений. <code>make bench</code> на Apple M4 Pro под HVF дает:</p>
<table>
<thead>
<tr>
<th>Рабочие ядра</th>
<th>рукопожатий/с в госте</th>
<th>масштабирование</th>
<th>через пользовательскую сеть QEMU (соединений/с)</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>862</td>
<td>1,00×</td>
<td>789</td>
</tr>
<tr>
<td>2</td>
<td>1 724</td>
<td>2,00×</td>
<td>636</td>
</tr>
<tr>
<td>4</td>
<td>3 438</td>
<td>3,99×</td>
<td>536</td>
</tr>
</tbody>
</table>
<p>Показатель в госте, где клиент и сервер находятся внутри одной виртуальной машины и каждое рабочее ядро работает само по себе, показывает, что плоскость данных масштабируется линейно. Сетевой показатель ограничен однопоточной пользовательской сетью QEMU, а не гостем; это тоже записано как ADR-032.</p>
<p>Шторма переподключений мы тестируем так: все устройства подключаются одновременно, повторяют отклоненные попытки с задержкой, а затем все падают без DISCONNECT. Параметр <code>heap_limit_mb=</code> в режиме разработки заставляет допуск считать кучу меньше, чем она есть, так что шторм, превышающий кучу, можно проверить на небольшом числе сокетов. В одном таком раунде из 3 000 устройств подключилось 1 368 за 36 секунд, а 5 619 попыток были аккуратно отклонены, при ограничении в 8 MiB; память после шторма вернулась.</p>
<h2 id="_9">Как мы работаем</h2>
<p>Проектом руководит письменная спецификация, <code>PROMPT.md</code>, с планом этапов, и каждый этап заканчивается одним и тем же ритуалом: <code>make check</code> и <code>make smoke</code> зеленые, один коммит с меткой <code>M&lt;n&gt;</code>, запись в <code>docs/PROGRESS.md</code> и ADR для каждого неочевидного решения. Правила короткие, и мы не идем на уступки: нет паники на внешний ввод, <code>unsafe</code> только в аппаратных пакетах и пакетах FFI и всегда с комментарием, нет аллокаций на горячем пути, комментарий к каждому порядку атомарных операций, каждое ограничение приходит из конфигурации, а намеренные временные части падают громко.</p>
<p>CI делает больше, чем мы успели бы вручную: fmt, clippy для хоста и для <code>aarch64-unknown-none</code>, typos, actionlint и shellcheck; тесты на Linux и macOS на arm64; сборку ядра для конфигурации разработки и продакшена с размером ELF в сводке; загрузку под QEMU со всеми сценариями дымового теста и сохранением последовательных журналов; cargo-deny для advisory, лицензий и источников; минуту фаззинга на каждую из одиннадцати целей, а ночью десять минут на цель с кэшированным корпусом. Кроме того, CodeQL, OpenSSF Scorecard, Dependabot и публикация с происхождением SLSA. Каждое чужое действие закреплено на полном commit SHA.</p>
<p>Воспроизводимость мы понимаем буквально: <code>make repro</code> собирает HEAD дважды в свежих рабочих деревьях и сравнивает ELF, ISO и сырой образ байт за байтом. Релизы подписаны minisign, а артефакты можно проверить с помощью <code>gh attestation verify</code>. Продакшен-образ содержит закрытый ключ сервера, поэтому сам ISO является секретом; образы к релизам на GitHub — для разработки, с временными ключами, и служат только для пробы.</p>
<h2 id="_10">Чему мы научились</h2>
<p>Несколько уроков, изменивших план:</p>
<ul>
<li><strong>Собственный TCP всё же окупился.</strong> Прототип работал на smoltcp и хорошо нам послужил до M13. Но серверу, который держит сотни тысяч соединений, не нужен клиентский TCP, ему нужны SYN-куки, колесо таймеров и состояние в 96 байт. В M14 мы заменили smoltcp собственным стеком только для серверной стороны, с тестами имитации потерь и фаззингом сегментов.</li>
<li><strong>Измеряйте память изнутри.</strong> Оценки на соединение были бесполезны, пока ядро само не начало считать кучу в блоках аллокатора, а не в запрошенных байтах. Только тогда мы увидели, куда на самом деле уходят 512 байт, и спустились до 494.</li>
<li><strong>Инструмент тестирования — часть продукта.</strong> Самым большим ограничением было не ядро, а пользовательская сеть QEMU и число эфемерных портов на компьютере разработчика. Для ста тысяч соединений и больше нужен <code>vmnet-shared</code> на macOS или Linux с KVM, интерфейсом tap и несколькими клиентскими адресами.</li>
<li><strong>SQLite без операционной системы возможен</strong>, но только при строгих условиях: фиксированная куча, эксклюзивная блокировка без разделяемой памяти, VFS, который ничего не знает о файлах, и допуск, не позволяющий базе отстать.</li>
<li><strong>Шторма переподключений — нормальное состояние</strong>, а не исключение. Когда мобильная сеть падает и возвращается, тысячи устройств стучатся одновременно. Поэтому допуск отклоняет соединение до какой-либо криптографии, а каждое отклоненное соединение стоит почти ничего.</li>
</ul>
<h2 id="_11">Что дальше</h2>
<p>Работа после M17 запланирована по трем направлениям, а порядок между ними записан в <code>docs/PLAN.md</code>. Ничего из этого пока не построено.</p>
<ul>
<li><strong>Один ISO для нескольких платформ.</strong> Один и тот же файл <code>urmqtt-&lt;env&gt;-&lt;версия&gt;.iso</code> и один сырой образ GPT, которые загружаются на aarch64 UEFI, x86_64 UEFI и x86_64 BIOS, в виртуальных машинах и на настоящем оборудовании, начиная с одного ПК на Intel. По-прежнему устройство со всем внутри, воспроизводимое и подписанное.</li>
<li><strong>Сырой диск, без файловой системы.</strong> Спецификация формата на уровне байтов, хост-инструмент <code>urmqtt-disk</code>, разделяющий парсеры с ядром, выравнивание регионов на 1 MiB, рост диска вместе с данными, discard для тонко выделяемых дисков, снимки базы A/B, проверка битов и, по выбору, шифрование AES-256-XTS с аппаратным AES.</li>
<li><strong>Доказуемо безопасный.</strong> Отзыв и список разрешенных устройств во время работы через подписанный канал управления, без нового образа; дифференциальное и конформационное тестирование собственных TLS и TCP; KASLR, защита стека, kCFI, MTE и усиленный аллокатор; SQLite, изолированный на уровне EL0; Secure Boot; SBOM и cargo-vet; и в конце пакет для внешнего аудита.</li>
</ul>
<p>Платформы, предлагающие только не-virtio устройства, такие как AWS ENA или Azure MANA, пока не поддерживаются. Raspberry Pi 4 и 5 в роли хоста KVM, Hetzner CAX и Oracle Ampere A1 задокументированы и поддержаны в коде, но еще не опробованы на реальном сервисе.</p>
<h2 id="_12">Попробуйте</h2>
<p>Репозиторий находится по адресу <a href="https://github.com/zeljan-alduk/urMQTT">github.com/zeljan-alduk/urMQTT</a>. С Rust с закрепленной цепочкой инструментов nightly и QEMU команд <code>make certs</code>, <code>make run</code> и одного клиента mosquitto достаточно для первого PUBLISH через mTLS на вашем компьютере. Предупреждение из начала действует и в конце: это пре-альфа, и всё, что работает, описано в <code>docs/PROGRESS.md</code>. Сообщения об ошибках, вопросы по дизайну и сообщения о безопасности по инструкциям в <code>SECURITY.md</code> приветствуются. О каждом следующем этапе мы сообщим здесь.</p>]]></content:encoded>
      <category>rust</category>
      <category>mqtt</category>
      <category>iot</category>
      <category>unikernel</category>
      <category>открытый исходный код</category>
    </item>
    <item>
      <title>Запускаем блог: заметки с мест</title>
      <link>https://aldo.tech/ru/blog/zapuskaem-blog/</link>
      <guid isPermaLink="true">https://aldo.tech/ru/blog/zapuskaem-blog/</guid>
      <pubDate>Sat, 17 Oct 2026 08:00:00 +0000</pubDate>
      <description>Почему компания, которая делает терминалы, приложения и электронику для общественного транспорта, начинает писать, и о чём вы здесь прочитаете.</description>
      <content:encoded><![CDATA[<p>Большую часть того, что мы делаем, никто не видит. Софт в терминале у двери автобуса, прошивка в устройстве, у которого никогда не будет экрана, сервер, который ночью превращает транзакции за день в отчёты. Когда всё работает, оно работает тихо. Когда не работает, замечают все.</p>
<p>В этом блоге мы записываем то, чему научились по пути. Не маркетинговые тексты, а конкретные заметки: что пошло не так, как мы это решили и что сегодня сделали бы иначе.</p>
<h2 id="_1">О чём мы будем писать</h2>
<ul>
<li><strong>Оплата проезда в общественном транспорте.</strong> Бесконтактные карты, SAM-модули, работа без сети и всё, что терминал должен выдерживать в машине, которую трясёт двенадцать часов в день.</li>
<li><strong>Мобильные приложения в поле.</strong> Android и Flutter на устройствах, которые не являются обычными телефонами: со считывателями карт, принтерами и батареями, которых должно хватать на целую смену.</li>
<li><strong>Бэкенд и мониторинг парка.</strong> Как узнать, что у устройства в машине проблема, раньше, чем о ней сообщит водитель.</li>
<li><strong>Железо.</strong> От прототипа на столе до платы, которая идёт в серийное производство, и все ошибки между ними.</li>
<li><strong>Локальные ИИ-модели.</strong> Где модели на собственном железе оправданы, а где облако и API просто лучший выбор.</li>
</ul>
<h2 id="_2">Как мы будем писать</h2>
<p>Коротко и по делу, с кодом и цифрами там, где они есть. Публиковать будем, когда есть что сказать, а не по расписанию. Записи пишутся на хорватском и переводятся на все языки сайта; следить за новостями можно и по <a href="https://aldo.tech/ru/blog/feed.xml">RSS</a>.</p>
<p>Если вас интересует какая-то тема или вы работаете над похожей задачей, напишите нам на <a href="mailto:info@aldo.tech">info@aldo.tech</a>.</p>]]></content:encoded>
      <category>введение</category>
    </item>
  </channel>
</rss>
