AI・生成AI

画像生成AIを本番で動かす — GPUを遊ばせないキュー設計とコスト管理

画像生成AIをサービスに組み込むとき、モデルを動かすこと自体は難しくありません。
難しいのは GPU を遊ばせずに、かつ待たせすぎない運用です。

GPU は「待機時間も課金される」

Web アプリの感覚で GPU インスタンスを常時起動しておくと、
リクエストが無い時間もそのまま費用になります。
かといって都度起動すると、モデルのロードに数分かかって使い物になりません。

現実的な落としどころはこうでした。

[Web(CPU)] --- ジョブ投入 ---> [キュー] <--- ポーリング --- [GPUワーカー]
                                              ↓
                                        生成 → ストレージ → 完了通知

Web と GPU を完全に分離し、GPU 側はキューを見るだけにします。

キューの状態を必ず持つ

class GenerationJob(models.Model):
    class Status(models.TextChoices):
        QUEUED = "queued"
        RUNNING = "running"
        DONE = "done"
        FAILED = "failed"

    status = models.CharField(max_length=10, choices=Status.choices)
    started_at = models.DateTimeField(null=True)
    finished_at = models.DateTimeField(null=True)
    error = models.TextField(blank=True)

started_at を持つのが重要です。
RUNNING のまま一定時間を超えたジョブを検出して復旧できます。

stale = GenerationJob.objects.filter(
    status="running", started_at__lt=now() - timedelta(minutes=15)
)
stale.update(status="queued", started_at=None)   # 拾い直す

GPU ワーカーが落ちると、ジョブが RUNNING のまま永遠に残ります。
この回収処理が無いと、ユーザーの画面は「生成中」のまま止まります。

同時実行数は GPU メモリで決まる

CPU のワーカー数の感覚で増やすと、すぐ VRAM が溢れて落ちます。
モデルのVRAM使用量から逆算して、1GPUあたりの同時実行数を固定します。

MAX_CONCURRENT = 2   # 実測して決める。増やすと OOM

推測ではなく、実際に生成しながら nvidia-smi で見て決めました。

コストを抑える3つの運用

1. アイドル時間で自動停止

一定時間ジョブが無ければインスタンスを停止し、
投入時に起動する構成にすると費用が大きく変わります。
起動待ちが発生するので、待ち時間が許容される機能に限って適用します。

2. 生成物のキャッシュ

同じパラメータの生成は結果を使い回します。
プロンプトとシードのハッシュをキーにするだけで、体感が変わります。

key = hashlib.sha256(f"{prompt}|{seed}|{model}".encode()).hexdigest()

3. 解像度を段階的に

まず低解像度でプレビューを返し、ユーザーが選んだものだけ高解像度で生成します。
GPU時間の大半は使われない候補の生成に消えていたので、これが一番効きました。

生成物の扱い

生成画像をアプリのサーバーに置くとディスクが溢れます。
オブジェクトストレージに逃がし、署名付きURLで期限を切って配信します。

直リンクで無制限に配れる状態にすると、帯域費用が読めなくなります。

まとめ

  • Web と GPU を分離し、GPU はキューを見るだけにする
  • started_at を持ち、放置された RUNNING を回収する
  • 同時実行数は VRAM から実測で決める
  • プレビュー段階の解像度を下げるのが最大のコスト削減
  • 生成物は署名付きURLで期限を切る