2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

無料で複数GPU推論バックエンドを構築する:Colab × Cloudflare Tunnel × GASレジストリ

2
Posted at

1. はじめに

あるイベント用(1日限定)に、ブラウザから音声生成(TTS)を叩けるWebアプリを作っていました。もちろん、モデル推論にはGPUが必要です。

しかし、

  • 常時起動のGPUサーバーを借りると、それなりにお金がかかる。
  • 同時に複数人が使う。1台では推論が詰まる。
  • 予算は実質ゼロ。

1日限定で使用する簡易的なアプリでしたので、「無料GPUを複数枚かき集めて束ねる」方向に振り切りました。
無料GPUと言えば!!そう!!Google Colabです。

全体像はこうなります。

2. なぜ Colab + トンネルなのか

Colab 上で FastAPI を起動すれば、無料でGPU付きの推論サーバーが手に入ります。が、Colab のインスタンスには外から到達できる固定IPもドメインも無い。なので「トンネル」で外に穴を開けます。

最初は ngrok を使っていましたが、無料枠は 1アカウントにつき同時1トンネル。5台同時に立てたい本番では使用できません。そこで本番は Cloudflare Quick Tunnel に切り替えました。

ngrok(無料) Cloudflare Quick Tunnel
同時トンネル数 1本 複数OK
アカウント/鍵 必要 不要
警告ページ 出る 出ない
用途 手元1台の動作確認 本番の複数台

cloudflared のバイナリを落として tunnel --url http://localhost:8000 を叩くだけで、https://xxxx.trycloudflare.com が即発行されます。ngrokと違い、鍵もアカウントも必要ないので、Colabに貼る秘密情報も減ります。

# Cloudflare Quick Tunnelを張る部分
_cloudflared_proc = subprocess.Popen(
    [bin_path, "tunnel", "--no-autoupdate", "--url", f"http://localhost:{PORT}"],
    stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
)
# 標準出力に流れてくる発行URLを正規表現で拾う
pattern = re.compile(r"https://[-a-z0-9]+\.trycloudflare\.com")
while time.time() < deadline:
    line = _cloudflared_proc.stdout.readline()
    m = pattern.search(line)
    if m:
        return m.group(0)   # ← これが今回の公開URL

⚠️ cloudflared の標準出力を読み捨てずに放置すると、パイプが詰まってトンネルごと固まります。URLを拾った後は別スレッドで出力を drain し続ける必要がありました。

3. 問題:「URLが毎回変わる」「いつ落ちるか分からない」

トンネルで公開はできました。でも本番運用には致命的な性質が2つあります。

  1. 公開URLは起動のたびに変わるtrycloudflare.com のサブドメインはランダム)。
  2. Colab はいつでも落ちうる(無料枠のアイドル切断、セッション上限など)。

つまりフロントに「バックエンドのURL」を固定で埋め込むことができない。しかも台数も可変(5台の日も10台の日もある)。

ここで必要になるのが「いま生きているサーバーのURL一覧を、どこかが集中管理する」仕組み = レジストリです。普通なら小さなDB + APIサーバーを立てるところ。でもそれにもお金と運用がかかる。

そこで、Google Apps Script(GAS)+ スプレッドシートをレジストリにしました。

4. GAS + スプレッドシートをサーバーレジストリにする

なぜ外部DBじゃなくGASなのか

  • 0円運用ゼロ(Googleアカウントがあるだけ)。
  • GAS の Web アプリは https://script.google.com/macros/s/.../exec という固定URLを1本くれる。これは絶対に変わらない。フロントにはこの1本だけ埋め込めばいい。
  • スプレッドシートがそのまま管理画面になる。今どのサーバーが生きてるか、目で見える・手で直せる。

スプレッドシート(servers シート)はこの列構成です。

serverId | color | label | apiUrl | enabled | capacity | assignedCount | lastSeen
  • apiUrl … トンネルの公開URL(毎回変わる)
  • lastSeen … 最後に heartbeat が来た時刻(生死判定に使う)
  • enabled … 手動でオフにもできるフラグ

GAS は4つ+αのアクションを持つWeb API

GAS の doGet / doPost で、レジストリの読み書きを薄いHTTP APIとして公開します。

Colab側が叩く(書き込み系)

  • register … 起動時に「自分のURLはこれ」と登録する
  • heartbeat … 30秒ごとに「まだ生きてる」と lastSeen を更新する

フロント側が叩く(読み取り+更新系)

  • list … 生きているサーバー一覧を取得する
  • presence … 「この端末はこの台を使用中」と在席を知らせる(後述)

registerheartbeat の実装はこれだけです。

function doPost(e) {
  var lock = LockService.getScriptLock();
  lock.waitLock(10000); // 同時更新を防ぐ(複数Colabが同時にregisterしてくる)
  try {
    var body = JSON.parse(e.postData.contents);
    var action = e.parameter.action;
    var now = Date.now();
    var existing = findRow_(readRows_(), body.serverId);

    if (action === 'register') {
      var rec = {
        serverId: body.serverId, color: body.color, label: body.label,
        apiUrl: body.apiUrl, enabled: true,
        capacity: Number(body.capacity), assignedCount: 0,
        lastSeen: now,
      };
      // 既にあれば上書き(URLが変わって再起動したケース)、無ければ追記
      existing ? writeRow_(sheet, existing.rowIndex, rec)
               : sheet.appendRow([/* ...rec... */]);
      return jsonOut_({ ok: true });
    }

    if (action === 'heartbeat') {
      existing.lastSeen = now;          // ← 生存を更新
      if (body.apiUrl) existing.apiUrl = body.apiUrl;  // URLが変わってたら追従
      writeRow_(sheet, existing.rowIndex, existing);
      return jsonOut_({ ok: true });
    }
    // ...
  } finally {
    lock.releaseLock();
  }
}

ポイントは LockService。複数のColabが同時に register/heartbeat してくるので、ロック無しだとスプレッドシートの読み書きが競合して行が壊れます。GASの LockService.getScriptLock() で直列化しています。

list 側はシンプルに今の一覧をJSONで返すだけ:

function doGet(e) {
  if (e.parameter.action === 'list') {
    var rows = readRows_().map(function (r) {
      return { serverId: r.serverId, color: r.color, label: r.label,
               apiUrl: r.apiUrl, enabled: r.enabled,
               capacity: r.capacity, activeCount: /* 後述 */,
               lastSeen: r.lastSeen };
    });
    return jsonOut_({ servers: rows });
  }
}

これで「変わり続けるバックエンドURLの一覧を、固定URL1本の裏に集約する」ことができました。

5. フロント側:空いてる1台を掴んで、落ちたら乗り換える

レジストリができたので、フロントは起動時に次をやります。

  1. ?action=list で一覧を取る
  2. 生きていて・空いている台を選ぶ
  3. その台の /health を叩いて本当に応答するか確かめる
  4. 通ったら localStorage に保存して、以降その端末はその台を使い続ける
  5. 途中で落ちたら、別の台へ自動で乗り換える

選定ロジックの中心が rankServers です。

export function rankServers(servers: ServerInfo[]): ServerInfo[] {
  const freshMs = getServerFreshSeconds() * 1000
  const now = Date.now()
  return servers
    .filter((s) => s.enabled)
    // heartbeat が新しい(=生きている)台だけ残す
    .filter((s) => now - lastSeenMs(s.lastSeen) <= freshMs)
    .sort((a, b) => {
      // 負荷が軽い順 → 同じなら heartbeat が新しい順
      const loadA = loadOf(a), loadB = loadOf(b)
      if (loadA !== loadB) return loadA - loadB
      return lastSeenMs(b.lastSeen) - lastSeenMs(a.lastSeen)
    })
}

lastSeen で生死を判定しているのがポイント。heartbeatが一定時間来ていない台は、たとえ行が残っていても候補から外れる。Colabが黙って落ちても、フロントは自然に避けてくれます。

そして実際の割り当て。選ぶ前に必ず /health を叩いて、死んでる台を掴まないようにしています。

export async function assignFreshServer(excludeId?: string): Promise<Assignment> {
  const servers = await fetchServers()
  const ranked = rankServers(servers).filter((s) => s.serverId !== excludeId)
  for (const s of ranked) {
    if (await checkHealth(s.apiUrl)) {   // ← 実際に応答する台だけ採用
      const assignment = toAssignment(s)
      saveAssignment(assignment)         // localStorage に固定
      void sendPresence(s.serverId)      // すぐ在席を知らせる
      return assignment
    }
  }
  throw new Error('使えるサーバーが見つかりませんでした')
}

excludeId で「さっき失敗した台」を除外できるので、接続失敗時はこれを渡して別の台へ乗り換えます。

起動時の入り口 ensureAssignment は「localStorage に保存済みの台があってヘルスが通るならそれを優先、ダメなら取り直し」という流れ:

export async function ensureAssignment(): Promise<EnsureResult> {
  const saved = loadAssignment()
  if (saved && (await checkHealth(saved.apiUrl))) {
    return { status: 'ok', assignment: saved }   // 前回の台が生きてればそのまま
  }
  // ダメなら別の台へ
  const assignment = await assignFreshServer(saved?.serverId)
  return { status: 'ok', assignment }
}

6. 「割り当て数」をどう数えるか:カウンタ方式 → TTL在席方式

最初は素朴に「assignassignedCount を +1、離脱で -1」というカウンタ方式にしていました。が、これはすぐ壊れます

  • 端末がタブを閉じる/リロードする/電源が落ちる、では「-1」が飛んでこない。
  • 結果、assignedCount が実態とずれて増え続け、全台が「満員」に見えて誰も割り当てられない

そこで TTL(在席)方式 に変えました。発想は「カウントを足し引きする」のをやめて、「今この瞬間、生きてる端末を数え直す」に倒すこと。

  • フロントは使用中、定期的に presence(deviceId + serverId)を送り続ける。
  • GAS は端末ごとに最後の presence 時刻を持つ(presence シート)。
  • list のたびに「直近90秒以内に presence が来た端末だけ」を数えて、それを各サーバーのライブ負荷(activeCount)として返す。
// TTL以内の在席端末だけをサーバー別に数える
var PRESENCE_TTL_MS = 90 * 1000;

function activeCounts_(now) {
  var map = {};
  readPresence_().forEach(function (p) {
    if (now - p.lastSeen <= PRESENCE_TTL_MS) {        // 古い在席は無視
      map[p.serverId] = (map[p.serverId] || 0) + 1;
    }
  });
  return map;  // { 'colab-1': 2, 'colab-2': 1, ... }
}

これなら端末が黙って消えても、90秒後には自動的に負荷から外れる。「減算イベントが届かない」問題が構造的に起きません。assignedCount のカラムは互換のため残しつつ、フロントは activeCount(在席数)を優先して負荷判定するようにしました。

// 在席数があればそれを、無ければ assignedCount で代替
function loadOf(s: ServerInfo): number {
  return s.activeCount ?? s.assignedCount ?? 0
}

学び:分散環境の「現在の負荷」は、増減イベントを積み上げる(push)より、生きてる物を毎回数え直す(poll + TTL)方が壊れにくい。heartbeat でサーバーの生死を見ているのと、まったく同じ発想を端末側にも適用した形です。

7. Colab側の起動コードは「全自動」にする

本番では人がColabを5〜10枚ぽちぽち起動します。毎回手で依存を入れてURLを登録して…は事故の元なので、最後のセルを1つ実行したら全部終わるようにしました。

# Colab 最後のセル
import os
os.environ['GAS_URL']      = userdata.get('GAS_URL')  # 直書きしない
os.environ['TUNNEL']       = 'cloudflare'             # 本番は複数台OKのCloudflare
os.environ['SERVER_ID']    = 'colab-1'
os.environ['SERVER_COLOR'] = 'red'
os.environ['CAPACITY']     = '2'
%run colab/colab_runner.py

colab_runner.py がやることは5ステップ:

def main():
    install_dependencies()       # [1] pip install(AI依存が失敗してもdummyで起動継続)
    start_backend()              # [2] FastAPI を別スレッドで起動 → /health 待ち
    api_url = open_tunnel()      # [3] Cloudflare/ngrok で公開URLを取得
    register_to_gas(api_url)     # [4] GASにそのURLをregister
    heartbeat_loop(api_url)      # [5] 30秒ごとにheartbeat(このセルを動かし続ける)

registerheartbeat は、さっきのGASに対して素直にPOSTするだけ:

def register_to_gas(api_url):
    requests.post(GAS_URL, params={"action": "register"},
                  json={"serverId": SERVER_ID, "color": SERVER_COLOR,
                        "label": SERVER_LABEL, "apiUrl": api_url, "capacity": CAPACITY})

def heartbeat_loop(api_url):
    while True:
        time.sleep(HEARTBEAT_SEC)   # 既定30秒
        requests.post(GAS_URL, params={"action": "heartbeat"},
                      json={"serverId": SERVER_ID, "apiUrl": api_url})

これで「Colabを起動 → 勝手にレジストリに載る → フロントから掴まれる」が成立します。サーバーを増やしたければColabのセルを1枚増やすだけ。台数はどこにもハードコードされていません

8. 限界

実際に運用してみて引っかかった点と、この構成の正直な弱点です

GAS の CORS は素直じゃない

GAS の Web アプリは OPTIONS(プリフライト)に応答しません。なのでフロントからカスタムヘッダを付けたり Content-Type: application/json でPOSTしたりすると、プリフライトで弾かれます。これを避けるため、フロントからGASへのリクエストは「単純リクエスト」に寄せました:

// presence はクエリパラメータに全部載せて、ヘッダもボディも付けない
await fetch(
  `${gas}?action=presence&serverId=${serverId}&deviceId=${deviceId}`,
  { method: 'POST' },   // ← ヘッダ無し = プリフライト無し
)

GAS には実行クォータがある

GAS の Web アプリには1日あたりの実行時間・呼び出し回数の上限があります。今回は「数十台 × 数十端末 × 30〜90秒間隔」くらいの規模なので余裕でしたが、秒間大量アクセスを捌くものではない。あくまで「ゆっくり変わるサーバー一覧」の管理に向いた使い方です。

1台あたりの同時処理

Colab 1台のGPUで推論を並列に走らせると詰まるので、バックエンド側は asyncio.Lock1台1件ずつ順番に処理しています。だから「台数 ≒ 同時に捌ける人数」。負荷分散がそのままスループットに効く構造です。

当然、SLAは無い

Colabもトンネルも無料枠。いつ落ちてもおかしくない前提で全部組んでいます。フロント側の「health確認 → 別の台へ乗り換え」が、その前提に対する保険になっています。

9. まとめ

  • 無料GPU = Colab、外に出すのは Cloudflare Quick Tunnel(複数台・鍵不要)。
  • URLが毎回変わる/落ちる問題は、GAS+Sheetsの簡易レジストリで吸収。固定URL1本だけフロントに埋める。
  • フロントは list → 空き順 → /health → localStorage固定 → 失敗で乗り換え
  • 負荷カウントは 増減カウンタではなくTTL在席方式にして壊れにくくした。
  • サーバー増設は Colabのセルを1枚増やすだけ。台数はハードコードしない。

「個人開発でGPU推論バックエンドが要るが、お金はかけたくない」人にそのまま流用できる構成だと思います。(本番環境では不安定すぎて、使えないかと。。)レジストリ部分(GAS)はTTS以外の用途、LLM推論でも画像生成でもそのまま使い回せると思います。

無料でGPUを利用できる環境を提供してくれるGoogleに深く感謝します。


構成まとめ

項目 採用
フロント React + TypeScript(固定URLでホスティング)
バックエンド FastAPI(Google Colab上)
外部公開 Cloudflare Quick Tunnel(本番・複数台) / ngrok(手元1台)
サーバーレジストリ GAS + Google スプレッドシート
端末ごとの接続先保存 localStorage
負荷カウント TTL在席方式(presence)
外部DB 使わない
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?