mdx.jp 上の A100 x2 サーバで vLLM を動かし、その API を Cloudflare Tunnel 経由で外部から使えるようにしました。目的は、ローカル PC や別サーバから OpenAI互換API として安全に叩けるようにすることです。

この記事では、vLLM 自体の起動ではなく、Cloudflare を使った公開部分に絞ってまとめます。LLM-jp-4-32b-a3b-thinkingvLLM で動かした話そのものは、以下の記事に分けてあります。

やりたかったこと

やりたかったのは次の構成です。

  • mdx.jp 上で vLLM を Docker コンテナとして起動する
  • サーバのインバウンドポートは開けない
  • Cloudflare Tunnel 経由で https://llm.example.jp/v1/... のような URL で到達させる
  • SSH は Cloudflare Zero Trust 経由に寄せる

イメージとしては以下です。

ローカルPC
  ├── https://llm.example.jp/v1/chat/completions
  └── ssh mdx-llm-cf
      Cloudflare
   cloudflared on mdx.jp
          ├── host.docker.internal:8000 -> vLLM API
          └── host.docker.internal:22   -> SSH

ポイントは、vLLMcloudflared を別コンテナで動かしつつ、cloudflared からホスト経由で vLLM に到達させることです。

前提

今回の前提は以下です。

  • サーバ: mdx.jp
  • OS: Ubuntu 22.04
  • GPU: A100 40GB x2
  • docker 導入済み
  • cloudflared を使う Cloudflare アカウントと管理対象ドメインがある
  • Hugging Face のトークンが設定済み

GPU は以下で確認しました。

nvidia-smi --query-gpu=name,memory.total --format=csv,noheader

出力例:

NVIDIA A100-SXM4-40GB, 40960 MiB
NVIDIA A100-SXM4-40GB, 40960 MiB

vLLM の起動

まず vLLM をホスト上で起動します。

HF_TOKEN=$(cat ~/.cache/huggingface/token)

docker run -d \
  --name vllm \
  --restart unless-stopped \
  --runtime nvidia \
  --gpus all \
  --ipc=host \
  -p 8000:8000 \
  -v ~/.cache/huggingface:/root/.cache/huggingface \
  -e HUGGING_FACE_HUB_TOKEN=$HF_TOKEN \
  vllm/vllm-openai:latest \
  --host 0.0.0.0 \
  --port 8000 \
  --model llm-jp/llm-jp-4-32b-a3b-thinking \
  --trust-remote-code \
  --tensor-parallel-size 2

重要なのは --host 0.0.0.0 です。

最初、127.0.0.1:8000 のまま動かしていたところ、Cloudflare 側からは 502 になりました。cloudflared は別コンテナなので、localhost バインドのままだと到達できません。これを 0.0.0.0 に変えると、ホストの 8000 に対してトンネル側から接続できるようになりました。

起動確認は以下でできます。

curl http://localhost:8000/v1/models

Cloudflare Tunnel の作成

ここでは、run --token を使う remote-managed tunnel を前提にします。先に Zero Trust のダッシュボードまたは API で Tunnel を作成し、API 用と SSH 用のホスト名を割り当てたうえで、TUNNEL_TOKEN を取得します。

cloudflared tunnel createlocally-managed tunnel の流れなので、この記事の後段にある run --token とそのまま組み合わせる説明にはしない方が安全です。

cloudflared コンテナの起動

次に、サーバ側で cloudflared を動かします。

docker run -d \
  --name cloudflared \
  --restart unless-stopped \
  --add-host host.docker.internal:host-gateway \
  cloudflare/cloudflared:latest \
  tunnel --protocol http2 run --token <TUNNEL_TOKEN>

--add-host host.docker.internal:host-gateway が重要です。これがないと、コンテナ内の cloudflared からホスト上の 800022 に届きません。

また、今回は --protocol http2 を付けました。mdx.jp 側の環境では、UDP 系の制限で QUIC が不安定になる可能性があるためです。

Ingress の設定

Tunnel に対して、API 用ホスト名と SSH 用ホスト名のルーティングを設定します。概念的には以下です。

{
  "config": {
    "ingress": [
      {
        "hostname": "llm.example.jp",
        "service": "http://host.docker.internal:8000"
      },
      {
        "hostname": "ssh-llm.example.jp",
        "service": "ssh://host.docker.internal:22"
      },
      {
        "service": "http_status:404"
      }
    ]
  }
}

API 用ホスト名は vLLM の OpenAI 互換 API に流し、SSH 用ホスト名はホストの SSH に流しています。

最後の http_status:404 はキャッチオールなので必須です。

外部からの動作確認

トンネル設定後、外部から以下で確認できます。

curl https://llm.example.jp/v1/models

今回の環境では、以下のようにモデル一覧が返りました。

{
  "object": "list",
  "data": [
    {
      "id": "llm-jp/llm-jp-4-32b-a3b-thinking"
    }
  ]
}

チャット補完もそのまま確認できます。

curl https://llm.example.jp/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llm-jp/llm-jp-4-32b-a3b-thinking",
    "messages": [
      {"role": "system", "content": "あなたは日本語アシスタントです。"},
      {"role": "user", "content": "日本の首都を一文で答えてください。"}
    ],
    "temperature": 0,
    "max_tokens": 256
  }'

返答は thinking モデルなので、次のように analysis を含むことがあります。

analysis ...
assistant final 日本の首都は東京です。

この点は vLLM 側というより、モデル出力の性質です。アプリケーション側で assistant final を抜く処理を入れておくと扱いやすくなります。

thinking モデルを vLLM で使うのは自然か

結論からいうと、thinking モデルを vLLM でサーブすること自体は自然です。大きめのモデルを GPU 上で効率よく動かし、OpenAI互換API としてまとめたいなら、vLLM はむしろ第一候補です。

ただし、自然なのはあくまで「サーブの方法」としてです。thinking モデルそのものは、API 利用時の出力が必ずしも素直ではありません。今回のように、最終回答の前に analysis のような中間表現が出る場合があります。

そのため、用途ごとの相性は次のように考えるのがよさそうです。

  • 推論力を優先したい: thinking + vLLM
  • 外部公開 API として素直に使いたい: 通常版または instruct 系モデル + vLLM

特に、後段のプログラムが「JSONだけ返ってきてほしい」「余計な説明を付けてほしくない」という前提で動く場合は、thinking より通常版の方が扱いやすいです。

もっと素直なやり方

今回の構成は動きますが、もし目的が「安定した API サービスを作ること」なら、より素直な選択肢もあります。

1. thinking ではないモデルを使う

最も素直なのはこれです。

  • 出力整形が減る
  • assistant final のような後処理を避けやすい
  • API クライアント側の実装が単純になる

研究用途や検証用途では thinking を試す意味がありますが、運用 API としては通常版の方がトラブルは少ないはずです。

2. thinking モデルは内部バッチ専用にする

thinking モデルの強みを活かしたいなら、公開 API というより、内部ジョブやバッチ処理向けに寄せるやり方もあります。

  • OCR テキストの抽出
  • ラベル付け
  • 候補生成
  • 人手確認前の下処理

このような用途であれば、多少出力に癖があっても、クライアント側の後処理で吸収しやすいです。

3. API は通常版、検証だけ thinking に分ける

実運用では、この分離が一番現実的かもしれません。

  • 公開 API: 通常版モデル
  • 検証用または高難度処理: thinking モデル

これなら、利用者向けの API は単純に保ちつつ、必要な場面だけ thinking の推論力を使えます。

Zero Trust SSH の設定

SSH も Cloudflare 経由にすると、22番ポートを外に開けずに済みます。

ここで 1 点重要なのは、ブラウザ認証を伴う Zero Trust SSH にしたい場合は、ssh-llm.example.jp に対して Cloudflare Access の SSH アプリケーションとポリシーを作成しておく必要があることです。~/.ssh/config だけでは認証画面は出ません。

その前提で、ローカルの ~/.ssh/config には以下を追加します。

Host mdx-llm-cf
  HostName ssh-llm.example.jp
  User mdx-user01
  IdentityFile ~/.ssh/mdx/id_rsa
  ProxyCommand cloudflared access ssh --hostname %h

この設定で、以下のように接続できます。

ssh mdx-llm-cf

初回はブラウザで Cloudflare Access 認証が入ります。

詰まった点

今回、実際に詰まったのは主に以下の 3 点でした。

1. 127.0.0.1 バインドのままだと 502 になる

これが一番ハマりやすいところでした。

  • vLLM は動いている
  • ホスト上では curl http://localhost:8000/v1/models が通る
  • しかし https://llm.example.jp/v1/models502

原因は、cloudflared が別コンテナであり、127.0.0.1vLLM に届かないことでした。--host 0.0.0.0 に変更して解消しました。

2. host.docker.internal が必要

cloudflared からホストへ流したいので、Linux 環境では以下が必要でした。

--add-host host.docker.internal:host-gateway

これがないと service: http://host.docker.internal:8000 が解決できません。

3. API をそのまま公開すると認証なし公開になる

https://llm.example.jp/v1/... は便利ですが、このままだと認証なし公開になり得ます。検索 API や IIIF と違い、LLM API は利用コストが高いので、そのまま誰でも叩ける状態にはしない方がよいです。

選択肢は次の 2 つです。

  • Cloudflare Access で保護する
  • API 用ホスト名を外部公開せず、SSH ポートフォワード経由だけで使う

個人的には、少人数利用なら AccessSSHトンネル限定 のどちらかに寄せるのが無難です。

この構成の利点

この構成にすると、次の点が扱いやすくなります。

  • サーバのインバウンドポートを開けなくてよい
  • vLLM の API を固定 URL で扱える
  • SSH も Zero Trust に寄せられる
  • 将来的に API 用ホスト名へ認証や制限を追加しやすい

特に mdx.jp のような GPU サーバは、検証用に短期間立てたり消したりすることがあるので、DNS と公開経路を Cloudflare 側にまとめられるのは便利でした。

まとめ

mdx.jp 上の vLLM は、Cloudflare Tunnel を使うことで比較的素直に外部公開できます。実際に重要だったのは、vLLM 側を 0.0.0.0 で待ち受けること、cloudflaredhost.docker.internal を渡すこと、そして公開後の認証方針を先に決めておくことでした。

OpenAI互換API として接続できるところまで作っておくと、ローカルのスクリプト、別サーバのバッチ、OCR の抽出処理などに流し込みやすくなります。一方で、LLM API は単なる情報配信よりも濫用コストが高いため、一般公開ではなく Cloudflare AccessZero Trust SSH と組み合わせた運用を前提にした方が安全です。