Как мы раздаём 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 и доступности.

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

Комментарии (6)


  1. akakoychenko
    01.07.2026 11:29

    А чего торрентом не захотели? Он прям просится сюда, учитывая сильную связность локалками и необходимость иметь быстрый канал в клубах и так. Если есть опасения за безопасность - можно раздавать шифрованный архив.


    1. Sedov91 Автор
      01.07.2026 11:29

      Согласен с вами. Для внутриклубной раздачи P2P напрашивается. Но у нас есть сценарий раздачи вне клубов - whitelist-игроки со своих машин за NAT. Я честно не силен в вопросах работы торрент протокола, но этот вариант рассматривал и пришёл к выводу, что для таких игроков за NAT swarm просто не собирается и торрент теряет свою суть. Поправьте меня, если я не прав. В данный момент у нас вполне хорошо работает схема, которую я описал в статье. Но при этом я смотрю в сторону локальных зеркал на клуб - как более дешевого способа получить локальную раздачу без переписывания текущей логики. В будущем мы хотим открывать лаунчер в online для всех и вот там возможно будет использоваться связка CDN + P2P. Но до этого масштаба и бюджета еще дожить нужно :)


      1. akakoychenko
        01.07.2026 11:29

        Ну, по сути, корректно работающий торрент и есть локальное зеркало.

        Раздача посевается на офисном сервере, откуда машины за натом забирают себе поблочно данные, после чего раздают эти блоки своим соседям по локалке, минуя глобальную сеть. То есть, каждый блок будет затягиваться вовнутрь клуба одной машиной, а внутренняя раздача будет просто взрывная

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


        1. Sedov91 Автор
          01.07.2026 11:29

          И снова согласен.

          Но у меня остаются сомнения в плане контроля за средой. То есть в разных клубах разные настройки сети. Есть “острова” в торговых центрах (там вообще своя история), есть клубы открытые по франшизе и, как мы говорили ранее, игроки вне клубов. Я отталкиваюсь от того, что раздача по HTTP практически обнуляет все вопросы настройки среды, которые могут возникнуть. Вы описываете идеальный рабочий сценарий, но я боюсь, что в проде на разных точках будут возникать разного рода проблемы. И это решение именно в моей ситуации может стать дорогим.

          За идею с бесплатными сидами на недозагруженном аплинке спасибо. Полезный для меня разбор.


  1. Acidter
    01.07.2026 11:29

    Посмотрите на Rustfs как на замену Minio. Он ещё Pre-Release, но уже очень хорошо показывает себя и не имеет некоторых проблем, которые в Minio за все время так и не решили.


    1. Sedov91 Автор
      01.07.2026 11:29

      Спасибо за совет, посмотрю.