ローカルLLM基盤のコンテナ構成: vLLM/llama.cppとRust Proxyを連携するDocker Compose
vLLM、llama.cpp、Qdrant、PostgreSQLをrootless Podman上でセキュアに連携させるDocker Compose構成の設計と運用手順。
はじめに
ローカル環境でLLMを運用する際、推論サーバー単体(vLLMやllama.cppなど)を立ち上げるのは簡単だが、RAG(検索拡張生成)やワークフローと連携させようとすると、途端に構成が複雑になる。私自身、LLM本体に過剰な役割を持たせず、フロントエンドのプロキシAPIでリクエストを制御するアーキテクチャに行き着いた。
この記事では、GPU環境およびrootless Podmanを前提とし、OpenAI互換の自作Proxy API、Qdrant、PostgreSQL、そしてvLLMとllama.cppを連携させる最小実用のDocker Compose構成を紹介する。
背景とアーキテクチャの狙い
LLM基盤を構築する際、推論サーバー自体にRAGのロジックやジョブ管理を組み込むと、モデルの差し替えやスケールが困難になる。そのため、以下のような方針でシステムを分割した。
- バックエンド(推論): vLLMやllama.cppは純粋な「OpenAI互換の推論エンドポイント」としてのみ機能させる。
- フロントエンド(Proxy): Rust(Axum)で実装したProxy APIがリクエストを受け、RAGの検索(Qdrant + PostgreSQL)や、NATSを経由したDagsterパイプラインのトリガーを行う。
- データスタック: 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 の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_net と llm_net の両方に所属している点だ。
推論サーバーであるvLLMやllama.cppは llm_net にしか所属していないため、誤ってDBにアクセスしてしまうことはないし、逆にQdrantやPostgresが外部ネットワークや推論側に触れることもない。Proxy APIがフロントに立ち、OpenAI互換のインターフェースとしてクライアントからのリクエストを受け付け、モデル名に応じてvLLMとllama.cppにルーティングするという当初の要件を、セキュアなネットワーク境界を持った状態で実現できた。
今後の課題
コンテナのログ監視については、このCompose内には含めていないが、現状は promtail を用いてstdoutやjournalから収集する形を想定している。今後は、RAGの精度向上のためにProxy側のメタデータ処理を拡充していく予定だ。
