Обновление контента игровых клубов. Отказ от внешнего S3-провайдера. Стоимость и механика

от автора

Как мы раздаём 500 ГБ игрового контента на 260 ПК в сети игровых клубов — и почему в какой‑то момент пришлось отказаться от внешнего S3-провайдера.

Реальный кейс: как из ручного хаоса с флешками и «у кого что скачалось» выросла централизованная система обновления игрового контента, во что она обошлась на облачном хранилище, и как одно инфраструктурное изменение убрало эту статью расходов практически в ноль.

TL;DR

  • У внешнего S3-провайдера при активном использовании основная часть расходов — не хранение данных, а их раздача (egress).

  • Раздать 500 ГБ контента на 260 игровых ПК = ~200 000 ₽ за полное обновление. Это неприемлемо. А с учётом того, что вес контента и количество игровых клубов — величины динамические — неприемлемо вдвойне.

  • Решение: подняли свое MinIO (S3-совместимое) + Caddy на офисном ПК с гигабитным безлимитным каналом.

  • Так как мы изначально работали через S3 API, миграция свелась к смене одной переменной окружения.

  • Внешний S3 остался — но только как холодный бэкап, а не как источник раздачи.

  • Результат: затраты на раздачу контента → практически 0. Ценой осознанных trade‑off’ов, о которых ниже.

Оглавление

Контекст

Введение в мой сайд‑проект.

Примечание: я работаю со всей системой один — в качестве архитектора, фронтендера, бэкендера, админа БД, DevOps и так далее. Мои решения по стеку приняты исходя из моих скилов и баланса скорости/качества разработки. Писал всё с нуля один. Уверен, что можно лучше. Но это тема другой статьи. Тут работаем с тем, что имеем.

Всю систему можно разделить на три основные части, которые общаются между собой:

  1. Игровой лаунчер — приложение на Electron. Что‑то типа Steam, но нацеленный конкретно на нашу экосистему. Он крутится на игровых ПК в клубах (и не только) под Windows. Даёт возможность запустить с необходимыми стартовыми параметрами игры (например, Assetto Corsa, Assetto Corsa Competizione и так далее), следит за актуальностью игрового контента, является точкой входа в нашу экосистему для клиента, пингует основной сервер (heartbeat каждые 60 секунд), пишет телеметрию игроков и делает много всего еще.

  2. Центр управления (далее ЦУ) — основной сервер со всей бизнес‑логикой. Написан на NestJS. Работает на выделенном VDS (Ubuntu). Это ядро всей архитектуры: все лаунчеры общаются с ним по API. Он управляет каталогом контента, обновлениями самих лаунчеров, сбором и фиксацией всех данных по клиентам (сбор локальной телеметрии с каждого ПК, сбор данных с игровых серверов и так далее). На отдельном VDS еще БД крутится, но это не столь важно в рамках этой статьи.

  3. Админка — админка на Vue для наблюдения и управления всей системой.

Что качается: контент для игр — треки, машины, моды. Полный набор — около 500 ГБ на момент написания статьи. В любое время может добавиться пачка пользовательских карт и машин. И каждый из 260 ПК в сети — это потенциальный потребитель всего этого объёма. Также нужно помнить, что открываются новые точки. А также брать во внимание ПК вне клубов — хотя пока мы не отдаём лаунчер в общий доступ (есть whitelist и прочее).


Предыстория. Как обновлялся контент раньше

Прежде чем говорить про деньги, S3, и механику обновлений, стоит объяснить, зачем вся эта система вообще появилась.

До лаунчера обновление контента было полностью ручным — и это был настоящий хаос. Админы забирали файлы из офиса, докачивали недостающее кто где может и кто как умеет, а потом руками раскатывали всё это по каждому компу в каждом клубе. Это долгий, неконтролируемый процесс, в котором постоянно что‑то шло не так: один админ забыл пакет, другой поставил не ту версию, третий скачал не тот файл. ПК даже в одном клубе оказывались в разном состоянии, и понять,у кого что стоит, было довольно трудно.

Решение, которое всё это оптимизировало — централизованное хранилище с «эталонным» манифестом контента для каждой игры. Появился единый источник правды: сервер знает, как должен выглядеть актуальный набор контента, а лаунчер на каждой машине сам приводит её к этому состоянию: скачивает недостающее, обновляет устаревшее, проверяет целостность. Никаких флешек и я.диска, никакой ручной раскатки, никакого «а у меня почему‑то другая версия». Для владельца сети это была ощутимая оптимизация: процесс стал быстрым, предсказуемым и, главное, контролируемым.

Вот только у этой красивой схемы обнаружилась цена. И связана она была с тем, где мы держали контент.


С чего всё началось

Примечание: мы работаем только с российскими провайдерами.

В нашей системе всегда присутствовало хранилище внешнего S3-провайдера — ещё до появления лаунчера.

И так как мы уже использовали его для различных задач, связанных с хранением и раздачей, решили и тут воспользоваться уже знакомым функционалом.

Моя ошибка заключалась в небрежной оценке ситуации. Владелец клубов говорил о небольших обновлениях по мере необходимости, и безусловно мне нужно было учесть, что может потребоваться и «загрузка всего что есть» на чистый комп или полное обновление компа на котором в данный момент «непонятная куча мусора». Но в тот момент я был больше поглощён написанием логики самого обновления и других механизмов системы.

В итоге, после того как админы залили все нужные модули игрового контента и стали итеративно запускать первые обновления, мы увидели стремительный вылет бюджета у S3-провайдера и путём очевидных вычислений пришли к сумме ~200 000 ₽ на одно полное обновление. Ещё раз повторю, что эта сумма касается только 500 ГБ на 260 компов, но и то, и другое будет только расти.

Далее я разберу:

  1. Как вообще устроена наша система обновления контента;

  2. Почему внешний S3-провайдер оказался дорогим именно на нашем сценарии;

  3. Как переезд на self‑hosted S3-совместимое хранилище решил проблему почти без изменений в коде;

  4. Trade‑off’ы.

Как устроено обновление контента

Прежде чем говорить о деньгах, нужно показать нашу систему раздачи. Трафик мы стараемся беречь.

Манифест и хеш.

ЦУ для каждой игры собирает манифест json — список пакетов контента с их версиями и контрольными суммами. Ключевая деталь — детерминированный manifestHash:

// цу: расчёт хеша манифеста (упрощённо)// sha256 от отсортированного среза пакетов — стабильный отпечаток состояния контентаconst manifestHash = sha256(  JSON.stringify(    packages      .map((p) => ({        categorySlug: p.categorySlug,        packageSlug: p.packageSlug,        version: p.version,        sha256: p.sha256,      }))      .sort(),  ),);

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

Heartbeat

Каждые 60 секунд лаунчер пингует ЦУ. В ответе heartbeat среди прочего приходит актуальный хеш манифеста по каждой игре:

// Ответ heartbeat (фрагмент){  // ...  content: {    "assetto-corsa": { manifestHash: "9f2c…" }  }}

Лаунчер сравнивает пришедший хеш со своим локальным. Совпал — тишина. Отличается ‑значит, на сервере что‑то поменялось, и пора проверить, что именно.

Дифф на стороне лаунчера

Когда хеши разошлись, лаунчер запрашивает полный манифест и считает дельту:

// лаунчер: computeDiff(server, installed) → что делать{  toInstall,  // новые пакеты  toUpdate,   // обновившиеся версии  obsolete,   // больше не нужны  upToDate,   // уже актуальны}

Дополнительно лаунчер проверяет, что папка пакета физически существует на диске — пользователь мог удалить её руками, и тогда пакет надо доставить заново, даже если в реестре он числится установленным.

Надёжная установка

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

Поэтому разберу установку по шагам — и на каждый покажу настоящий код из лаунчера (Electron, main‑процесс).

1. Потоковая проверка sha256 на лету. Хеш считается прямо в потоке загрузки,параллельно записи на диск — отдельного прохода по файлу нет. Для этого в pipelineвставлен Transform, который обновляет хешер на каждом чанке (заодно тут же считается скорость и шлётся прогресс в UI):

const hasher = createHash('sha256');const hashTransform = new Transform({  transform(chunk: Buffer, _enc, cb) {    if (cancelRequested) return cb(new Error('Загрузка отменена'));    hasher.update(chunk);            // хеш «на лету»    bytesDownloaded += chunk.length; // прогресс/скорость считаем тут же    cb(null, chunk);  },});await pipeline(res, hashTransform, fs.createWriteStream(partialPath));const sha256 = hasher.digest('hex');

2. Проверка целостности до распаковки. Если посчитанный хеш не совпал с тем, что обещал манифест, то битый .partial удаляется, а ошибка летит наверх. Ни один байт сомнительного архива не доедет до папки игры:

if (sha256 !== pkg.sha256) {  try { fs.unlinkSync(partialPath); } catch { /* ignore */ }  throw new Error(`Несовпадение sha256: ожидалось ${pkg.sha256}, получено ${sha256}`);}fs.renameSync(partialPath, finalPath); // .partial → финал только после успеха

3. Кеш скачанных архивов. Файл лежит в userData/content-cache под именем {packageSlug}-{version}.zip. Перед загрузкой проверяем: если архив уже есть и его sha256 совпадает с манифестом — качать не нужно, отдаём из кеша. Это спасает при повторной установке и обрывах:

const finalPath = path.join(cacheDir, `${pkg.packageSlug}-${pkg.version}.zip`);if (fs.existsSync(finalPath)) {  const existingSha = await sha256OfFile(finalPath);  if (existingSha === pkg.sha256) {    const stat = fs.statSync(finalPath);    return { filePath: finalPath, sha256: existingSha, bytes: stat.size };  }  fs.unlinkSync(finalPath); // протух — выкидываем}

4. Без ретраев — fail‑fast. Сознательное решение: на ошибке загрузки/проверки мы не зацикливаемся на ретраях, а помечаем пакет проблемным, сообщаем на сервер и идём дальше. Один битый пакет не должен валить всё обновление клуба, а сервер получает сигнал, что объект на S3, возможно, повреждён.

О новых проблемных пакетах админы получают уведомления в корпоративном боте, идут в админку и проверяют/перезаливают/удаляют.

5. Атомарная установка с откатом. Распаковка идёт во временную папку на том же томе, что и игра (иначе rename упадёт с EXDEV). Затем — бэкап текущей версии, rename новой и rollback из бэкапа при любой ошибке. Папка игры либо старая целая, либо новая целая — промежуточного «полуустановленного» состояния не существует:

let backupCreated = false;try {  if (fs.existsSync(finalDir)) {    fs.renameSync(finalDir, backupDir); // бэкап текущей версии    backupCreated = true;  }  fs.renameSync(sourceDir, finalDir);   // атомарная подмена} catch (err) {  if (fs.existsSync(finalDir) && backupCreated) {    fs.rmSync(finalDir, { recursive: true, force: true });  }  if (backupCreated && fs.existsSync(backupDir)) {    fs.renameSync(backupDir, finalDir); // откат  }  throw err;}

6. Защита от zip‑slip. Архив — это недоверенные данные. Злонамеренный (или просто кривой) zip может содержать запись вроде ../../../Windows/... и записать файл вне целевой папки. Поэтому каждый путь проверяется на выход за пределы директории распаковки:

function isPathInside(parent: string, child: string): boolean {  const rel = path.relative(parent, child);  return !rel.startsWith('..') && !path.isAbsolute(rel);}// при обработке каждой entry:const target = path.join(destDir, safeName);if (!isPathInside(destDir, target)) {  finish(new Error(`Zip-slip: ${entry.fileName}`));  return;}

Первая архитектура: внешний S3-провайдер

Изначально весь контент жил во внешнем S3.

  • S3-совместимость и предсказуемый API.

  • Ноль DevOps — не надо администрировать железо, диски, аптайм.

  • Публичные URL для объектов через S3_PUBLIC_BASE_URL — лаунчер просто делал https.get по ссылке из манифеста.

Откуда взялись 200 000 р.

Хранение в горячем бакете — 2,33 ₽/Гб в мес.

Исходящий трафик — 1,38 ₽/Гб.

Получается для 500Гб и 260 компов:

Хранение: 2,33 * 500 = 1165 ₽/мес.

Раздача (одно полное обновление): 1,38  500  260 = 179 400 ₽

Один чистый прогон «всё на все» — это ~179 тыс. А так как первые обновления админы запускали итеративно (дозаливки, повторные раскатки на часть машин), реальный счёт быстро приблизился к ~200 000 ₽. И это — только на текущих 500 ГБ и 260 машинах; и то, и другое будет только расти. Хранить наши ~500 ГБ контента стоило копейки. Дорого стоило отдать эти ~500 ГБ.

Дифф работает на инкрементальных апдейтах. Изменилась пара пакетов — лаунчер тянет только их, трафик минимальный. Но первичная заливка нового клуба или крупное обновление контента — это всё равно сотни гигабайт **на каждую из 260 машин**.

Для владельца сети клубов ~200к за обновление — это просто нерабочая экономика.

Решение: self‑hosted S3 на офисном гигабите

Решение оказалось достаточно простым:

Если наша боль это egress — давайте раздавать оттуда, где egress бесплатный.

В офисе есть гигабитный безлимитный канал. А значит, можно поднять собственное S3-совместимое хранилище и раздавать контент с него. Выбор пал на MinIO — просто потому, что работал с ним ранее и был уверен в быстрой установке/переходе. Я знаю, что сейчас есть проблемы с его поддержкой, не выпускаются обновления, но в данный момент это был наиболее быстрый путь. Пока MinIO выполняет свои функции на отлично, мы спокойно можем попробовать варианты и подобрать ему замену.

Почему миграция была почти бесплатной

MinIO — S3-совместимый. Поэтому переезд в коде свёлся, по сути, к **смене базового URL**:

- S3_PUBLIC_BASE_URL=https://<bucket>.selstorage.ru+ S3_PUBLIC_BASE_URL=https://content.example.com

Вся бизнес‑логика — сборка манифеста, расчёт manifestHash, дифф на лаунчере, потоковая проверка sha256, атомарная установка — осталась нетронутой. Лаунчер как делал https.get по URL из манифеста, так и продолжил. Он буквально «не заметил» переезда.

Инфраструктура

Машина в офисе — на Windows, поэтому оба сервиса я оформил как нативные Windows‑службы через NSSM (Non‑Sucking Service Manager).

  • MinIO — собственно объектное хранилище с S3 API.

  • Caddy — reverse‑proxy перед MinIO: TLS с автоматическим Let’s Encrypt и аккуратная маршрутизация по домену.

Минимальный Caddyfile получается почти декларативным:

content.example.com {    reverse_proxy localhost:9000}

Caddy сам выпускает и продлевает сертификат — отдельной возни с TLS нет.

Но для раздачи тяжёлых файлов в боевой конфиг стоит добавить несколько важных директив:

content.example.com {    # тело запроса под заливку крупных пакетов в MinIO    request_body {        max_size 5GB    }    # zip-пакеты иммутабельны (версия зашита в имя файла) — разрешаем клиентам кешировать    header Cache-Control "public, max-age=2592000, immutable"    header -Server  # прячем версию Caddy в ответах    reverse_proxy 127.0.0.1:9000 {        flush_interval -1   # стримим ответ как есть, без буферизации        transport http {            dial_timeout 30s            response_header_timeout 5m            read_timeout 1h            write_timeout 1h        }    }}

Что здесь действительно важно для нашего сценария:

  • flush_interval -1 — ключевая строка. Caddy не буферизует ответ, а стримит его от MinIO к клиенту чанк за чанком. На файлах в гигабайты буферизация означала бы лишнюю память и задержку до первого байта.

  • header Cache-Control … immutable — имя файла содержит версию, значит содержимое неизменно. Клиент может смело кешировать пакет и не перезапрашивать его.

  • request_body max_size 5GB — крупные пакеты в MinIO

  • transport http { … timeouts } — большие файлы по неидеальной сети едут долго; без поднятых таймаутов соединение рвётся на середине.

  • header -Server и admin off в глобальном блоке — меньше информации наружу и меньше поверхность атаки.

А что с внешним S3?

Внешний S3 мы оставили как холодный бэкап. И это укладывается в экономику: хранение стоит копейки, а egress за бэкап мы не платим.

В итоге: горячая раздача со своего гигабита, аварийная копия — в облаке.

Заключение. Результат и trade‑off’ы

Результат: оптимизация/автолматизация процесса обновления игрового контента в клубах. Первичные затраты на раздачу контента упали практически до нуля. Обновления проходят успешно.

Self‑hosting — это не «бесплатно», это «по‑другому»:

  • Аптайм теперь на нас. Офисный ПК — это не облачный провайдер. Пропадёт электричество или интернет в офисе — раздача встанет. Для нашего сценария это терпимо (обновления не критичны к минутам), но это явный риск, который надо понимать.

  • Single point of failure. Один ПК раздаёт на всю сеть. Страхуем холодным бэкапом в облачном S3: если железо умрёт, контент не потерян и схему можно поднять заново.

  • Безопасность. Машина теперь смотрит в сеть через Caddy. Значит — TLS (его даёт Caddy из коробки), ограничение доступа к MinIO‑консоли. Публичный эндпоинт требует внимания.

  • Нет авто‑масштабирования и геораспределения. Облако дало бы CDN и точки присутствия по миру. Но наши клубы — локальные, в одном городе, и тянут с офисного гигабита прекрасно. Геораспределение нам просто не нужно. Пока.

Когда так стоит делать, а когда нет

Когда self‑hosted S3 выигрывает:

  • Большой и регулярный egress (раздача тяжёлого контента).

  • Предсказуемая, локальная аудитория (не глобальная).

  • Есть доступ к дешёвому/безлимитному широкому каналу.

  • Можно пережить редкие простои без катастрофы для бизнеса.

И когда лучше остаться в s3 облаке/ CDN:

  • Глобальная аудитория, нужна низкая латентность по всему миру.

  • Жёсткие требования к SLA и доступности.

  • Нет ресурса администрировать собственную инфраструктуру.

ссылка на оригинал статьи https://habr.com/ru/articles/1054344/