はじめに
LoLのプロシーン向けに、BPGGというWebツールを作って公開しました。
League of Legendsのプロの試合(LCK、LJLなど)のバン/ピックを集計して、パッチ別・リージョン別のメタを見られる統計サイトです。バン/ピック率のランキング、パッチごとの推移、シナジー(味方の同時ピック)、カウンター(対面の相性)、ピック順の傾向、5体構成のランキングなど、8つのウィジェットを載せています。日英対応、完全無料、ログイン不要です。
前回の記事では、Champ Akinatorというアキネイター風ゲームを作った話を書きました。今回はその第2弾です。前作は静的JSONとCloudflare Workersで完結する小さなゲームでしたが、今回はDB、1日2回のバッチ、クラウドへの自動デプロイ、統計の補正まで入った、ひと回り大きいプロジェクトです。
この記事では、BPGGを作った背景から、どう作ったか、最終的なアーキテクチャ、アプリとバッチの中身までを順に紹介します。
この記事のポイント
- DBはSQLite。ファイルごとDockerイメージに焼き込んで、DBサーバーなしで運用(月約200円)
- 統計サイトで踏んだ“それっぽい嘘”3つと、その直し方(経験ベイズ縮約、ピック順の定義、最低サンプル数)
- GCPで約1,000円の謎の請求が来た原因と、Artifact Registryの保持ポリシーの罠
課題・背景
きっかけは、Clash(LoLの大会モード)やアマチュアチームのBan/Pickの準備でした。作戦を立てるたびに、次のようなことを手作業で調べていて、効率がよくありませんでした。
- 今のパッチで、どのチャンピオンがよくBan/Pickされているか
- どのチャンピオン同士が一緒に選ばれやすいか
- リージョンによってメタがどう違うか
プロシーンのBan/Pickをパッチ・リージョン単位で集計して見られる場所があれば、メタの把握や作戦立案の材料になる。これが出発点です。想定した利用者は次のとおりです。
- Clashに出るプレイヤー・チーム
- アマチュアチームのコーチ・アナリスト
- プロシーンのメタを学びたいプレイヤー
もうひとつの前提が「趣味でやる」ことです。収益化が目的ではなく、広告でサーバー代がまかなえれば十分。なので、運用コストをどこまで下げられるかが最初から大きなテーマでした。
どう作るか
設計はClaudeと相談しながら、データソース、Webアプリ、DB、バッチ処理の4つを順に決めていきました。あわせてリポジトリの構成も相談しています。前提にしたのは、BPGGの性質である「読み取り専用・ログインなし・データ更新はバッチだけ」という点です。実は、最初の計画からはかなり変わっています。
最終的に決めたのは次のとおりです。
| 項目 | 最終的な形 |
|---|---|
| データソース | Oracle's Elixir のCSV |
| Webアプリ | Next.js 1つで画面とデータ取得をまとめる |
| DB | SQLite(イメージに同梱) |
| バッチ処理 | GitHub Actions(試合データは1日2回、チャンピオン情報は毎週) |
| リポジトリ構成 | npm workspaces のモノレポ(アプリ・バッチ・共通スキーマ) |
データソース:スクレイピング → 非公式API → Oracle's Elixir
最初は、LoL Esportsの公式サイト(lolesports.com)から試合データをスクレイピングすることを考えていました。ただ、そもそもスクレイピングしにくい作りになっているうえ、サイトの構成が変われば一発で壊れます。
そこで別の方法を探したところ、サイトが内部で使っている非公式のAPIが見つかりました。とはいえ、これも公式に公開されたものではないので、仕様変更や廃止があっても文句は言えず、利用規約の面でもグレーです。
最終的に、プロの試合データをCSVで公開している Oracle's Elixir をメインのデータソースにしました。運営者に連絡して、利用についても確認を取っています。
Webアプリ:Next.js を「プレーンに」使う
フロントは htmx + FastAPI も検討しましたが、今後のリッチな表現(インタラクティブな相性マトリクスなど)を見据えてNext.js(App Router)にしました。その代わり、実験的な機能や凝ったキャッシュ制御は使わない、状態管理ライブラリも入れない、というルールを CLAUDE.md に書いています。年1回のバージョンアップのコストを抑えるためです。
DB:Cloud SQL をやめて SQLite に
当初はPostgreSQL(本番はCloud SQL)の予定でしたが、Cloud SQLは最小構成でも常時起動で月$10前後かかり、インフラ費のほとんどをDBが占める見込みでした。
そこで、バッチが作った stats.dbをDockerイメージに焼き込んで、Cloud Runに載せる構成にしました。
- 書き込みはバッチだけなので、SQLiteの弱点(同時書き込み)が問題にならない
- 「アプリ+データ」がCloud Runのリビジョン単位でセットになるので、ロールバックするとデータも一緒に戻る
- Cloud Runはアクセスがなければゼロまでスケールするので、DBサーバーの固定費が消える
DBドライバは当初 better-sqlite3 を想定していましたが、Node.js 24 組み込みの node:sqlite で足りたので、依存がさらに1つ減りました。
一方で、この構成だとイメージが大きくなります(stats.db だけで約295MB)。データを更新するたびにビルドとデプロイが走り、イメージの保管コストもかさみます(後述のArtifact Registryの件はまさにこれです)。また、Webからの書き込みが必要になったらこの構成は使えません。そのときにPostgreSQLへ戻せるよう、クエリはリポジトリ層に閉じ込めてあります。
バッチ処理:Cloud Run Jobs ではなく GitHub Actions
バッチは2種類あります。
-
試合データの更新:Oracle's ElixirのCSVから新しい試合を取り込み、
stats.dbを更新する(1日2回) - チャンピオン情報の更新:Riot公式のゲームデータ配信(Data Dragon)から、チャンピオンの名前と画像を取り込む(毎週水曜)
どちらも軽い処理なので、GitHub Actionsの無料枠で十分です。当初はCloud Run Jobsで動かす計画でしたが、Cloud Run Jobsが有利になるのは、処理が6時間を超える場合やVPC内のリソースにつなぐ場合くらいで、どちらにも当てはまりませんでした。
リポジトリ構成:アプリとバッチを1つのモノレポに
アプリとバッチは、npm workspaces のモノレポ(1つのリポジトリ)にまとめています。理由は、バッチ(DBに書く側)とアプリ(DBを読む側)が同じスキーマを使うからです。リポジトリを分けると、テーブルを変えるたびに2か所を揃える必要がありますが、モノレポなら1つのPRで両方を直せます。
フォルダ構成はClaudeに丸投げせず、相談しながら人間が見て分かる形に決めました。コードの大半をAIが書くとしても、何がどこにあるかを人間が把握できていないと、レビューもデバッグもできないからです。
bpgg/
├── apps/
│ └── web/ # Next.js アプリ(読み取り専用)
├── packages/
│ ├── batch/ # バッチ(CSV取り込み・stats.db の更新)
│ └── db/ # スキーマ定義・カラム名の定数(共通)
└── data/
└── stats.db # バッチが生成(Git管理外)
スキーマとカラム名の定数は packages/db に置き、バッチとアプリの両方から読み込んでいます。書く側と読む側で名前がずれる事故も起きません。
最終アーキテクチャ
最終的な構成はこうなりました。
流れは大きく3つです。
- ユーザーは Cloud Run 上の Next.js アプリを見る。DBはイメージに同梱された SQLite を読むだけ
- バッチ(GitHub Actions)がチャンピオン・試合データを更新し、Cloud Build 経由で新しいイメージをデプロイする
- main へのマージでも、同じ Cloud Build でデプロイされる
使っている技術は次のとおりです。
| 層 | 技術 |
|---|---|
| フロントエンド / API | Next.js 16(TypeScript, App Router) |
| DB | SQLite(Node.js 24 組み込みの node:sqlite) |
| バッチ | TypeScript + GitHub Actions(試合データは1日2回、チャンピオン画像は毎週) |
| データソース | Oracle's Elixir(CSV、2014年〜)、Data Dragon(チャンピオン画像) |
| ホスティング | GCP Cloud Run(東京リージョン)+ Cloud Build + Artifact Registry |
| ドメイン / DNS | Cloudflare(Registrar・DNS) |
Webアプリの本番依存は next react react-dom の3つ(+モノレポ内の共通スキーマパッケージ)だけです。SQLiteもNode.js 24の組み込みモジュールを使っているので、DBドライバのnpmパッケージすらありません。
アプリの話
アプリは、トップのダッシュボードにウィジェットのカードを並べ、カードから各ウィジェットの詳細ページに飛ぶ構成です。ウィジェットは、バン/ピック率のランキング、パッチごとの推移、レーン別、リーグ比較、シナジー、カウンター、ピック順、5体構成の8つ。
画面の作り
画面まわりで意識したのは次の5つです。
- 入口はランキング、深掘りは検索:シナジーやカウンターは、まず人気のペアや有利な対面をランキングで見せ、チャンピオン名で検索して絞り込む形にしています。当初は特定の1体を初期表示にしていましたが、ランキングから入る方が今のメタが一目で分かります
- 味方と敵を混ぜない:シナジー(同じチームでの同時ピック)とカウンター(対面)は画面を分けています。混ぜると数字の意味が変わってしまうためです
-
フィルタはURLに持たせる:パッチとリージョンは
?patch=?region=のURLパラメータで持ち、状態管理ライブラリは使っていません。見ている画面をそのままURLで共有できます - 既定は直近6パッチ:意味のあるバランス調整はだいたい2パッチに1回なので、6パッチあれば調整3回分。今のメタを反映しつつ、サンプル数も確保できる幅です
- 5体構成は検索前提:5体完全一致の構成はプロでもほとんど繰り返されません(最多でも数試合)。ランキングを眺めるより、チャンピオンで絞り込んで探す使い方を前提にしています
統計の“それっぽい嘘”は人間が見つける
いちばん気を使ったのが、「コードとしては正しく動いているのに、統計としては嘘になっている」箇所です。lintも型チェックも通るので、LoLのドラフトや統計の意味を知っている側が見ないと気づけません。
カウンターの上位が「ガリオ vs アッシュ」
最初のカウンター集計は、対面勝率をそのまま並べていました。
ORDER BY (1.0 * wins / games) DESC
すると上位に、ガリオとアッシュのような、実際の試合ではほとんど干渉しない組み合わせが並びます。原因は2つありました。
- 試合数が少ないペアで極端な勝率が出る
- 単体で強いチャンピオンと弱いチャンピオンの組み合わせが「相性」に見えてしまう
それぞれに、経験ベイズ縮約と、log-odds(対数オッズ)で見た期待勝率との差を当てました。
// 試合数が少ないほど事前分布(母集団の勝率)に寄せる。k の既定は 20
export function shrinkWinRate(wins: number, games: number, priorWr: number, k = SHRINK_K): number {
const denom = games + k;
return denom > 0 ? (wins + k * priorWr) / denom : priorWr;
}
// 単体の強さから期待される勝率と、実際の対面勝率との差(log-odds)
export function counterDelta(shrunkPairWr: number, allyWr: number, enemyWr: number): number {
const expected = logit(allyWr) + logit(1 - enemyWr);
return logit(shrunkPairWr) - expected;
}
たとえば3戦3勝のペアでも、k=20・事前分布50%なら縮約後は約56.5%まで中央に寄ります。ランキングはこの delta の順に並べ、生の勝率と試合数は表示用に残しています。縮約後の値は必ず0〜1の開区間に入るので、勝率0%や100%のペアでもlogitが発散しない、という点もこの式のありがたいところです。
なお、実際のレーン対面だけに絞る案は採用していません。ヴァイ→ゼリのように、ロールをまたいでも正当に干渉する組み合わせまで消えてしまうからです。
ファーストピックにR1が混ざっていた
LoLのドラフトのピック順は B1 → R1 → R2 → B2 → B3 → R3 → R4 → B4 → B5 → R5 で固定です。最初の実装は、DBにある「チーム内で何番目のピックか(1〜5)」だけで判定していました。
- SUM(CASE WHEN gpb."order" = 1 THEN 1 ELSE 0 END) AS firsts,
- SUM(CASE WHEN gpb."order" = 5 THEN 1 ELSE 0 END) AS lasts,
+ SUM(CASE WHEN ${firstPickSql('gpb.')} THEN 1 ELSE 0 END) AS firsts,
これだとファーストピックにB1とR1の両方が入ります。でも相手のピックが1体も見えていない本当のブラインドピックはB1だけで、R1はB1を見てからの応手です。ラストピックも同じで、R5は敵9体を見てから出せますが、B5は相手の最後の1枚を見ずに出します。「チーム内5番目」で束ねると、青と赤が平均されてしまいます。
修正では、サイドとチーム内の順番から通しのスロット番号1〜10を引く表を1か所に置き、SQLの判定もその表から生成するようにしました。区分は「ファーストピック(B1)/ 後出し(残り9枠)」の2つです。
集計はスロット単位のままで、2つに束ねるのは表示側だけです。あとで区切りを変えても再集計がいりません。
ピック数1件で「100%」
公開後にサイト全体を見直していて気づいたのが、トップページのピック順ウィジェットに、ジャンナやマスター・イー、クインといったピック数がごく少ないチャンピオンが100%で並んでいる問題です。最低ピック数が1になっていたため、1回だけ選ばれたチャンピオンでも100%になっていました。
計算としては正しいのですが、見た人は「このチャンピオンは必ずこの順番で取られる」と受け取ります。最低ピック数を10に引き上げ、ホームのウィジェットと詳細ページで同じ閾値を共有するようにしました。
バッチの話
バッチは2本あり、どちらもGitHub Actionsで動いています。
| バッチ | 頻度 | 更新するもの | 置き場所 |
|---|---|---|---|
| 試合データの更新 | 1日2回 |
stats.db(試合データ) |
GCS(デプロイ時にイメージへ焼き込み) |
| チャンピオン情報の更新 | 毎週水曜 |
champions.json とチャンピオン画像 |
リポジトリ(コミットして管理) |
置き場所が違うのは、サイズと更新頻度の差です。stats.db は約295MBあり、GitHubの1ファイル上限(100MB)を超えるのでGCSに置いています。チャンピオンのJSONと画像は小さく、変わるのもパッチのときだけなので、リポジトリで履歴ごと管理しています。
試合データの更新
データの流れは一本道です。
GitHub Actions(1日2回)
└─ GCSから既存の stats.db を取得
└─ Oracle's Elixirから当年のCSVを取得し、新しい試合だけ追記
└─ stats.db をGCSにアップロード → Cloud Buildを起動
└─ stats.db をイメージに焼き込み → Cloud Runへローリングデプロイ
ただし毎回フルで作り直すのではなく、「変化がなければ何もしない」「壊れそうなら止まる」判定を3か所に入れています。
- CSVのmd5が前回と同じなら、約48MBのダウンロードごとスキップ
- 既存DBが空なら、縮小版で本番を上書きしないようエラーで止める
- 新しい試合がなければ、ビルドもデプロイもしない
前回取り込んだCSVのmd5は stats.db 自身の中に記録しています。DBと一緒にGCSへ上がるので、次の実行はそれを見て判断できます。状態を持つための置き場所を、別に用意する必要がありません。
Google Driveの仕様変更を、API移行で根本解決
ある日、バッチが received HTML page without a confirm token で落ち始めました。Oracle's ElixirのCSVはGoogle Driveで公開されていて、大きなファイルをダウンロードするとウイルススキャンの確認ページが挟まります。その確認ページの形式が変わり、トークンを抜き出していた処理が動かなくなったのが原因でした。
選択肢は「抜き出し処理を直す」「gdownを使う」「Google Drive API v3に移行する」の3つ。gdownも結局はページを解析するスクレイパーなので、いずれ同じ壊れ方をします。バッチはすでにWorkload Identity連携でGCPに認証しているので、API移行なら鍵ファイルもSecretも増えません。ということでDrive API v3に決めました。
移行にあわせて、ファイルIDのハードコードをやめて、公開フォルダ内をファイル名で検索する形にしました。
// 年ごとのCSVを、公開フォルダ内からファイル名一致で探す
const { data } = await client.request({
url: DRIVE_API_BASE,
params: {
q: `'${folderId}' in parents and name = '${escapedName}' and trashed = false`,
fields: 'files(id,name,md5Checksum,modifiedTime)',
},
});
年が変わってもファイル名のパターンで見つかるので、毎年のメンテは不要です。
「壊したら戻せない」場所に二重のガード
stats.db はリポジトリに入れていない(gitignore)ので、GitHub Actionsのランナー上には存在しません。バッチの前にGCSから既存のDBを取ってくる手順が抜けると、空のDBに当年分だけ入った縮小版ができて、それで本番のDBを上書きしてしまいます。10年以上分のデータが一瞬で消えるパターンです。
そこで、ワークフロー側の取得ステップ(失敗したらジョブごと止める)に加えて、バッチ本体にもガードを入れています。
// 本番アップロード時に既存DBが空なら、GCSからの取得ステップが抜けている疑いが濃い。
// このまま進めると縮小DBで過去データを上書きしてしまうため、ここで中断する。
if (process.env.UPLOAD_TO_GCS === 'true' && existingIds.size === 0) {
logger.error('Refusing to run: existing DB is empty in upload mode.');
closeDb();
process.exit(1);
}
コミットメッセージにも「取得ステップが将来外れても本番を壊さない二重防御」と理由が残っているので、半年後の自分が読んでも意図が分かります。
チャンピオン情報の更新
チャンピオンの名前と画像は、Riot公式のData Dragonから取り込んでいます。画像はData DragonのCDNを直接参照せず、リポジトリに静的ファイルとして置いています。こうしておくと外部CDNに依存せず、画像もアプリと一緒にイメージに入ります。
GitHub Actions(毎週水曜)
└─ Data Dragonの最新バージョンを確認(取り込み済みと同じなら何もしない)
└─ チャンピオン一覧(英語名・日本語名)と画像を取得
└─ champions.json と画像を更新し、差分があればコミット
└─ mainへのpushでCloud Buildが起動 → イメージに入って本番に反映
更新するのは次の3つです。
-
apps/web/public/champions.json:チャンピオン一覧(英語名・日本語名・画像ファイル名) -
apps/web/public/champions/*.png:チャンピオン画像 -
packages/batch/data/ddragon-version.json:取り込み済みのData Dragonのバージョン
LoL Classicの“分身”で、アイコンが60体ぶん旧アートに
LoL Classicが始まってから、Data Dragonのチャンピオン一覧に Jade_Ahri のような別モード用のエントリが並ぶようになりました。厄介なのは、表示名が本家とまったく同じ(Jade_Ahri も名前は「Ahri」)なことです。アプリは表示名でアイコンを引いていたので、後から来た分身が本家を上書きして、60体のアイコンが旧アートになっていました。
対策は「表示名がかぶったら、数値IDが小さい方を残す」です。Riotは本家に元のID、別モード用の複製にはずらしたID(60000番台)を振っているので、Jade_ という接頭辞を決め打ちせずに済み、今後別の分身が増えても同じように弾けます。あわせて、どこからも参照されなくなった画像を消す処理も入れ、一覧は233体から173体に戻りました。
運用でハマったこと
公開してからハマったのは、主にGCPの請求とインフラの設定でした。
約1,000円の請求、犯人は消し忘れたサービス
アクセスもまだ少ないのに、ある月に約1,000円の請求が来ました。SKU別に分解すると、7月分のCloud Run 866円のうち822円(95%)が「Min Instance」でした。
| SKU | 使用量 | 金額 |
|---|---|---|
| Min Instance CPU | 2,678,419秒 | 426円 |
| Min Instance Memory | 約134万GiB秒 | 396円 |
| リクエスト処理のCPU | 20,088秒 | 36円 |
2,678,419秒 ÷ 31日 = 86,400秒/日。つまり1インスタンスが1か月まるごと起動しっぱなしで、実際にリクエストを処理していたのはそのうち0.75%でした。
原因は、CI/CDでデプロイしている本番の bpgg-web とは別に、初期にコンソールから手で作った bpgg というサービスが残っていたことです。しかも手動スケーリングで、インスタンス数が1に固定されていました。
ハマったのは設定の場所です。minScale をいくら探しても出てきません。手動スケーリングは自動スケーリングとは別系統の設定で、リビジョン側(spec.template)ではなく、サービス側のメタデータに出ていました。
gcloud run services describe bpgg --region asia-northeast1 \
--format="value(metadata.annotations)"
# 出力の中に以下が含まれていた
# run.googleapis.com/scalingMode=manual
# run.googleapis.com/manualInstanceCount=1
ドメインが bpgg-web を向いていることを確認してから bpgg を削除して解決しました。教訓は「請求はサービス別ではなくSKU別に見る」です(Cloud Billing → レポート → SKUでグループ化)。
削除後のCloud Runの請求は、月200円程度に落ち着いています。
Artifact Registryが36.4GBに膨らんでいた
もうひとつ増え続けていたのが、Dockerイメージの置き場であるArtifact Registryです。stats.db を焼き込んでいる分、1イメージがそれなりに大きくなります。
「2世代だけ残す」つもりでクリーンアップポリシーを入れていたのですが、入っていたのはKEEP(残す)のルールだけでした。Artifact Registryは「DELETEが拾ったものの中から、KEEPが守る」という二段構えなので、KEEP単体では1件も削除されません。気づいたときには158ビルド分、36.4GB(約18円/日)まで溜まっていました。
さらに罠がもうひとつありました。実際のバージョン一覧を数えると474件で、158ビルド × 3。1回のビルドで「実イメージ1個+0バイトのマニフェスト2個」の3バージョンができていて、マニフェストのほうが実イメージより約1分後の作成時刻を持っています。そのため素直に keepCount: 2 にすると、上位2件がマニフェストで埋まり、稼働中の実イメージが削除対象になります。
本番の bpgg-web はアクセスがないとゼロまでスケールするので、次のアクセスでコンテナを起動するときにイメージを取りに行きます。そこでイメージが消えていたら502、復旧には再ビルドが必要です。最終的に、こうなりました。
[
{ "name": "delete-all", "action": { "type": "Delete" },
"condition": { "tagState": "any" } },
{ "name": "keep-recent", "action": { "type": "Keep" },
"mostRecentVersions": { "keepCount": 6 } }
]
keepCount は「残したい世代数 × 3」の6です。ポリシーを変えるときは必ずdry-runで入れて、稼働中リビジョンのイメージが残る側に入っているかを確認してから本適用しています。この経緯はリポジトリの CLAUDE.md に書き残しました。
ドメイン:CloudflareのプロキシはOFFに
ドメインはCloudflare Registrarで bpgg.app を取り、Cloud Runのドメインマッピングで紐づけています。注意点はひとつだけで、CloudflareのDNSでプロキシをOFF(灰色の雲)にしておかないと、Cloud Run側の証明書の発行が進みません。
おわりに
BPGGは「読み取り専用・ログインなし・更新はバッチだけ」という前提を最後まで崩さなかったことで、DBサーバーを持たないシンプルな構成にまとまりました。前提がはっきりしていると、SQLiteにするか、バッチをどこで動かすか、といった判断はほとんど迷わずに決まります。
一方で、統計サイトならではの難しさもありました。コードが正しく動いていても、「ファーストピックにR1を数えていいのか」「この勝率は相性なのか、単体の強さなのか」といった“数字の意味”は、LoLのドラフトと統計の両方を知っている人間が確かめるしかありません。ここは今後も手を抜けないところだと思っています。
BPGGはこちらから見られます。LoLのプロシーンを追っている方は、ぜひ触ってみてください。
感想・フィードバックはXまで。
本記事は、実際の開発履歴(Gitのコミット・PR・設計メモ)をもとに、Claudeと一緒に構成・執筆しました。


