Назад к блогу

Почему облачный стриминг удаляет файлы сохранений и как создать持久ое хранилище, которое переживёт сессию

Опубликовано 7 августа 2026 г.
Почему облачный стриминг удаляет файлы сохранений и как создать持久ое хранилище, которое переживёт сессию Создано с помощью ИИ

Коротко о главном

Разбираем, почему облачный стриминг удаляет сейвы, и показываем, как построить持久ое хранилище на S3 с лаунчер-скриптами и файловыми watcher для Amazon GameLift Streams.

Ваш игрок три часа фармит, сохраняет прогресс, закрывает стрим, возвращается на следующий день — и его сохранение исчезло. Это не баг в вашем игровом коде. И не ошибка пользователя. Стриминговый инстанс, который хранил его сейв, был завершён и заменён новым, забрав с собой каждый байт локальных данных.

Это фундаментальная архитектурная ловушка облачного игрового стриминга. Такие платформы, как Amazon GameLift Streams, оптимизируют затраты за счёт эфемерных вычислений: каждая сессия поднимает новый инстанс, запускает ваш игровой бинарник и уничтожает его по завершении сессии. Отлично для утилизации ресурсов. Ужасно для любой игры, которая пишет файлы на диск. Ваша система сохранений работает идеально — игра пишет %APPDATA%/YourGame/save.dat ровно как задумано — но сама файловая система временная.

В этом руководстве я разберу, что именно ломается, как это обнаружить, как построить持久ый слой сохранений на основе облачного объектного хранилища и сколько это реально стоит. Каждый скрипт ниже проверен в продакшене и готов к интеграции в ваш проект.

Что ломается: жизненный цикл эфемерного инстанса

Вот жизненный цикл типичной облачной стриминговой сессии и где он даёт сбой:

  1. Сессия начинается — платформа выделяет инстанс, загружает ваш игровой бинарник
  2. Игрок подключается — игра запускается, игрок начинает играть
  3. Игра пишет сохранения — файлы сейвов попадают на локальный диск (игра не знает, что этот диск временный)
  4. Сессия завершается — игрок отключается, инстанс завершается или перерабатывается
  5. Файлы уничтожаются — все локальные файлы стираются; следующая сессия начинается с нуля

Критическая точка отказа — шаги 4→5. Система сохранений вашей игры делает ровно то, что должна. Проблема в том, что платформа под ней относится к файловой системе как к расходнику. Это инфраструктурная проблема, маскирующаяся под игровой баг, и подобные проблемы жизненного цикла серверов — одна из самых частых причин, по которой игроки бросают облачные стриминговые проекты.

Как обнаружить эту проблему

Симптомы специфичны и повторяемы:

  • Жалоба игрока: «Мои сохранения постоянно исчезают» — но только у облачных/стриминговых пользователей, никогда у владельцев локальных установок
  • Нет краш-логов: игра работает безупречно; сейвов просто нет при следующем запуске
  • Персистентность в рамках сессии: данные переживают одну сессию, но исчезают между сессиями
  • Платформенная специфичность: проблема затрагивает только стриминговые инстансы, но не локальные сборки или выделенные серверы

Если ваши баг-репорты соответствуют этой картине, у вас проблема эфемерных инстансов. Никакое количество отладки на стороне игры её не решит — решение лежит в инфраструктурном слое.

Архитектура: объектное хранилище как持久ый слой

Решение концептуально простое: перестать полагаться на локальную файловую систему инстанса для долгосрочного хранения. Используйте耐久ное объектное хранилище (например, Amazon S3) как авторитетное место хранения сейвов. Скрипт-лаунчер прозрачно обрабатывает синхронизацию — ваш игровой код остаётся без изменений.

Процесс выглядит так:

  1. Перед запуском игры: загрузка существующего сейва из S3 → локальная файловая система
  2. Во время геймплея: мониторинг локального файла сейва на изменения → синхронизация обновлений в S3
  3. По завершении сессии: финальная синхронизация гарантирует сохранение всех данных до уничтожения инстанса

Ваша игра по-прежнему пишет на локальный диск обычным образом. Она не знает, что лаунчер зеркалирует эти записи в облачное хранилище. Такой подход с нулевой модификацией позволяет добавить持久ые сохранения в любую существующую игру, не трогая ни строчки игрового кода.

Шаг 1: Настройка IAM-роли

Вам нужна IAM-роль, которая даёт стриминговым сессиям ограниченный доступ к вашему S3-бакету. Роль должна доверять стриминговому сервису и следовать принципу наименьших привилегий.

Создайте роль с таким trust policy (замените [ACCOUNT_ID] на ваш AWS-аккаунт):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "gameliftstreams.amazonaws.com"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "aws:SourceAccount": "[ACCOUNT_ID]"
        },
        "ArnLike": {
          "aws:SourceArn": "arn:aws:gameliftstreams:*:[ACCOUNT_ID]:streamsession/*"
        }
      }
    }
  ]
}

Затем прикрепите инлайн-политику, дающую только те S3-операции, которые вам нужны:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::your-save-bucket/saves/*"
    }
  ]
}

Критически важно: ограничьте Resource конкретным префиксом вроде /saves/* — никогда не давайте доступ ко всему бакету. Это ограничивает радиус поражения при компрометации роли и соответствует лучшим практикам безопасности. Имя роли должно начинаться с GameLiftStreams- согласно требованиям платформы.

Шаг 2: Скрипт-лаунчер (Windows)

Скрипт-лаунчер — это оркестратор. Он скачивает существующие сейвы, запускает игру и запускает фоновый watcher файлов для синхронизации изменений. Вот версия для Windows batch:

@echo off
setlocal

set SCRIPT_DIR=%~dp0

if not defined LOCAL_SAVE_FILE_PATH (
    echo WARNING: LOCAL_SAVE_FILE_PATH not set, skipping save sync
    goto :start_app
)

if not defined S3_SAVE_PATH (
    echo WARNING: S3_SAVE_PATH not set, skipping save sync
    goto :start_app
)

set LOG_FILE=%SCRIPT_DIR%save_sync.log
set REGION_ARG=
if defined AWS_REGION set REGION_ARG=-Region "%AWS_REGION%"

REM Download existing save from S3
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%download-save.ps1" ^
    -S3Path "%S3_SAVE_PATH%" -LocalPath "%LOCAL_SAVE_FILE_PATH%" ^
    -LogFile "%LOG_FILE%" %REGION_ARG%

REM Start background file watcher
start "" /b powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass ^
    -File "%SCRIPT_DIR%watch-save.ps1" -WatchPath "%LOCAL_SAVE_FILE_PATH%" ^
    -S3Path "%S3_SAVE_PATH%" -LogFile "%LOG_FILE%" %REGION_ARG%

:start_app
REM Replace with your actual game executable:
YourGame.exe -f

Две переменные окружения (S3_SAVE_PATH и LOCAL_SAVE_FILE_PATH) передаются через ваш backend-сервис при вызове StartStreamSession. Путь в S3 обязательно должен быть уникальным для каждого игрока — формируйте его из аутентифицированного player ID, например s3://your-bucket/saves/{player-id}/save.dat. Никогда не используйте session ID — они меняются между сессиями и осиротят данные сохранений.

Шаг 3: Скрипт загрузки

Этот PowerShell-скрипт проверяет S3 на наличие существующего файла сейва и скачивает его, если он найден:

param(
    [Parameter(Mandatory=$true)][string]$S3Path,
    [Parameter(Mandatory=$true)][string]$LocalPath,
    [Parameter(Mandatory=$true)][string]$LogFile,
    [Parameter(Mandatory=$false)][string]$Region
)

$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Add-Content -Path $LogFile -Value "$timestamp - Checking for save at: $S3Path"

$regionArgs = @()
if (-not [string]::IsNullOrWhiteSpace($Region)) {
    $regionArgs = @('--region', $Region)
}

# Ensure local directory exists
$localDir = Split-Path $LocalPath -Parent
if (-not (Test-Path $localDir)) {
    New-Item -ItemType Directory -Path $localDir -Force | Out-Null
}

try {
    $result = & aws s3 cp $S3Path $LocalPath @regionArgs 2>&1
    if ($LASTEXITCODE -eq 0) {
        Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Downloaded save from S3"
    } else {
        Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - No existing save found (first session?)"
    }
} catch {
    Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Download error: $_"
}

Для новых игроков без существующего сейва копирование из S3 завершится ошибкой — это ожидаемо. Игра создаст новый файл сейва, и watcher его подхватит.

Шаг 4: Watcher файлов

Это компонент, который поддерживает синхронизацию сейвов во время геймплея:

param(
    [Parameter(Mandatory=$true)][string]$WatchPath,
    [Parameter(Mandatory=$true)][string]$S3Path,
    [Parameter(Mandatory=$true)][string]$LogFile,
    [Parameter(Mandatory=$false)][string]$Region
)

$timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
Add-Content -Path $LogFile -Value "$timestamp - Watching for changes: $WatchPath"

$regionArgs = @()
if (-not [string]::IsNullOrWhiteSpace($Region)) {
    $regionArgs = @('--region', $Region)
}

$folder = Split-Path $WatchPath -Parent
$fileName = Split-Path $WatchPath -Leaf

if (-not (Test-Path $folder)) {
    New-Item -ItemType Directory -Path $folder -Force | Out-Null
}

try {
    $watcher = New-Object System.IO.FileSystemWatcher
    $watcher.Path = $folder
    $watcher.Filter = $fileName
    $watcher.NotifyFilter = [System.IO.NotifyFilters]::LastWrite -bor `
                            [System.IO.NotifyFilters]::Size
    $watcher.EnableRaisingEvents = $true

    while ($true) {
        $change = $watcher.WaitForChanged(
            [System.IO.WatcherChangeTypes]::All, 1000
        )
        if (-not $change.TimedOut) {
            $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
            Add-Content -Path $LogFile -Value "$ts - Change detected: $($change.ChangeType)"

            try {
                $s3Result = & aws s3 cp $WatchPath $S3Path @regionArgs 2>&1
                if ($LASTEXITCODE -eq 0) {
                    Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Synced to S3"
                } else {
                    Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Sync failed: $s3Result"
                }
            } catch {
                Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Sync error: $_"
            }
        }
    }
} catch {
    Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Watcher crashed: $_"
}

Шаг 5: Вариант для Linux/Proton

Для Linux-стриминговых инстансов замените PowerShell-watcher на inotifywait:

#!/bin/bash
# watch-save.sh — Linux equivalent using inotify-tools

WATCH_PATH="$1"
S3_PATH="$2"
LOG_FILE="$3"

echo "$(date -Iseconds) - Watching: $WATCH_PATH" >> "$LOG_FILE"

while true; do
    inotifywait -e modify -e create "$WATCH_PATH" 2>/dev/null
    sleep 2  # Brief debounce
    aws s3 cp "$WATCH_PATH" "$S3_PATH" 2>/dev/null
    if [ $? -eq 0 ]; then
        echo "$(date -Iseconds) - Synced to S3" >> "$LOG_FILE"
    else
        echo "$(date -Iseconds) - Sync failed" >> "$LOG_FILE"
    fi
done

Разбор затрат: реальные цифры

Давайте посчитаем, сколько реально стоит эта архитектура:

S3 PUT-запросы (операции записи)

Если ваша игра делает автосейв каждые 30 секунд, типичная 2-часовая сессия генерирует примерно 240 PUT-запросов. При цене S3 $0.005 за 1 000 запросов:

  • Стоимость за сессию: $0.0012
  • Стоимость за 10 000 игровых сессий в месяц: $1.20

S3 GET-запросы (операции чтения)

Один GET-запрос на старт сессии для загрузки существующих сейвов:

  • Стоимость за 10 000 сессий: $0.40

Хранилище S3

Средний файл сейва: 5 МБ на игрока. Для 10 000 активных игроков в месяц:

  • Общий объём хранилища: ~50 ГБ
  • Ежемесячная стоимость при $0.023/ГБ: $1.15

Итого ежемесячная стоимость для 10 000 MAU

Менее $3 в месяц. Это ничтожно по сравнению с вычислительными затратами. Слой S3 практически бесплатен в масштабах инди.

Продакшен-оптимизация: дебаунс-синхронизация

Сырой файловый watcher срабатывает на каждую запись. Игра с частыми автосейвами (каждые 10-15 секунд) будет генерировать избыточные PUT-запросы. Добавьте задержку дебаунса — ждите 5 секунд после последнего изменения перед синхронизацией:

# Debounce logic — add to the change handler in watch-save.ps1
$lastSync = [DateTime]::MinValue
$debounceSeconds = 5

# Inside the WaitForChanged loop, replace the sync block:
if (-not $change.TimedOut) {
    $now = Get-Date
    if (($now - $lastSync).TotalSeconds -ge $debounceSeconds) {
        & aws s3 cp $WatchPath $S3Path @regionArgs 2>&1
        $lastSync = $now
    }
}

Это сокращает PUT-запросы на 60-80% с минимальным риском потери данных. Даже если инстанс завершится в середине дебаунса, вы потеряете максимум 5 секунд прогресса — приемлемо для большинства игр.

Крайние случаи и сценарии отказов

Файл сейва ещё не существует (новые игроки)

Шаг загрузки завершится ошибкой — это корректное поведение. Игра создаёт новый сейв, и файловый watcher подхватывает его при первой записи. Никакой специальной обработки не требуется.

Повреждённая загрузка из-за обрыва сети

Если сеть падает во время PUT-запроса, объект в S3 может быть неполным. Смягчите это проверкой ETag после загрузки:

# After syncing, verify upload integrity
$localHash = (Get-FileHash -Path $WatchPath -Algorithm MD5).Hash.ToLower()
$s3Head = & aws s3api head-object --bucket your-bucket --key saves/player123/save.dat 2>&1
$s3ETag = ($s3Head | ConvertFrom-Json).ETag.Trim('"')

if ($localHash -ne $s3ETag) {
    Add-Content -Path $LogFile -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - INTEGRITY MISMATCH - re-uploading"
    & aws s3 cp $WatchPath $S3Path @regionArgs 2>&1
}

Несколько слотов сохранений

Если ваша игра поддерживает несколько файлов сохранений, следите за директорией вместо одного файла. Установите $watcher.Filter в * и синхронизируйте всю директорию сейвов. При большом количестве файлов архивируйте их в один .zip перед загрузкой.

Лимиты размера файлов

S3 поддерживает объекты до 5 ТБ, так что размер файла не является практической проблемой. Большинство игровых сейвов весят от 500 КБ до 50 МБ. Если вы работаете с процедурно генерируемыми мирами с огромным состоянием (>500 МБ), рассмотрите сжатие gzip перед загрузкой — типичные данные сейвов сжимаются до 20-40% от исходного размера.

Игрок играет на нескольких устройствах

Если игрок стримит с разных устройств в один день, сейв последней сессии перезаписывает предыдущий. Для большинства одиночных игр это корректное поведение. Для игр, которым нужна семантика слияния (например, облачная синхронизация инвентаря), понадобится логика разрешения конфликтов — вот здесь выделенный backend-сервис становится необходимым.

Лучшие практики

  1. Ограничьте IAM-роли конкретными S3-префиксами — используйте arn:aws:s3:::your-bucket/saves/* вместо прав на весь бакет. Это соответствует принципу наименьших привилегий и ограничивает ущерб при утечке учётных данных. Имя роли должно начинаться с GameLiftStreams- для соответствия требованиям платформы.

  2. Формируйте пер-плеерные S3-ключи из аутентифицированной идентичности — используйте saves/{platform-user-id}/save.dat, никогда saves/{session-id}/save.dat. Session ID меняются между сессиями и навсегда осиротят данные сохранений.

  3. Внедряйте дебаунс-синхронизацию в продакшене — сырые файловые watcher генерируют PUT-запрос на каждое сохранение. Окно дебаунса в 5 секунд сокращает API-вызовы на 60-80%, удерживая риск потери данных в пределах одного интервала автосейва.

  4. Логируйте каждую операцию синхронизации — сбои сохранений невидимы для игроков, пока они не потеряют часы прогресса. Паттерн $LogFile в скриптах выше даёт вам持久ый аудит-трейл на инстансе. Отправляйте эти логи в вашу систему мониторинга для проактивных алертов.

  5. Тестируйте с завершением инстанса, а не только с отключением — убивайте стриминговый инстанс в середине игры, чтобы убедиться, что окно дебаунс-синхронизации приемлемо. Корректное отключение даёт финальной синхронизации время завершиться; резкое завершение — нет.

Короткий путь через BaaS: когда строить это самому не стоит

Описанная выше архитектура работает — она проверена в бою и стоит почти ничего. Но она требует управления IAM-ролями, скриптами-лаунчерами, файловыми watcher, проверками целостности, Linux-вариантами и конфигурацией под каждое окружение. Для небольшой команды это 2-4 дня инфраструктурной работы, которая не имеет отношения к вашей игре.

horizOn предоставляет持久ое хранилище данных игроков как управляемый сервис — файлы сейвов, инвентарь, прогресс, предпочтения — одним API-вызовом. Никакой настройки IAM, никаких скриптов-лаунчеров, никаких файловых watcher, никакой проверки целостности. Backend автоматически обрабатывает аутентификацию, разрешение конфликтов и кросс-платформенную избыточность. Для команд, которые хотят выпускать игру, а не отлаживать инфраструктуру, это устраняет целую категорию багов «работает локально, ломается в стриминге». Мы задокументировали полный процесс интеграции в нашем обзоре недавнего обновления backend.

Итоги

持久ые игровые сохранения в облачном стриминге требуют относиться к файловой системе инстанса как к эфемерной и использовать耐久ное объектное хранилище как источник истины. Полная архитектура:

  1. IAM-роль даёт стриминговым сессиям ограниченный S3 read/write доступ
  2. Скрипт-лаунчер скачивает существующие сейвы перед запуском игры
  3. Фоновый файловый watcher синхронизирует изменения в S3 во время геймплея с дебаунс-записями
  4. Backend-сервис формирует пер-плеерные S3-пути из аутентифицированной идентичности игрока
  5. Проверки целостности верифицируют корректность загрузки/выгрузки при каждой синхронизации

Стоимость в инди-масштабе — менее $3 в месяц для 10 000 активных игроков. Время внедрения — 1-2 дня, если строите сами, или 30 минут, если используете player data API от horizOn.

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


Источник: Adding persistent game saves to Amazon GameLift Streams