- PVSM.RU - https://www.pvsm.ru -

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

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

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

У нас парк 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. Отчёт о версии читает то, что работает, а не то, что было велено поставить. Сверьте после отката, а не после успешной установки

Автор: redtom

Источник [1]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/ota/456596

Ссылки в тексте:

[1] Источник: https://habr.com/ru/articles/1070610/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1070610