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つあります。
-
公開URLは起動のたびに変わる(
trycloudflare.comのサブドメインはランダム)。 - 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… 「この端末はこの台を使用中」と在席を知らせる(後述)
register と heartbeat の実装はこれだけです。
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台を掴んで、落ちたら乗り換える
レジストリができたので、フロントは起動時に次をやります。
-
?action=listで一覧を取る - 生きていて・空いている台を選ぶ
- その台の
/healthを叩いて本当に応答するか確かめる - 通ったら
localStorageに保存して、以降その端末はその台を使い続ける - 途中で落ちたら、別の台へ自動で乗り換える
選定ロジックの中心が 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在席方式
最初は素朴に「assign で assignedCount を +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(このセルを動かし続ける)
register と heartbeat は、さっきの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.Lock で1台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 | 使わない |