Обновление применилось, устройство рапортует ОК. Проверьте, что именно оно проверило
Исправное устройство успешно обновилось, доказало своё здоровье, а через два перезапуска откатилось на старую прошивку. Причина оказалась вообще не в обновлении: в тот раз наш бэкенд отвечал три минуты вместо одной.
У нас парк 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 наш провижн‑бэкенд лежал четыре минуты из‑за выката. В этот момент ни одно устройство парка не смогло бы получить ключ. Таймер бы это увидел и радостно расстрелял весь парк по кругу. Условие перезагрузки в итоге стало сочетанием трёх наблюдаемых фактов:
-
у меня нет улики
-
у соседнего слота счётчик полон — то есть он когда‑то был подтверждён
-
у меня самого попытки ещё есть
Перезагружаемся только тогда, когда сосед заведомо лучше.
Исправное устройство, которое откатится
Теперь зеркало. Нашли побочно, когда снимали обезоруживание предыдущего тикета.
-
Устройство загрузилось на слоте B, а сервис выдачи ключей был погашен намеренно
-
Скрипт подтверждения честно прождал
WITNESS_WAIT=180с, улики не увидел, слот не подтвердил и завершился. Он one‑shot:respawnу него нет, вinit.dтакой строки не существует -
Бэкенд вернули. Супервизор, который procd перезапускает, добыл ключ и поднял интерфейс сам, без перезагрузки. Uptime непрерывен: 288 секунд одного бута
-
Устройство работает и обслуживает трафик. При этом
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 из модели убирается — и дефект всплывает сразу. Модель не бесполезна. Половину списка выше нашли именно контрпримеры.
Полезно другое: зелёный результат ровно настолько силён, насколько модель способна выразить отказ.
Поэтому проверять надо в первую очередь это, а не количество проверенных состояний.
Чеклист
Десять вопросов, которые я бы задал любому парку устройств с обновлением по воздуху. Каждый вырос из дефекта выше. Каждый можно проверить за один вечер.
-
Бандл подписан, и вы проверили отказ: пересобранный чужим ключом обязан быть отвергнут до записи в слот. Зелёный SHA-256 сам по себе не значит ничего
-
Есть
hardware-compatibility, и бандл для другой ревизии платы отвергается -
Есть antirollback, и бандл с версией ниже установленной отвергается
-
Окружение загрузчика имеет резервную копию, и вы резали питание в цикле, а не рассуждали
-
Счётчик попыток не требует переписывать окружение на каждом старте
-
Подтверждение слота — одна durable‑транзакция, а не последовательность из двух
-
У наблюдателя, который подтверждает слот, есть тот, кто его перезапускает. Один процесс, а не два независимых наблюдателя над одним слотом
-
Устройство, у которого userspace поднялся, а рабочая нагрузка — нет, не остаётся в этом состоянии навсегда. Проверьте руками: убейте нагрузку и подождите
-
Обновление опознаёт содержимое, а не номер версии. Пересоберите payload под тем же номером и убедитесь, что устройство действительно обновилось
-
Отчёт о версии читает то, что работает, а не то, что было велено поставить. Сверьте после отката, а не после успешной установки
Автор: redtom
