Как мы раздаём 500 ГБ игрового контента на 260 ПК в сети игровых клубов — и почему в какой‑то момент пришлось отказаться от внешнего S3-провайдера.
Реальный кейс: как из ручного хаоса с флешками и «у кого что скачалось» выросла централизованная система обновления игрового контента, во что она обошлась на облачном хранилище, и как одно инфраструктурное изменение убрало эту статью расходов практически в ноль.
TL;DR
-
У внешнего S3-провайдера при активном использовании основная часть расходов — не хранение данных, а их раздача (egress).
-
Раздать 500 ГБ контента на 260 игровых ПК = ~200 000 ₽ за полное обновление. Это неприемлемо. А с учётом того, что вес контента и количество игровых клубов — величины динамические — неприемлемо вдвойне.
-
Решение: подняли свое MinIO (S3-совместимое) + Caddy на офисном ПК с гигабитным безлимитным каналом.
-
Так как мы изначально работали через S3 API, миграция свелась к смене одной переменной окружения.
-
Внешний S3 остался — но только как холодный бэкап, а не как источник раздачи.
-
Результат: затраты на раздачу контента → практически 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-провайдера — ещё до появления лаунчера.
И так как мы уже использовали его для различных задач, связанных с хранением и раздачей, решили и тут воспользоваться уже знакомым функционалом.
Моя ошибка заключалась в небрежной оценке ситуации. Владелец клубов говорил о небольших обновлениях по мере необходимости, и безусловно мне нужно было учесть, что может потребоваться и «загрузка всего что есть» на чистый комп или полное обновление компа на котором в данный момент «непонятная куча мусора». Но в тот момент я был больше поглощён написанием логики самого обновления и других механизмов системы.
В итоге, после того как админы залили все нужные модули игрового контента и стали итеративно запускать первые обновления, мы увидели стремительный вылет бюджета у S3-провайдера и путём очевидных вычислений пришли к сумме ~200 000 ₽ на одно полное обновление. Ещё раз повторю, что эта сумма касается только 500 ГБ на 260 компов, но и то, и другое будет только расти.
Далее я разберу:
-
Как вообще устроена наша система обновления контента;
-
Почему внешний S3-провайдер оказался дорогим именно на нашем сценарии;
-
Как переезд на self‑hosted S3-совместимое хранилище решил проблему почти без изменений в коде;
-
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/