DCGのバックエンドをサーバーレスで作ったら、AIの学習データ収集まで楽になった
DCGのバックエンドをサーバーレスで作ったら、AIの学習データ収集まで楽になった
Epic Quest CTOの森本です。
現在開発中のデジタルカードゲーム(DCG)のバックエンドは、Lambda + DynamoDBのサーバーレス構成で作っています。この選択が思った以上に正解だったので、その理由を書きます。
構成
- API Gateway + Lambda(Python):ゲームロジック
- DynamoDB:対戦ステート管理
- クライアント:Unity
ターン制のカードゲームなので、リアルタイム性はそこまで求められない。WebSocketでゴリゴリ同期する必要がなく、REST APIで十分。
なぜサーバーレスか
理由は3つ。
1. 運用が楽
EC2やECSだと、サーバーの面倒を見る必要がある。パッチ当て、スケーリング設定、死活監視。小規模チームでそこに時間を取られたくなかった。
Lambdaなら、コードをデプロイすれば動く。インフラの心配をせずにゲームロジックに集中できる。
2. テストが楽
Lambdaは関数単位で動く。入力を渡して、出力を検証する。これだけ。
ステートレスだから、テスト用のDBを用意したり、前後の状態を気にしたりする必要がない。モックも最小限で済む。
3. コストが良い
使った分だけ課金。開発中はアクセスが少ないから、ほぼお金がかからない。リリース後にスケールしても、トラフィックに応じて自動で伸びる。
コールドスタート対策と実測値
Lambdaの弱点といえばコールドスタート。初回起動に時間がかかる問題。
実測データ(Python 3.9、メモリ512MB)
| 指標 | コールドスタート | ウォームスタート |
|---|---|---|
| p50 | 約800ms | 約50ms |
| p95 | 約1,200ms | 約120ms |
| p99 | 約1,500ms | 約200ms |
正直、ターン制のカードゲームなら数百ミリ秒の遅延は致命的じゃない。でも、体感の良さは大事。カードを出したときにワンテンポ遅れると、それだけでストレスになる。
Provisioned Concurrencyのコスト感
常に一定数のLambdaをウォーム状態で待機させる Provisioned Concurrency も検討した。
試算(東京リージョン、512MBメモリ):
- 1インスタンス常時起動: 約$15/月
- 5インスタンス常時起動: 約$75/月
現状は開発フェーズなので未導入。本番リリース時にユーザー数を見て判断する予定。Pythonは起動が比較的速い言語なので、まずはコールドスタートを許容して運用し、問題が出たら導入する方針。
DynamoDBの設計
対戦のステートはDynamoDBに保存している。
キー設計
- パーティションキー(pk): マッチID
- ソートキー(sk):
STATE|LOG_000001|LOG_000002| …
1つの対戦が1つのマッチIDに紐づく。メインの状態は sk=STATE、操作ログは sk=LOG_XXXXXX で保存。
DynamoDBはキーアクセスが爆速なので、「このマッチの現在の状態を取得」が一瞬で返る。カードゲームのターン制なら、これで十分。
更新競合の防止(楽観的ロック)
同時に2人のプレイヤーが操作した場合、データが壊れる可能性がある。これを防ぐために matchVersion を使った楽観的ロックを実装。
def bump(item):
"""Increment match version by 1."""
item["matchVersion"] = item.get("matchVersion", Decimal(0)) + 1
更新時は必ずバージョンをインクリメント。クライアントは自分の持っているバージョンと比較して、ズレていたら再取得する。
マッチメイキングのレースコンディション防止
2人が同時にマッチングに来たとき、同じマッチに3人入ってしまう問題。DynamoDBの条件付き書き込みで解決。
match_table.put_item(
Item={...},
ConditionExpression=(
"attribute_not_exists(pk) OR "
"expiresAt < :now OR "
"lockedBy = :owner"
),
ExpressionAttributeValues={...}
)
ロックが存在しない、または期限切れ、または自分がオーナーの場合のみ書き込み成功。これでアトミックな排他制御ができる。
TTL(自動削除)
終了したマッチデータは一定期間後に自動削除。DynamoDBのTTL機能を使う。
ttl {
enabled = true
attribute_name = "ttl"
}
Terraformで設定。ttl 属性にUnixタイムスタンプを入れておくと、その時刻を過ぎたアイテムが自動で消える。ストレージコストの削減と、古いデータの掃除が自動化できる。
将来的な拡張:DynamoDB Streams
まだ未実装だが、DynamoDB Streamsを使えば以下が可能になる:
- マッチ終了時に自動でリプレイデータをS3にアーカイブ
- 統計情報の非同期集計
- 不正検知のためのリアルタイム監視
テーブル変更をトリガーにLambdaを起動できるので、本体のゲームロジックを汚さずに機能追加できる。
思わぬ副産物:AI学習データが勝手に溜まる
これが一番の収穫だった。
Lambdaはステートレス。毎回のリクエストが独立してる。つまり、入力(ゲーム状態 + プレイヤーの操作)と出力(次のゲーム状態)がセットで記録できる。
これがそのまま、ゲームAI(CPU対戦)の学習データになる。
- プレイヤーがどの局面でどのカードを出したか
- その結果どうなったか
ステートフルなサーバーだと、この情報を取るために別途ロギングの仕組みを作る必要がある。サーバーレスなら、リクエスト/レスポンスを保存するだけで勝手にデータが溜まっていく。
将来的に強いCPU対戦を作るとき、このデータが活きてくる。
AI駆動開発の実践
このプロジェクトは、Claude(AI)を積極的に活用して開発している。せっかくなので、再現性のあるやり方を共有する。
プロンプトの型(CLAUDE.md)
プロジェクトルートに CLAUDE.md を置いて、AIへの指示を標準化している。
## 基本作業方針
1. **PRD 受領 → Plan 化**
- 要件を受け取ったら、疑問点を質問しクリアにする
- `DocsForAI/Plan/` に実装計画を作成
2. **実装(Imp)**
- 触って良いファイルを明示
- 1機能ずつコミット
3. **テスト**
- pytest + moto でDynamoDBモック
- tests/ にユニットテスト作成
ポイントはスコープの明示。「このファイルだけ触っていい」「1機能ずつコミット」と制約を与えることで、AIが暴走しない。
よくある失敗パターン
| パターン | 原因 | 対策 |
|---|---|---|
| 既存コードを壊す | コンテキスト不足 | 変更前に関連ファイルを読ませる |
| 過剰な抽象化 | 「きれいなコード」への偏り | 「シンプルに」と明示 |
| テストなしで実装 | 指示の省略 | 「テストも書いて」を必ず入れる |
| 型定義の不整合 | GraphQLスキーマとの乖離 | スキーマを先に読ませる |
特に「既存コードを壊す」は頻出。AIは与えられた情報だけで判断するので、関連ファイルを事前に読ませるのが重要。
人間のレビュー観点(チェックリスト)
AIが生成したコードは、以下の観点で必ず人間がレビューする。
セキュリティ
- 入力値のバリデーションは適切か
- SQLインジェクション/NoSQLインジェクションの対策は
- 認証・認可のチェックは漏れていないか
データ整合性
- 楽観的ロック(matchVersion)の更新は漏れていないか
- エラー時のロールバック処理は適切か
- 境界値・異常系のテストはあるか
パフォーマンス
- N+1クエリになっていないか(batch_get_itemを使っているか)
- 不要なスキャンをしていないか
- レスポンスサイズは適切か
可読性
- 変数名・関数名は意図が伝わるか
- 過剰な抽象化をしていないか
- コメントは必要十分か(多すぎず少なすぎず)
AIは「動くコード」は書けるが、「運用しやすいコード」かどうかは人間が判断する必要がある。
まとめ
サーバーレス構成を選んだ理由:
- 運用の手間を減らしたかった
- テストを楽に書きたかった
- コストを抑えたかった
実際やってみて、これに加えて「AI学習データが自然に溜まる」という副産物がついてきた。
ターン制のゲームなら、サーバーレスは相性がいい。リアルタイム性が求められるジャンル(FPSとか格ゲーとか)だと話は変わるけど、DCGならこの構成で十分戦える。
質問や感想があれば、お問い合わせからどうぞ。
ちなみにこの記事もClaudeに書いてもらいました。