🌟

vllm-openai dockerを使って、GPU nodeにLLM Webサービスを構築する

に公開

この記事について

こんにちは、東京大学鈴村研究室で、インフラエンジニアとしてお手伝いさせていただいています、福田と申します。

https://sites.google.com/view/toyolab/鈴村研究室概要

これまで、クラウド基盤mdxの上でKubernetes環境を構築し、サーバレスWebアプリケーションを開発するための手順や、分散学習を行うための手順について説明してきました。

https://zenn.dev/suzumura_lab/articles/627b5063d6884d

今回のこの記事では、vllmというLLMエンジンのOpenAI Docker imageを用いて、GPUのworker nodeが存在するKubernetes上に、簡単にLLM Webサービスを構築するための手順について説明します。

以前、以下の記事でGPU worker nodeでLLM Webサービスを構築する方法を説明しました。
https://zenn.dev/suzumura_lab/articles/05b7d1c548d76b
ただ、vllm-openai dockerを使えば、もっと簡単で軽量なLLM Webサービスを構築できることが分かり、今回のこの記事ではその手順について記載していこうと思います。

前提条件

  • mdx仮想マシン上で、Kubernetesクラスタが構築されていること
  • GPU worker nodeが存在すること
  • KNativeやMetalLBのインストールが済んでいること

デプロイしたいモデルを決める

まず、Hugging Faceでデプロイしたいモデルを探して決めます。

vllmに対応しているモデルは、添付画像の赤枠にvllmのタグが付いていたり、Use this modelのボタンを押すと、vllmの例が出てきたりします。

今回の記事では、gpt-ossの20 billionのモデルを用いて進めたいと思います。
https://huggingface.co/openai/gpt-oss-20b

deployment.yamlの作成

まず、Hugging Faceのモデルのページの赤枠部分をコピーして、モデル名をコピーします。

次に、以下のような、deployment.yamlを作成します。
その際、コピーしたモデル名を--modelの引数に指定します。

また、--max-model-len は、推論時に扱える最大トークン長(シーケンス長)ですので、リソースに応じて適宜、適切な値を指定します。
私の環境では、mdxのGPUインスタンスであれば、32000のトークン数でも動きました。

--api-keyの引数は、APIにアクセスするアクセストークンとなりますので、第三者から推測されにくいものを用います。

また、metadata.nameの値が、WebサービスとしてデプロイしたときのURLのサブドメインとなりますので、例えば、モデル名が類推しやすい名前を使うと良いです。

apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: gpt-oss-20b # これがWebサービスのサブドメインとなる
  namespace: default
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/minScale: "1" # 最低でも1Podは起動させる
    spec:
      containers:
        - image: vllm/vllm-openai:latest
          args:
            - "--model"
            - "openai/gpt-oss-20b" # ここでコピーしたモデル名を指定する
            - "--host"
            - "0.0.0.0"
            - "--port"
            - "8000"
            - "--max-model-len"
            - "50000" # 推論時に扱える最大トークン長。
            - "--download-dir"
            - "/large"
            - "--api-key"
            - "XXXXXXXXXXXXXX" # APIキーは第三者から推測されにくいものを用いる
          ports:
            - containerPort: 8000
          resources:
            limits:
              nvidia.com/gpu: 1
          volumeMounts:
            - mountPath: /large  # Pod内のマウント先を指定する
              name: pvc-large-volume
      volumes:
        - name: pvc-large-volume
          persistentVolumeClaim:
            claimName: hostpath-pvc-large

踏み台サーバへのログイン

いつもの通り、mdx踏み台サーバに接続します。
PCにインストールされたLENSからもアクセスできるように、kube masterへのssh portfowardを有効にします。

eval `ssh-agent`
ssh-add ~/.ssh/(ssh公開秘密鍵のファイル名)
ssh -L 6443:(master nodeのPrivate IPアドレス):6443 -A mdxuser@(踏み台サーバのGlobal IPアドレス)

Kubernetes Clusterの選択

Lensを開いて、対象のKubernetes Clusterを選択します。

ターミナルを開きます

deployment.yamlを使ったデプロイ

Lensのターミナルで、以下のコマンドを実行し、先ほど作成した、deployment.yamlをKubernetes Clusterにデプロイします。

kubectl apply -f (deployment.yamlへのpath)

Lens上で、サービスが立ち上がるのを確認します。

以下のコマンドを実行し、サービスにアクセスするためのURLを確認します。

kubectl get ksvc

以下ような結果が得られます。

NAME                  URL                                                           LATESTCREATED               LATESTREADY                 READY   REASON
pt-oss-20b           https://gpt-oss-20b.default.example.com                        gpt-oss-20b-00001           gpt-oss-20b-00001           True    

クライアントからのアクセス

今回デプロイしたLLM Webサービスは OpenAIのAPIに準拠していますので、PythonのOpenAIクライアントをアクセス元のPCなどにインストールします。

pip install openai

uvなどのパッケージマネージャーを使用している場合は、以下のコマンドインストールしてください。

uv add openai

次に、アクセスするためのスクリプトを作成します。
ここで、api_keyには、KNativeのWebサービス定義のyamlに定義した、api_keyの値と一致させます。
またアクセスするためのURLとしては、kubectl get ksvc で得られたURLの末尾に、/v1を付与したものを使用するようにします。

if __name__ == '__main__':
    from openai import OpenAI
    client = OpenAI(
        base_url="https://gpt-oss-20b.default.example.com/v1", # URLの末尾には/v1を付与する
        api_key="xxxxxxxxxxxxxx", # KNativeのapi_keyの値と一致させる。
    )

    completion = client.chat.completions.create(
        model="openai/gpt-oss-20b", # KNativeの--modelの引数に指定した値を設定する
        temperature=0.1,
        messages=[
            {"role": "user", "content": "フーリエ変換について教えてください"}
        ],
        stream=True,
    )
    for chunk in completion:
        content = chunk.choices[0].delta.content
        if content:
            print(chunk.choices[0].delta.content, flush=True, end="")

すると以下のような結果が得られ、正しくDeepSeekがデプロイされていることを確認できました。

## フーリエ変換(Fourier Transform)とは?

フーリエ変換は「時間(または空間)領域で表現された信号」を「周波数領域で表現された信号」に変換する数学的手法です。  
簡単に言えば、ある波形がどんな周波数成分(音の高さ、色の波長など)で構成されているかを解析するための「窓関数」や「スペクトル」を得る方法です。

---

### 1. 基本的な定義

#### 連続フーリエ変換(Continuous Fourier Transform, CFT)
- 時間領域(または空間領域)の信号 \(x(t)\) を周波数領域の信号 \(X(f)\) に変換します。

\[
X(f) = \int_{-\infty}^{\infty} x(t)\, e^{-j2\pi ft}\, dt
\]

- 逆変換(Inverse Fourier Transform, IFT)

\[
x(t) = \int_{-\infty}^{\infty} X(f)\, e^{j2\pi ft}\, df
\]

#### 離散フーリエ変換(Discrete Fourier Transform, DFT)
- 時間領域の離散データ列 \(x[n]\)(\(n=0,\dots,N-1\))を周波数領域の離散データ列 \(X[k]\) に変換します。

東京大学鈴村研究室について

https://sites.google.com/view/toyolab/鈴村研究室概要

Discussion