Почему облачный стриминг удаляет файлы сохранений и как создать持久ое хранилище, которое переживёт сессию
Коротко о главном
Разбираем, почему облачный стриминг удаляет сейвы, и показываем, как построить持久ое хранилище на S3 с лаунчер-скриптами и файловыми watcher для Amazon GameLift Streams.
Ваш игрок три часа фармит, сохраняет прогресс, закрывает стрим, возвращается на следующий день — и его сохранение исчезло. Это не баг в вашем игровом коде. И не ошибка пользователя. Стриминговый инстанс, который хранил его сейв, был завершён и заменён новым, забрав с собой каждый байт локальных данных.
Это фундаментальная архитектурная ловушка облачного игрового стриминга. Такие платформы, как Amazon GameLift Streams, оптимизируют затраты за счёт эфемерных вычислений: каждая сессия поднимает новый инстанс, запускает ваш игровой бинарник и уничтожает его по завершении сессии. Отлично для утилизации ресурсов. Ужасно для любой игры, которая пишет файлы на диск. Ваша система сохранений работает идеально — игра пишет %APPDATA%/YourGame/save.dat ровно как задумано — но сама файловая система временная.
В этом руководстве я разберу, что именно ломается, как это обнаружить, как построить持久ый слой сохранений на основе облачного объектного хранилища и сколько это реально стоит. Каждый скрипт ниже проверен в продакшене и готов к интеграции в ваш проект.
Что ломается: жизненный цикл эфемерного инстанса
Вот жизненный цикл типичной облачной стриминговой сессии и где он даёт сбой:
- Сессия начинается — платформа выделяет инстанс, загружает ваш игровой бинарник
- Игрок подключается — игра запускается, игрок начинает играть
- Игра пишет сохранения — файлы сейвов попадают на локальный диск (игра не знает, что этот диск временный)
- Сессия завершается — игрок отключается, инстанс завершается или перерабатывается
- Файлы уничтожаются — все локальные файлы стираются; следующая сессия начинается с нуля
Критическая точка отказа — шаги 4→5. Система сохранений вашей игры делает ровно то, что должна. Проблема в том, что платформа под ней относится к файловой системе как к расходнику. Это инфраструктурная проблема, маскирующаяся под игровой баг, и подобные проблемы жизненного цикла серверов — одна из самых частых причин, по которой игроки бросают облачные стриминговые проекты.
Как обнаружить эту проблему
Симптомы специфичны и повторяемы:
- Жалоба игрока: «Мои сохранения постоянно исчезают» — но только у облачных/стриминговых пользователей, никогда у владельцев локальных установок
- Нет краш-логов: игра работает безупречно; сейвов просто нет при следующем запуске
- Персистентность в рамках сессии: данные переживают одну сессию, но исчезают между сессиями
- Платформенная специфичность: проблема затрагивает только стриминговые инстансы, но не локальные сборки или выделенные серверы
Если ваши баг-репорты соответствуют этой картине, у вас проблема эфемерных инстансов. Никакое количество отладки на стороне игры её не решит — решение лежит в инфраструктурном слое.
Архитектура: объектное хранилище как持久ый слой
Решение концептуально простое: перестать полагаться на локальную файловую систему инстанса для долгосрочного хранения. Используйте耐久ное объектное хранилище (например, Amazon S3) как авторитетное место хранения сейвов. Скрипт-лаунчер прозрачно обрабатывает синхронизацию — ваш игровой код остаётся без изменений.
Процесс выглядит так:
- Перед запуском игры: загрузка существующего сейва из S3 → локальная файловая система
- Во время геймплея: мониторинг локального файла сейва на изменения → синхронизация обновлений в S3
- По завершении сессии: финальная синхронизация гарантирует сохранение всех данных до уничтожения инстанса
Ваша игра по-прежнему пишет на локальный диск обычным образом. Она не знает, что лаунчер зеркалирует эти записи в облачное хранилище. Такой подход с нулевой модификацией позволяет добавить持久ые сохранения в любую существующую игру, не трогая ни строчки игрового кода.
Шаг 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-сервис становится необходимым.
Лучшие практики
Ограничьте IAM-роли конкретными S3-префиксами — используйте
arn:aws:s3:::your-bucket/saves/*вместо прав на весь бакет. Это соответствует принципу наименьших привилегий и ограничивает ущерб при утечке учётных данных. Имя роли должно начинаться сGameLiftStreams-для соответствия требованиям платформы.Формируйте пер-плеерные S3-ключи из аутентифицированной идентичности — используйте
saves/{platform-user-id}/save.dat, никогдаsaves/{session-id}/save.dat. Session ID меняются между сессиями и навсегда осиротят данные сохранений.Внедряйте дебаунс-синхронизацию в продакшене — сырые файловые watcher генерируют PUT-запрос на каждое сохранение. Окно дебаунса в 5 секунд сокращает API-вызовы на 60-80%, удерживая риск потери данных в пределах одного интервала автосейва.
Логируйте каждую операцию синхронизации — сбои сохранений невидимы для игроков, пока они не потеряют часы прогресса. Паттерн
$LogFileв скриптах выше даёт вам持久ый аудит-трейл на инстансе. Отправляйте эти логи в вашу систему мониторинга для проактивных алертов.Тестируйте с завершением инстанса, а не только с отключением — убивайте стриминговый инстанс в середине игры, чтобы убедиться, что окно дебаунс-синхронизации приемлемо. Корректное отключение даёт финальной синхронизации время завершиться; резкое завершение — нет.
Короткий путь через BaaS: когда строить это самому не стоит
Описанная выше архитектура работает — она проверена в бою и стоит почти ничего. Но она требует управления IAM-ролями, скриптами-лаунчерами, файловыми watcher, проверками целостности, Linux-вариантами и конфигурацией под каждое окружение. Для небольшой команды это 2-4 дня инфраструктурной работы, которая не имеет отношения к вашей игре.
horizOn предоставляет持久ое хранилище данных игроков как управляемый сервис — файлы сейвов, инвентарь, прогресс, предпочтения — одним API-вызовом. Никакой настройки IAM, никаких скриптов-лаунчеров, никаких файловых watcher, никакой проверки целостности. Backend автоматически обрабатывает аутентификацию, разрешение конфликтов и кросс-платформенную избыточность. Для команд, которые хотят выпускать игру, а не отлаживать инфраструктуру, это устраняет целую категорию багов «работает локально, ломается в стриминге». Мы задокументировали полный процесс интеграции в нашем обзоре недавнего обновления backend.
Итоги
持久ые игровые сохранения в облачном стриминге требуют относиться к файловой системе инстанса как к эфемерной и использовать耐久ное объектное хранилище как источник истины. Полная архитектура:
- IAM-роль даёт стриминговым сессиям ограниченный S3 read/write доступ
- Скрипт-лаунчер скачивает существующие сейвы перед запуском игры
- Фоновый файловый watcher синхронизирует изменения в S3 во время геймплея с дебаунс-записями
- Backend-сервис формирует пер-плеерные S3-пути из аутентифицированной идентичности игрока
- Проверки целостности верифицируют корректность загрузки/выгрузки при каждой синхронизации
Стоимость в инди-масштабе — менее $3 в месяц для 10 000 активных игроков. Время внедрения — 1-2 дня, если строите сами, или 30 минут, если используете player data API от horizOn.
В любом случае, не выпускайте облачную стриминговую игру, не решив эту проблему. Ваши игроки не скажут вам, что их сохранения исчезают — они просто перестанут играть.
Источник: Adding persistent game saves to Amazon GameLift Streams