
Убрать обязательный хаб из умного дома — звучит как продуктовое решение. На практике в этой формулировке спрятано много неприятной инженерии. Устройство должно само войти в экосистему, само держать долгоживущее облачное соединение, само обновляться — в общем, всё делать без посредника. И это на железе с ограниченной памятью и слабым CPU, в домашней сети, которая в любой момент может пропасть.
На таком масштабе каждый лишний TLS‑коннект, каждая дополнительная аллокация, каждый хрупкий ретрай начинает стоить по‑настоящему дорого. Мощным бэкендом здесь не поможешь: ограничение — на клиентской стороне. Поэтому мы шли в обратную сторону: последовательно убирали лишнюю сложность на каждом слое — от заводского контура до транспортного канала.
Мы строим новую платформу для устройств умного дома Яндекса, которая должна держать больше миллиона активных устройств. В этой статье — про конкретные решения: комиссионинг без хаба, один канал вместо зоопарка соединений, однопоточную модель исполнения и дисциплину переподключений на плохой сети.
Первое узкое место: устройство без хаба должно войти в экосистему само
Комиссионинг — это то, что происходит до того, как устройство вообще появляется в приложении. Получение параметров Wi‑Fi, авторизация, регистрация в облаке, переход в рабочий режим. У устройств с хабом всё это делает, собственно, хаб — он в сети, он доверенный, он знает облако. Без хаба этот путь устройство проходит само, с нуля, в чужой домашней сети. Для нас комиссионинг — не приятное дополнение к системе, а такое же узкое место, как транспорт или рантайм.
Мы сделали ставку на комиссионинг через BLE (Bluetooth Low Energy) — устройство настраивается прямо с телефона. После первого запуска оно входит в режим первичной настройки и получает по BLE предварительно зашифрованный набор данных:
параметры Wi‑Fi;
код авторизации;
данные клиента для обмена кода на токен;
служебные данные для регистрации в облаке.
В этом месте легко недооценить проблему. BLE удобен для первичной настройки, но как транспорт он очень тесный. MTU небольшой, поэтому данные приходится передавать чанками. В одной из реализаций под входной payload был фиксированный буфер на 1024 байта, а первый чанк содержал заголовок с длиной всего сообщения. Это позволило избежать бесконтрольных аллокаций и больших временных буферов.
После этого устройство проходит доверенную цепочку:
расшифровывает payload, переданный по BLE;
подключается к Wi‑Fi;
по HTTPS меняет код авторизации на access token;
регистрируется в облаке;
поднимает постоянное облачное соединение;
отправляет описание своих возможностей;
перезагружается в рабочий режим.
И всё это должно вести себя предсказуемо на плохих сетях и слабом железе. Поэтому у флоу появились жёстко заданные ретраи и тайм‑ауты: три попытки на подключение к Wi‑Fi, пять попыток на обмен токена, три попытки на регистрацию и общий таймаут настройки в 90 секунд.
Когда у тебя сотни тысяч устройств, подключение — уже не редкий сценарий первого запуска, а постоянный массовый поток. Если комиссионинг хрупкий, пользователь видит спиннер. А мы — повторные попытки, шум в облаке и нестабильные метрики подключения.
Поэтому мы относились к онбордингу как к части общей архитектуры масштаба, а не как к отдельному экрану в приложении.
Следующий шаг — перестать плодить соединения
Довольно быстро стало понятно, что главная проблема — в транспорте.
Идея дать каждой задаче свой сетевой канал кажется очень соблазнительной: отдельно телеметрия, отдельно команды, отдельно логи, отдельно OTA, отдельно регистрация описания устройства. На сервере это выглядит опрятно. А на устройстве быстро превращается в набор лишних TLS‑рукопожатий, сокетов, буферов и независимых наборов стейтов — и это ещё до того, как ты написал хоть строчку прикладной логики.
Поэтому почти всё облачное общение устройства, кроме начального OAuth‑обмена и первичной регистрации, мы завели в один мультиплексированный WebSocket/TLS‑канал.
Через него у нас пошли:
директивы (команды) от облака;
события и состояния устройства;
телеметрия;
логи;
публикация описания возможностей устройства (какие команды принимает, какие состояния отдаёт);
OTA.
Стоимость одного соединения на слабом клиенте не нулевая. По нашим оценкам, только одна TLS‑сессия на embedded‑стеке легко съедает около 30–40 КБ RAM. Дальше добавляются буферы, сокет, CPU на криптографию и ещё пара секунд на DNS, TCP и TLS‑рукопожатие в плохой сети. Если таких соединений несколько, мы масштабируем уже не полезную работу, а накладные расходы.
Один канал дал нам сразу несколько эффектов.
Во‑первых, сетевой профиль устройства стал почти постоянным. Неважно, сколько у конкретного устройства возможностей — три или тридцать, — базовая стоимость облачного транспорта оставалась примерно одной и той же.
Во‑вторых, у нас появилась одна reconnect‑стейт‑машина вместо нескольких. На массовом флоте это критично: reconnection storm всегда проблема и клиента, и сервера.
В‑третьих, стало проще планировать серверную часть. Для облачных устройств мы выделили отдельный контур сервисов вокруг WebSocket‑прокси и доставки сообщений, чтобы нагрузку можно было изолировать и проще считать.
Отдельно помогло то, как мы обращались с данными внутри канала. События не улетали по одному сразу после каждого изменения состояния — мы собирали их в короткое окно примерно на 100 мс. В результате стало меньше кадров, накладных расходов на TLS‑record, криптографической работы и шума на сервере. На одном из устройств под это был заранее выделен буфер батча примерно на 1,5 КБ и отдельный стековый буфер запроса около 2 КБ — опять старались избежать динамической аллокации.
А как же локальная работа?
Устройства без обязательного хаба по умолчанию завязаны на облако. Но если у пользователя хаб есть — мы хотели, чтобы устройства умели работать и локально, без интернета.
Поэтому рядом с облачным WebSocket/TLS‑каналом у нас появился отдельный локальный контур поверх Zenoh — протокола с минимальными накладными расходами, который не требует центрального брокера и в локальной сети умеет работать напрямую, в сценариях peer‑to‑peer и mesh. В одной модели у него живут и pub/sub, и query/reply поверх именованных ресурсов.
Локальное взаимодействие получалось компактным и по протоколу, и по архитектуре: хаб и устройство находили друг друга в LAN, обменивались данными локально и не зависели от внешнего интернета. Для локального контура мы использовали PSK‑based TLS, чтобы не тащить в домашнюю сеть полноценную сертификатную инфраструктуру.
Масштаб на слабом железе начинается не с транспорта, а с модели исполнения
Довольно быстро мы упёрлись ещё в одну вещь: даже хороший транспорт не спасает, если рантайм устройства сам по себе сложный и дорогой.
Многопоточность на сервере — естественный выбор. Однако на слабом железе она очень быстро становится роскошью. Нужно держать отдельные стеки, думать о гонках, мьютексах, порядке колбэков, ловить редкие зависания и разбираться с багами, которые проявляются раз в неделю на конкретной прошивке и конкретной Wi‑Fi‑сети.
Поэтому в нашем SDK мы сознательно выбрали однопоточную модель исполнения: почти вся логика устройства — обработка директив, отправка событий, OTA, комиссионинг, сетевые реакции — проходит через одну очередь задач на FreeRTOS.
Это решение выглядит менее эффектно, чем набор независимых потоков, но для embedded‑платформы оказалось гораздо честнее.
Что оно нам дало:
общий state почти перестал требовать мьютексов;
поведение стало детерминированнее;
ушла большая часть проблем с гонками между сетью, BLE и application‑логикой;
стоимость по памяти стала заранее ограниченной.
В одной из конфигураций этот рантайм укладывался в один поток со стеком 10 КБ и отдельный watchdog‑поток на 512 байт. У очереди были фиксированные пулы для немедленных и отложенных задач в конфигурации 32 × 32, а вся инфраструктура диспетчеризации занимала меньше 14 КБ на ARM‑платформе.
Тот же принцип мы протащили через весь SDK: фиксированные ETL‑контейнеры вместо структур, явные лимиты на строки, буферы и контексты задач. Только в этом случае система начинает вести себя предсказуемо на большом флоте слабых устройств.
В этом месте масштабируемость получалась двусторонней: облако не тратило лишние ресурсы на шум со стороны клиента, а само устройство не разваливалось от собственной внутренней сложности.
Плохая сеть — это базовый режим работы
IoT‑систему очень удобно проектировать так, будто всегда доступна нормальная сеть. А потом появляются реальные квартиры, дачи, роутеры, провайдеры, captive portal, странный DNS, скачки сигнала, и выясняется, что «нормальная» сеть была слишком смелым допущением с самого начала. Так что мы отдельно занялись вопросом DNS и политикой переподключений.
Как правило, устройство при каждом переподключении проходит полный путь: сначала запрашивает DNS, получает один адрес, а если не может подключиться, всё начинает заново. А теперь представьте то же самое, но на плохой сети: оно будет дольше висеть в офлайне, сжигать CPU и создавать всплески повторных попыток.
Мы сделали в SDK собственный DNS‑кеш с fallback IP для критичных хостов: есть DNS — используем его, нет или барахлит — берём резервные адреса. Сам DNS lookup вынесли в отдельную короткоживущую задачу со стеком 3 КБ и тайм‑аутом в 1 секунду, чтобы случайный вызов системного резолвера не блокировал основную очередь исполнения.
Но одного DNS‑кеша мало, важна и дисциплина переподключений. Для облачного канала мы использовали экспоненциальный backoff с джиттером: базовая задержка — 5 секунд, потолок — 90 секунд, а само ожидание рандомизируется, чтобы после общей аварии множество устройств не начинало ломиться обратно одновременно.
А ещё мы сбрасываем backoff не сразу после первого удачного подключения — только после определённого периода стабильной работы. Иначе система может слишком рано «забыть», что сеть всё ещё может быть нестабильной и в любой момент отвалиться снова.
В масштабе сотен тысяч устройств это критично: один плохой DNS‑сервер или короткая деградация сети не должны разворачиваться в лавину переподключений.
Безопасность без права на тяжёлую криптографию
В IoT есть распространённая ловушка: объявить безопасность абсолютным приоритетом, а потом спроектировать систему, которая на реальном железе просто не помещается. Мы старались выбирать механизмы, которые устройство может себе позволить, и при этом не жертвовать защитой. Отсюда вырос наш подход ECC‑first.
На ESP32-C3 генерация ECC‑ключа P-256 занимает примерно 50–150 мс. Для RSA-2048 это уже примерно 800 мс — 3 секунды. На ещё более слабых ARM‑платформах разница становится совсем неприятной: RSA‑ключи могут генерироваться секунды и даже десятки секунд. Для заводского контура это означает очень простую вещь: линия начинает простаивать.
Дальше разница становится ещё заметней:
приватный ECC‑ключ — 32 байта против ~1232 байт;
подпись — 64 байта против 256 байт;
base64-представление подписи в HTTP‑заголовке — 88 символов против 344 символов.
Для встроенного устройства это не абстрактная математика, а конкретные байты во Flash, RAM, BLE‑пакетах и HTTP‑заголовках, которых либо хватает, либо нет.
Поэтому доверенную цепочку мы строили так:
при комиссионинге данные по BLE шифруются гибридной схемой на ECC и AES-128;
устройство получает OAuth‑токен по HTTPS;
регистрацию в облаке подписывает собственной ECDSA‑подписью (алгоритм на основе ECC);
приватный ключ генерируется на устройстве и не покидает его.
Безопасность транспорта мы тоже ужимали до разумного embedded‑формата. PEM‑наборы — это текст, а значит, лишние байты, поэтому мы перешли к более лёгким бинарным DER‑сертификатам. Основной bundle — 21 корневой сертификат, суммарно это около 20 КБ. Размер TLS‑буферов подбирали вручную: 5 КБ на вход, 4 КБ на выход. Ну и дополнительно уменьшили размер фрагментов TLS — так мы перестали переплачивать памятью на каждом соединении.
Мы пошли дальше и начали сокращать сами наборы корневых сертификатов под реальные хосты. В наших экспериментах вместо системного bundle примерно на 223 КБ получался минимальный набор из 13 сертификатов на 18 КБ, а в упакованном DER‑архиве — около 10 КБ.
Логика прежняя: масштаб достигается не героизмом одного компонента, а последовательным уменьшением цены каждого обязательного шага.
И ещё кое‑что
Есть ещё одна вещь, которую легко недооценить в начале проекта, — производство.
Когда строишь платформу под большой флот и внешних производителей, ручной заводской контур становится таким же узким местом, как лишний TLS‑коннект на устройстве. Поэтому мы стандартизировали и автоматизировали то, что обычно долго остаётся набором ручных процедур:
генерацию ключей на устройстве во время производства;
выгрузку наружу только публичной части;
проверку связки между устройством, его ключом и облачной регистрацией;
сбор метаданных о произведённых устройствах;
self‑тестирование и валидированный выход из factory mode только устройств, прошедших все тесты.
Принцип здесь тот же, что и в рантайме: не плодить ручную неоднозначность. ECC‑ключ генерируется прямо на устройстве, публичный уходит наружу, приватный — нет. Устройство выходит из factory mode только после обязательных проверок, а отладочные возможности в production‑контуре мы жёстко режем.
Облачная масштабируемость ломается не только на серверных графиках. Она ломается ещё и там, где на линию выходит партия устройств с несогласованными ключами, вручную заведёнными идентификаторами и нестабильной процедурой выпуска.
Что в итоге у нас получилось
Наше новое облако уже применяется в ИК‑пульте и эмби‑лампе Яндекса, а также в системе для защиты от протечек NEPTUN.
Мы не пошли по пути одного большого бэкенда, который держит всё на себе. Мы убирали лишнюю сложность из каждого слоя, и теперь у нас:
в подключении — BLE‑комиссионинг с контролируемой доверенной цепочкой;
в транспорте — один мультиплексированный WebSocket/TLS‑канал вместо зоопарка соединений;
в рантайме — однопоточная модель с фиксированной стоимостью по памяти;
в сетевой устойчивости — DNS‑кеш, fallback IP и аккуратный backoff;
в безопасности — подход ECC‑first и уменьшенный сертификатный след;
в производстве — стандартизация и автоматизация заводского контура.
Платформа для ресурсоограниченного железа и плохих сетей дотянулась до большого флота не потому, что где‑то в центре стоит один очень сильный сервис, а потому, что на каждом уровне мы старались сделать систему чуть проще, чуть дешевле и чуть предсказуемее.
В этом и состоит суть настоящей масштабируемости для IoT: не держать миллион соединений любой ценой, а сделать так, чтобы цена каждого соединения, каждого байта и каждого нового устройства оставалась контролируемой.
Комментарии (2)

nbkgroup
28.07.2026 08:08На ESP32-C3 генерация ECC‑ключа P-256 занимает примерно 50–150 мс. Для RSA-2048 это уже примерно 800 мс — 3 секунды. На ещё более слабых ARM‑платформах разница становится совсем неприятной: RSA‑ключи могут генерироваться секунды и даже десятки секунд. Для заводского контура это означает очень простую вещь: линия начинает простаивать.
https://esp32.com/viewtopic.php?t=23830#p85332
Right now I have variable base implementations (i.e. shared secret calculations) for Curve25519 and P-256 written that runs in 7 ms and 8 ms respectively on the ESP32-S2 and fixed base implementations (i.e. keygen) that are ~60% faster. I am now about to write implementations for the original ESP32 as well, which I expect to be ~50% faster after making some timing experiments.
fedotovartuom76
Один вопрос. Как решить проблему с автономностью без хаба? Мы все знаем, что Wi-Fi устройства живут на батарейке существенно меньше, нежели ZigBee, которому нужен хаб.