Обновление контента игровых клубов. Отказ от облачного S3. Стоимость и механика.
Раздача игрового контента
Как мы раздаём 500 ГБ игрового контента на 260 ПК в сети игровых клубов — и почему в какой-то момент пришлось съехать с облачного S3, не переписав при этом ни строчки бизнес-логики.
Реальный кейс: как из ручного хаоса с флешками и «у кого что скачалось» выросла централизованная система обновления игрового контента, во что она обошлась на облачном хранилище — и как одно инфраструктурное изменение убрало эту статью расходов практически в ноль.
TL;DR
- В облачном S3 при активном использовании основная часть расходов — не хранение данных, а их раздача (egress).
- Раздать 500 ГБ контента на 260 игровых ПК = ~200 000 ₽ за полное обновление. Это неприемлемо. А с учётом того, что вес контента и количество игровых клубов — величины динамические — неприемлемо вдвойне.
- Решение: подняли свое MinIO (S3-совместимое) + Caddy на офисном ПК с гигабитным безлимитным каналом.
- Так как мы изначально работали через S3 API, миграция свелась к смене одной переменной окружения.
- Selectel остался — но только как холодный бэкап, а не как источник раздачи.
- Результат: затраты на раздачу контента → практически 0. Ценой осознанных trade-off'ов, о которых ниже.
Контекст: что за система
Введение в мой сайд-проект. Примечание: я работаю со всей системой один — в качестве архитектора, фронтендера, бэкендера, админа БД, DevOps и т.д. Мои решения по стеку приняты исходя из моих скилов и баланса скорости/качества разработки. Писал всё с нуля один. Уверен, что можно лучше. Но это тема другой статьи. Тут работаем с тем, что имеем.
Всю систему можно разделить на три основные части, которые общаются между собой:
- Игровой лаунчер — приложение на Electron. Что-то типа Steam, но нацеленный конкретно на нашу экосистему. Он крутится на игровых ПК в клубах (и не только) под Windows. Даёт возможность запустить с необходимыми стартовыми параметрами игры (например, Assetto Corsa, Assetto Corsa Competizione и т.д.), следит за актуальностью игрового контента, является точкой входа в нашу экосистему для клиента, пингует основной сервер (heartbeat каждые 60 секунд), пишет телеметрию игроков и делает много всего еще.
- Центр управления (далее ЦУ) — основной сервер со всей бизнес-логикой. Написан на NestJS. Работает на выделенном VDS (Ubuntu). Это ядро всей архитектуры: все лаунчеры общаются с ним по API. Он управляет каталогом контента, обновлениями самих лаунчеров, сбором и фиксацией всех данных по клиентам (сбор локальной телеметрии с каждого ПК, сбор данных с игровых серверов и т.д.). На отдельном VDS еще БД крутится, но это не столь важно в рамках этой статьи.
- Админка — админка на Vue для наблюдения и управления всей системой.

Что качается: контент для игр — треки, машины, моды. Полный набор — около 500 ГБ на момент написания статьи. В любое время может добавиться пачка пользовательских карт и машин. И каждый из 260 ПК в сети — это потенциальный потребитель всего этого объёма. Также нужно помнить, что открываются новые точки. А также брать во внимание ПК вне клубов — хотя пока мы не отдаём лаунчер в общий доступ (есть whitelist и прочее).
Предыстория: как обновляли контент раньше
Прежде чем говорить про деньги, S3, и механику обновлений, стоит объяснить, зачем вся эта система вообще появилась.
До лаунчера обновление контента было полностью ручным — и это был настоящий хаос. Админы забирали файлы из офиса, докачивали недостающее кто где может и кто как умеет, а потом руками раскатывали всё это по каждому компу в каждом клубе. Это долгий, неконтролируемый процесс, в котором постоянно что-то шло не так: один админ забыл пакет, другой поставил не ту версию, третий скачал не тот файл. ПК даже в одном клубе оказывались в разном состоянии, и понять, у кого что стоит, было довольно трудно.
Решение, которое всё это оптимизировало — централизованное хранилище с «эталонным» манифестом контента для каждой игры. Появился единый источник правды: сервер знает, как должен выглядеть актуальный набор контента, а лаунчер на каждой машине сам приводит её к этому состоянию: скачивает недостающее, обновляет устаревшее, проверяет целостность. Никаких флешек и я.диска, никакой ручной раскатки, никакого «а у меня почему-то другая версия». Для владельца сети это была ощутимая оптимизация: процесс стал быстрым, предсказуемым и, главное, контролируемым.
Вот только у этой красивой схемы обнаружилась цена — в самом прямом смысле. И связана она была с тем, где мы держали контент.
С чего всё началось
Примечание: мы работаем только с российскими провайдерами.
В нашей системе всегда присутствовало хранилище S3 Selectel — ещё до появления лаунчера. И так как мы уже использовали его для различных задач, связанных с хранением и раздачей, решили и тут воспользоваться уже знакомым функционалом. Моя ошибка заключалась в небрежной оценке ситуации. Владелец клубов говорил о небольших обновлениях по мере необходимости, и безусловно мне нужно было учесть, что может потребоваться и "загрузка всего что есть" на чистый комп или полное обновление компа на котором в данный момент "непонятная куча мусора". Но в тот момент я был больше поглощён написанием логики самого обновления и других механизмов системы. В итоге, после того как админы залили все нужные модули игрового контента и стали итеративно запускать первые обновления, мы увидели стремительный вылет бюджета в Selectel и путём очевидных вычислений пришли к сумме ~200 000 ₽ на одно полное обновление. Ещё раз повторю, что эта сумма касается только 500 ГБ на 260 компов, но и то, и другое будет только расти.
В этой статье я разберу:
- как вообще устроена наша система обновления контента;
- почему облачный S3 оказался дорогим именно на нашем сценарии;
- как переезд на собственное S3-совместимое хранилище решил проблему почти без изменений в коде;
- и trade-off'ы.
Как устроено обновление контента
Прежде чем говорить о деньгах, нужно показать нашу систему раздачи. Трафик мы стараемся беречь.
Манифест и хеш
ЦУ для каждой игры собирает манифест json — список пакетов контента с их версиями
и контрольными суммами. Ключевая деталь — детерминированный manifestHash:
// tr-main: расчёт хеша манифеста (упрощённо)
// 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…" }
}
}
Лаунчер сравнивает пришедший хеш со своим локальным. Совпал — тишина. Отличается — значит, на сервере что-то поменялось, и пора проверить, что именно.
Дифф на стороне лаунчера
Когда хеши разошлись, лаунчер запрашивает полный манифест и считает дельту:
// tr-launcher: 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, возможно, повреждён:
// Без ретраев: один проход. При любой ошибке (сеть/HTTP/sha256) кидаем наверх —
// вызывающий код пометит пакет как проблемный, сообщит на сервер и перейдёт
// к следующему пакету.
О новых проблемных пакетах админы получают уведомления в корпоративном боте, идут в админку и проверяют/перезаливают/удаляют.
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 (Selectel)
Изначально весь контент жил в облачном объектном хранилище — S3 от Selectel.
- S3-совместимость и предсказуемый API.
- Ноль DevOps — не надо администрировать железо, диски, аптайм.
- Публичные URL для объектов через
S3_PUBLIC_BASE_URL— лаунчер просто делалhttps.getпо ссылке из манифеста.
Откуда взялись 200 000 ₽
Тарифы Selectel на момент написания статьи.
Хранение в горячем бакете - 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к за обновление — это просто нерабочая экономика.
Решение: своё S3 на офисном гигабите
Решение оказалось достаточно простым:
Если наша боль это egress — давайте раздавать оттуда, где egress бесплатный.
В офисе есть гигабитный безлимитный канал. А значит, можно поднять собственное S3-совместимое хранилище и раздавать контент с него. Выбор пал на 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— крупные пакеты в MinIOtransport http { … timeouts }— большие файлы по неидеальной сети едут долго; без поднятых таймаутов соединение рвётся на середине.header -Serverиadmin offв глобальном блоке — меньше информации наружу и меньше поверхность атаки.
Регистрация службы через NSSM — одна команда на сервис:
nssm install minio "путь_к_minio_exe" server "путь_к_data" --console-address ":порт"
nssm install caddy "путь_к_caddy_exe" run --config "путь_к_CaddyFile"
Дальше службы поднимаются сами при загрузке Windows.
А что с Selectel?
Selectel мы оставили как холодный бэкап. И это укладывается в ту же экономику: хранение в облаке стоит копейки, а egress за бэкап мы не платим. В итоге: горячая раздача со своего гигабита, аварийная копия — в облаке.
flowchart LR
Clubs["Игровые клубы (260 ПК)"] -- скачивание --> MinIO["MinIO + Caddy<br/>(офис, гигабит)"]
MinIO -. холодный бэкап .-> Selectel["S3 Selectel<br/>(только хранение)"]
Заключение. Результат и trade-off'ы
Результат: затраты на раздачу контента упали практически до нуля. Обновления проходят успешно.
Self-hosting — это не «бесплатно», это «по-другому»:
- Аптайм теперь на нас. Офисный ПК — это не облачный провайдер. Пропадёт электричество или интернет в офисе — раздача встанет. Для нашего сценария это терпимо (обновления не критичны к минутам), но это явный риск, который надо понимать.
- Single point of failure. Один ПК раздаёт на всю сеть. Страхуем холодным бэкапом в Selectel: если железо умрёт, контент не потерян и схему можно поднять заново.
- Безопасность. Машина теперь смотрит в сеть через Caddy. Значит — TLS (его даёт Caddy из коробки), ограничение доступа к MinIO-консоли, регулярные обновления MinIO/Caddy. Публичный эндпоинт требует внимания.
- Нет авто-масштабирования и геораспределения. Облако дало бы CDN и точки присутствия по миру. Но наши клубы — локальные, в одном городе, и тянут с офисного гигабита прекрасно. Геораспределение нам просто не нужно. Пока.
Когда так стоит делать, а когда нет
Этот переезд — не серебряная пуля. Чек-лист, когда self-hosted S3 выигрывает:
- ✅ Большой и регулярный egress (раздача тяжёлого контента).
- ✅ Предсказуемая, локальная аудитория (не глобальная).
- ✅ Есть доступ к дешёвому/безлимитному широкому каналу.
- ✅ Можно пережить редкие простои без катастрофы для бизнеса.
И когда лучше остаться в облаке / CDN:
- ❌ Глобальная аудитория, нужна низкая латентность по всему миру.
- ❌ Жёсткие требования к SLA и доступности.
- ❌ Нет ресурса администрировать собственную инфраструктуру.