はじめに
OpenAI 互換の LLM API ゲートウェイ Apixo を個人開発しています(OpenRouter のように、統一 API・トークン課金・複数モデルのバックエンド)。上流 API との接続に加えて、Hugging Face のオープンウェイトモデルを自前でホストしようとして、RunPod Serverless でいくつもハマりました。同じことをやろうとしている方の参考になれば幸いです。
要件
- 使った分だけ課金・アイドル時はゼロ円(常時稼働の Pod は RTX PRO 6000 で約 $3.49/時間なので NG)
- 長いコンテキスト:256K(262144)トークン
-
標準の OpenAI
/v1エンドポイント:OpenCode や OpenAI SDK からそのまま使いたい
モデルは Qwen3.8-27B の GGUF(Q5 量子化)、プラットフォームは RunPod Serverless です。
ハマりどころ 1:GGUF に vLLM Worker は使えない
RunPod のおすすめは vLLM Worker ですが、最初に FP8 モデルを vLLM で動かしたところ、デプロイは成功するのにクライアントに応答が返ってきませんでした。GGUF に切り替えて気づいたのは:
- GGUF は llama.cpp で動かすべき
- 公式イメージ
ghcr.io/ggml-org/llama.cpp:server-cudaがそのまま使える
起動コマンド例(リポジトリ名は自分のものに置き換えてください):
llama-server \
--host 0.0.0.0 --port 8080 \
-hf <hfユーザー名>/<リポジトリ名>:<量子化名> \
-c 262144 \
-ngl 99 \
--flash-attn on \
--cache-type-k q8_0 --cache-type-v q8_0
-
-hfは指定した量子化ファイル 1 つだけをダウンロードします(ハマりどころ 3 で重要) - KV キャッシュを
q8_0にすると 256K コンテキストの VRAM 使用量を大きく削減できます。V キャッシュの量子化には flash attention が必要です(オプション表記はバージョンにより異なるのでllama-server --helpで確認してください) - ポートは RunPod エンドポイントの設定と合わせる
実測では、llama.cpp でこのモデルは問題なくロードでき、Blackwell 世代の RTX PRO 6000(96GB)で 262144 コンテキスト + q8_0 KV キャッシュの確保にも成功しました。
ハマりどころ 2:Queue 型エンドポイントには OpenAI /v1 がない
| 種類 | 呼び出し方 | OpenAI API としてそのまま使えるか |
|---|---|---|
| Queue 型 |
/run、/runsync にジョブ投入 |
❌ 自前でラップが必要 |
| Load Balancer 型 | HTTP をコンテナにそのまま転送 | ✅ /v1/chat/completions に直接アクセス可 |
OpenCode や OpenAI SDK から直接つなぐなら Load Balancer 型一択 です。
from openai import OpenAI
client = OpenAI(
base_url="https://<エンドポイントID>.api.runpod.ai/v1",
api_key="<RUNPOD_API_KEY>",
)
resp = client.chat.completions.create(
model="local",
messages=[{"role": "user", "content": "こんにちは"}],
)
print(resp.choices[0].message.content)
ハマりどころ 3:「Cached model」に複数量子化の GGUF リポジトリを入れると永遠に initializing
RunPod には Hugging Face モデルを事前キャッシュする「Cached model」欄があります。ここに GGUF リポジトリを指定したところ:
- Worker がずっと initializing のまま
- コンテナログがまったく出ない
- ゲートウェイは 403 / 500 を返す
原因は、GGUF リポジトリには十数種類の量子化ファイルが入っており、RunPod が リポジトリ全体をキャッシュしようとする こと(私の場合は約 180GB)。終わるはずがありません。
対策:Cached model 欄を空にして、llama.cpp の -hf で必要な量子化だけを実行時に取得する。空にした瞬間にエンドポイントが Ready になりました。
診断のコツ
Worker が initializing のまま、ログがゼロ、課金も $0.00 → コンテナがそもそも起動していない(起動前に詰まっている)。
クラッシュループなら必ずログが出て、課金時間も発生します。
ハマりどころ 4(根本原因):ヘルスチェックの仕様 × 2 分を超えるコールドスタート
ここまで直しても timed out waiting for worker が出続けました。原因は 2 つの仕様の組み合わせです。
1. RunPod Load Balancer のヘルスチェック
/health のステータス |
RunPod の解釈 |
|---|---|
| 200 | 正常、トラフィックを受ける |
| 204 | 初期化中 |
| それ以外(503 など) | 異常、ルーティングから外す |
llama-server はモデルのロード中、/health に 503 を返します。つまりロード中ずっと「壊れた Worker」と見なされます。
2. 待ち時間の上限は約 2 分、コールドスタートは約 5 分
- そのうち約 97 秒 は 256K コンテキスト用の KV キャッシュ確保で、キャッシュでは短縮できない
- イメージのエントリポイントが
llama-serverバイナリそのものなので、204 を返すサイドカーも置けない
結論:コールドスタートを 2 分未満にするか、「ウォームアップしてから使う」しかない。
打ち手:
- コンテキスト長を下げる(最大のレバー)。全タスクに 256K は不要
- ウォームアップスクリプト:200 が返るまでダミーリクエストを投げ続け、アイドルタイムアウト(私は 300 秒)内に作業する
- Network Volume でのモデルキャッシュ:私の DC(US-PA-1)では使えず、RTX PRO 6000 がある対応リージョンも在庫が少ないため断念
import time, requests
URL = "https://<エンドポイントID>.api.runpod.ai/v1/chat/completions"
HEADERS = {"Authorization": "Bearer <RUNPOD_API_KEY>"}
PAYLOAD = {"model": "local", "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}
start = time.time()
while True:
try:
r = requests.post(URL, json=PAYLOAD, headers=HEADERS, timeout=150)
if r.status_code == 200:
print(f"ウォームアップ完了:{time.time() - start:.0f} 秒")
break
print("ステータス", r.status_code, "待機中…")
except requests.RequestException as e:
print("リクエスト例外:", e)
time.sleep(10)
最終構成とコスト
- エンドポイント種別:Load Balancer
- イメージ:
ghcr.io/ggml-org/llama.cpp:server-cuda - GPU 優先順位:96GB PRO(RTX PRO 6000)→ 80GB PRO(H100)
- Worker:常駐 0 / 最大 1、アイドルタイムアウト 300 秒
- Network Volume なし、Cached model なし
初日のデバッグで約 $1.10 使いましたが、すべて「起動したのに 1 リクエストも処理できなかった」Worker の分でした。
まとめ
- GGUF → llama.cpp。vLLM Worker は使わない
- OpenAI API が欲しいなら Load Balancer 型エンドポイント
- 複数量子化の GGUF リポジトリを Cached model に入れない
- ログゼロ + 課金ゼロ = コンテナ未起動
- llama-server はロード中
/healthが 503。コールドスタートは 2 分未満にするか、ウォームアップ前提で - コールドスタート時間の最大要因は長いコンテキスト。必要な分だけ設定する
この知見は、OpenAI 互換の LLM API ゲートウェイ Apixo を作る中での自前ホストの試行から得たものです。同じように Serverless で LLM を動かしている方、ぜひコメントで情報交換しましょう。