DevOps

Откат при неудачном обновлении (rollback)

Введение

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

Концепция

QtIFW не предоставляет полностью автоматического, готового «из коробки» механизма откат к точно предыдущей версии после уже завершённого обновления (в отличие от некоторых специализированных корпоративных систем развёртывания) — реализация надёжного rollback обычно строится на комбинации нескольких приёмов: резервное копирование критичных файлов/конфигурации перед началом обновления (через кастомный JavaScript-скрипт компонента, выполняемый перед операциями обновления), проверка целостности и базовой работоспособности после обновления (smoke test, запускаемый maintenance tool или самим приложением при первом старте после обновления), и явная логика восстановления из резервной копии при обнаружении неудачи.

Пример кода: Откат при неудачном обновлении (rollback)

// installscript.qs — резервное копирование перед обновлением компонента
function Component()
{
    component.beginInstallation.connect(this, Component.prototype.onBeginInstallation);
}

Component.prototype.onBeginInstallation = function()
{
    if (installer.isUpdater() || installer.isPackageManager()) {
        var targetDir = installer.value("TargetDir");
        var backupDir = targetDir + "_backup_" + new Date().getTime();

        // Создаём резервную копию текущей установки перед применением обновления
        component.addOperation("Copy", targetDir, backupDir);
    }
};
// startup_healthcheck.h — проверка работоспособности при первом запуске после обновления
#pragma once
#include <QSettings>
#include <QCoreApplication>
#include <QDir>
#include <QProcess>

class StartupHealthCheck
{
public:
    static bool wasJustUpdated()
    {
        QSettings settings;
        const QString lastKnownGoodVersion = settings.value("update/lastKnownGoodVersion").toString();
        return lastKnownGoodVersion != QCoreApplication::applicationVersion();
    }

    static void markCurrentVersionAsHealthy()
    {
        QSettings settings;
        settings.setValue("update/lastKnownGoodVersion", QCoreApplication::applicationVersion());
        settings.setValue("update/consecutiveCrashCount", 0);
    }

    static void recordCrashAndCheckRollbackNeeded()
    {
        QSettings settings;
        int crashCount = settings.value("update/consecutiveCrashCount", 0).toInt();
        crashCount++;
        settings.setValue("update/consecutiveCrashCount", crashCount);

        // Если новая версия крашится несколько раз подряд сразу после обновления —
        // это сильный сигнал для автоматического отката к предыдущей версии
        if (crashCount >= 3) {
            triggerRollback();
        }
    }

private:
    static void triggerRollback()
    {
        QSettings settings;
        const QString backupPath = settings.value("update/lastBackupPath").toString();
        if (backupPath.isEmpty() || !QDir(backupPath).exists()) {
            qWarning() << "Резервная копия для отката не найдена";
            return;
        }

        qWarning() << "Обнаружены повторные сбои после обновления, выполняется откат к резервной копии";
        // Реальная логика восстановления файлов из backupPath на место текущей установки —
        // выполняется отдельным вспомогательным процессом, поскольку основное
        // приложение не может перезаписать собственные исполняемые файлы во время своей работы
    }
};
// main.cpp — интеграция проверки в точку входа приложения
int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    if (StartupHealthCheck::wasJustUpdated()) {
        qDebug() << "Обнаружен первый запуск после обновления, отслеживаем стабильность";
    }

    // Через 30 секунд успешной работы без краша считаем версию здоровой
    QTimer::singleShot(30000, []() {
        StartupHealthCheck::markCurrentVersionAsHealthy();
    });

    return app.exec();
}

Пояснения к коду

onBeginInstallation() в скрипте установщика проверяет, выполняется ли текущая операция именно в контексте обновления (installer.isUpdater()), а не первоначальной установки — и если так, добавляет операцию резервного копирования текущей директории установки перед применением изменений; путь резервной копии включает временную метку, что позволяет потенциально сохранять несколько последовательных резервных копий, если процесс обновления выполняется многократно без очистки старых бэкапов (что само по себе требует отдельной политики ротации, не показанной в упрощённом примере).

StartupHealthCheck реализует паттерн «детектора повторяющихся крашей сразу после обновления» — recordCrashAndCheckRollbackNeeded() должна вызываться из глобального обработчика необработанных исключений или сигналов краша (вне рамок этой статьи, но концептуально похоже на сохранение состояния перед крашем) при каждом аварийном завершении, и если счётчик последовательных крашей превышает порог, инициируется откат. Критичная деталь — фактическое восстановление файлов из резервной копии не может быть выполнено самим работающим приложением (поскольку нельзя перезаписать собственные исполняемые файлы во время их выполнения на большинстве ОС), и реальная реализация triggerRollback() должна запускать отдельный, независимый вспомогательный процесс именно для этой операции, что обозначено комментарием в примере как концептуальное место для такой логики.

Подводные камни

  • Резервное копирование всей директории установки перед каждым обновлением значительно увеличивает требуемое временное дисковое пространство и время выполнения процесса обновления, особенно для крупных приложений — без явной политики очистки старых резервных копий после успешного подтверждения стабильности новой версии, диск пользователя может постепенно заполняться накопленными бэкапами от множества последовательных обновлений.
  • Детектирование «краша» только через подсчёт неуспешных завершений без различения типов проблем. Не каждый краш сразу после обновления связан именно с обновлением — пользователь может столкнуться с краша по совершенно отдельной причине (повреждённый файл данных пользователя, проблема с другим, не связанным с обновлением компонентом системы), и автоматический откат версии в этом случае не решит реальную проблему, а лишь откатит приложение без устранения настоящей причины сбоя.
  • Race condition между записью lastKnownGoodVersion и реальным крашем, если приложение крашится до того, как успело пометить себя как «здоровое» (например, крашится через 25 секунд после старта, а порог здоровья установлен в 30 секунд) — выбор подходящего временного порога для подтверждения «здоровья» версии — это компромисс между слишком ранним подтверждением (которое могло не успеть выявить медленно проявляющиеся проблемы) и слишком поздним (которое увеличивает окно, в течение которого нормальное закрытие приложения пользователем может быть неверно интерпретировано как незавершённый цикл проверки здоровья).
  • Сложность реализации полностью автоматического, надёжного rollback существенно выше, чем кажется на первый взгляд, и для многих продуктов более практичным компромиссом оказывается не полностью автоматический откат, а упрощённая альтернатива — явное предложение пользователю (через уведомление при обнаружении проблем) вручную переустановить предыдущую известную стабильную версию из отдельно сохранённого офлайн-установщика, что требует значительно меньшей инфраструктурной сложности при сохранении большей части практической пользы для конечного пользователя.