Powrót do Bloga

Dlaczego cloud streaming usuwa pliki zapisu i jak zbudować trwały storage, który przetrwa

Opublikowano 7 sierpnia 2026
Dlaczego cloud streaming usuwa pliki zapisu i jak zbudować trwały storage, który przetrwa Wygenerowano przy użyciu AI

W skrócie

Dowiedz się, dlaczego cloud streaming usuwa pliki zapisu i jak zbudować trwały storage, który przetrwa dzięki S3 — z gotowymi skryptami i kosztami.

Twój gracz grinduje przez trzy godziny, zapisuje postępy, zamyka strumień, wraca następnego dnia — a jego zapis zniknął. To nie bug w kodzie gry. To nie błąd użytkownika. Instancja strumieniowania, która przechowywała plik zapisu, została zakończona i zastąpiona nową, zabierając ze sobą każdy bajt lokalnych danych.

To jest fundamentalna pułapka architektoniczna cloud gamingu. Platformy takie jak Amazon GameLift Streams optymalizują koszty, używając efemerycznych zasobów obliczeniowych: każda sesja uruchamia nową instancję, odpala binarkę gry i usuwa ją po zakończeniu sesji. Świetnie dla wykorzystania zasobów. Fatalnie dla każdej gry, która zapisuje pliki na dysku. Twój system zapisu działa idealnie — gra zapisuje %APPDATA%/YourGame/save.dat dokładnie tak, jak zaprojektowano — ale sam system plików jest tymczasowy.

W tym runbooku pokażę dokładnie, co się psuje, jak to wykryć, jak zbudować trwałą warstwę zapisu przy użyciu cloud object storage i ile to naprawdę kosztuje. Każdy skrypt poniżej jest przetestowany w produkcji i gotowy do wdrożenia w twoim projekcie.

Co się psuje: cykl życia efemerycznej instancji

Oto cykl życia typowej sesji cloud streamingu i miejsce, w którym następuje awaria:

  1. Sesja startuje — Platforma provisionuje instancję, wgrywa binarkę gry
  2. Gracz się łączy — Gra się uruchamia, gracz zaczyna grać
  3. Gra zapisuje sejwy — Pliki zapisu trafiają na lokalny dysk (gra nie ma pojęcia, że ten dysk jest tymczasowy)
  4. Sesja się kończy — Gracz się rozłącza, instancja jest kończona lub recyklingowana
  5. Pliki zniszczone — Wszystkie lokalne pliki są kasowane; następna sesja startuje od zera

Krytyczny punkt awarii to krok 4→5. System zapisu twojej gry robi dokładnie to, co powinien. Problem polega na tym, że platforma pod spodem traktuje system plików jako jednorazowy. To problem infrastrukturalny udający buga rozgrywki, a problemy z cyklem życia serwerów to jeden z najczęstszych powodów, dla których gracze porzucają tytuły w cloud streamingu.

Jak wykryć ten problem

Objawy są konkretne i powtarzalne:

  • Zgłoszenia graczy: „Mój zapis ciągle znika" — ale tylko u użytkowników cloud/streaming, nigdy przy instalacjach lokalnych
  • Brak logów crashy: Gra działa bez zarzutu; zapisów po prostu nie ma przy następnym uruchomieniu
  • Trwałość ograniczona do sesji: Dane przetrwają w obrębie jednej sesji, ale znikają między sesjami
  • Specyficzne dla platformy: Dotyczy tylko instancji streamingowych, nie buildów lokalnych ani dedicated server

Jeśli twoje zgłoszenia bugów pasują do tego wzorca, masz problem efemerycznej instancji. Żadna ilość debugowania po stronie gry tego nie naprawi — rozwiązanie leży w warstwie infrastruktury.

Architektura: object storage jako trwała warstwa

Naprawa jest koncepcyjnie prosta: przestań polegać na lokalnym systemie plików instancji w przypadku długoterminowego przechowywania. Użyj trwałego serwisu object storage (takiego jak Amazon S3) jako autorytatywnego miejsca zapisu. Skrypt launcher obsługuje synchronizację w sposób przezroczysty — kod twojej gry pozostaje bez zmian.

Przepływ wygląda tak:

  1. Przed uruchomieniem gry: Pobierz istniejący zapis z S3 → lokalny system plików
  2. Podczas rozgrywki: Monitoruj lokalny plik zapisu pod kątem zmian → synchronizuj aktualizacje do S3
  3. Na koniec sesji: Finalna synchronizacja zapewnia utrwalenie wszystkich danych przed zniszczeniem instancji

Twoja gra nadal zapisuje normalnie na lokalny dysk. Nie ma pojęcia, że launcher lustruje te zapisy do chmury. To podejście zero-modification oznacza, że możesz dodać trwałe zapisy do dowolnej istniejącej gry bez dotykania ani jednej linijki kodu gry.

Krok 1: Skonfiguruj rolę IAM

Potrzebujesz roli IAM, która daje sesjom streamingowym ograniczony dostęp do twojego bucketa S3. Rola musi ufać serwisowi streamingowemu i przestrzegać zasad least-privilege.

Utwórz rolę z tą polityką zaufania (zastąp [ACCOUNT_ID] swoim ID konta 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/*"
        }
      }
    }
  ]
}

Następnie dołącz politykę inline przyznającą tylko operacje S3, których potrzebujesz:

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

Krytyczne: Ogranicz Resource do konkretnego prefiksu, np. /saves/* — nigdy do całego bucketa. To ogranicza promień rażenia w przypadku kompromitacji roli i jest zgodne z najlepszymi praktykami bezpieczeństwa. Nazwa roli musi zaczynać się od GameLiftStreams- zgodnie z wymaganiami platformy.

Krok 2: Skrypt launcher (Windows)

Skrypt launcher jest orkiestratorem. Pobiera istniejące zapisy, uruchamia grę i odpala w tle obserwatora plików, który synchronizuje zmiany. Oto wersja batch dla Windows:

@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

Dwie zmienne środowiskowe (S3_SAVE_PATH i LOCAL_SAVE_FILE_PATH) są przekazywane przez twój backend, gdy wywołuje StartStreamSession. Ścieżka S3 musi być unikalna dla gracza — zbuduj ją z uwierzytelnionego ID gracza, np. s3://your-bucket/saves/{player-id}/save.dat. Nigdy nie używaj ID sesji; te zmieniają się między sesjami i osierociłyby dane zapisu.

Krok 3: Skrypt pobierania

Ten skrypt PowerShell sprawdza S3 pod kątem istniejącego pliku zapisu i pobiera go, jeśli istnieje:

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: $_"
}

Dla graczy grających pierwszy raz, bez istniejącego zapisu, kopiowanie z S3 zakończy się błędem — to oczekiwane. Gra utworzy nowy plik zapisu, a obserwator go wychwyci.

Krok 4: Obserwator plików

To komponent, który utrzymuje synchronizację zapisów podczas rozgrywki:

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: $_"
}

Krok 5: Wariant Linux/Proton

Dla instancji streamingowych opartych na Linuksie zastąp obserwatora PowerShell narzędziem 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

Analiza kosztów: konkretne liczby

Policzmy, ile naprawdę kosztuje utrzymanie tej architektury:

Żądania PUT S3 (operacje zapisu)

Jeśli twoja gra robi autosave co 30 sekund podczas rozgrywki, typowa 2-godzinna sesja generuje około 240 żądań PUT. Przy cenniku S3 wynoszącym 0,005 USD za 1 000 żądań:

  • Koszt na sesję: 0,0012 USD
  • Koszt na 10 000 sesji graczy miesięcznie: 1,20 USD

Żądania GET S3 (operacje odczytu)

Jedno żądanie GET na start sesji w celu pobrania istniejących zapisów:

  • Koszt na 10 000 sesji: 0,40 USD

Pamięć S3

Średni plik zapisu: 5 MB na gracza. Dla 10 000 aktywnych graczy miesięcznie:

  • Łączna pamięć: ~50 GB
  • Koszt miesięczny przy 0,023 USD/GB: 1,15 USD

Łączny koszt miesięczny dla 10 000 MAU

Poniżej 3 USD miesięcznie. To wartość pomijalna w porównaniu z kosztami obliczeniowymi. Warstwa S3 jest praktycznie darmowa w skali indie.

Optymalizacja produkcyjna: synchronizacja z debounce

Surowy obserwator plików odpala się przy każdym zapisie. Gra, która robi autosave często (co 10-15 sekund), będzie generować nadmierną liczbę żądań PUT. Dodaj opóźnienie debounce — poczekaj 5 sekund po ostatniej zmianie przed synchronizacją:

# 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
    }
}

To redukuje liczbę żądań PUT o 60-80% przy minimalnym ryzyku utraty danych. Nawet jeśli instancja zostanie zakończona w trakcie debounce, tracisz maksymalnie 5 sekund postępów — akceptowalne dla większości gier.

Przypadki brzegowe i tryby awarii

Plik zapisu jeszcze nie istnieje (gracze pierwszorazowi)

Krok pobierania zakończy się błędem — to poprawne zachowanie. Gra tworzy nowy zapis, a obserwator plików wychwytuje go przy pierwszym zapisie. Nie jest potrzebna żadna specjalna obsługa.

Uszkodzony upload w wyniku przerwania sieci

Jeśli sieć zrywa się podczas żądania PUT, obiekt S3 może być niekompletny. Można to ograniczyć, weryfikując ETag po uploadzie:

# 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
}

Wiele slotów zapisu

Jeśli twoja gra obsługuje wiele plików zapisu, obserwuj katalog zamiast pojedynczego pliku. Ustaw $watcher.Filter na * i synchronizuj cały katalog zapisów. Przy dużej liczbie plików spakuj je do jednego .zip przed wysłaniem.

Limity rozmiaru pliku zapisu

S3 obsługuje obiekty do 5 TB, więc rozmiar pliku nie jest praktycznym problemem. Większość zapisów gier ma od 500 KB do 50 MB. Jeśli masz do czynienia z proceduralnie generowanymi światami z ogromnym stanem (>500 MB), rozważ kompresję gzip przed wysłaniem — typowe dane zapisu kompresują się do 20-40% pierwotnego rozmiaru.

Gracz gra na wielu urządzeniach

Jeśli gracz streamuje z różnych urządzeń tego samego dnia, zapis z ostatniej sesji nadpisuje poprzedni. Dla większości gier single-player to poprawne zachowanie. W grach wymagających semantyki scalania (jak synchronizowany w chmurze ekwipunek) potrzebna będzie logika rozwiązywania konfliktów — i tu dedykowany backend staje się niezbędny.

Najlepsze praktyki

  1. Ogranicz role IAM do konkretnych prefiksów S3 — Użyj arn:aws:s3:::your-bucket/saves/* zamiast uprawnień obejmujących cały bucket. To zgodne z zasadą least-privilege i ogranicza szkody w przypadku wycieku poświadczeń. Nazwa roli musi zaczynać się od GameLiftStreams-, aby spełnić wymagania platformy.

  2. Buduj klucze S3 per gracz na podstawie uwierzytelnionej tożsamości — Użyj saves/{platform-user-id}/save.dat, nigdy saves/{session-id}/save.dat. ID sesji zmieniają się między sesjami i trwale osierociłyby dane zapisu.

  3. Wdróż synchronizację z debounce w produkcji — Surowe obserwatory systemu plików generują żądanie PUT przy każdym zapisie. Okno debounce wynoszące 5 sekund ogranicza wywołania API o 60-80%, utrzymując ryzyko utraty danych poniżej jednego interwału autosave.

  4. Loguj każdą operację synchronizacji — Błędy zapisu są niewidoczne dla graczy, dopóki nie stracą godzin postępów. Wzorzec $LogFile w skryptach powyżej daje trwały ślad audytowy na instancji. Wysyłaj te logi do systemu monitorowania, aby otrzymywać proaktywne alerty.

  5. Testuj przez zakończenie instancji, nie tylko rozłączenie — Zabij instancję streamingową w trakcie gry, aby zweryfikować, czy okno synchronizacji z debounce jest akceptowalne. Łagodne rozłączenie daje finalnej synchronizacji czas na zakończenie; nagłe zakończenie już nie.

Skrót przez BaaS: kiedy budowanie tego samemu nie ma sensu

Opisana architektura działa — jest sprawdzona w boju i kosztuje prawie nic w utrzymaniu. Wymaga jednak zarządzania rolami IAM, skryptami launcher, obserwatorami plików, kontrolami integralności, wariantami Linuksa i konfiguracją per środowisko. Dla małego zespołu to 2-4 dni pracy nad infrastrukturą, która nie ma nic wspólnego z samą grą.

horizOn zapewnia trwałe przechowywanie danych gracza jako usługę zarządzaną — pliki zapisu, ekwipunek, postępy, preferencje — za pomocą jednego wywołania API. Bez konfiguracji IAM, bez skryptów launcher, bez obserwatorów plików, bez weryfikacji integralności. Backend automatycznie obsługuje uwierzytelnianie, rozwiązywanie konfliktów i redundancję międzyplatformową. Dla zespołów, które chcą wydawać grę zamiast debugować infrastrukturę, eliminuje całą kategorię bugów „działa lokalnie, psuje się w streamingu". Pełny proces integracji opisaliśmy w naszym omówieniu aktualizacji backendu.

Podsumowanie

Trwałe zapisy w cloud streamingu wymagają traktowania systemu plików instancji jako efemerycznego i używania trwałego object storage jako źródła prawdy. Kompletna architektura:

  1. Rola IAM daje sesjom streamingowym ograniczony dostęp do odczytu/zapisu S3
  2. Skrypt launcher pobiera istniejące zapisy przed uruchomieniem gry
  3. Obserwator plików w tle synchronizuje zmiany do S3 podczas rozgrywki z zapisami z debounce
  4. Backend buduje ścieżki S3 per gracz na podstawie uwierzytelnionej tożsamości
  5. Kontrole integralności weryfikują poprawność uploadu/downloadu przy każdej synchronizacji

Koszt w skali indie to poniżej 3 USD miesięcznie dla 10 000 aktywnych graczy. Czas wdrożenia to 1-2 dni, jeśli budujesz to sam, albo 30 minut, jeśli użyjesz API danych graczy horizOn.

Tak czy inaczej, nie wydawaj gry w cloud streamingu bez rozwiązania tego problemu. Twoi gracze nie powiedzą ci, że ich zapisy znikają — po prostu przestaną grać.


Źródło: Dodawanie trwałych zapisów gier do Amazon GameLift Streams