Масштабирование CDN для игровых ассетов: runbook по выживанию при пиковых нагрузках в день запуска
Коротко о главном
Узнайте, как масштабировать CDN для игровых ассетов и пережить пиковые нагрузки в день запуска без сбоев, потери игроков и простоев инфраструктуры.
Ваш CDN не выдержит — вот как понять, когда
Каждый игровой разработчик боится одного и того же сценария в день запуска: ваша страница в Steam становится публичной, число игроков переваливает за 10 000 одновременных, и внезапно загрузка текстур застревает на p99-латентности 200 мс вместо обычных 12 мс. Игроки сообщают о пропавших моделях. Загрузка патча зависает на 43%. Панель мониторинга краснеет, и вы понятия не имеете, какой слой отказывает.
Это не гипотетический сценарий. cdnjs — одна из самых широко используемых открытых CDN-сетей в мире — недавно завершила полную миграцию инфраструктуры на Cloudflare Developer Platform, чтобы обрабатывать 9 миллиардов запросов в день. История этой миграции раскрывает архитектурные паттерны, которые напрямую применимы к доставке игровых ассетов, где одно обновление 4K-текстур может породить терабайты трафика за считанные минуты.
Главный урок: масштабирование CDN для игровых ассетов — это не покупка дополнительной пропускной способности. Это проектирование иерархии кэшей, логики отката и origin shielding, чтобы пики трафика становились рутиной, а не сбоями.
Этот runbook описывает, что ломается при насыщении CDN, как обнаружить насыщение до того, как ваш Discord заполнится гневными сообщениями, как устранять проблемы в продакшене и как проектировать архитектуру для предотвращения повторений.
Что ломается, когда ваш CDN насыщается
Доставка игровых ассетов имеет уникальный профиль трафика по сравнению со стандартным веб-контентом. Чтобы понять сценарии отказов, нужно разобраться в этом профиле.
Проблема формы трафика
Типичная инди-игра с мультиплеером имеет такие паттерны трафика:
- Базовый уровень: 50–200 запросов/сек для ассетов лобби, UI-спрайтов и конфигурационных JSON
- Пик в день патча: 15 000–80 000 запросов/сек в течение 3-минутного окна, когда Steam запускает автообновления
- Региональные каскады: игроки из Азиатско-Тихоокеанского региона обращаются к CDN через 8–12 часов после NA, создавая вторую волну
- Взрыв версий ассетов: каждый патч инвалидирует кэшированные объекты, вынуждая origin отдавать новые хэши
Когда cdnjs мигрировала на инфраструктуру Cloudflare, она столкнулась с похожей проблемой взрыва версий. Их версионирование в стиле npm означало, что каждое обновление библиотеки создавало новые ключи кэша, а при 4 200+ библиотеках, обновляемых ежедневно, архитектура origin shielding должна была выдерживать постоянную ротацию кэша, а не только статический контент.
Три режима отказа
1. Насыщение origin-запросов
Когда ваш edge-кэш даёт промах (новый патч, холодный кэш, истечение срока), каждый запрос попадает на ваш origin-сервер. Один origin с пропускной способностью 1 Гбит/с может обслуживать примерно 1 250 одновременных загрузок ассетов по 1 МБ. При 80 000 одновременных игроков, каждый из которых скачивает патч на 2 ГБ, нужна мощность origin, которой у большинства инди-проектов просто нет.
2. Cache Stampede (лавина кэша)
Когда самый востребованный ассет исчезает из edge-кэша (неверная конфигурация TTL, purge при деплое), тысячи edge-узлов одновременно запрашивают один и тот же объект с origin. Это проблема «бегущего стада» (thundering herd), и она роняет origin за секунды.
3. Региональное голодание edge-узлов
Ваши edge-узлы в NA прогреты. Ваш edge-узел в Сингапуре имеет 60% попаданий в кэш, потому что у вас всего 12 000 игроков в APAC — пока японский YouTube-блогер не покажет вашу игру, и это число не вырастет до 300 000 за ночь. Edge-узел тянет данные с origin в огромных масштабах, и игроки в APAC получают время загрузки 2–4 секунды, тогда как игроки в NA видят 40 мс.
Сигналы обнаружения
# Cloudflare API: check cache hit ratio by region (run every 60 seconds)
curl -s -X POST "https://api.cloudflare.com/client/v4/graphql" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query": "{
viewer {
zones(filter: {zoneTag: \"YOUR_ZONE\"}) {
httpRequests1hGroups(limit: 24, filter: {date_gt: \"2025-01-01\"}) {
dimensions { datetime, cacheStatus, clientCountryName }
sum { requests, bytes }
}
}
}
}"
}' | jq '.data.viewer.zones[0].httpRequests1hGroups[] |
select(.dimensions.cacheStatus == "miss") |
{region: .dimensions.clientCountryName, misses: .sum.requests}'
Если процент промахов кэша превышает 8% в любом регионе в период стабильного состояния, вы в одном патче от потопа запросов к origin.
Немедленное устранение: что делать прямо сейчас
Когда CDN горит, у вас есть 15 минут до того, как игроки начнут заваливать игру негативными обзорами. Вот последовательность действий при триаже.
Шаг 1: Включите Origin Shielding
Большинство CDN-провайдеров предлагают функцию «origin shield» или «shielding» — промежуточный кэширующий слой между вашими edge-узлами и origin. Вместо того чтобы 200 edge-узлов независимо обращались к origin при промахе кэша, только shield-узел связывается с origin и распространяет ответ.
Пример конфигурации (обобщённый CDN API):
{
"shielding": {
"enabled": true,
"shield_region": "us-east-1",
"fallback_shield_region": "eu-west-1",
"shield_ttl_override": 86400,
"pass_on_shield_error": false
}
}
Одно это изменение может снизить нагрузку на origin на 95% во время cache stampede. Миграция cdnjs опиралась на аналогичную логику shielding — их origin-серверы увидели снижение с миллионов прямых запросов до нескольких тысяч запросов через shield в час.
Шаг 2: Увеличьте TTL для статического контента
Ваши 4K-текстуры, аудиобанки и меш-файлы не меняются между патчами. Нет причин для TTL в 1 час.
# nginx origin server: aggressive caching for immutable game assets
location /assets/v*/ {
# Version-prefixed paths mean new versions get new URLs
# No need to purge — old URLs stay cached forever
add_header Cache-Control "public, max-age=31536000, immutable";
add_header CDN-Cache-Control "max-age=31536000";
}
# Short TTL only for manifest files that change each patch
location /manifest.json {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
}
Ключевая мысль из архитектуры cdnjs: версионируйте ассеты в пути URL, а не через query-строки. Многие CDN-узлы считают ?v=2 и ?v=3 одним и тем же ключом кэша. Вместо этого используйте /assets/v2/texture_pack.bin.
Шаг 3: Включите Stale-While-Revalidate
Это самая эффективная настройка для трафика в день запуска. Когда срок действия кэшированного ассета истекает, CDN отдаёт устаревшую версию запросившему игроку, одновременно загружая свежую версию в фоне. Игрок получает ответ за 12 мс вместо 1 200 мс.
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
Это говорит CDN: «Этот ассет свежий в течение 1 часа. После этого отдавай устаревшую версию до 24 часов, пока в фоне происходит ревалидация».
Для игровых ассетов, которые не критичны с точки зрения безопасности (фоны лобби, превью косметики, аудио-стелы), это безопасно и значительно снижает воспринимаемую задержку.
Шаг 4: Реализуйте запасные сценарии с circuit breaker
Если origin CDN действительно перегружен, ваш игровой клиент должен иметь путь плавной деградации, а не застывший экран загрузки.
// C# Unity: CDN circuit breaker with local fallback
public class AssetLoader
{
private const int MAX_RETRIES = 3;
private const int TIMEOUT_MS = 5000;
private static int _failureCount = 0;
private static DateTime _circuitOpened = DateTime.MinValue;
private static readonly TimeSpan CIRCUIT_RESET = TimeSpan.FromMinutes(2);
public async Task<byte[]> LoadAsset(string assetPath)
{
// Circuit breaker: skip CDN if recent failures exceeded threshold
if (_failureCount >= MAX_RETRIES &&
DateTime.UtcNow - _circuitOpened < CIRCUIT_RESET)
{
Debug.LogWarning($"CDN circuit open — loading {assetPath} from local cache");
return LoadFromLocalStorage(assetPath);
}
try
{
using var client = new HttpClient { Timeout = TimeSpan.FromMilliseconds(TIMEOUT_MS) };
var response = await client.GetAsync($"https://cdn.yourgame.com/{assetPath}");
response.EnsureSuccessStatusCode();
_failureCount = 0; // Reset on success
return await response.Content.ReadAsByteArrayAsync();
}
catch (Exception ex)
{
_failureCount++;
if (_failureCount >= MAX_RETRIES)
_circuitOpened = DateTime.UtcNow;
Debug.LogWarning($"CDN fetch failed ({_failureCount}/{MAX_RETRIES}): {ex.Message}");
return LoadFromLocalStorage(assetPath);
}
}
private byte[] LoadFromLocalStorage(string assetPath)
{
// Ship a minimal "emergency asset pack" with your game binary
// This covers the 20 most critical assets: UI, default textures, lobby music
var localPath = Path.Combine(Application.streamingAssetsPath, "fallback", assetPath);
return File.Exists(localPath) ? File.ReadAllBytes(localPath) : Array.Empty<byte>();
}
}
Этот паттерн гарантирует, что ваша игра останется работоспособной, даже если CDN полностью ляжет. Игроки могут видеть текстуры низкого разрешения несколько минут, но они всё ещё смогут играть.
Профилактика: многоуровневая архитектура кэширования
Своевременное устранение спасает вас в день запуска. Архитектура предотвращает саму необходимость в нём.
Трёхуровневый паттерн
Миграция cdnjs на Cloudflare Workers продемонстрировала архитектуру кэширования, которая масштабируется до миллиардов запросов. В адаптации для игровых ассетов:
Уровень 1 — Edge-кэш (CDN PoPs)
- Обслуживает 95–99% запросов
- TTL: 365 дней для версионированных ассетов, 60 секунд для манифестов
- Покрывает текстуры, меши, аудио, шейдеры
Уровень 2 — Shield/промежуточный кэш
- Перехватывает промахи кэша от edge-узлов
- TTL: как у edge, но выступает прокси для origin
- Снижает нагрузку на origin на 95%+
Уровень 3 — Origin-сервер
- Генерирует ассеты, подписывает URL, отдаёт манифесты
- Защищён ограничением частоты запросов и shielding
- Должен видеть <0.1% всего объёма трафика
Конвейер версионированных ассетов
Вот рабочий процесс версионирования ассетов, который предотвращает штормы инвалидации кэша:
# Python: asset pipeline that generates cache-safe versioned URLs
import hashlib
import json
import os
def build_asset_manifest(asset_dir: str, cdn_base: str) -> dict:
"""
Walk asset directory, hash each file, and produce a manifest
with versioned URLs that CDN edge nodes can cache forever.
"""
manifest = {"version": "", "assets": {}}
for root, _, files in os.walk(asset_dir):
for filename in sorted(files):
filepath = os.path.join(root, filename)
relative_path = os.path.relpath(filepath, asset_dir)
# Content hash — identical files get identical URLs
with open(filepath, "rb") as f:
file_hash = hashlib.sha256(f.read()).hexdigest()[:12]
# Version in the PATH, not query string
# CDN treats /assets/a3f9b2c1e8d4/texture.bin as a unique object
versioned_url = f"{cdn_base}/assets/{file_hash}/{relative_path}"
manifest["assets"][relative_path] = {
"url": versioned_url,
"hash": file_hash,
"size": os.path.getsize(filepath),
}
# Manifest version = hash of the entire asset set
all_hashes = "".join(
a["hash"] for a in sorted(manifest["assets"].values(), key=lambda x: x["url"])
)
manifest["version"] = hashlib.sha256(all_hashes.encode()).hexdigest()[:16]
return manifest
# Usage
manifest = build_asset_manifest("./build/assets", "https://cdn.yourgame.com")
with open("./build/manifest.json", "w") as f:
json.dump(manifest, f, indent=2)
print(f"Manifest version: {manifest['version']}")
print(f"Total assets: {len(manifest['assets'])}")
# Output:
# Manifest version: a8f3e1c92b4d7061
# Total assets: 2,847
При таком подходе:
- Старые ассеты никогда не удаляются. Они остаются в edge-кэше бессрочно, потому что имеют уникальные URL.
- Новые ассеты получают новые URL. CDN автоматически кэширует их при первом запросе.
- Единственный файл, который меняется, — это манифест. Небольшой JSON-файл с TTL 60 секунд.
Именно так cdnjs обрабатывает версионирование библиотек в масштабе. Каждая версия библиотеки получает уникальный путь URL, поэтому CDN никогда не требуется операция purge — самая дорогая и подверженная ошибкам операция в CDN.
Этот архитектурный паттерн особенно важен, если вы запускаете выделенные серверы (dedicated servers), которым нужно отдавать конфигурационные данные вместе с игровой логикой. Как мы рассказывали в нашем руководстве по освоению ассет-стриппинга для выделенных серверов Unreal Engine, разделение статических ассетов и критичных для сервера данных — это базовая оптимизация, которая даёт нарастающий эффект в масштабе.
Географическое распределение: решение проблемы региональных каскадов
Миграция cdnjs показала, что сырое количество edge-узлов менее важно, чем интеллектуальная маршрутизация. Иметь 300 PoPs бессмысленно, если логика маршрутизации при промахе кэша отправляет запросы из APAC на origin в США.
Умный выбор origin
{
"origin_rules": [
{
"name": "us-primary",
"origin_server": "origin-us.yourgame.com",
"regions": ["NA", "SA"],
"health_check": "/health",
"failover_origin": "origin-eu.yourgame.com"
},
{
"name": "eu-primary",
"origin_server": "origin-eu.yourgame.com",
"regions": ["EU", "AF"],
"health_check": "/health",
"failover_origin": "origin-us.yourgame.com"
},
{
"name": "apac-primary",
"origin_server": "origin-apac.yourgame.com",
"regions": ["AS", "OC"],
"health_check": "/health",
"failover_origin": "origin-us.yourgame.com"
}
]
}
Региональные origin-серверы стоят $20–40 в месяц каждый у крупных облачных провайдеров. Три региональных origin обходятся дешевле, чем один инцидент, когда ваш NA-origin обслуживает трафик APAC в течение 4 часов с деградацией производительности — и потерянные игроки, которые с этим приходят.
Такая архитектура мультирегионального failover перекликается с тем, что мы обсуждаем в нашем анализе архитектуры zero-waste серверов со стратегиями гибернации — принцип не платить за простаивающую инфраструктуру, оставаясь готовым к масштабированию.
Лучшие практики масштабирования CDN для игровых ассетов
1. Версионируйте ассеты в пути URL, а не в query-строках.
/assets/{hash}/texture.bin гарантирует уникальность кэша. ?v=2 — нет: многие CDN-узлы удаляют query-параметры из ключей кэша, что приводит к устаревшему контенту или сломанным кэшам.
2. Разделяйте TTL манифеста и TTL ассетов.
Манифесты должны иметь TTL 30–60 секунд с stale-while-revalidate. Ассеты — TTL 1 год с immutable. Это различие — разница между плавным раскатыванием патча и cache stampede.
3. Поставляйте запасной пакет ассетов вместе с бинарником игры. 50–100 самых критичных ассетов (UI, стандартный скин, окружение лобби) должны лежать внутри установленной игры как аварийный пакет на 200–500 МБ. Ваша логика circuit breaker переключается на них, когда CDN недоступен.
4. Отслеживайте процент попаданий в кэш по регионам, а не глобально. Глобальные 97% попаданий могут скрывать 72% в Юго-Восточной Азии. Мониторинг по регионам позволяет заметить региональное голодание edge-узлов до того, как игроки начнут сообщать об инциденте.
5. Нагружайте CDN до запуска, а не во время. Используйте инструменты вроде k6, Locust или Vegeta, чтобы симулировать ожидаемый паттерн трафика в день запуска против вашего CDN-endpoint. 10-минутный тест с 50 000 виртуальных пользователей, бьющих по вашему манифесту и топ-20 ассетов, выявит неверные TTL, отсутствующий shielding и узкие места origin до того, как это сделают реальные игроки.
# k6: simulate 50,000 concurrent players hitting the asset manifest
cat <<'EOF' > cdn_load_test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 10000 }, // Ramp to 10K VUs
{ duration: '3m', target: 50000 }, // Spike to 50K VUs
{ duration: '5m', target: 50000 }, // Sustain
{ duration: '2m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<200'], // 95th percentile under 200ms
http_req_failed: ['rate<0.01'], // Less than 1% errors
},
};
export default function () {
const manifestRes = http.get('https://cdn.yourgame.com/manifest.json');
check(manifestRes, {
'manifest status 200': (r) => r.status === 200,
'manifest under 100ms': (r) => r.timings.duration < 100,
'cache HIT': (r) => r.headers['Cf-Cache-Status'] === 'HIT',
});
// Simulate a player downloading 5 random assets
for (let i = 0; i < 5; i++) {
const assetPath = `assets/placeholder_${Math.floor(Math.random() * 100)}/mesh.bin`;
const assetRes = http.get(`https://cdn.yourgame.com/${assetPath}`);
check(assetRes, {
'asset under 500ms': (r) => r.timings.duration < 500,
});
}
sleep(1);
}
EOF
k6 run cdn_load_test.js
Когда строить самостоятельно, а когда использовать платформу
Построить полную многоуровневую архитектуру кэширования, описанную выше, вполне реально для команды с выделенными инфраструктурными инженерами. Компоненты хорошо документированы, а CDN-провайдеры предоставляют базовые примитивы.
Но если ваша команда — это три разработчика, выпускающие игру, потратить 4–6 недель на создание origin shielding, регионального failover, конвейеров версионирования ассетов и логики circuit breaker в клиенте означает 4–6 недель, не потраченных на геймплей. horizOn берёт на себя инфраструктуру доставки ассетов как часть своего backend-стека, давая вам то же мультирегиональное кэширование и автоматический failover без операционной нагрузки. Вы загружаете ассеты — платформа из коробки обрабатывает версионирование, распределение по edge-узлам и мониторинг здоровья.
Архитектурные принципы из этой статьи остаются критически важными независимо от выбора инфраструктуры. Понимание того, почему важны версионированные пути URL, почему stale-while-revalidate предотвращает лавины, и почему региональные origin снижают задержку, позволяет принимать обоснованные решения — настраиваете ли вы Cloudflare Workers вручную или оцениваете управляемый backend-сервис.
Следующий шаг: запустите нагрузочный тест перед следующим патчем
Выберите дату следующего патча. За две недели запустите приведённый выше k6-скрипт против вашего CDN-endpoint. Если p95-латентность превышает 200 мс при симулированном масштабе запуска, у вас есть время это исправить. Если вы обнаружите, что процент попаданий в кэш падает ниже 90% во время фазы устойчивой нагрузки, включите origin shielding и увеличьте TTL ассетов.
Разница между плавным запуском и катастрофой в день запуска редко заключается в игровом коде. Это инфраструктура, которая отдаёт 2 ГБ ассетов, скачиваемых каждым игроком в первые пять минут. Сделайте это правильно — и остальное будет геймплеем.