Обновления и версии: каналы, откат, поддержка старых
В пятницу вечером в разборе архивов вашего агента находят уязвимость. Исправление — одиннадцать строк и полчаса. Дальше начинается настоящая работа: агент стоит у 340 клиентов в девяти версиях; у сорока автообновление выключено политикой безопасности; версия 3.8 не переходит на 4.x напрямую, потому что между ними необратимая миграция локальной базы; три клиента подписали договор, где смена версии в проде происходит только после их внутренней сертификации длиной шесть недель; и, наконец, апдейтер в версиях до 3.6 умеет обновлять приложение, но не умеет обновлять сам себя.
Ни одна из этих проблем не про уязвимость. Все они — про решения, принятые за два года до пятницы: как нумеруются версии, кто решает, когда ставить новую, что происходит при неудаче, сколько веток вы обещали чинить и чем связаны в договоре. Эта глава — про то, как принять эти решения заранее и во что каждое обойдётся.
Рамка трека — в карте трека; от модели поставки из главы о моделях напрямую зависит, кто нажимает кнопку «обновить». Техника выката — канареечные релизы, blue-green, флаги — разобрана в devops и sre; здесь она используется, а не повторяется. Нас интересует не «как выкатить на свой прод», а «как довести версию до чужой машины, которой вы не управляете, и как забрать её обратно».
Обновление как обязательство: три колонки
| Что получает получатель | Во что обходится поставщику | Какое обязательство создаёт | |
|---|---|---|---|
| Автообновление по умолчанию | исправления без действий, одна версия в поддержке | канал, подпись, откат, метрики охвата | не сломать чужой прод молча; уметь остановить выкат за минуты |
| Обновление по решению получателя | контроль над своей средой и окном обслуживания | зоопарк живых версий, бэкпорты, воспроизведение багов | поддерживать заявленные версии заявленный срок |
| Принудительное обновление | защита от известной уязвимости | механизм блокировки старых клиентов и его отладка | не превращать защиту в рычаг продаж |
| Долгая поддержка (LTS) | предсказуемость на годы | вторая и третья линия сборки и тестов | доводить до объявленной даты, а не до потери интереса |
Мысль, которую стоит принять до чтения дальше: обновление — не функция продукта, а обязательство поставщика. Функцию можно не сделать; обязательство содержится годами и стоит денег каждый месяц. В юнит-экономике эта строка обычно отсутствует — поэтому маржа продуктов с самостоятельной установкой регулярно ниже расчётной.
Три разные вещи, которые называют «версией»
Самая дешёвая полезная привычка в этой теме — перестать говорить «версия» без уточнения. Их минимум три, они меняются с разной скоростью, и путаница между ними даёт самые дорогие инциденты.
| Что версионируется | Кто читает номер | Что означает несовместимость | Частота |
|---|---|---|---|
| Артефакт (сборка, пакет, образ) | установщик, апдейтер, админ | не установится или не запустится на этой платформе | каждый релиз |
| Протокол/API между вашими компонентами | клиент и сервер в рантайме | клиент не понимает ответ, сервер — запрос | редко, но больно |
| Формат данных на диске и в базе | сама программа при старте | старая версия не прочитает новые данные — а это ломает откат | ещё реже, необратимо |
Четвёртая примыкающая сущность — редакция условий (оферта, тарифы, политика конфиденциальности). Она версионируется по тем же правилам: у сделки фиксируется та редакция, что действовала на момент продажи (биллинг).
что видит чужой код?"} Q1 -->|"нет"| P["Патч артефакта"] Q1 -->|"да"| Q2{"Кто именно чужой?"} Q2 -->|"клиент по сети"| API["Версия протокола:
нужен период,
когда работают обе"] Q2 -->|"файлы и база"| DATA["Версия формата:
нужна миграция
и путь назад"] Q2 -->|"плагины и скрипты"| EXT["Версия точки расширения:
своя политика устаревания"] API --> Q3{"Старый может
работать дальше?"} DATA --> Q4{"Старая версия прочтёт
новые данные?"} Q3 -->|"да"| MINOR["Минорная:
добавили, не убрав"] Q3 -->|"нет"| MAJOR["Мажорная: объявляем
устаревание и срок"] Q4 -->|"да"| MINOR Q4 -->|"нет"| ONEWAY["Односторонняя дверь:
откат кода не вернёт данные"]
Правая нижняя ветка — самая важная во всей главе. Если новая версия переписала формат так, что предыдущая его не читает, откатить код вы больше не можете: только восстановить резервную копию, потеряв всё накопленное после обновления. Любая регрессия превращается из «откатились за две минуты» в инцидент с потерей данных. Отсюда правило: релиз, меняющий формат данных, и релиз, меняющий поведение, — два разных релиза, разнесённые во времени.
SemVer и CalVer: что вы обещаете номером
Семантическое версионирование — не формат номера, а публичный контракт: мажорная часть меняется при несовместимом изменении, минорная — при совместимом добавлении, патч — при исправлении. Полезен ровно настолько, насколько честно сказано, что именно охватывается контрактом.
- Для библиотеки это обычно экспортируемый API. Для приложения — почти никогда. Что считать ломающим изменением в редакторе? Формат файла — да. Убранный пункт меню? Он ломает чужой скрипт автоматизации, о котором вы не знали. Приложению полезнее зафиксировать список поверхностей совместимости и версионировать их явно.
- Ветка
0.x— не «ещё не готово», а формальное «контракта нет». Задерживаться в ней после первого платящего получателя нечестно: люди читают номер как обещание. - Номер версии и идентификатор сборки — разные поля, и в артефакте нужны оба:
4.4.0для человека,4.4.0+2026.07.16.a91f3c2для расследования. Восстановить, из какого коммита собрана версия у клиента, — задача на минуту, если это записано, и на день, если нет (цепочка поставки). - CalVer (
2026.07,24.04) честнее там, где главное обещание — не совместимость, а срок жизни: дистрибутивы, платформы, продукты для организаций. Из номера сразу видно, насколько версия устарела, и это ровно то, что нужно администратору. - Сравнение версий пишется один раз и переиспользуется: строковое сравнение считает
4.10.0младше4.9.0, разбор «по точкам» спотыкается на предрелизных суффиксах (1.0.0-rc.2обязан быть младше1.0.0), а метаданные сборки после плюса в сравнении не участвуют вовсе.
Окно совместимости: кто с кем обязан работать
Как только процессов стало больше одного, версии перестают меняться синхронно. Во время выката на вашем же кластере одновременно работают N и N+1 — это нормальный режим, а не исключение. У получателей с самостоятельной установкой разрыв измеряется годами.
Окно совместимости — письменное утверждение вида «сервер версии N обслуживает клиентов от N-2 до N+1», проверяемое в конвейере. Иначе это пожелание. Образец формулировки — политика расхождения версий Kubernetes, где отдельно указано, насколько узловой агент может отставать от API-сервера (https://kubernetes.io/releases/version-skew-policy/): цифры там менялись от релиза к релизу, ценна сама форма обязательства.
| Пара | Кто обязан быть терпимым | Практическое правило |
|---|---|---|
| Клиент новее сервера | сервер | обновляем сервер первым; клиент не использует новое, пока сервер не подтвердил поддержку |
| Клиент старее сервера | сервер | N-2 поддерживаем явно, ниже — внятный отказ, а не пятисотка |
| Две версии сервиса рядом при выкате | обе | ни один релиз не требует одновременной остановки всех экземпляров |
| Новая версия и старые данные | новая | миграция на старте, идемпотентная и прерываемая |
| Старая версия и новые данные | новая | поле добавляем, но не делаем обязательным целое окно поддержки |
Отсюда порядок операций, который стоит выучить наизусть: сначала расширяем, потом переключаем, потом убираем — и между «переключаем» и «убираем» проходит не спринт, а целое окно поддержки. Это parallel change Фаулера, применённый не к рефакторингу, а к поставке.
Каналы: способ ограничить радиус поражения
Канал — не наклейка «бета», а ответ на вопрос: сколько получателей пострадает, если этот билд плохой. Механика проста: у установки есть подписка на канал, сервер обновлений отдаёт по каналу свой «последний хороший» номер.
| Канал | Кто там | Что обещано | Скорость |
|---|---|---|---|
| dev / nightly | команда, автотесты, десяток энтузиастов | ничего, кроме «собирается» | каждый день |
| beta | добровольцы, внутренние стенды клиентов | близко к финальному, откат бесплатный | недели |
| stable | все остальные | всё, что заявлено в политике поддержки | по графику |
| extended / LTS | организации с окнами обслуживания | только исправления, никаких новых функций | месяцы |
Браузеры — наглядный публичный пример: параллельные каналы плюс отдельный расширенный стабильный для организаций (https://chromiumdash.appspot.com/schedule). Цена видна сразу: каждый канал — отдельная линия сборки, матрица тестов и поток багов. Три канала на команду из пяти человек почти всегда ошибка; два (stable плюс beta) окупаются.
Фазовый выкат: проценты, которые нельзя брать случайно
Внутри stable обновление отдают долями: 1, 5, 20, 50, 100 процентов. Магазины предоставляют механизм из коробки — у одних поэтапная публикация растянута на неделю фиксированными ступенями, у других процент задаётся вручную и выкат можно остановить (конкретные ступени и правила меняются, сверяйтесь с документацией площадки на момент чтения; общий разбор — в главе о магазинах). Если механизм ваш собственный, критично одно свойство: назначение когорты должно быть устойчивым. Случайный бросок на каждом запросе означает, что при расширении с 5 до 20 процентов часть уже обновившихся «выпадет» обратно.
import hashlib
def cohort(install_id: str, release: str) -> float:
"""Устойчивая доля от нуля до единицы для пары «установка + релиз».
Одна и та же установка при одном релизе всегда получает одно число, поэтому
расширение выката только ДОБАВЛЯЕТ получателей и никогда не отбирает обновление
у тех, кому его уже предложили. Соль включает номер релиза: иначе одни и те же
невезучие всегда идут первыми. Время O(1), память O(1).
"""
digest = hashlib.sha256(f"{release}:{install_id}".encode()).digest()
return int.from_bytes(digest[:8], "big") / 2 ** 64
def offer_update(install, release, rollout_percent: float) -> bool:
if install.channel != release.channel:
return False
if install.pinned_version: # админ зафиксировал версию — уважаем
return False
if release.halted: # рубильник важнее процентов
return False
if install.id in release.early_ring: # добровольцы всегда впереди
return True
return cohort(install.id, release.version) < rollout_percent / 100.0
«Ранний круг» добровольцев — не роскошь: проценты дают статистику, а люди, готовые к поломке и умеющие её описать, дают текст. Сами же ступени бессмысленны без критериев остановки, заданных до выката: доля установок, не подтвердивших успешный запуск; частота падений на новой версии против предыдущей с учётом доверительного интервала; всплеск обращений с упоминанием версии (самый ранний сигнал там, где телеметрии нет); рост доли откатившихся вручную — всё в тех же терминах, что бюджет ошибок. Рубильник «остановить» обязан быть отдельной операцией, доступной дежурному без релиза: если для остановки нужно собрать билд, вы будете останавливаться час.
Состояние «Остановлен» отделено от «Отозван» не из педантизма. Остановка стоит секунды и ничего не ломает; отзыв — это второй выкат в обратную сторону со своими рисками. Большинство инцидентов лечится остановкой: пострадал один процент, остальные не пострадают.
Как обновление физически доезжает
предложенная версия не ниже установленной alt текущая версия ниже minimum_required A->>A: режим «только обновление», работа заблокирована end A->>C: скачиваем артефакт или дельту от текущей версии C-->>A: байты A->>A: сверяем хеш, распаковываем в свободный слот A->>A: атомарно переключаем указатель, перезапускаем alt самопроверка прошла A->>A: слот помечен рабочим A->>U: телеметрия: версия установлена else самопроверка не прошла или процесс упал A->>A: загрузчик возвращает предыдущий слот A->>U: телеметрия: откат, код причины end
Манифест — документ, а не ссылка на файл. Минимальный честный набор полей:
# Ответ сервера обновлений установке 4.2.1, канал stable, платформа linux-amd64
schema: 1
channel: stable
latest: "4.4.0"
minimum_supported: "4.1.0" # ниже этой версии мы вообще не разговариваем
minimum_required: "4.2.3" # ниже этой версии работа блокируется: уязвимость
expires: "2026-07-23T00:00:00Z" # защита от подсовывания старого манифеста
sequence: 4471 # монотонный счётчик: назад не откатывается
upgrade_path: # через 4.3 не прыгать: там необратимая миграция
- { from_range: "<4.2.0", via: "4.2.7" }
- { from_range: ">=4.2.0", to: "4.4.0" }
artifact:
url: "https://updates.example.com/4.4.0/agent-linux-amd64.tar.zst"
size_bytes: 41203712
sha256: "3f7a…"
deltas: # дельты считаются от конкретных версий
"4.3.0": { url: "…/4.3.0-to-4.4.0.patch", size_bytes: 3118422, sha256: "9c1b…" }
"4.2.7": { url: "…/4.2.7-to-4.4.0.patch", size_bytes: 7740115, sha256: "aa04…" }
notes_url: "https://example.com/releases/4.4.0"
signature: "…" # подпись всего документа ключом релизов
Поля expires и sequence защищают не от абстрактного злоумышленника, а от двух конкретных атак
на канал: заморозки (получателю бесконечно отдают старый валидный манифест, чтобы он не узнал
о заплатке) и отката (подсовывают подписанную, но уязвимую версию). Систематически модель
угроз описана в The Update Framework; криптография — в
security, форматы пакетов и подпись
установщиков — в следующей главе трека.
Дельты экономят не трафик, а деньги. Разница между 40 мегабайтами и 3 на миллионе установок — это терабайты исходящего трафика, то есть заметная строка в счёте (модель расчёта — в платформенных затратах). Цена — комбинаторика: дельты генерируются от каждой поддерживаемой версии, хранятся и тестируются. Компромисс — дельты от двух-трёх массовых версий плюс полный артефакт как запасной путь.
Апдейтер обновляет всё, кроме себя. Классическая ловушка: механизм обновления живёт внутри приложения, а ошибка — именно в нём; доставить исправление нечем. Лечится разделением: маленький, редко меняющийся компонент обновления отдельно от приложения. Правило — чем реже меняется апдейтер, тем он ценнее, и любая «удобная фича» в нём это правило нарушает.
Схема со слотами родом из мобильных и встраиваемых систем — там она обязательна: устройство может
остаться без питания посреди записи, а физического доступа к нему нет (в Android это «бесшовные»
обновления с двумя наборами разделов, https://source.android.com/docs/core/ota/ab; во
встраиваемом Linux те же идеи в RAUC, SWUpdate, Mender). Полезна она везде: на сервере тот же
инвариант достигается дёшево — распаковать релиз в каталог releases/4.4.0, прогреть, атомарно
переставить символическую ссылку current, перезапустить; откат — переставить ссылку обратно.
Откат: что откатывается, а что нет
| Что откатываем | Механика | Время | Что может пойти не так |
|---|---|---|---|
| Поведение (флаг) | выключить флаг, код остаётся | секунды | флагов накопилось столько, что комбинацию в проде никто не знает |
| Код (артефакт) | предыдущий слот или образ | минуты | новая версия успела записать данные в новом формате |
| Данные (миграция) | резервная копия или обратная миграция | часы, с потерями | теряется всё, что произошло после обновления |
Отсюда практика, экономящая больше всего нервов: держите изменение поведения отдельно от изменения формата. Новая функциональность приезжает выключенной и включается флагом, откат делается флагом, а не бинарником (Feature Toggles). Тогда откат кода нужен редко, а откат данных — почти никогда. Для схемы данных тот же приём разворачивается в три отдельных релиза:
-- Релиз N (expand): добавляем, ничего не убирая. Версия N-1 колонки не знает и работает.
ALTER TABLE orders ADD COLUMN customer_email_norm text; -- без NOT NULL и без DEFAULT
CREATE INDEX CONCURRENTLY idx_orders_email_norm ON orders (customer_email_norm);
-- Релиз N (код): двойная запись, чтение ещё из старой колонки.
-- Откат на N-1 в этот момент бесплатен.
-- Между релизами: перенос истории пачками, без долгих транзакций.
UPDATE orders SET customer_email_norm = lower(btrim(customer_email))
WHERE customer_email_norm IS NULL AND id BETWEEN :lo AND :hi;
-- Релиз N+1 (код): читаем из новой, пишем по-прежнему в обе. Откат на N всё ещё бесплатен.
-- Релиз N+2 (contract): убираем старое — и только когда в поддержке
-- не осталось версий, которые её читают.
ALTER TABLE orders DROP COLUMN customer_email;
Ключевая деталь здесь не SQL, а календарь: между первым и последним шагом проходит всё окно
поддержки. Поэтому «expand/contract» так часто вырождается в «expand» — контракцию никто не
планирует, и через два года в схеме тридцать мёртвых колонок. Лечится организационным приёмом:
задача на шаг contract заводится с датой в момент шага expand (подробнее —
у Фаулера и в
databases).
выключающий поведение?"} F -->|"да"| FF["Выключить флаг:
минуты, без релиза"] F -->|"нет"| D{"Новая версия писала данные
в новом формате?"} D -->|"нет"| RB["Откат артефакта:
предыдущий слот или образ"] D -->|"да"| R{"Старая версия
их прочитает?"} R -->|"да, лишнее игнорирует"| RB R -->|"нет"| FW["Откат невозможен:
только исправление вперёд,
при потере данных —
восстановление из копии"] FF --> POST["Разбор без спешки"] RB --> POST FW --> POST POST --> L["Вопрос в разборе: почему изменение
формата приехало вместе с поведением?"]
Практическое следствие для коробочных и self-hosted продуктов: откат должен быть возможен на стороне получателя без вас. Инструкция «как вернуться на предыдущую версию» — такой же элемент поставки, как установщик. Если она начинается со слов «обратитесь в поддержку», при массовой регрессии поддержка ляжет первой.
Принудительное обновление и право не обновляться
Механика проста: сервер сообщает minimum_required, клиент ниже этой версии отказывается
работать и показывает экран обновления. Сложным здесь является не код, а решение, когда этим
пользоваться.
Оправданно: эксплуатируемая уязвимость в клиенте, особенно позволяющая навредить другим; изменение протокола, при котором старый клиент портит данные, а не просто не работает; требование площадки или регулятора со сроком; истечение объявленного заранее срока поддержки. Неоправданно: заставлять обновляться ради новой функциональности, ради удаления бесплатного тарифа, ради телеметрии или потому, что поддерживать старый код надоело, а сроков вы не объявляли. Кнопка одна и та же; разница только в том, что вы обещали.
Есть третий вариант, применяемый реже, чем стоило бы: деградация вместо блокировки. Старый клиент работает, но с предупреждением и без функций, которые сервер больше не поддерживает. Для многих продуктов это честнее полной блокировки и дешевле полной поддержки; логика частичной работоспособности — в sre.
Отдельный класс ограничений — внешние. Клиент, у которого версия проходит внутреннюю сертификацию, может быть по договору вправе оставаться на старой сборке; площадки, наоборот, регулярно требуют обновлять приложение под новые требования платформы, иначе снимают публикацию (у мобильных магазинов это ежегодные требования к целевому уровню API — см. магазины приложений и mobile).
Вопрос «имеем ли мы право отключить версию 3.x у клиента с годовым договором» — не инженерный. Инженерная работа здесь другая: заметить, что такой вопрос возник, и задать его юристу в пригодной для ответа форме. Плохой вопрос: «а можно нам отключить старую версию?» Хороший: «В договоре с клиентом X от такого-то числа сказано то-то про версии и сроки поддержки. Мы планируем прекратить обслуживание версий ниже 4.2 через 90 дней после письменного уведомления, потому что в них уязвимость такого класса. Достаточно ли уведомления на адрес из договора, обязаны ли мы предложить альтернативу и какой срок считается разумным?» Разница между формулировками — это разница между счётом за пять часов и ответом за двадцать минут. Живой пример того, как такие обязательства формулируются, лежит на самом портале: оферта, правила отмены и возврата и политика обработки персональных данных в разделе «Легальные документы» в подвале сайта.
Поддержка старых версий как строка бюджета
Каждая поддерживаемая ветка — отдельная линия сборки, отдельный набор тестов, отдельный поток бэкпортов и отдельная очередь багов.
Картинка выглядит аккуратно ровно до момента, когда исправление не переносится черри-пиком: в ветке 4.2 нет модуля, который успели отрефакторить, и заплатку приходится писать заново под старую архитектуру, а потом отдельно тестировать. Эта работа, а не сам перенос, и составляет основную стоимость.
def support_cost(branches_besides_main: int, fixes_per_month: float,
h_backport: float = 1.5, h_verify: float = 2.0,
share_rewrite: float = 0.3, rewrite_mult: float = 4.0) -> float:
"""Часы в месяц на поддержку старых веток. Считаем только безопасность и
критические ошибки: новые функции в старые ветки не переносятся. O(1)."""
simple = h_backport + h_verify
hard = h_backport * rewrite_mult + h_verify
avg = (1 - share_rewrite) * simple + share_rewrite * hard
return branches_besides_main * fixes_per_month * avg
print(round(support_cost(2, 6), 1)) # 63.0 часа в месяц при двух ветках и шести фиксах
# Это около 40 процентов ставки инженера — навсегда, каждый месяц.
Шестьдесят три часа в месяц — не «немного дополнительной работы», а строка бюджета, которую видно в цене продукта. Отсюда следует, что длительная поддержка старых версий — платная услуга, а не жест доброй воли: именно так она и продаётся у зрелых поставщиков. Модель цены для неё — в главе о ценообразовании.
| Политика | Формулировка | Кому подходит | Скрытая цена |
|---|---|---|---|
| N-1 | «поддерживается текущая и предыдущая минорная» | SaaS, быстрые продукты, внутренние платформы | требует, чтобы обновление было дёшево получателю, иначе он застрянет |
| LTS-линии | «каждая четвёртая версия живёт 24 месяца» | инфраструктура, self-hosted, продукты для организаций | две-три параллельные линии сборки и тестов |
| Календарный EOL | «версия 4.x поддерживается до 1 сентября 2028» | всё, что покупают по договору | дату нельзя двигать назад, только вперёд |
Порядок величин по публичным политикам (цифры меняются, проверяйте актуальные): линия LTS у Node.js живёт около 30 месяцев от первого релиза (https://github.com/nodejs/release); минорная версия Kubernetes получает исправления примерно год с небольшим хвостом (https://kubernetes.io/releases/patch-releases/); мажорная версия PostgreSQL — пять лет (https://www.postgresql.org/support/versioning/); LTS-выпуск Ubuntu — пять лет обычной поддержки с платным продлением сверх. Диапазон «год — пять лет» и есть интервал, внутри которого лежат разумные обещания. «Поддерживаем всё» в него не попадает: это бесконечность при нулевом бюджете.
В SaaS роль старой версии играет старая версия интерфейса, а не установленный артефакт. Механика
та же: объявленный срок, машиночитаемое предупреждение (заголовок Sunset,
RFC 8594), измеримый остаток трафика на устаревшем
эндпойнте и адресные письма тем, кто ещё на нём. Образец письменного обязательства — политика
устаревания API у Kubernetes, где заранее зафиксировано, сколько релизов проживёт объявленный
устаревшим объект (https://kubernetes.io/docs/reference/using-api/deprecation-policy/).
Хвост установок: метрики, которые считают за вас
| Метрика | Как считать | Зачем нужна |
|---|---|---|
| Время до 50 и до 90 процентов | дни от публикации до доли установок | реалистичный срок между expand и contract |
| Хвост | доля установок на версиях старше предыдущей на 12-й неделе | прямая оценка числа веток, которые придётся чинить |
| Время до заплатки, 95-й процентиль | от готовности исправления до установки у 95 процентов | ваш настоящий SLA по безопасности, а не тот, что на сайте |
| Доля неудачных обновлений | откаты и неподтверждённые запуски к числу попыток | качество самого канала обновлений |
| Доля установок после EOL | по телеметрии или по обращениям | сколько людей затронет отключение |
Хвост в 5–7 процентов — типичная картина для продукта, где автообновление можно выключить, и он почти не тает: стенды без интернета, зафиксированные политикой сборки, забытые установки, клиенты с согласованием обновлений. Планируйте не по среднему, а по хвосту. Как смотреть на распределения вместо средних — в performance; как связывать эти числа с решениями — в product-management. При этом сам факт «эта установка проверила обновления» — уже данные о получателе, и в некоторых юрисдикциях к ним есть требования: что вы собираете, зачем и на каком основании — вопрос главы про ограничения и трека security.
Как это выглядит в разных моделях поставки
| Модель | Кто решает, когда | Что тяжелее всего | Что обязательно сделать |
|---|---|---|---|
| SaaS | вы | старые интеграции и клиенты API | версионировать API, объявлять срок, мерить остаток трафика |
| Self-hosted | получатель | цепочки миграций через несколько версий | описать поддерживаемые пути обновления и проверять их в CI |
| Мобильное приложение | пользователь и магазин | ревью посередине, старые версии ОС | поэтапная публикация, minimum_required на сервере, план на отказ ревью |
| Десктоп | пользователь | права на запись, антивирусы, самообновление апдейтера | подписанный установщик, тихое обновление, ручной откат |
| Встраиваемое и IoT | вы, но устройство офлайн месяцами | питание, память, отсутствие доступа | A/B-слоты, watchdog, устойчивость к обрыву |
| Игры | магазин и консольная платформа | сертификация патчей, размер, синхронность релиза | планировать патч как отдельный релизный цикл |
Клиент, который два года не обновлялся, попытается перейти с 3.8 сразу на 4.4. Если между ними были необратимые миграции, честный ответ — «нельзя, сначала 4.2.7». Такие обязательные промежуточные релизы полезно называть явно (в Mozilla для этого есть термин watershed) и, главное, проверять автоматически: тест обновления с каждой поддерживаемой версии до текущей либо стоит в конвейере, либо не работает (тесты в CI). Ошибка, которую делают почти все, — тестировать обновление только с предыдущей версии. В игровых патчах к этому добавляются все ограничения сразу: сертификация платформы, размер обновления и обязательное совпадение версий клиента и сервера (дистрибуция игр).
Типичные ошибки
- Считать, что откат кода откатывает всё. Данные, отправленные письма и проведённые платежи он не откатывает; список необратимого составьте заранее.
- Один релиз, меняющий и поведение, и формат данных. Первая же регрессия ставит выбор между «жить со сломанным» и «терять данные».
- Случайная когорта при фазовом выкате. Расширение процента отбирает обновление у тех, кому его уже отдали, и метрики перестают что-либо значить.
- Отсутствие рубильника «остановить». Если остановка требует релиза, вы будете останавливаться час вместо минуты.
- Тестировать обновление только с предыдущей версии. Реальные получатели прыгают через четыре — ломается именно эта дорожка.
- Апдейтер, который умеет всё. Чем больше в нём логики, тем выше шанс, что чинить придётся именно его — тем самым, что сломан.
- Обещать поддержку без даты и принуждать к обновлению ради функций. Первое нельзя спланировать, второе обесценивает механизм принуждения к моменту настоящей уязвимости.
- Считать хвост исчезающим и не фиксировать, из какого коммита собрана версия. Хвост — постоянный бюджет, а расследование старого бага без ссылки на коммит начинается с археологии.
Мини-итог
Версия — не число, а обещание, и обещаний минимум три: артефакт, протокол и формат данных. Совместимость держится письменным окном «кто с кем обязан работать» и порядком «расширяем — переключаем — убираем», где между первым и последним шагом проходит всё окно поддержки. Каналы и фазовый выкат ограничивают число пострадавших и работают только при устойчивой когорте и заранее заданных критериях остановки. Откат бывает трёх видов и стоит по-разному: флаг — секунды, артефакт — минуты, данные — часы и потери; поэтому изменение формата всегда едет отдельным релизом. Принудительное обновление — инструмент для уязвимостей, а не для продаж, и там, где вопрос упирается в договор, работа инженера — задать юристу точный вопрос. А поддержка старых версий измеряется в человеко-часах в месяц: посчитайте их до того, как объявите срок.
Источники
- Semantic Versioning 2.0.0 — https://semver.org/lang/ru/; критический взгляд на версионирование как на контракт — доклад Rich Hickey «Spec-ulation», https://www.youtube.com/watch?v=oyLBGkS5ICk.
- The Update Framework — https://theupdateframework.io/: модель угроз канала обновлений, включая атаки заморозки и отката. Android A/B system updates — https://source.android.com/docs/core/ota/ab.
- Kubernetes: version skew policy, deprecation policy, patch releases — образцы письменных обязательств по совместимости и срокам.
- Node.js Release WG — https://github.com/nodejs/release; PostgreSQL versioning policy — https://www.postgresql.org/support/versioning/: публичные графики поддержки как жанр документа.
- Martin Fowler: ParallelChange, Evolutionary Database Design, Feature Toggles.
- RFC 8594, The Sunset HTTP Header Field — https://www.rfc-editor.org/rfc/rfc8594.html; Google SRE Book, Release Engineering — https://sre.google/sre-book/release-engineering/; Chromium release schedule — https://chromiumdash.appspot.com/schedule.
Что дальше
Мы разобрались, как версия доезжает до получателя и как её забрать обратно. Осталась форма, в которой она едет: пакет, образ, установщик — и подпись, без которой всё описанное выше не имеет смысла, потому что подменить можно любой канал.
Упаковка и установка: пакеты, контейнеры, инсталляторы, подпись