Привет, Хабр! Меня зовут Пётр Растегаев, лид backend-разработки команды девайсов. Сегодня я расскажу о нашей системе обновления прошивок устройств: как мы вообще дошли до жизни такой текущих принципов, какой стек технологий выбрали, какие архитектурные паттерны использовали при разработке и как выстроили удобный релизный цикл прошивок. Заваривайте чай — вас ждёт увлекательное путешествие ;)

Предыстория: от зоопарка устройств к единой прошивке

У Wildberries свыше 130 объектов логистической инфраструктуры, где используется около 30 000 ТСД-устройств. ТСД — терминал сбора данных, основной инструмент складского сотрудника, логиста, грузчика. Внешне это смартфон или микрокомпьютер со сканером штрих-кода, но внутри него — довольно умная прошивка, интеграция с WMS (системой управления складом), логистической системой и т. д.

Только представьте масштаб: десятки тысяч интеллектуальных приборов, которые нужно обслуживать, обновлять, учитывать, контролировать и поддерживать в рабочем состоянии.

Исторически в компании сложилась ситуация зоопарка: использовались разные модели ТСД от разных вендоров, с разными версиями прошивок. Закупки шли вразнобой, небольшими партиями, и со временем это стало большой проблемой. Почему?

Во-первых, такая разрозненность непрозрачна. Трудно даже отследить, что есть на балансе и в каком количестве: часть устройств теряется, ломается, заменяется новыми.

Во-вторых, страдает безопасность. Разные модели с разными прошивками имеют свои уязвимости, за всеми не уследишь.

В-третьих, централизованно управлять этим парком невозможно. Каждый производитель предлагает своё решение для управления, а некоторые не предлагают вовсе — выкручивайся как хочешь.

Из всего этого выросла задача: привести ТСД к единому стандарту. Мы решили выбрать один вид устройств и разработать на основе прошивки вендора единую прошивку под наши нужды. Такую, которая удовлетворяла бы нашим функциональным требованиям и строго соблюдала нефункциональные требования типа безопасности.

Мы приступили к разработке собственной прошивки, и почти сразу перед нами возник новый вопрос: как эту прошивку оперативно развернуть на тысячах и тысячах уже используемых устройств? Прошивать каждое вручную — непрактично. Раздать прошивку на десять устройств можно за час-другой с помощью кабелей; на сотню устройств — это уже целый день рутинной работы. Что говорить о 30 000!

Так мы пришли к выводу, что нужна ещё и своя система удалённого обновления через сеть — OTA.

Ограничения и требования для OTA

Прежде чем разрабатывать OTA-систему, мы оценили исходные условия и ограничения нашей инфраструктуры. Архитектура — это часто про компромиссы. Наши ключевые ограничения и требования были такими:

  • Закрытый контур. Складская сеть изолирована по соображениям безопасности. Доступа в интернет со склада нет, а выход во внутреннюю корпоративную сеть — ограниченный и узкополосный. Следовательно, решение должно быть способно частично развёртываться локально на складе. Модульность системы приветствуется — чтобы выносить отдельные части на склад при необходимости.

  • Хранилище прошивок. Нужно хранить образы прошивок, а один файл прошивки весит около 2 Гб. Прошивки имеются для разных моделей и разных версий, прибавим архивные версии. Значит, требуется S3-хранилище, способное выдерживать всплески нагрузки.

  • Хранение метаданных. Помимо самих файлов прошивок, система должна хранить метаданные — сведения об устройствах, доступных версиях прошивок, соответствии файлов конкретным версиям и прочие служебные данные. Здесь оптимально использовать реляционную базу данных.

  • Производительность и масштабируемость. Необходимо кеширование запросов к сервисам и к базе и заложенная возможность масштабирования компонентов под рост нагрузки.

Первая архитектура OTA-системы

Наша начальная версия состояла из двух сервисов:

  1. Сервис обновления прошивки, который непосредственно отвечает за процесс обновления. Он получает от устройства запрос на обновление, по своим метаданным определяет, какую версию прошивки следует загрузить на данное устройство, проверяет информацию об устройстве через второй сервис (о нём ниже) и возвращает пользователю временную ссылку для скачивания файла прошивки из S3-хранилища.

  2. Сервис метаданных устройств, который хранит информацию обо всех устройствах. Сервис обновления обращается к нему, чтобы валидировать устройство и получить нужные данные перед выдачей ссылки.

Почему мы сразу разделили функциональность на два микросервиса? Всё просто: мы хотели иметь возможность масштабировать компонент, связанный с передачей больших файлов, и при необходимости разворачивать его локально на складе.

Выбор технологий

Определившись с архитектурой, мы перешли к выбору стека. Не хочу устраивать холиваров о том, что лучше, — каждый инструмент хорош на своём месте :) Просто покажу логику наших решений.

Язык программирования. Битва шла между Python и Go. Python позволял очень быстро создать прототип и затем довести его до готового продукта — во многом благодаря богатой экосистеме библиотек под любые задачи. Go значительно быстрее Python: в 5–10 раз по разным бенчмаркам, а на некоторых CPU-bound задачах и в 40 раз. Также Go эффективнее использует ресурсы: там нет GIL, задействуются все ядра, статическая типизация экономит память. В итоге мы выбрали Python, так как скорость разработки для нас была важнее производительности.

Веб-фреймворк. Мы построили микросервисы на базе FastAPI — современного легковесного асинхронного фреймворка для REST. Он достаточно быстрый, поддерживает асинхронность из коробки и позволяет на лету создавать Swagger, доступный для разработчиков клиентского приложения. Последнее значительно ускоряет разработку.

Валидация данных. Для описания моделей и валидации JSON мы использовали библиотеку Pydantic. Она обеспечивает удобную статическую типизацию данных и очень быстра благодаря реализации некоторых частей на Rust. С Pydantic мы оперативно наладили надёжный контракт между клиентом и сервером.

Асинхронные задачи. Несмотря на плюсы Python, у него есть ограничение GIL, которое мешает параллельно выполнять CPU-bound задачи. Для обхода этого мы применили Dramatiq — фреймворк для фоновых задач. Мы вынесли тяжёлые операции (например, шифрование/дешифрование файлов прошивок) в отдельный воркер, запускающийся в отдельном контейнере Kubernetes.

Планировщик задач. Чтобы создавать регулярные задачи, мы выбрали APScheduler четвёртой версии — один из немногих планировщиков, корректно работающих в Kubernetes-кластере. Он гарантирует, что задача выполнится только один раз вне зависимости от числа запущенных реплик сервиса.

База данных. Здесь выбора не было: корпоративный стандарт диктует использовать PostgreSQL, что мы и сделали.

Кеш. Для кеширующей базы данных мы рассматривали Redis и KeyDB. Memcached отбросили как устаревший вариант. Приглядывались к DragonflyDB, но на момент внедрения он показался сыроватым. Redis — проверенное временем решение: все детские болезни уже выявлены и исправлены, он доступен как управляемый сервис, да и практически любой разработчик и девопс с ним знаком. KeyDB работает в многопоточном режиме и опережает Redis по скорости в 2–5 раз в ряде сценариев. Мы остановились на Redis ради скорости и простоты внедрения, однако заложили возможность перейти на KeyDB, если позже узкое место возникнет именно в кешировании.

S3-хранилище. Здесь мы выбирали между Ceph и MinIO. Ceph — очень надёжное, отказоустойчивое, антихрупкое распределённое хранилище, сложное в развёртывании и поддержке. Для полноценного кластера Ceph требуется настраивать несколько видов нод и как минимум две отдельные базы данных (для репликации и для доступа к данным). MinIO значительно проще поднять и запустить, но у него меньше возможностей для тонкой настройки и масштабирования. Сердцем нашей системы стал Ceph, однако MinIO тоже пригодился: для тестов и дев-окружения (чтобы не поднимать тяжёлый Ceph там, где не нужен), а также для локальных установок на складах, где нет связи с основным хранилищем.

Архитектура микросервисов

Мы придерживались подхода, близкого к Domain-Driven Design. Логика сервисов разделена на три слоя: домен, приложение и инфраструктура.

Доменный слой содержит бизнес-логику — например, классы, описывающие объекты предметной области: устройства, прошивки и операции над ними.

Слой приложения отвечает за реализацию сценариев использования: здесь находятся сервисы, которые оркестрируют выполнение операций. Они получают запросы (например, через API), обращаются к бизнес-объектам доменного слоя и вызывают необходимые методы.

Инфраструктурный слой содержит техническую реализацию абстракций, которые определены в домене и приложении. При этом слой приложения косвенно использует то, что есть в слое инфраструктуры, через Dependency Injection.

Пример. Представим в слое приложения класс Firmware Update Service, который обрабатывает запрос на обновление прошивки на устройстве. Он вызывает метод некоего интерфейса репозитория прошивок (например, IFirmwareRepository) из доменного слоя. В домене этот репозиторий определён как абстракция, а конкретная реализация подключается из слоя инфраструктуры. Репозиторий, в свою очередь, обращается к базе данных и получает набор объектов Firmware (например, доступные версии прошивок для данного устройства). Затем Firmware Update Service, оперируя этими объектами и руководствуясь бизнес-логикой, выбирает нужную прошивку и возвращает пользователю ссылку на соответствующий файл в S3-хранилище.

Зачем нужен такой паттерн репозитория? Раньше наш доменный слой напрямую ходил в базу данных, что плохо по двум причинам. Во-первых, бизнес-логика оказывалась разбавлена инфраструктурными деталями. Во-вторых, усложнялось тестирование: чтобы проверить логику, требовалась запущенная БД, без неё код не работал. Репозиторий решает эти проблемы.

Если нам нужна SQL-БД, мы пишем реализацию SQLFirmwareRepository и настраиваем там всё взаимодействие с базой данных. Когда домен хочет получить тот или иной бизнес-объект, он обращается к репозиторию.

Если мы захотим поменять базу данных, мы напишем ещё одну реализацию — скажем, MongoFirmwareRepository — и переключимся на MongoDB без изменений бизнес-логики.

Если нужно провести тестирование, не используя при этом SQL-базу, можно написать In-Memory репозиторий, то есть реализацию того же репозитория, со всеми его методами, но с сохранением и получением объектов из памяти.

Подход с разделением на слои и репозиториями может показаться избыточным, зато он даёт гибкость. Когда нужно быстро разработать прототип, а затем эволюционно превратить его в полноценное решение, такой условный DDDL очень выручает. Можно менять технические детали (базы, хранилища, внешние интеграции) или проводить эксперименты, не трогая основной код бизнес-логики. Этот небольшой оверхед в архитектуре заметно ускорил нам разработку.

Автоматизация метаданных: паттерн Reconciliation Loop

Вернёмся к обновлению прошивок. Изначально процесс хранения прошивок и метаданных выглядел так: при выпуске новой прошивки сборочный конвейер загружал файл прошивки в S3-хранилище, а затем инженер вручную создавал запись о новой версии в базе (указывая версию, имя файла, контрольную сумму и прочие атрибуты). Понятно, что это долго и чревато человеческими ошибками.

Мы упростили и автоматизировали создание метаданных с помощью Reconciliation Loop. Этот паттерн с определённой периодичностью сверяет состояние хранилища с записями в базе и при расхождениях приводит систему в согласованное состояние. Проще говоря:

  • Если в базе нет метаданных о каких-то файлах (например, система запущена впервые) — сервис пробегается по всем файлам и для каждого создаёт запись, вытаскивая из файла служебные данные.

  • Если в базе уже есть метаданные, а в хранилище появилось несколько новых файлов — добавляются записи только для новых файлов.

  • Если какого-то файла больше нет в S3, а в базе запись осталась — лишняя запись удаляется как неактуальная.

Простая фоновая процедура решает задачу поддержания актуального каталога прошивок.

Релизный цикл обновлений

Допустим, мы научились загружать прошивки на устройства. Возникает следующий вопрос: как выпускать обновления на 30 000 устройств безопасно? Если одновременно отправить свежую прошивку на все терминалы, велик шанс, что всплывёт какая-то проблема — и ляжет сразу весь парк устройств. Поэтому мы внедрили многоступенчатый релизный цикл — поэтапное раскатывание обновлений.

Основные этапы такие:

  1. QA-группа. Несколько тестовых устройств, используемых нашими инженерами QA. Команда тестирования проверяет обновление в контролируемой обстановке.

  1. Канареечный релиз. Если тестирование прошло успешно, обновление отправляется ограниченной выборке реальных пользователей. Это небольшое число устройств у складских сотрудников, которые согласились в тестовых целях получать новые прошивки раньше остальных.

  2. Продакшн-релиз. При благополучном завершении канареечного этапа начинается основное развёртывание. Новая прошивка постепенно раскатывается на все остальные устройства.

Таким образом, массовое обновление происходит только после того, как релиз обкатан на мелких выборках.

Для каждой модели устройства — свой набор групп на каждом этапе. Одно устройство может состоять только в одной группе, но мы можем в любой момент переместить его между группами. Например, если какой-то терминал не должен больше получать тестовые обновления, мы переводим его из канареечной группы в основную продакшн-группу.

Помимо вышеописанных этапов, предусмотрены отдельные группы для экспериментов. Если мы хотим протестировать новую фичу или настройку, то можем разбить устройства на несколько групп и установить им разные версии прошивки. Затем собираем обратную связь и сравниваем результаты, чтобы на основе данных принять решение.

На случай, если после выпуска обновления понадобится вернуться на предыдущую версию, мы предусмотрели механизм отката. Прямая установка старой версии поверх новой, к сожалению, невозможна по техническим причинам, поэтому мы выпускаем новый билд с предыдущим функционалом. Версия билда увеличивается (то есть откатный билд формально новее), но по факту устройство возвращается к старой прошивке.

Проблемы при масштабировании

Когда наша система развернулась в полном масштабе, мы столкнулись с новыми трудностями. В основном они связаны с особенностями сети в закрытом контуре.

Складской канал связи узок и загружен. Кроме нашей системы, по нему идут данные множества других сервисов. Передавать по такой сети образ прошивки размером 2 Гб — проблема: несколько устройств, скачивая обновление одновременно, сразу забивают канал, а ещё могут обрываться.

Плохое Wi-Fi покрытие — ещё один нюанс. В огромном складском помещении легко попасть в мёртвую зону без сигнала. Устройство начинает обновление, пользователь уходит с терминалом в другой конец склада, соединение прерывается, а выданная ссылка на файл к моменту восстановления успевает протухнуть. Вернувшись в зону покрытия, устройство уже не может докачать файл, и обновление падает.

Чтобы решить эти проблемы, нам стала необходима очередь на закачку, регулирующая нагрузку на сеть, поддержка возобновления загрузки (докачки прерванного файла), постоянный URL для скачивания прошивки вместо временного, локальный кеш файлов на складе, а также — раз ссылка больше не одноразовая — механизм аутентификации доступа к файлам. То есть нам понадобился дополнительный сервис.

Архитектура 2.0: локальный сервис и очередь

Мы доработали систему, введя третий микросервис и изменив схему взаимодействия. Теперь процесс обновления прошивки выглядит так:

  1. Выдача токена. Сервис обновления прошивки (OTA Manage Service) больше не возвращает устройству прямую ссылку на S3. Вместо этого он генерирует JWT-токен, в который зашита информация о требуемом файле прошивки.

  2. Запрос файла по токену. Терминал получает JWT и обращается уже не напрямую к S3, а к новому сервису хранения прошивок (OTA Storage Service) по постоянному URL.

  3. Верификация и выдача. Сервис хранения проверяет валидность JWT. URL этого сервиса защищён аутентификацией, посторонний доступ исключён. Далее находится файл.

  4. Стриминг с кешем. OTA Storage Service скачивает файл, кеширует его локально и отдаёт пользователю.

  5. Очередь загрузок. Этот же сервис реализует механизм очереди для обновлений и ограничивает так нагрузку на конкретный канал.

Новый сервис сравнительно простой, поэтому мы можем разворачивать его непосредственно на складе (в локальной сети). Тогда все устройства на складе будут качать прошивки по внутреннему Wi-Fi.

Ограничение параллельных загрузок оказалось очень полезным. Мы проанализировали нагрузку на сеть с очередью и без неё. Без очереди устройства в пике занимали весь доступный канал. С очередью (скажем, лимит на пять устройств одновременно) время обновления было примерно таким же, зато канал не насыщался до отказа, сеть оставалась устойчивой, другие системы на складе не страдали, а обновления проходили стабильнее.

Доработки: обратная связь и контроль устройств

Звучит хорошо, а как всё это воспринимают конечные пользователи — те самые складские работники, которые ходят с терминалами? Понять их потребности непросто: интернета на складе нет, опрос не отправишь. Ездить «в поля» и вытягивать фидбек — за гранью разумного (мы пробовали).

Мы решили, что самым адекватным вариантом будет написать для обратной связи ещё один сервис. Это небольшой модуль, встроенный в прошивку, который позволяет отправить оценку прямо с устройства. Этот же сервис собирает логи и отчёты о сбоях прошивки и отправляет их нам для анализа.

Вслед за этим появился ещё один компонент — сервис контроля устройств. Он позволяет удалённо отдавать команды терминалу. Например, можно отправить команду блокировки или сброса к заводским настройкам, запросить текущее местоположение, подать сигнал, чтобы потерянный девайс нашли на складе. Всё это критично в условиях большого складского пространства.

Таким образом, из небольшой узкоспециализированной OTA-системы выросла целая микроэкосистема для управления парком устройств.

Итоги

Чему мы научились, пока разрабатывали OТА-систему?

Прежде всего — работа в закрытом контуре. Мы научились строить современное решение, не опираясь на облачные технологии и доступ в интернет.

Второе — мы прощупали, как быстро можно пройти путь от прототипа к продакшну. Нам удалось за две недели создать прототип и всего за шесть месяцев довести его до промышленного внедрения, поддерживающего тысячи устройств.

Наконец, мы выстроили эффективный и надёжный процесс постепенного обновления прошивок для embedded-устройств. Думаю, наш подход может пригодиться не только в контексте складов, но и вообще при работе с большими парками устройств.

Буду рад ответить на вопросы в комментариях!

Комментарии (0)