urMQTT: строим MQTT-брокер, который одновременно является операционной системой
Заметки о разработке urMQTT, unikernel на Rust, который сам является MQTT-брокером: один ELF с собственным TCP/IP, TLS 1.3, брокером и SQLite, загружаемый с одного ISO.
У устройств в автомобилях, на парковках и в шкафах с электроникой есть общая черта: они отправляют небольшие сообщения, нечасто, но их очень много, и они постоянно на связи. Годами мы подключаем их к MQTT-брокерам и годами нас мучает один и тот же вопрос. Зачем для чего-то столь простого нам нужны целый Linux, OpenSSL, среда выполнения какого-нибудь языка, SSH, менеджер пакетов и только потом брокер? Каждый из этих слоев приносит свои уязвимости, расходует свою память и добавляет еще одно место, которое может сломаться, а парку устройств от сервера нужно всего три вещи: держать много почти неактивных соединений, надежно аутентифицировать каждое устройство и передавать сообщения без потерь.
Так появился urMQTT: unikernel, который является MQTT-брокером. Ядро, TCP/IP, TLS 1.3, MQTT и встроенная база данных в одном бинарном файле на Rust, запускаемом с одного ISO. Нет процессов, нет системных вызовов, нет оболочки. Первая платформа, на которой он собирается и тестируется, это aarch64, а тот же код движется к x86_64, через UEFI и классический BIOS, и к настоящему железу: цель в том, чтобы один ISO загружался на всем этом. Проект с открытым исходным кодом под лицензией MIT или Apache 2.0, и в этом тексте мы хотим записать, до чего мы дошли, как он устроен изнутри и чему мы попутно научились.
Что именно означает «unikernel»
Обычный брокер — это программа, работающая в операционной системе. У urMQTT под собой нет операционной системы: он сам загружается через UEFI firmware и загрузчик Limine, настраивает собственные таблицы страниц, программирует контроллер прерываний и таймер, находит virtio-устройства через PCIe, получает адрес по DHCP и слушает на одном порту. Всё, что обычно делали бы ядро, драйверы, сетевой стек и приложение, здесь собрано в один ELF, работающий на уровне EL1.
Сравнение с типовой установкой говорит само за себя:
| Типичный брокер на Linux | urMQTT | |
|---|---|---|
| Открытые сервисы | SSH, брокер, часто и порт для метрик или администрирования | только TCP 8883 (MQTT поверх TLS 1.3 с mTLS) |
| Код в образе | ядро, userland, libc, OpenSSL, среда выполнения | один Rust ELF (плюс вендорный SQLite) |
| Удаленное управление | оболочка, конфигурационные файлы, обновления пакетов | никакого. Новая версия — это новый ISO |
| Память на неактивное соединение | килобайты (буферы сокетов, объекты TLS, куча) | цель ≤ 512 Б |
| Многоядерная модель | потоки и блокировки или один цикл событий | один поток на ядро, без общего состояния, обработка до конца |
| Целостность кода | возможны отображения, которые одновременно доступны для записи и исполнения | W^X, заблокированные таблицы страниц, PAC/BTI |
Приоритеты, в этом порядке: безопасность, масштабируемость, многоядерная производительность, малый объем и неизменяемая установка. Когда две цели вступают в конфликт, побеждает та, что выше в этом списке.
Где мы сейчас
Проект находится на стадии пре-альфа, но закрыто семнадцать этапов разработки, от M0 до M17. Сегодня urMQTT обслуживает MQTT 3.1.1 поверх TLS 1.3 с обязательными клиентскими сертификатами, а состояние хранит в SQLite на сыром диске. Коротко, что уже работает:
- Ядро: собственные векторы исключений с декодированными отчетами, собственные таблицы страниц с принудительным W^X, защитные страницы, PAC и BTI там, где они есть у процессора, GICv3 и GICv2, универсальный таймер, SMP до 64 ядер.
- Обнаружение оборудования из DTB или ACPI в единое описание платформы, с идентичным результатом из обоих источников.
- Драйверы: PCIe ECAM, MSI-X, virtio-net, virtio-blk, virtio-scsi и virtio-rng, а также CSPRNG, инициализируемый аппаратной энтропией.
- Сеть: собственные DHCPv4, ARP, ICMP и строгий IPv4, затем собственный TCP только для серверной стороны: SYN-куки, иерархическое колесо таймеров, NewReno, объединение ACK, без состояния TIME_WAIT.
- TLS: только TLS 1.3, обязательный mTLS, идентификатор устройства из CN или SAN URI; рукопожатие выполняет rustls, а после него работает собственный слой записей с AES-128-GCM и ChaCha20-Poly1305. Возобновление сессии по PSK-билетам, список отзыва, контроль допуска уже при рукопожатии.
- MQTT: кодек без копирования, проверенный фаззингом и тестами с настоящими клиентами mosquitto; QoS 0 и 1, подстановочные символы, сохраненные сообщения, завещание (LWT), постоянные сессии, ACL с ограничением на
devices/{id}/#, ограничения скорости по устройству. - Хранение: SQLite внутри ядра через VFS поверх сырого диска, в режиме WAL, с проверкой целостности при загрузке; реестр устройств, отзывы, сохраненные сообщения, постоянные сессии с очередями сообщений для устройств, находящихся офлайн; на каждое ядро отдельный журнал телеметрии с CRC на запись и восстановлением после сбоя.
- Эксплуатация и надзор: сторожевой таймер для обработчиков, проверка работоспособности, ограниченное логирование, syslog и statsd по UDP, допуск, переживающий шторма переподключений.
- Поставка: побитово идентичные повторные сборки, проверяемые в CI, ISO и сырой образ диска GPT, подписи minisign, инструкции для KVM, Proxmox и Hetzner.
Каждый шаг отмечен этапом, который его принес, а файл docs/PROGRESS.md в репозитории — единственный источник истины о том, что действительно работает.
Архитектура в трех картинках
Весь дизайн описан в docs/ARCHITECTURE.md, а причины каждого важного решения записаны как ADR в docs/DECISIONS.md. Здесь приведен обзор.
Слои
Сверху вниз: брокер (сессии, дерево тем, QoS, сохраненные сообщения, завещания, ACL, ограничения), кодек MQTT, TLS, TCP, сетевой слой, затем драйверы. Рядом с этим основным путем стоит хранилище (SQLite и журнал телеметрии), а под всем этим управление памятью, обнаружение платформы и конфигурация, преобразованная на этапе сборки в константы, вместе со встроенным сертификатом CA, серверным сертификатом и ключом. Гипервизор с virtio-устройствами — единственное, что находится вне образа.
Поток на ядро, без общего состояния
Каждое ядро данных крутит собственный цикл со своим стеком TCP, состояниями TLS, частью брокера (сессии и дерево тем), кэшем кучи и разделом телеметрии. Ядра не разделяют никакого изменяемого состояния, только ограниченные кольца без блокировок, по одному для каждой упорядоченной пары ядер. Старшее ядро запускает SQLite и больше ничего.
У сетевой карты сегодня одна пара очередей, которой владеет ядро 0. Оно направляет каждый поток MQTT по хешу Тёплица его четверки на одно из рабочих ядер и отправляет то, что возвращают рабочие ядра. Сессия живет на своем домашнем ядре, определяемом хешем идентификатора клиента, поэтому перехват, завещания и устойчивость никогда не пересекают границы ядер. Соединение, попавшее на другое ядро, сохраняет там свои TCP и TLS, а обычный MQTT пересылает домашнему ядру с управлением потоком на основе кредитов. Каждое маршрутизируемое сообщение также рассылается остальным частям брокера, каждая из которых держит реплику сохраненных сообщений.
Эту модель держат вместе три правила. Обработка до конца: принятый пакет полностью обрабатывается на одном ядре, от TCP через расшифровку TLS на месте, разбор MQTT и маршрутизацию до зашифрованного ответа, без очередей задач и без копирования в буферы приложения. Адаптивный опрос: под нагрузкой очереди приема опрашиваются активно, а в покое ядро возвращается к прерываниям и WFI; обработчики прерываний лишь подтверждают прерывание и отмечают работу. Сторожевой таймер: тик в 10 мс на ядро ловит обработчики, превысившие свой бюджет.
Жизнь одного PUBLISH
Устройство отправляет запись TLS. Сетевая карта передает ее циклу ядра дескриптором, без копирования. Цикл проверяет TCP, расшифровывает TLS на месте и разбирает MQTT из заимствованного буфера, затем в памяти проверяет ACL, ограничение скорости и путь по дереву тем. Если другое ядро интересуется этой темой, оно получает сообщение для маршрутизации со ссылкой на содержимое и идентификатором темы; база получает запись телеметрии или обновление сохраненного сообщения, пакетом. Ответ PUBACK шифруется прямо в исходящий буфер и объединяется с TCP ACK, так что к устройству уходит один сегмент. Другому ядру сообщение стоит одного шифрования на подписчика в момент отправки.
Сколько стоит одно соединение
Цель — не более 512 байт на неактивное TLS-соединение, со стремлением к 256. Пока соединение простаивает, для него в буфере ничего не держится: принятые данные обрабатываются прямо из сетевого кадра, а буферы для отправки, записей и MQTT существуют только пока что-то не завершено или не подтверждено. Ядро измеряет это само: точные счетчики кучи в запрошенных байтах и в блоках, которые они занимают, записываются каждые десять секунд как куча сверх базового уровня на одно открытое соединение.
| Часть | Размер | Как |
|---|---|---|
| Блок управления TCP | 96 Б | упакованные флаги, 16-битные RTT/RTO, слот в колесе таймеров, без буферов в покое |
| Слот в таблице TCP | 4–8 Б | только индекс соединения, таблица заполнена не более чем наполовину |
| Соединение и состояние записей TLS 1.3 | 152 Б | секрет и порядковый номер по направлению (40 Б); ключ и IV выводятся для каждой записи |
| Соединение MQTT | 48 Б | идентификатор и завещание в куче только пока существуют |
| Сессия MQTT, горячая форма | 64 Б | одна подписка в строке, интернированные идентификаторы узлов |
Подписка devices/<id>/cmd/# |
0 узлов дерева | хранится относительно идентификатора сессии, общая для всех устройств |
Измерено под HVF с четырьмя виртуальными ядрами, из них три рабочих, при этом каждое устройство держит соединение mTLS и одну подписку с QoS 1, включая округление аллокатора:
| Соединения | Байт на соединение |
|---|---|
| 960 | 531 Б |
| 9 922 | 494 Б |
| 10 197 | 474 Б |
Пул сетевых кадров фиксирован и составляет около 2 МиБ, то есть примерно 200 Б на соединение при десяти тысячах соединений и около 2 Б при миллионе. Больше измерить мы не смогли: пользовательская сеть QEMU на macOS перестала принимать соединения на 10 197, примерно на границе в 10 240 дескрипторов процесса QEMU. Показатель снижается по мере распределения фиксированных частей на большее число соединений, поэтому он не растет вместе с их числом.
Модель безопасности
Безопасность — первый приоритет, и она выигрывает все конфликты с остальными целями. В модели несколько слоев:
- Один порт. TCP 8883, только TLS 1.3, обязательные клиентские сертификаты, подписанные CA, встроенным в образ. Идентификатор клиента MQTT должен совпадать с идентификатором устройства из сертификата.
- Нет входа. Нет оболочки, консольного ввода, SSH, API администрирования, динамического кода, SQL, доступного по сети, и файловой системы.
- Безопасность памяти. Rust, с
forbid(unsafe_code)в протокольных пакетах; у каждого блокаunsafeесть комментарий// SAFETY:, который проверяет линтер, а на внешний ввод нет паники: clippy в пакетах на пути данных запрещаетunwrap,expect, индексирование иpanic!. - Смягчение эксплуатации. W^X обеспечивается кодированием дескрипторов и битом
SCTLR_EL1.WXNи проверяется при каждой загрузке. Таблицы страниц после загрузки доступны только для чтения, без записываемого алиаса. Нижняя половина адресного пространства исключена. У каждого стека есть защитные страницы, а исключения выполняются на отдельном стеке. PAC и BTI включаются, когда процессор их поддерживает, и это проверяют дымовые тесты. - Устойчивость к злоупотреблениям. SYN-куки; допуск отклоняет новые соединения, до какой-либо криптографии, когда куча мала, когда база отстает, когда слишком много рукопожатий выполняется на ядро или на исходный адрес, или когда скорость рукопожатий слишком высока. Ограничения скорости по устройству, ограниченные очереди, окно TCP, привязанное к противодавлению брокера, ограниченное логирование, сторожевой таймер и проверка работоспособности.
- Неизменяемость. Конфигурация, CA и серверный ключ встроены в образ. Изменение чего-либо означает новый образ.
Модель угроз записана в docs/SECURITY.md.
Хранение без файловой системы
Данные живут на отдельном диске, virtio-blk или virtio-scsi, на котором нет файловой системы. Раскладка минимальна и собственная:
LBA 0 [ суперблок: магическое число · версия раскладки · uuid · таблица регионов · CRC-32C ]
LBA 8, 16 [ таблица размеров файлов, слоты A/B (побеждает последний валидный) ]
LBA 64 [ основная база SQLite ½ ][ SQLite WAL ⅛ ][ журнал 1 MiB ][ журнал телеметрии: 8 разделов … ]
последние 4 KiB [ область для самопроверки блочного устройства при загрузке ]
SQLite (вендорная версия 3.54.1, с проверкой хеша при сборке) компилируется в ядро с помощью clang, без стандартной библиотеки, и использует фиксированную кучу memsys5. Его VFS отображает три фиксированных файла на регионы диска. Он работает в режиме WAL с locking_mode=EXCLUSIVE, поэтому ему не нужен индекс в разделяемой памяти, и с synchronous=FULL; если quick_check при загрузке находит ошибку, система безопасно останавливается. В базе находятся реестр устройств, отзывы, сохраненные сообщения и постоянные сессии с очередями сообщений QoS 1 для устройств, которые не подключены.
Ядро для базы, то есть с наибольшим номером, запускает SQLite и больше ничего. Плоскость данных отправляет ему изменения состояния через кольцо SPSC без блокировок; оно собирает их в одну транзакцию каждые 5 мс или каждые 512 операций и возвращает подтверждения другим кольцом. Пустой диск форматируется при первой загрузке; всё остальное никогда не форматируется поверх.
Телеметрия идет другим путем. Каждый принятый PUBLISH по темам из storage.telemetry_topics попадает в раздел журнала телеметрии своего ядра: кольцо сегментов с CRC на запись, посоленное порядковым номером сегмента, чтобы старые проходы кольца никогда не появлялись снова. Ядра передают записи базе собственным кольцом, без копирования, а она сбрасывает их на диск каждые 5 мс или каждые 256 KiB. При загрузке восстановление сохраняет каждую сброшенную запись, отсекает незавершенный хвост и продолжает в новом сегменте. Хранение ограничено по размеру, через кольцо, и по возрасту, через retention_days. Мы проверили это на миллионе записей и с kill -9 посреди записи.
Цифры
Инструмент нагрузки в tools/loadgen делает три вещи: шторм рукопожатий TLS 1.3 с mTLS и CONNECT, долгоживущие соединения с PINGREQ, публикующие телеметрию каждую секунду, и шторма переподключений. make bench на Apple M4 Pro под HVF дает:
| Рабочие ядра | рукопожатий/с в госте | масштабирование | через пользовательскую сеть QEMU (соединений/с) |
|---|---|---|---|
| 1 | 862 | 1,00× | 789 |
| 2 | 1 724 | 2,00× | 636 |
| 4 | 3 438 | 3,99× | 536 |
Показатель в госте, где клиент и сервер находятся внутри одной виртуальной машины и каждое рабочее ядро работает само по себе, показывает, что плоскость данных масштабируется линейно. Сетевой показатель ограничен однопоточной пользовательской сетью QEMU, а не гостем; это тоже записано как ADR-032.
Шторма переподключений мы тестируем так: все устройства подключаются одновременно, повторяют отклоненные попытки с задержкой, а затем все падают без DISCONNECT. Параметр heap_limit_mb= в режиме разработки заставляет допуск считать кучу меньше, чем она есть, так что шторм, превышающий кучу, можно проверить на небольшом числе сокетов. В одном таком раунде из 3 000 устройств подключилось 1 368 за 36 секунд, а 5 619 попыток были аккуратно отклонены, при ограничении в 8 MiB; память после шторма вернулась.
Как мы работаем
Проектом руководит письменная спецификация, PROMPT.md, с планом этапов, и каждый этап заканчивается одним и тем же ритуалом: make check и make smoke зеленые, один коммит с меткой M<n>, запись в docs/PROGRESS.md и ADR для каждого неочевидного решения. Правила короткие, и мы не идем на уступки: нет паники на внешний ввод, unsafe только в аппаратных пакетах и пакетах FFI и всегда с комментарием, нет аллокаций на горячем пути, комментарий к каждому порядку атомарных операций, каждое ограничение приходит из конфигурации, а намеренные временные части падают громко.
CI делает больше, чем мы успели бы вручную: fmt, clippy для хоста и для aarch64-unknown-none, typos, actionlint и shellcheck; тесты на Linux и macOS на arm64; сборку ядра для конфигурации разработки и продакшена с размером ELF в сводке; загрузку под QEMU со всеми сценариями дымового теста и сохранением последовательных журналов; cargo-deny для advisory, лицензий и источников; минуту фаззинга на каждую из одиннадцати целей, а ночью десять минут на цель с кэшированным корпусом. Кроме того, CodeQL, OpenSSF Scorecard, Dependabot и публикация с происхождением SLSA. Каждое чужое действие закреплено на полном commit SHA.
Воспроизводимость мы понимаем буквально: make repro собирает HEAD дважды в свежих рабочих деревьях и сравнивает ELF, ISO и сырой образ байт за байтом. Релизы подписаны minisign, а артефакты можно проверить с помощью gh attestation verify. Продакшен-образ содержит закрытый ключ сервера, поэтому сам ISO является секретом; образы к релизам на GitHub — для разработки, с временными ключами, и служат только для пробы.
Чему мы научились
Несколько уроков, изменивших план:
- Собственный TCP всё же окупился. Прототип работал на smoltcp и хорошо нам послужил до M13. Но серверу, который держит сотни тысяч соединений, не нужен клиентский TCP, ему нужны SYN-куки, колесо таймеров и состояние в 96 байт. В M14 мы заменили smoltcp собственным стеком только для серверной стороны, с тестами имитации потерь и фаззингом сегментов.
- Измеряйте память изнутри. Оценки на соединение были бесполезны, пока ядро само не начало считать кучу в блоках аллокатора, а не в запрошенных байтах. Только тогда мы увидели, куда на самом деле уходят 512 байт, и спустились до 494.
- Инструмент тестирования — часть продукта. Самым большим ограничением было не ядро, а пользовательская сеть QEMU и число эфемерных портов на компьютере разработчика. Для ста тысяч соединений и больше нужен
vmnet-sharedна macOS или Linux с KVM, интерфейсом tap и несколькими клиентскими адресами. - SQLite без операционной системы возможен, но только при строгих условиях: фиксированная куча, эксклюзивная блокировка без разделяемой памяти, VFS, который ничего не знает о файлах, и допуск, не позволяющий базе отстать.
- Шторма переподключений — нормальное состояние, а не исключение. Когда мобильная сеть падает и возвращается, тысячи устройств стучатся одновременно. Поэтому допуск отклоняет соединение до какой-либо криптографии, а каждое отклоненное соединение стоит почти ничего.
Что дальше
Работа после M17 запланирована по трем направлениям, а порядок между ними записан в docs/PLAN.md. Ничего из этого пока не построено.
- Один ISO для нескольких платформ. Один и тот же файл
urmqtt-<env>-<версия>.isoи один сырой образ GPT, которые загружаются на aarch64 UEFI, x86_64 UEFI и x86_64 BIOS, в виртуальных машинах и на настоящем оборудовании, начиная с одного ПК на Intel. По-прежнему устройство со всем внутри, воспроизводимое и подписанное. - Сырой диск, без файловой системы. Спецификация формата на уровне байтов, хост-инструмент
urmqtt-disk, разделяющий парсеры с ядром, выравнивание регионов на 1 MiB, рост диска вместе с данными, discard для тонко выделяемых дисков, снимки базы A/B, проверка битов и, по выбору, шифрование AES-256-XTS с аппаратным AES. - Доказуемо безопасный. Отзыв и список разрешенных устройств во время работы через подписанный канал управления, без нового образа; дифференциальное и конформационное тестирование собственных TLS и TCP; KASLR, защита стека, kCFI, MTE и усиленный аллокатор; SQLite, изолированный на уровне EL0; Secure Boot; SBOM и cargo-vet; и в конце пакет для внешнего аудита.
Платформы, предлагающие только не-virtio устройства, такие как AWS ENA или Azure MANA, пока не поддерживаются. Raspberry Pi 4 и 5 в роли хоста KVM, Hetzner CAX и Oracle Ampere A1 задокументированы и поддержаны в коде, но еще не опробованы на реальном сервисе.
Попробуйте
Репозиторий находится по адресу github.com/zeljan-alduk/urMQTT. С Rust с закрепленной цепочкой инструментов nightly и QEMU команд make certs, make run и одного клиента mosquitto достаточно для первого PUBLISH через mTLS на вашем компьютере. Предупреждение из начала действует и в конце: это пре-альфа, и всё, что работает, описано в docs/PROGRESS.md. Сообщения об ошибках, вопросы по дизайну и сообщения о безопасности по инструкциям в SECURITY.md приветствуются. О каждом следующем этапе мы сообщим здесь.
Комментарии
Правила комментирования