はじめに

vLLM と llama.cpp の前に Rust のプロキシを置きました。推論サーバーは生成を担当し、RAG やワークフローの制御はプロキシに分けています。

GPU と rootless Podman を前提に、OpenAI 互換 Proxy API、Qdrant、PostgreSQL を連携させる Docker Compose 構成です。ローカル LLM 導入の実行環境をまとめています。

背景とアーキテクチャの狙い

モデルを差し替えやすくするため、推論と RAG・ジョブ管理を分けました。

  1. バックエンド(推論): vLLMやllama.cppは純粋な「OpenAI互換の推論エンドポイント」としてのみ機能させる。
  2. フロントエンド(Proxy): Rust(Axum)で実装したProxy APIがリクエストを受け、RAGの検索(Qdrant + PostgreSQL)や、NATSを経由したDagsterパイプラインのトリガーを行う。
  3. データスタック: Qdrantはベクトル専用、PostgreSQLはメタデータやジョブ管理、チャンクのペアリング保持専用と役割を分ける。

この構成を docker-compose.yml に定義しています。

コンテナ構成の詳細 (docker-compose.yml)

配置先は /opt/containers/compose/llm-stack/docker-compose.yml を想定しています。全コンテナに cap_drop: ["ALL"] と no-new-privileges:true を設定し、必要なディレクトリだけをマウントします。内部ネットワークは db_net と llm_net に分けました。

  version: "3.9"

name: llm-stack

networks:
  db_net:
    driver: bridge
    internal: true
  llm_net:
    driver: bridge
    internal: true

volumes:
  pg_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /mnt/data/postgres/data
  qdrant_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /mnt/data/qdrant/data
  api_data:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /mnt/data/llm-api
  vllm_models:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /mnt/data/models/vllm
  llama_models:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /mnt/data/models/llama

services:
  postgres:
    image: postgres:17
    container_name: pg
    restart: always
    command: ["postgres","-c","max_connections=300","-c","shared_buffers=4GB","-c","wal_compression=on"]
    environment:
      POSTGRES_USER: ${PG_USER:-loft}
      POSTGRES_PASSWORD: ${PG_PASSWORD:-change_me}
      POSTGRES_DB: ${PG_DB:-loftdb}
    healthcheck:
      test: ["CMD-SHELL","pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB -h 127.0.0.1"]
      interval: 10s
      timeout: 3s
      retries: 10
    networks: [db_net]
    volumes:
      - pg_data:/var/lib/postgresql/data:rw
    read_only: true
    tmpfs:
      - /tmp:rw,nosuid,nodev,noexec,size=256m
      - /var/run/postgresql:rw,mode=775
    security_opt: ["no-new-privileges:true"]
    cap_drop: ["ALL"]
    ulimits:
      nofile: 262144

  qdrant:
    image: qdrant/qdrant:latest
    restart: always
    environment:
      QDRANT__SERVICE__GRPC_PORT: 6334
      QDRANT__STORAGE__WAL_MEMORY_CAPACITY: "33554432"
      QDRANT__STORAGE__OPTIMIZERS__DEFAULT_SEGMENT_NUMBER: "2"
    healthcheck:
      test: ["CMD","/qdrant/tools/healthcheck.sh"]
      interval: 10s
      timeout: 3s
      retries: 10
    networks: [db_net]
    volumes:
      - qdrant_data:/qdrant/storage:rw
    ports:
      - "6333:6333"
      - "6334:6334"
    read_only: false
    security_opt: ["no-new-privileges:true"]
    cap_drop: ["ALL"]
    ulimits:
      nofile: 262144

  vllm:
    image: vllm/vllm-openai:latest
    restart: always
    command:
      [
        "python","-m","vllm.entrypoints.openai.api_server",
        "--model","${VLLM_MODEL:-/models}",
        "--host","0.0.0.0",
        "--port","8000",
        "--tensor-parallel-size","${VLLM_TP:-1}",
        "--max-num-seqs","${VLLM_MAX_SEQS:-32}"
      ]
    environment:
      NVIDIA_VISIBLE_DEVICES: "all"
    devices:
      - "nvidia.com/gpu=all"
    healthcheck:
      test: ["CMD","curl","-sf","http://127.0.0.1:8000/health"]
      interval: 10s
      timeout: 5s
      retries: 20
    networks: [llm_net]
    volumes:
      - vllm_models:/models:ro
    ports:
      - "8000:8000"
    security_opt: ["no-new-privileges:true"]
    cap_drop: ["ALL"]
    ulimits:
      nofile: 262144

  llamacpp:
    image: ghcr.io/ggerganov/llama.cpp:full
    restart: always
    command:
      [
        "server",
        "-m","/models/${LLAMA_MODEL:-model.gguf}",
        "--host","0.0.0.0",
        "--port","8080",
        "--mlock",
        "--no-mmap",
        "--ctx-size","${LLAMA_CTX:-8192}",
        "--batch-size","${LLAMA_BATCH:-512}",
        "--embedding"
      ]
    environment:
      NVIDIA_VISIBLE_DEVICES: "all"
    devices:
      - "nvidia.com/gpu=all"
    healthcheck:
      test: ["CMD","curl","-sf","http://127.0.0.1:8080/health"]
      interval: 10s
      timeout: 5s
      retries: 20
    networks: [llm_net]
    volumes:
      - llama_models:/models:ro
    ports:
      - "8080:8080"
    security_opt: ["no-new-privileges:true"]
    cap_drop: ["ALL"]
    ulimits:
      nofile: 262144

  openai-proto-api:
    image: ghcr.io/your-org/openai-proto-api:latest
    restart: always
    environment:
      VLLM_BASE_URL: http://vllm:8000
      LLAMA_BASE_URL: http://llamacpp:8080
      QDRANT_URL: http://qdrant:6333
      QDRANT_API_KEY: ${QDRANT_API_KEY:-}
      PGHOST: postgres
      PGPORT: 5432
      PGUSER: ${PG_USER:-loft}
      PGPASSWORD: ${PG_PASSWORD:-change_me}
      PGDATABASE: ${PG_DB:-loftdb}
      API_PORT: 9000
      API_KEY: ${API_KEY:-change_me}
    depends_on:
      - qdrant
      - postgres
      - vllm
    healthcheck:
      test: ["CMD","curl","-sf","http://127.0.0.1:9000/healthz"]
      interval: 10s
      timeout: 5s
      retries: 30
    networks:
      - llm_net
      - db_net
    volumes:
      - api_data:/var/lib/llm-api
    ports:
      - "9000:9000"
    read_only: true
    tmpfs:
      - /tmp:rw,nosuid,nodev,noexec,size=256m
    security_opt: ["no-new-privileges:true"]
    cap_drop: ["ALL"]
    ulimits:
      nofile: 262144
  

設定ファイル (.env)

環境変数は .env にまとめ、/opt/containers/compose/llm-stack/.env に置きます。

  PG_USER=loft
PG_PASSWORD=change_me
PG_DB=loftdb
QDRANT_API_KEY=
API_KEY=change_me

VLLM_MODEL=/models
VLLM_TP=1
VLLM_MAX_SEQS=32

LLAMA_MODEL=model.gguf
LLAMA_CTX=8192
LLAMA_BATCH=512
  

セットアップと起動手順

初期セットアップでは、PostgreSQL のデータディレクトリの所有者を合わせます。

  mkdir -p /mnt/data/{postgres/data,qdrant/data,models/vllm,models/llama,llm-api}
podman unshare chown -R 999:999 /mnt/data/postgres/data
cd /opt/containers/compose/llm-stack
podman-compose up -d
  

Postgres は uid=999 で動きます。podman unshare chown でマウント先の所有者を合わせないと、初期化が権限エラーで止まります。

GPUの指定について

GPU は devices: ["nvidia.com/gpu=all"] で指定します。ホスト側の CDI 設定と nvidia-ctk runtime configure --runtime=crun が済んでいる前提です。

systemdへの登録(自動起動)

再起動後も起動するよう、systemd のユーザーサービスに登録します。

  podman generate systemd --files --name llm-stack_openai-proto-api
podman generate systemd --files --name llm-stack_vllm
podman generate systemd --files --name llm-stack_llamacpp
podman generate systemd --files --name llm-stack_qdrant
podman generate systemd --files --name llm-stack_postgres
mkdir -p ~/.config/systemd/user
mv *.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now llm-stack_*.service
loginctl enable-linger ksh3
  

loginctl enable-linger により、ログアウト後もユーザーのプロセスを動かせます。

結果と考察

API コンテナ(openai-proto-api)だけを db_net と llm_net の両方に接続しています。

vLLM と llama.cpp は llm_net、Qdrant と Postgres はデータ側に置き、ネットワークを分けました。Proxy API が OpenAI 互換のリクエストを受け、モデル名から推論先を選びます。

今後の課題

ログ監視は Compose に含めていません。promtail で stdout と journal を集める想定でした。Proxy のメタデータ処理を増やし、RAG の精度を改善する案もあります。