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 を使った楽観的ロックを実装。

python
def bump(item):
    """Increment match version by 1."""
    item["matchVersion"] = item.get("matchVersion", Decimal(0)) + 1

更新時は必ずバージョンをインクリメント。クライアントは自分の持っているバージョンと比較して、ズレていたら再取得する。

マッチメイキングのレースコンディション防止

2人が同時にマッチングに来たとき、同じマッチに3人入ってしまう問題。DynamoDBの条件付き書き込みで解決。

python
match_table.put_item(
    Item={...},
    ConditionExpression=(
        "attribute_not_exists(pk) OR "
        "expiresAt < :now OR "
        "lockedBy = :owner"
    ),
    ExpressionAttributeValues={...}
)

ロックが存在しない、または期限切れ、または自分がオーナーの場合のみ書き込み成功。これでアトミックな排他制御ができる。

TTL(自動削除)

終了したマッチデータは一定期間後に自動削除。DynamoDBのTTL機能を使う。

hcl
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への指示を標準化している。

markdown
## 基本作業方針

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に書いてもらいました。