urMQTT после VPS: SQLite в пользовательском режиме, сравнение с Mosquitto, конфигурация как данные и сборщик в браузере
Что произошло после второго текста об urMQTT: SQLite, вынесенный за пределы ядра, измерения против Mosquitto на том же VPS (и то, что оказалось не в нашу пользу), найденные причины медленного одиночного потока, конфигурация как данные с веб-сборщиком ISO и эмулятором в браузере, необязательный обычный MQTT и то, что еще не доказано.

Второй текст, urMQTT на настоящем VPS, закончился списком «Что дальше»: H6 (SQLite вне ядра), H7 (безопасность времени), X11 (сравнение с Mosquitto на том же VPS) и первый релиз. Этот текст проходит по этому списку в том же порядке, а затем добавляет две вещи, которых в списке не было, потому что они появились по ходу дела: конфигурацию как данные, с веб-сборщиком ISO, и необязательный обычный MQTT без TLS. Как и раньше: что мы планировали, что построили, что нас удивило и что остается открытым. В этом тексте есть и несколько результатов, которые не в пользу urMQTT; мы их оставили, потому что они часть правды.
Предупреждение заранее прежнее: проект находится на стадии пре-альфа, первый релиз только для VPS с KVM, а цифры измеряют код, тесты и определенные эксперименты, а не зрелость в продакшене.
Как это выглядело во времени
С предыдущего текста (31.10. в 23:00) до сегодняшнего дня (1.11. в 20:41) в репозитории 59 новых коммитов, всего 178. Все они появились в течение одного календарного дня, поэтому действует то же предупреждение, что и раньше: проект строится с Claude Code (коммиты несут Co-Authored-By: Claude), план, код, тесты и ADR создаются короткими замкнутыми циклами, а цифры ниже говорят об объеме кода и проверок, а не о том, сколько в них человеческого труда.
Цифры вычислены тем же методом, что и в прошлом тексте: все файлы .rs вне vendor/ и fuzz/, функции #[test], заголовки ## ADR- в docs/DECISIONS.md.
| Момент | Коммит | Время | Строк Rust | Тестов | ADR |
|---|---|---|---|---|---|
| прошлый текст | 794f607 | 31.10. 23:00 | 89 790 | 739 | 104 |
| H6 готов | a0423cc | 1.11. 03:59 | 95 078 | 754 | 107 |
| X11 | a29f49b | 1.11. 07:56 | 95 579 | 754 | 108 |
| веб-сборщик и зеркало релизов | 582783f | 1.11. 16:49 | 105 645 | 848 | 122 |
| обычный MQTT, документация | 8f959f1 | 1.11. 19:05 | 112 408 | 926 | 125 |
| сегодня | d21e3ec | 1.11. 20:41 | 112 408 | 926 | 126 |
Работа шла параллельно в нескольких ветках, поэтому порядок коммитов не строго совпадает с порядком работы. ADR пронумерованы до 232, но в нумерации есть пропуски (часть была переименована при слиянии веток); мы считаем заголовки, а не наибольший номер. Релизы: v0.2.0 (1.11., 18:45 UTC) и v0.2.1 (1.11., 20:22 UTC), оба помечены как пре-релиз.
H6: SQLite в пользовательском режиме
Что мы планировали. SQLite это 254 268 строк C, которые разбирают базу, WAL и журнал с диска, а диск может быть подделан или изменен. До этого он работал в ядре, с правами ядра. Что мы построили (ADR-210 до ADR-212): SQLite, его части libc и куча перенесены в отдельную программу, dbworker, которая работает на EL0 (aarch64) или в ring 3 (x86_64), в собственном адресном пространстве. Ядро больше не линкует SQLite. Воркер общается с ядром через общее окно памяти и двенадцать системных вызовов, а каждая возвращаемая длина и смещение проверяется перед использованием. Ошибка в воркере (недопустимый доступ, неопределенная инструкция, превышенный временной бюджет в 30 с) завершает вызов, воркер перезапускается, и база открывается из WAL, как при загрузке; после 5 перезапусков за минуту хранилище объявляется неисправным, а не перезапускается бесконечно.
Цена, измеренная на aarch64 под HVF, на той же VM, ядро со встроенным SQLite против ядра с воркером, по шесть чередующихся запусков каждого:
| Измерение | До (в ядре) | После (воркер) | Разница |
|---|---|---|---|
| вход в пользовательский режим и выход из него | - | 1,1 us за круг | |
db-bench, 5 000 вставок в 50 транзакциях, лучшее / медиана |
61,9k / 58,4k op/s | 54,7k / 53,3k op/s | -11,6 % / -8,7 % |
| одна вставка на транзакцию, лучшее / медиана | 2 319 / 2 148 op/s | 2 307 / 2 126 op/s | -0,5 % / -1 % |
| вся резервная копия одним вызовом (23 MiB) | 514 ms | 635 ms | +23 % |
Записи на диск (1 572) и сбросы (319) идентичны, как и должно быть. Не изолировано вот что: ядро по-прежнему доверяет тому, что вернет воркер, после проверки формы. Скомпрометированный воркер может лгать (например, вернуть пустой список отозванных сертификатов и тем самым «снять отзыв» с них), но не может читать память ядра, ключи TLS и ключи тикетов. KPTI нет, поэтому утечки типа Spectre/Meltdown не предотвращены, и мы ничего о них не утверждаем; ключ PAC разделяют EL0 и EL1. На x86_64 цену переключения мы на KVM не измеряли (под TCG число было бы бессмысленным), а фаззинг самого воркера и протокола не закончен. Набор тестов хранилища (79 сценариев, каждый гоняет SQLite в воркере) проходит на aarch64 HVF и x86_64 TCG, на aarch64 TCG 76 (один пропускается, потому что у TCG нет MSI-X).
H7 закрыт, но не так, как мы объявляли. Нижняя граница времени (ADR-050) и SNTP с консенсусом (T1) покрывают объем H7; NTS (RFC 8915) остается вне объема и записан как оставшийся риск, а не как то, что работает. Мы не считаем, что безопасность времени «решена», а считаем, что готова ее доказуемая часть.
Новая сетевая опция. Contabo не выдает DHCP пользовательскому ISO, поэтому VPS до сих пор требовал другой образ, чем лабораторная VM. Теперь network.mode = "auto" (ADR-214) сначала спрашивает DHCP, а если аренды нет в заданный срок (dhcp_timeout_s, по умолчанию 15 s) или он ее теряет, применяет заданный статический адрес. Проверено вручную на QEMU (с DHCP использует аренду, без сети через 5 s переходит на статический адрес); smoke-сценария для этого пока нет.
X11: сравнение с Mosquitto на том же VPS
Тот же тип машины, что и в прошлом тексте (Contabo, 4 vCPU AMD EPYC, 8 GiB), нагрузка с Linux-хоста на расстоянии 15 ms, взаимный TLS 1.3 с одними и теми же сертификатами с обеих сторон, Mosquitto 2.0.21 на Debian 13 без персистентности. Метод и решения в ADR-213, сырые данные в docs/perf-results/. Медиана трех запусков.
| urMQTT | Mosquitto 2.0.21 | |
|---|---|---|
| рукопожатий/с, 32 / 64 / 128 клиентов (профиль bench) | 169 / 278 / 413 | 82 / 140 / 184 |
| то же, с ограничениями, как в поставляемой версии | 185 / 262 / 333 (при 128: от 202 до 286 ошибок, сбросы рукопожатия) | - |
| память на неактивное соединение (20 000 соединений) | 0,50 KB кучи ядра (0,66 KB при 5 000) | 26,8 KB роста процесса |
| задержка p50, QoS 0 / QoS 1, 800 сообщений/с, 64 B | 14,0 / 14,8 ms | 21,4 / 39,5 ms |
| QoS 1, 4 800 сообщений/с, доставлено/с (p50) | 4 209 (77 ms) | 4 235 (197 ms) |
| 100 подписчиков, 50 сообщений/с: p50 | 16,6 ms | 99 ms |
| один поток, QoS 0, 64 B, опубликовано/с | 2 962 (от 0 до 7 733) | 22 749 |
Что это значит, а что нет. Рукопожатия это нижние границы для обоих серверов: генератор нагрузки это разделяемая VM с 60 до 70 % украденного процессорного времени, и при 128 потоках он был близок к собственному пределу, а рабочие ядра urMQTT были заняты лишь на 12 до 13 %. Отношение при 128 клиентах (около 2,2 раза) наиболее осмысленно; при 32 и 64 оно в основном измеряет ожидание клиентов. Цифры памяти считают не одно и то же (куча ядра против RSS процесса), поэтому «примерно в 50 раз меньше» это порядок величины, а не точное отношение. Задержка в 14 ms это один круг сети (около 15 ms), то есть «измеримого времени брокера нет»; хвосты (p99 около 100 до 200 ms) у обоих брокеров определяются разделяемыми хостами. Различия ниже примерно 20 % мы не утверждаем.
Что оказалось не в нашу пользу и осталось в таблицах: один TCP-поток через urMQTT нес в 2 до 8 раз меньше, чем через Mosquitto, с колебаниями от нуля до 7,7 тысячи сообщений в секунду между запусками, а 20-миллисекундный watchdog обработчика обрывал живые соединения, когда гипервизор останавливал vCPU (в продакшен-настройке action = "drop", от 6 до 8 соединений за запуск при затоплении QoS 0). Обе проблемы описаны в следующем разделе, потому что их причины были в нашем коде.
Почему один поток был медленным: причины, а не отговорки
Проще всего было бы сказать «разделяемый VPS». Мы этого не сказали, а на том же VPS и тем же методом искали причины. Найдено пять причин, четыре на пути данных и одна в watchdog (ADR-221 до ADR-224):
| Симптом в X11 | Причина в urMQTT | Изменение |
|---|---|---|
| один поток 1,9k сообщений/с, часто 0 доставлено | Потерянное пробуждение: цикл вывода возвращал список готовых соединений поверх только что записанного; подписчик, у которого выходной буфер однажды заполнился, больше не обслуживался, пока его не будил PINGRESP | исправлено (ADR-223) |
| несколько процентов потерь превращают 48k в 14k сообщений/с | каждый сегмент после потерянного отбрасывался (прием только по порядку), поэтому восстановление стоило круг сети на сегмент | очередь сегментов вне порядка, SACK (ADR-222) |
| потолок 4,4 MB/s при 15 ms | 16-битное окно, очередь отправки 64 KiB и медленный старт, считавший ACK, а не байты | масштабирование окна, метки времени, подсчет байтов (ADR-222) |
| каждый круг потока, который помещается, ждет от 0 до 10 ms | диспетчер спал до следующего тика в 10 ms (у virtio-net нет прерывания на прием) | опрос NIC каждые 200 us, пока есть соединения (ADR-224) |
| от 1 до 8 оборванных соединений за запуск | watchdog начислял обработчику прошедшее время, а VPS останавливает vCPU часто и надолго | бюджет это время, которое ядро действительно работало (ADR-221) |
Остановки настоящие: на этом VPS у ядер 0 до 2 была медиана от 36 до 80 опоздавших тиков за 5 секунд, а худшая остановка в окне имела медиану около 70 ms и доходила до 0,9 s (ядро 3, база и журнал: до 1,55 s), при том что хост не сообщает украденное время (host steal 0 us). Поэтому watchdog нужен был другой источник времени, а не больший бюджет.
| код X11 (7f9517e) | сейчас (86a71ff) | Mosquitto | |
|---|---|---|---|
| один поток, QoS 0, 64 B, опубликовано / доставлено в с | 1,9k / 0 | 23,7k / 12,6k | 29,3k / 29,2k |
| один поток, QoS 0, 1 KiB | 0 / 0 | 3,9k / 2,3k | 5,4k / 5,4k |
| 16 пар, затопление, QoS 0, 64 B | 24,7k / 20,0k | 43,2k / 37,4k | 31,4k / 30,7k |
| 16 пар, затопление, QoS 0, 1 KiB | 3,7k / 2,9k | 5,0k / 4,5k | 5,9k / 5,7k |
| потерянные соединения за запуск (16 пар, 64 B) | 13 (от 7 до 16) | 0 (от 0 до 0) | 0 |
Watchdog: 148 оборванных соединений в 30 нагруженных запусках до, 0 в 24 запусках после; на локальной установке, которая задерживает и теряет кадры, при потере 0,1 % один поток в 64 B с 14,3k сообщений/с (ядро X11) теперь имеет 41,0k, а при 0,5 % с 5,0k до 17,7k.
Чего это не говорит. urMQTT по-прежнему не быстрее Mosquitto на одном потоке: при 64 B он публикует около 80 % его скорости, а доставляет около 40 %, потому что отбрасывает QoS 0, когда поток подписчика медленнее потока издателя (Mosquitto без границы ставит в очередь). При 1 KiB в 16-парном затоплении Mosquitto впереди примерно на 15 %, что в пределах шума. Генератор снова был разделяемой VM с 58 до 77 % украденного времени. Второй раунд (блокировка кучи вне пути данных, SACK на стороне отправки и другое) находится в неинтегрированной ветке perf-single-flow-wip; его цифры еще не утверждения, и здесь мы их не приводим.
Обещание, которого мы не давали, но выполнили: конфигурация это данные
До сих пор каждая конфигурация была новой сборкой: ключи и ограничения были вшиты в образ. C1 переворачивает это (ADR-215 до ADR-219): ядро собирается один раз, а конфигурация это данные.
- Блоб
urmqtt-cfg(C1a): версионированная, каноническая запись (поля по возрастающим меткам, отпечаток SHA-256, необязательная отдельная подпись Ed25519), 70 полей со значениями по умолчанию и границами, декодер, который отвергает все неправильное. - Ядро читает его как модуль Limine (C1b), раньше всего остального, на aarch64 и x86_64. Существует одно выпускаемое ядро для каждого
ENV, кромеdev(ELF aarch64 дляrelease,prodиbenchимеют один и тот же SHA-256; проверено только на aarch64). Выпускаемое ядро отвергает конфигурацию для разработки, а без конфигурации пишет «refusing to serve» и ничего не обслуживает. - Установленный диск несет конфигурацию, и обновление ее сохраняет (C1c).
urmqtt-configи шаблон ISO (C1d):pack,show,verify, аpack --isoзаменяет оба экземпляра места для конфигурации и 24 дайджеста модулей в готовом ISO. Результат байт в байт совпадает с ISO, который собираетmkiso.sh --config. Шаблон релиза v0.2.0 имеет 63 111 168 байт.- Заявление о доверии (ADR-219): сконфигурированный образ при загрузке выводит отпечаток конфигурации (
config: fingerprint ...), а подпись необязательна и по умолчанию выключена. У измененного ISO нет официального дайджеста; отпечаток это способ увидеть, что в нем находится.
Сборщик на aldo.tech
То, что делает urmqtt-config pack --iso, делает и веб-страница, тем же кодом: библиотека инструмента, скомпилированная в WebAssembly (1,06 MB, без импортов и без блока unsafe). Страница находится по адресу aldo.tech/ru/produkty/urmqtt-builder/ и существует на всех 15 языках сайта (например, hr).
- Ничто не покидает браузер. Сертификаты, закрытые ключи и готовый ISO остаются на компьютере; страница скачивает только файлы релиза с этого сайта, без отслеживания и без сторонних скриптов. В живом тесте ни один запрос не нес ключевой материал.
- Зеркало релизов (ADR-227): каждый час (таймер systemd, низкий приоритет) копирует релизы с GitHub, каждый проверяет перед публикацией, а страница в браузере проверяет подпись
SHA256SUMSключом, встроенным в страницу, и только потом записывает конфигурацию в шаблон. Неудачная проверка блокирует скачивание. - Просто по умолчанию, расширенно по необходимости (ADR-232): первый экран это три шага (где будет работать, ваши сертификаты, имя образа). Одно поле для перетаскивания файлов раскладывает сертификат, ключ и CA по содержимому. Расширенный вид открывает каждое из 70 полей, поиск, импорт и экспорт TOML; оба вида редактируют одно и то же состояние. Шаблон (63 MB) скачивается, пока пользователь заполняет форму. В живой пробе, с пробным зеркалом и пробным ключом, сборка ISO в браузере заняла от 5 до 18 s.


Эмулятор, необязательно. Рядом с «Скачать ISO» стоит «Попробовать в эмуляторе»: QEMU, скомпилированный в WebAssembly, запускает только что собранный образ во вкладке браузера. Ничего из него не скачивается до клика (около 3 MB в первый раз). Эмулятор работает только с BIOS, без сети (чтобы трафик пользователя не прошел через какого-либо посредника и не нарушил «ничто не покидает браузер»), требует около 1,5 GB памяти и компьютерный браузер (протестирован Chrome; Firefox и Safari нет). В нем urMQTT доходит до URMQTT READY примерно за 8 s (с пропущенным обратным отсчетом) и выводит тот же отпечаток конфигурации, что и страница, что доказывает, что поднята именно эта конфигурация. Загрузка примерно в 2 раза медленнее исходного QEMU на том же Mac (0,82 s против 1,9 s), а криптография примерно в 58 раз медленнее, потому что аппаратные инструкции эмулируются. Поэтому из эмулятора мы не делаем никаких утверждений о скорости, и страница говорит об этом до запуска. При сборке пришлось исправить четыре байта в .wasm из-за ошибки в трансляции инструкции POPCNT в исходном эмуляторе (мы о ней еще не сообщили); сам эмулятор распространяется под GPL, с предложением исходного кода.

Обычный MQTT, только для сетей, которым вы доверяете
Обычный MQTT (TCP 1883, без TLS) очень распространен в IoT: он по умолчанию в Mosquitto, в большинстве прошивок для Arduino и ESP, в Tasmota, Zigbee2MQTT и Home Assistant, а используется он и внутри сегментированных промышленных сетей, за VPN и в частных LAN, в том числе на устройствах без часов (сертификатам нужна дата), без достаточного объема flash или процессора для TLS. Через недоверенную сеть он неприемлем: кто находится на пути, читает и изменяет трафик. Поэтому P1 (ADR-229 до ADR-231) это явный, ограниченный выбор, по умолчанию выключенный, а порт TLS остается; TLS этим не отключается, обычный порт появляется рядом с ним.
- Список разрешенных сетей обязателен (
allow_from, CIDR) и проверяется при SYN: вне списка нет ни SYN-ACK, ни состояния. Весь интернет (0.0.0.0/0) требует отдельной явной опции. - Три способа входа: пароль (по умолчанию, когда включено; PBKDF2-HMAC-SHA-256, соль, 10 000 итераций, сравнение за постоянное время, блокировка по адресу), анонимно (одна настроенная идентичность со своим ACL) и по адресу (
ip). Сборщик хэширует пароль в браузере и хранит в памяти только дайджест. - Меньше прав, чем при mTLS: обычный клиент никогда не получает служебных, операторских и управляющих прав, у него нет
$SYSи консоли, у него свои сессии ($plain/<идентичность>/<id клиента>, только чистые), собственные ограничения, и реестр допустимых по-прежнему его обязывает. - Что теряется, записано в
SECURITY.md: конфиденциальность, целостность, аутентичность брокера и повтор записанного входа. Это нельзя исправить конфигурацией; так бывает, когда нет TLS.
Измерено на ноутбуке (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, а вход по паролю это дорогая часть: один PBKDF2 стоит около 2,5 ms, поэтому число новых входов ограничено примерно 200 на ядро данных в секунду (измерено 308 до 342 на двух ядрах). Затопление на этой машине не является стабильным инструментом (от 20k до 440k доставленных в секунду между запусками), и из него мы не делаем вывода. Сравнение обычного пути с Mosquitto на VPS еще не сделано. Тесты: smoke-группа plaintext 7 из 7 на aarch64 HVF и на x86_64 TCG, новая цель фаззинга plain_input (2,3 миллиона запусков без находок).
Ошибки, которые нашла работа вживую
Две вещи поймали не тесты, а первое включение на настоящем сайте:
- Страница искала файлы релиза на один каталог выше, поэтому каждая проверка подписи отвечала 404 (исправление 13af301). Тест использовал пробное зеркало по другому пути.
- Клавиатура эмулятора никогда не доходила до гостя: причиной был не плохой перевод клавиш, а то, что диалог вообще не слушал клавиши (исправление в ADR-232, с входом для загрузки без клавиш).
Уроки
- Ищите причину, не обвиняйте окружение. Большинство слабостей одного потока и все оборванные соединения были нашими; гипервизор настоящий, но его нужно было измерять, а не предполагать.
- Сравнение с другим продуктом должно включать и потери. Таблица, в которой только наши победы, не была бы сравнением.
- У измерительного инструмента есть границы. Разделяемый генератор с 60 до 70 % украденного времени меняет то, что означают цифры; поэтому мы пишем «нижняя граница» и не утверждаем различий ниже 20 %.
- Изоляция это не доказательство. SQLite в EL0 уменьшает то, что может натворить ошибка в нем, но не то, о чем он может лгать.
- Настоящая установка находит то, чего тесты не ищут: неправильно составленный путь и клавиши, которые никто не слушает.
Что еще не доказано
- Ключ подписи релиза пока пробный; v0.2.0 и v0.2.1 это пре-релизы, а образы к ним для разработки с пробными сертификатами (кроме шаблона, который без конфигурации).
- 13 переводов страницы сборщика (и этого текста) написал ИИ, и проверили их тесты и выборочные проверки, а не носители языка.
- Сравнение с Mosquitto это одна сессия, один путь и один разделяемый генератор, на разделяемом VPS; различия ниже 20 % не являются утверждениями.
- Обычный путь не измерен на VPS и не против Mosquitto.
- Lighthouse на новом простом виде не подтвержден (старый вид: доступность 95 или выше).
- Bare metal, цена воркера на x86_64 на KVM, Firefox и Safari для эмулятора: не измерено.
Что дальше
- D1: публичный демо-брокер. Эмулятор из браузера мы не можем сделать публичным: он живет во вкладке одного посетителя, в 20 до 100 раз медленнее KVM, у него нет входящей сети, и он исчезает при закрытии вкладки; перенаправление интернета в чужой браузер было бы к тому же злоупотреблением. Возможен настоящий демо-брокер: urMQTT на VPS за именем на aldo.tech, с опубликованным пробным CA, строгим ACL и небольшими ограничениями. Пока это только идея, не разработка, и ей нужно явное согласие на публичное размещение.
- Второй раунд для одного потока (ветка
perf-single-flow-wip): только когда он будет измерен на VPS и слит. - Обычный путь против Mosquitto на VPS, вход и отзыв учетных записей для обычного MQTT через подписанный управляющий канал.
- После релиза: bare metal (X12 до X16), IOMMU и Secure Boot, шифрование диска.
Код находится на github.com/zeljan-alduk/urMQTT, а docs/PROGRESS.md по-прежнему единственный источник истины о том, что действительно работает, docs/PERFORMANCE.md о том, что измерено. Пре-альфа, как и раньше; сообщения об ошибках и о безопасности по инструкциям в SECURITY.md приветствуются.
Комментарии
Правила комментирования