はじめに

ローカル環境でLLMを運用する際、推論サーバー単体(vLLMやllama.cppなど)を立ち上げるのは簡単だが、RAG(検索拡張生成)やワークフローと連携させようとすると、途端に構成が複雑になる。私自身、LLM本体に過剰な役割を持たせず、フロントエンドのプロキシAPIでリクエストを制御するアーキテクチャに行き着いた。

この記事では、GPU環境およびrootless Podmanを前提とし、OpenAI互換の自作Proxy API、Qdrant、PostgreSQL、そしてvLLMとllama.cppを連携させる最小実用の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_netllm_net の2つに完全に分離しているのがポイントだ。

  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 を使ってホスト側のマウントディレクトリの所有者を合わせる必要がある。これを忘れるとDBの初期化フェーズで権限エラーとなり起動しない。

GPUの指定について

vLLMとllama.cppのサービス定義において、GPUへのアクセスは devices: ["nvidia.com/gpu=all"] で統一している。これはホスト側でCDI(Container Device Interface)の設定、具体的には nvidia-ctk runtime configure --runtime=crun が完了している前提の記述だ。

systemdへの登録(自動起動)

サーバー再起動時にも確実にサービスが立ち上がるよう、Podmanの機能を使って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_netllm_net の両方に所属している点だ。

推論サーバーであるvLLMやllama.cppは llm_net にしか所属していないため、誤ってDBにアクセスしてしまうことはないし、逆にQdrantやPostgresが外部ネットワークや推論側に触れることもない。Proxy APIがフロントに立ち、OpenAI互換のインターフェースとしてクライアントからのリクエストを受け付け、モデル名に応じてvLLMとllama.cppにルーティングするという当初の要件を、セキュアなネットワーク境界を持った状態で実現できた。

今後の課題

コンテナのログ監視については、このCompose内には含めていないが、現状は promtail を用いてstdoutやjournalから収集する形を想定している。今後は、RAGの精度向上のためにProxy側のメタデータ処理を拡充していく予定だ。