Мы проверяли, что устройство работает. Оказалось — мы проверяли не то

Обновление применилось, устройство рапортует ОК. Проверьте, что именно оно проверило

Исправное устройство успешно обновилось, доказало своё здоровье, а через два перезапуска откатилось на старую прошивку. Причина оказалась вообще не в обновлении: в тот раз наш бэкенд отвечал три минуты вместо одной.

У нас парк ARM‑устройств: удалённое обновление ядра, A/B‑слоты, автоматический откат. Казалось бы, схема известная, готовые движки есть, документация написана. Сам движок действительно поехал за неделю. Потом полтора месяца мы выковыривали дефекты, которые не падали, ничего не писали в лог и не ловились обычными тестами.

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

Устройство говорило «обновился», проверив доставку обновления, а не его применение. Или: «я здоров, братан, проверяй», глядя на факт запуска, а не на работу прошивки. Или: «версия 0.0.17», потому что это было написано в манифесте бэкенда, а не потому, что именно эта версия реально крутилась на железе.

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

Скрытый текст

Про слово «улика». Дальше оно встречается часто, поэтому договоримся сразу: улика — это наблюдаемый признак того, что слот действительно работает. У нас это поднявшийся сетевой интерфейс gw0, то есть факт, а не рапорт. Датаплейн — то, что через него ходит: полезная нагрузка устройства.

Контекст и почему цена ошибки здесь другая

Устройство — сетевой шлюз на RK3328: четыре Cortex‑A53, гигабайт памяти, OpenWrt, U‑Boot. Стоит в разрыве между роутером и провайдером в квартире живого человека. Не в стойке и не у нас в офисе. Приехать к нему нельзя: он в другом городе, статического адреса нет, владелец — не инженер. У веб‑сервиса баг обычно стоит отката деплоя и нескольких минут. Здесь баг стоит выезда, курьера или потерянного клиента.

Платформа тоже не прощает. На RK3328 нет пути экстренной перезагрузки: ни panic‑reset, ни сторожевого таймера в тойконфигурации, что нам досталась. Устройство, не поднявшееся после обновления, само уже никогда не вылечится.

Можно, конечно, нагрузить пользователя: «Вот тебе Rufus, вот образ прошивки, давай». Но это не только ломает короткое плечо Continuous Delivery, это ещё и, мягко говоря, фу.

Отсюда A/B: два независимых слота, загрузчик даёт новому слоту N попыток, слот должен доказать здоровье, иначе загрузчикуходит на соседний. Схема известная. Дефекты — в стыках. Чтобы дальше это читалось как карта, а не как поток сознания, вот все семь сразу:

Мы думали

Как было на самом деле

Раздел

Взяли отраслевой движок — взяли и его гарантии

Половина требований его же документации у нас не выполнена

1

Состояние A/B переживёт перезагрузку

Обрыв питания бьёт CRC, устройство уходит в слот A, а заодно немеет по UART

2

Запись durable, значит подтверждение durable

Подтверждений два, а обрыв помещается между ними

3

Слот без нагрузки доживёт попытки и откатится сам

Попытку списывает только загрузка, а перезагружаться некому. Состояние стабильно

4

а исправный слот, наоборот, закрепится

Наблюдатель ушёл через 180 секунд. Исправное устройство откатится за два ребута

4

Номер версии говорит, что лежит в слоте

Пересборка под тем же номером — и устройство не обновится никогда

5

Отчёт показывает, что работает

Отчёт показывает, что было велено поставить. Расходятся они после отката

6

1. Купил готовый движок — купи и его требования

Мы взяли SWUpdate. Отраслевой стандарт, живой апстрим, внятная документация. Собрали под своё железо, прогнали два цикла A/B на стенде — обновление поехало.

Ну просто песня, да?

Потом сели делать ревью того, что получилось. Восемь тикетов. Четыре из восьми оказались не нашими дефектами, а невыполненными требованиями самого SWUpdate. Они были написаны в его документации ещё до того, как мы начали. Надо же.

CONFIG_SIGNED_IMAGES выключен: бандл проверялся только по SHA-256. То есть от порчи при передаче, но не от подмены. Кто может подсунуть устройству URL, тот ставит свою прошивку и получает root в доме владельца. Самое неприятное здесь — тайминг. Канал доставки уже работал, два цикла сняты живьём, и соблазн «едет же, подпишем потом» был максимальным. Работающий неподписанный канал хуже отсутствующего: он создаёт ложное чувство готовности.

hardware-compatibility не заполнен. В meta‑swupdate это обязательный элемент описания бандла; у нас его не было вовсе. Пока модель платы одна — не жжёт. Появится вторая ревизия, и бандл установится на неё молча, окирпичив устройство уже увладельца, а не на стенде.

Antirollback: CONFIG_SW_VERSIONS_FILE объявлен, файл sw-versions не ведётся, проверки в описании бандла нет. Поверхновой прошивки ставится старая — включая ту, где известная дыра ещё не закрыта. Для устройства с удалённым обновлением это валидная атака, и подпись её не закрывает: злоумышленник не ломает криптографию, он подсовывает подлинный старый бандл. Вывод простой:

если берёте готовый инструмент, берите и канон этого инструмента

2. saveenv на каждой загрузке

Да, да, тот самый единственный дружище, у которого было так же. Я вижу твои руки.

Состояние A/B — какой слот выбран и сколько попыток осталось — мы хранили в окружении U‑Boot. Скрипт загрузчика списывалпопытку и делал saveenv на каждом старте. При этом CONFIG_ENV_OFFSET_REDUND в нашей сборке отсутствовал, то есть резервной копии окружения не было.

На каждой загрузке есть окно, в котором обрыв питания оставляет битую CRC. Дальше загрузчик берёт компилированныедефолты, BOOT_ORDER теряется, устройство грузится в слот A — даже если рабочим был B. Свежеустановленное обновление «исчезает» без единой ошибки. Чистый кайф. Тут ещё можно немного подушнить насчёт износа SD, в который я, конечно, верю, но не всем сердцем.

Отдельным сюрпризом были дефолты: baudrate-115200 просто делал нашу коробку немой по UART — у нашей платы 1500000. Как делают правильно: счётчик попыток держат не в общем окружении, а в CONFIG_BOOTCOUNT_LIMIT — отдельном месте (SRAM,регистр RTC, выделенный сектор), которое переживает перезагрузку и не требует переписывать env. Окружение трогают только при смене состояния: установка, подтверждение. Не на каждом старте. И это уже хороший пример того, почему «мы проверили, что запись durable» недостаточно. Нужно ещё проверить, что именно и когда вы записываете.

3. Durability одной записи — не атомарность пары

Этот дефект мы нашли уже после того, как починили предыдущий. Он хорошо показывает, как легко успокоиться на закрытомтикете. Резервное окружение мы сделали: две копии, CRC, серийный счётчик, есть тест с обрывом питания. Одна запись стала durable. Проблема в том, что подтверждение слота — это две записи:

fw_setenv "BOOT_${slot}_LEFT" "$BOOT_TRIES" && fw_setenv BOOT_ORDER "$slot $other"

Подтверждение по смыслу есть конъюнкция двух фактов: счётчик попыток восстановлен и порядок загрузки указывает наэтот слот. Записываются они порознь. Обрыв питания помещается между ними. Каждая транзакция атомарна, а их последовательность — нет.

Что получается на устройстве: слот назначен первым, но счётчик у него не восстановлен. Улика здоровья сошлась,устройство работает, всё хорошо — а запас попыток продолжает убывать с каждой неудачной загрузкой. Дойдя до нуля, загрузчик уходит на соседний слот. То есть устройство, которое успешно обновилось и доказало своё здоровье, через несколько сбоев питания молча возвращается на старую прошивку.

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

4. Зеркальная пара: один механизм, два противоположных отказа

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

Слот, который не откатится никогда

В скрипте подтверждения слота стоял такой финал:

# Молчим не "на всякий случай": невзведённый счётчик означает, что слот доживёт свои попытки и
# откатится САМ. Это и есть задуманное поведение для слота без датаплейна.
echo "улики gw0 нет за ${WITNESS_WAIT}с - слот $slot не подтверждаю"

Комментарий объясняет намерение. Намерение выглядит правильным. А утверждение в нём — неверное. Попытку списывает код внутри U‑Boot, исполняемый только при загрузке. Если userspace поднялся, а датаплейна нет,никто не перезагружается. Счётчик не убывает. Откат не наступает. Слот не подтверждается. И это состояние стабильно. Устройство, обновившееся на прошивку, где датаплейн не встаёт, остаётся в этом состоянии навсегда. Включено, светится, не работает.

A/B, заведённый ровно ради такого случая, не срабатывает ни разу. Возврат — только руками, то есть ровно тем способом,которого мы и хотели избежать. Вдобавок слот не подтверждён, значит установщик откажет в следующем обновлении: там стоит охрана «ставить можнотолько с подтверждённого слота». Устройство теряет и датаплейн, и способность починиться по воздуху.

Очевидное лечение — «перезагружаться по таймеру» — мы не взяли по идеологическим причинам. Если датаплейн не встал из‑за внешней причины — например, сервис выдачи ключей недоступен или mesh не поднялся, — перезагрузка ничего не чинит. Зато парк может уйти в круговой ребут и потерять mesh — единственный канал, которым его можно спасти.

Случай не гипотетический: 11.08 наш провижн‑бэкенд лежал четыре минуты из‑за выката. В этот момент ни одно устройство парка не смогло бы получить ключ. Таймер бы это увидел и радостно расстрелял весь парк по кругу. Условие перезагрузки в итоге стало сочетанием трёх наблюдаемых фактов:

Перезагружаемся только тогда, когда сосед заведомо лучше.

Исправное устройство, которое откатится

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

  1. Устройство загрузилось на слоте B, а сервис выдачи ключей был погашен намеренно

  2. Скрипт подтверждения честно прождал WITNESS_WAIT=180с, улики не увидел, слот не подтвердил и завершился. Он one‑shot: respawn у него нет, в init.d такой строки не существует

  3. Бэкенд вернули. Супервизор, который procd перезапускает, добыл ключ и поднял интерфейс сам, без перезагрузки. Uptime непрерывен: 288 секунд одного бута

  4. Устройство работает и обслуживает трафик. При этом BOOT_B_LEFT=2, BOOT_ORDER=B A, знак подтверждения не выставлен и уже не будет выставлен — подтверждать некому

Каждая следующая загрузка списывает попытку. Через два ребута исправное устройство откатывается на предыдущую прошивку при полностью рабочем датаплейне. Владелец видит: «обновление откатилось само». А причина не в обновлении вообще. Просто в тот раз бэкенд отвечал дольше трёх минут.

Тот же класс, что и первый дефект, только с обратным знаком. Там слот работает плохо — и откат не наступает. Здесь слот работает хорошо — и откат наступит. Обратите внимание, чем эти два случая склеены: окном ожидания. Любое конечное окно проигрывает достаточно долгому простою внешней зависимости. Поэтому «сделать окно шире» лечением неявляется. Это отсрочка с красивым названием.

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

5. Версия — не идентификатор содержимого

Вот небольшой кусок логики:

pub fn need_provision(is_luks: bool, slot_version: Option<&Version>, target: &Version) -> bool {
    !is_luks || slot_version != Some(target)
}

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

Цитата из нашего же тикета, лучше не сформулирую:

Хуже кирпича — кирпич виден.

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

6. Отчёт врёт ровно в тот момент, когда он нужен

Статус‑страница устройства печатала в поле version объявленную цель — то, что сервер сказал поставить, а не то,что реально прицеплено и работает. Замер сразу после отката:

отчёт:              slot=A   version=0.0.17
метка слота:        slot-version-A=0.1.8
смонтировано:       /dev/mapper/payload_A
содержимое слота:   бинарь, соответствующий 0.1.8
интерфейс UP, датаплейн жив

Устройство обслуживает трафик версией 0.1.8 и всем наблюдателям сообщает 0.0.17. Прелесть дефекта — в его расписании. Пока цель и факт совпадают, врать не о чем. Отчёт корректен месяцами и заслуживает полного доверия. Расходятся они ровно после отката — то есть в единственный момент, когда кто‑то смотрит в этот отчёт и принимает по нему решение. Идеальный сотрудник: врёт только на дейлике.

Лечится тем же приёмом, что и всё остальное в этом списке: знак должен быть заземлён. version — это версия прицепленного слота, взятая из его содержимого. Цель, если она нужна наблюдателю, — отдельное поле. Тогда расхождение version != target становится видимым фактом: это уже сигнал «идёт роллаут» или «был откат», а нетихая подмена одного факта другим.

7. Где врала формальная модель

Скрытый текст

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

Остальным будет интересно, чем именно зелёный TLC отличается от «всё хорошо».

Мы держим на эту подсистему модели TLA+, и было бы красиво написать, что они всё поймали. Не поймали. Разбор того, почему именно, оказался полезнее самих моделей.

Дефект из раздела 3 — две записи — невыразим в модели.

Действие MarkGood там атомарно: обе переменные меняются одним шагом, UNCHANGED накрывает остальное, а действияCrash в модели нет вовсе. Torn‑состояние не выражается ничем. Поэтому зелёный по этой оси вакуумен: он не значит «так не бывает». Он значит «мы этого не спрашивали».

Дефекты из раздела 4 держались на fairness‑допущении.

В модели подтверждение слота было доказано слабо: раз оно заслужено, оно в конце концов случится. На железе у этого «в конце концов» нет исполнителя. Наблюдатель уходит через 180 секунд и не возвращается. Это был третий такой случай подряд в одном эпике.

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

допущение справедливости — это утверждение о механизме.

Не «оно как‑нибудь случится», а «вот процесс, вот кто его перезапускает, вот почему он не может уйти навсегда». Если исполнителя назвать не удаётся, fairness из модели убирается — и дефект всплывает сразу. Модель не бесполезна. Половину списка выше нашли именно контрпримеры.

Полезно другое: зелёный результат ровно настолько силён, насколько модель способна выразить отказ.

Поэтому проверять надо в первую очередь это, а не количество проверенных состояний.

Чеклист

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

  1. Бандл подписан, и вы проверили отказ: пересобранный чужим ключом обязан быть отвергнут до записи в слот. Зелёный SHA-256 сам по себе не значит ничего

  2. Есть hardware-compatibility, и бандл для другой ревизии платы отвергается

  3. Есть antirollback, и бандл с версией ниже установленной отвергается

  4. Окружение загрузчика имеет резервную копию, и вы резали питание в цикле, а не рассуждали

  5. Счётчик попыток не требует переписывать окружение на каждом старте

  6. Подтверждение слота — одна durable‑транзакция, а не последовательность из двух

  7. У наблюдателя, который подтверждает слот, есть тот, кто его перезапускает. Один процесс, а не два независимых наблюдателя над одним слотом

  8. Устройство, у которого userspace поднялся, а рабочая нагрузка — нет, не остаётся в этом состоянии навсегда. Проверьте руками: убейте нагрузку и подождите

  9. Обновление опознаёт содержимое, а не номер версии. Пересоберите payload под тем же номером и убедитесь, что устройство действительно обновилось

  10. Отчёт о версии читает то, что работает, а не то, что было велено поставить. Сверьте после отката, а не после успешной установки

@
14.08.2026 19:02 UTC
Первоисточник