vLLM+Open-WebUI環境をdocker containerで作る

## はじめに

手持ち環境を整理して以下のマシンを構築したので、ここにvLLMを使える環境をDocker containerとして構築します。また動作テスト用にOpen-WebUIも動作させます。


* CPU: AMD Ryzen 7 5700X
* メモリ: 64GB, DDR4-3200/2ch
* ストレージ: NVMe Gen.4 SSD 2TB
* GPU: NVIDIA RTX 4060Ti 16GB x2
* OS: Ubuntu 24.04 LTS Server

なお、GPUがコンテナで利用可能な状態になっていることを前提とします。以下の記事を参考にしてください。

https://qiita.com/Marron-chan/items/a28851f34a56d03ccda1

## 構成

適当なディレクトリを作成して、そこに必要なファイルを置きます。私の場合は以下のようにしました。

```bash
mkdir -p ~/Containers/vLLM
cd ~/Containers/vLLM
```

先にモデルを保存するディレクトリを作成します。

```bash
mkdir -p ./models
```

次に以下の`docker-compose.yml`をカレントディレクトリに作成します。

```yaml
services:
  vllm:
    image: vllm/vllm-openai:latest
    runtime: nvidia
    ipc: host
    shm_size: "4g"
    command: >
      --model=/models/Qwen3-4B-AWQ
      --tokenizer=Qwen/Qwen3-4B-AWQ
      --quantization=awq
      --dtype=float16
      --tensor-parallel-size=2
      --max-model-len=8192       
      --download-dir=/models
      --host=0.0.0.0
      --port=8000
    ports: ["8000:8000"]
    environment:
      - VLLM_USE_V1=0
      - DISABLE_CUSTOM_ALL_REDUCE=1
      - NVIDIA_VISIBLE_DEVICES=0,1
    volumes:
      - ./models:/models
    deploy:
      resources:
        reservations:
          devices:
            - capabilities: [gpu]

  webui:
    image: ghcr.io/open-webui/open-webui:main
    ports: ["3000:8080"]
    environment:
      - OPENAI_API_BASE_URL=http://vllm:8000/v1
      - WEBUI_AUTH=False
    depends_on: [vllm]
```

ここではモデルを`Qwen3-4B-AWQ`にしました。このファイルはGPUが2枚刺さっているマシンを対象にしています。もし、1枚しかなかったり、2つのvLLMのコンテナを立ち上げてそれぞれ独立にGPUを使わせたい場合は、`- NVIDIA_VISIBLE_DEVICES=0,1`の行の`=`より右側を0だけ、1だけなどのように書き換えてください。

【2025/5/19 追記】
----- ここから -----
GPU１枚で実行する場合、` --tensor-parallel-size=2`を削除してください。
----- ここまで -----

次にモデルをダウンロードするのですが、そのために以下のスクリプトを書きました。これは `model_downloader.sh` とします。

```bash
#!/usr/bin/env bash
# ------------------------------------------------------------
# 使い方:
#   export HF_TOKEN=hf_xxx
#   ./model_downloader.sh <repo_id>
# ------------------------------------------------------------
set -euo pipefail

HF_TOKEN=hf_********************************

if [[ $# -lt 1 ]]; then
  echo "Usage: $0 <huggingface_repo_id>" >&2
  exit 1
fi
REPO_ID="$1"
: "${HF_TOKEN:?Please export HF_TOKEN before running this script}"

# 保存先 ./models/<repo末尾>
BASE_DIR="$(pwd)/models"
DIR_NAME="${REPO_ID##*/}"
DEST_DIR="$BASE_DIR/$DIR_NAME"
mkdir -p "$BASE_DIR"

echo -e "\n▶ Downloading  \e[1m$REPO_ID\e[0m  →  $DEST_DIR\n"

docker run --rm -it \
  -e HF_TOKEN="$HF_TOKEN" \
  -v "$BASE_DIR":/models \
  python:3.11-bullseye bash -c "\
    pip install --quiet --no-cache-dir 'huggingface_hub[cli]' && \
    huggingface-cli download ${REPO_ID} --token \$HF_TOKEN \
        --local-dir /models/${DIR_NAME} --local-dir-use-symlinks False"

echo -e "\n✅ Download finished."

# ────────────────────────────────────────────────────────────
# docker-compose 用 3 行ひな形
# ────────────────────────────────────────────────────────────
case "$DIR_NAME" in
  *awq*|*AWQ*)   QUANT=awq  ;;
  *gptq*|*GPTQ*) QUANT=gptq ;;
  *gguf*|*GGUF*) QUANT=gguf ;;
  *)             QUANT=None ;;
esac

TOKENIZER_ID="$REPO_ID"     # ← ここを『repo_id そのまま』に変更

cat <<EOF

------------------------------------------------------------
📋  docker-compose.yml にコピペする 3 行
------------------------------------------------------------
      --model=/models/${DIR_NAME}
      --tokenizer=${TOKENIZER_ID}
      --quantization=${QUANT}
------------------------------------------------------------
EOF
```

`model_downloader.sh`内にHuggingFaceのアクセストークンを記述するので、他の人から見られないようにするため、また、実行権限を与えるため、以下の操作をしてアクセス制限をします。

```bash
chmod 700 ./model_downloader.sh
```

`model_downloader.sh`内の`HF_TOKEN=hf_********************************`の行の`=`より右側はHuggingFaceから取得したアクセストークンを書きます。HuggingFaceのアカウント作成、アクセストークン取得は以下のページを参照してください。

https://zenn.dev/protoout/articles/73-hugging-face-setup

それでは、先程`docker-compose.yml`に書いた`Qwen3-4B`のモデルをダウンロードします。以下のように実行してください。

```bash
./model_downloader.sh Qwen/Qwen3-4B-AWQ
```

ダウンロードが完了すると以下のようなメッセージが最後に表示されます。

```
✅ Download finished.

------------------------------------------------------------
📋  docker-compose.yml にコピペする 3 行
------------------------------------------------------------
      --model=/models/Qwen3-4B-AWQ
      --tokenizer=Qwen/Qwen3-4B-AWQ
      --quantization=awq
------------------------------------------------------------
```

この「コピペする3行」に従い、は`docker-compose.yml`の該当行を書き換えます。`--tokenizer`と`--quantization`は推定です。うまく動作しない場合はvLLMを使っている他のページを参考にしてください。今回提示した`docker-compose.yml`はすでに上記の内容が反映されています。

vLLMはモデルを動的に変更する機能がないようなので、モデル変更時に毎回`docker-compose.yml`を書き換える必要があります。そのため、上記の編集したファイルは以下のようにコピーを作成しておくことをおすすめします。

```bash
cp docker-compose.yml docker-compose.yml-qwen3-4b-awq
```

## コンテナ実行

以下のように実行し、コンテナを実行します。

```bash
docker compose up -d
docker logs -f vllm-vllm-1
```

vLLMのコンテナを立ち上げるには暫く時間がかかります。完全に立ち上がると、最後に以下のような表示になります。

```
INFO:     Started server process [1]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
```

この表示が出てから1分くらい後にウェブブラウザで`http://localhost:3000`、または当該マシンのIPアドレスが`192.168.100.100`なら、他のマシンのウェブブラウザから`http://192.168.100.100:3000`にアクセスしてください。そうすると以下のように表示されます。

![Open-WebUI](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519489/096dd1ec-5500-44ec-816e-e6ee35cb7e08.png)

「OK、始めましょう！」ボタンを押すと会話を始められます。モデルが選択されていないことがまれにありますが、その場合は以下のように「新しいチャット」の右側にあるフィールドをクリックするとモデルが選べます。

![select a model](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519489/af085fd6-63d3-44d9-9e47-9e0826d66d69.png)

起動ができたら、`docker logs -f vllm-vllm-1`の実行はCtrl-Cで止めてもらって構いません。

なお、コンテナの終了は以下のコマンドを実行します。

```
docker compose down
```

## 実行テスト

では、実際に入力してみます。プロンプトは以下のとおりです。

```
有史以来の日本の歴史について教えてください。
```

結果は以下のようになります。

![Result1](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519489/e81d7dee-dfb7-44ec-bf3e-b206fe6fcd8d.png)

このときのGPUの実行状態は以下のとおりです。2つのGPUが使われていることが分かります。VRAMの使用量が多い気もしますが、理由は分かりません。

![Resource1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519489/64506ff8-df52-4a91-8939-6f3bdce2bbcc.png)

もう少し大きモデルでも試してみましょう。ここでは`Aratako/DeepSeek-R1-Distill-Qwen-32B-Japanese-AWQ`を使います。`model_downloader.sh`でダウンロードして、`docker-compose.yml`を修正したら、以下のようにコマンドを実行します。

```bash
docker compose down
docker compose up -d
docker logs -f vllm-vllm-1
```

先ほどと同じように待ってからブラウザからアクセスします。モデルの名前が`/models/DeepSeek-R1-Distill-Qwen-32B-Japanese-AWQ`と表示されていることを確認して先ほどと同じプロンプトを入力します。結果は以下のとおりです。

![Result2](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519489/f89bffb2-7cf4-4a8f-ab7e-98f7883ea5e2.png)

モデルが違うので内容も変わりますが、こうやって見るとQwen3-4Bはかなり頑張ってますね。

さて、このときのGPUの実行状態は以下のとおりです。2つのGPUともリソースをほぼ目一杯使っています。動作速度も実用上十分で、約 20 tokens / sec の速度になりました。

![Resource2](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/519489/312d2e8d-45b8-4a0d-9e80-c7637aff6a17.png)

## 備考

以下のことにご留意ください。

* `Open WebUI`の認証は外してあります。他の方が触る可能性がある環境では認証することをご検討ください。
* 今のところ、vLLMは動的にモデルを変更することができないようです。モデルを変更するには毎回 `docker-compose.yml` を入れ替えてコンテナを再起動してください。
* `model-downloader.sh`にHuggingFaceのアクセストークンを直書きしています。この行を削除して外部環境変数とすることも可能です。都合の良いようにお使いください。特にGitHubなどへのアップロードをする場合はアクセストークンが漏洩しないようご注意ください。

以上です。

【2025/5/24追記】
* GPTQモデルをvLLMは扱うことができますが、現在のバージョンではマルチGPUでは動かないとのことです。この記事ではマルチGPUを前提としたdocker-compose.ymlを提示しています。ご注意ください。
* vLLM 0.8.5 系の AWQ 実装は “MoE + TP>1” をまだサポートしていないとのことで、上記構成では今の所Qwen3-30B-A3B-AWQを動かす手段はなさそうです。GPTQの方は1つ目の方の理由で動きません。