MacのDockerでクラッシュするローカルLLMを救うエンジニアリング設定
オープンソースのAIワークスペースのコードをダウンロードしてMacのDocker上で動かすと、最初のプロンプトから壁にぶつかります。Mシリーズチップを使っているはずなのにファンがうるさく回るだけで、1秒あたり10トークンに満たない惨憺たる速度になります。Docker Desktopの仮想化レイヤーがApple Silicon GPU(Metal)をコンテナ内にパススルーできず、CPUの力任せの演算に落ちてしまうからです。
このボトルネックを解消しないままFastAPIバックエンドとローカルベクトルDBを組み合わせて動かすと、メモリリークとコネクション枯渇によってコンテナがダウンします。GitHubのスター数だけを見て持ってきたコードを、ローカル環境で商用レベルに引き上げるために修正すべき4つの設定です。
CPUフォールバックの原因とハードウェアアクセラレーションのバイパス経路
macOSのDocker Desktopは軽量なLinux VM上で動作します。LinuxゲストOSコンテナの内部へホストのMetal GPU APIを直接パススルーする機能はありません。コンテナ内のOllamaやllama.cppはVRAMの割り当てに失敗し、静かにCPU演算へと切り替えます。M3 Max基準でネイティブ環境なら40〜80 t/sが出ていたLlama 3 8Bモデルが、Dockerに入った瞬間プロンプト評価 21.45 t/s、トークン生成 12.17 t/sレベルまで急落する理由がこれです。
`
[仮想化コンテナ内でのCPUフォールバック発生メカニズム]
+-----------------------------------------------------------------------+
| Docker Container (Linux Guest OS) |
| +---------------------+ |
| | Local LLM Engine | --(VRAM Allocation Req)--> [VirtGPU Missing] |
| +---------------------+ | |
| | v |
| +<--(Fallback to CPU Execution)--- [Silent slog.Debug] |
+-------------|---------------------------------------------------------+
v
[Apple Silicon Host CPU (ARM Neon/DotProd)] -> 速度低下発生
`
デフォルトの共有メモリ(shm)設定も問題です。デフォルトの割り当て量である64MBでは、大規模なテンソル演算時に即座にBus Errorを吐きます。~/.docker/daemon.jsonを開いて共有メモリを拡張し、リソース制限を解放します。
`json
{
"builder": {
"gc": {
"defaultKeepStorage": "20GB",
"enabled": true
}
},
"experimental": false,
"default-shm-size": "8g"
}
`
`yaml
version: '3.8'
services:
llm-inference-engine:
image: ollama/ollama:latest
container_name: local_llm_engine
shm_size: '16gb'
ipc: host
deploy:
resources:
limits:
cpus: '8.0'
memory: 24G
reservations:
cpus: '4.0'
memory: 12G
ports:
- "11434:11434"
volumes:
- ollama_storage:/root/.ollama
volumes:
ollama_storage:
`
コンテナ内でGPUアクセラレーションを引き出すには、libkrun仮想マシンモニターとkrunkitドライバを使用するPodmanに切り替える必要があります。コンテナ内のVulkan演算リクエストをホストmacOSのMetal APIに転送する方式(Virtio-GPU Venus)です。ネイティブMetalと比較して75%水準までの処理速度を引き上げます。
- HomebrewでkrunkitとPodmanをインストールします。
brew tap slp/krunkit && brew install krunkit podman
- libkrunプロバイダベースで8コア、32GB RAMのマシンを起動します。
export CONTAINERS_MACHINE_PROVIDER="libkrun" && podman machine init --cpus 8 --memory 32768 && podman machine start
- VM内部にドライバがアタッチされているか確認します。
podman machine ssh "ls -la /dev/dri"
`dockerfile
FROM fedora:40
RUN dnf -y install dnf-plugins-core &&
dnf -y copr enable slp/mesa-krunkit fedora-40-aarch64 &&
dnf -y install mesa-vulkan-drivers vulkan-loader vulkan-tools &&
dnf -y downgrade mesa-vulkan-drivers.aarch64 --repo copr:copr.fedorainfracloud.org:slp:mesa-krunkit &&
dnf clean all
ENV GGML_VULKAN=1
`
インフラの規定上CPU演算のみを使用する必要がある場合は、ARMv8.4-A DotProductとNeonベクトル命令に最適化されたQ4_0_4_4量子化フォーマットを使用します。プロンプト評価速度を50.63 t/sまで維持し、通常のDockerでのCPU実行と比較して処理時間を半分以下に削減します。
| ランタイム構成方式 |
プロンプト評価 (t/s) |
トークン生成 (t/s) |
構築難易度 |
特徴 |
| Docker Desktop (デフォルトCPU) |
~21.45 |
~12.17 |
低 |
仮想化の制約によるCPUフォールバック |
| Docker Container (ARM Q4_0_4_4) |
~50.63 |
~14.01 |
普通 |
ARM Neonベクトル命令を活用 |
| Podman + libkrun (Vulkan Venus) |
Native対比 ~75% |
Native対比 ~75% |
高 |
コンテナ内部のVirtio-GPUアクセラレーション |
| Host Native Metal + Container |
Native 100% (40~80) |
Native 100% |
普通 |
ホストエンジンを直接呼び出す構造 |
FastAPIのメモリリーク追跡と切断されたストリームの回収
オープンソースのバックエンドは、非同期タスクの状態管理やCPythonのガベージコレクション処理が不十分であるケースが多いです。リクエストが蓄積するにつれてメモリが膨らみ、プロセスがクラッシュします。tracemallocを使用してメモリを消費している箇所を特定します。
`python
import gc
import tracemalloc
tracemalloc.start()
def log_memory_snapshot():
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
print("[Memory Debug] Top 5 Allocations:")
for stat in top_stats[:5]:
print(stat)
gc.collect()
`
プロセス管理の段階でメモリを強制的に解放する必要があります。Gunicornワーカーがリクエストを1,000件処理するごとに自動的にメモリを解放して再起動するようにライフサイクルを設定します。
`bash
gunicorn
--workers 4
--worker-class uvicorn.workers.UvicornWorker
--bind 0.0.0.0:8000
--max-requests 1000
--max-requests-jitter 100
--timeout 120
--keep-alive 5
--preload-app
app.main:app
`
--max-requests-jitter 100を付与して4つのワーカーが同時に停止・再起動してリクエストがドロップする現象を防ぎ、応答のないタスクは120秒のタイムアウトで切断します。
ユーザーが回答の途中でブラウザを閉じた際、非同期ジェネレーターがループを回し続けると演算リソースがそのまま無駄になります。request.is_disconnected()をチェックし、接続が切断された場合は即座にループを抜け出します。
`python
import asyncio
import gc
import logging
from typing import AsyncGenerator
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import StreamingResponse
app = FastAPI(title="Production AI Workspace Backend")
logger = logging.getLogger("stream_logger")
async def robust_llm_token_stream(prompt: str, request: Request) -> AsyncGenerator[str, None]:
try:
for token_idx in range(2000):
if await request.is_disconnected():
logger.warning(f"[Stream Aborted] Client disconnected at step {token_idx}.")
break
await asyncio.sleep(0.01)
yield f"event: message\ndata: {\"id\": {token_idx}, \"text\": \"chunk_{token_idx} \"}\n\n"
except asyncio.CancelledError:
logger.info("[Stream Cancelled] Task cancelled by ASGI server.")
raise
except Exception as err:
logger.error(f"[Stream Error] Error during streaming: {str(err)}")
raise
finally:
logger.info("[Stream Cleanup] Releasing context and triggering GC.")
gc.collect()
@app.post("/api/v1/chat/stream")
async def chat_stream_endpoint(request: Request, body: dict):
prompt = body.get("prompt", "")
if not prompt:
raise HTTPException(status_code=400, detail="Prompt string is missing.")
return StreamingResponse(
robust_llm_token_stream(prompt, request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
`
依存関係の固定とオフラインビルドパイプライン
アップストリームのマイナーアップデート1つでローカルコンテナのビルドが壊れることはよくあります。SHA-256ハッシュを記述した番地指定のrequirements.txtにより、パッケージの改ざんやバージョンの競合を防ぎます。
`plaintext
fastapi==0.110.0 --hash=sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
uvicorn==0.28.0 --hash=sha256:2c6a81387e0037a34ca6a7f8087796030c79eb299f116515822ee483c6604e43
pydantic==2.6.4 --hash=sha256:d82e212f451f2d6c19f5a5e3a89369322e70e1781297587786411516279f64a5
sqlalchemy==2.0.28 --hash=sha256:a611116c2bb45f8f3077e6822ec3a37b384ff6b9a84d4dd88a0e8eb876b5cf12
`
社内セキュリティ網や機内などのオフライン環境でDockerを再ビルドする際、pipのダウンロードエラーが発生しないよう、ホイール(Wheel)バイナリをローカルディレクトリに予めダウンロードしておきます。
`bash
pip wheel --wheel-dir=./wheels_repository -r requirements.txt
`
`dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY ./wheels_repository /app/wheels_repository
COPY requirements.txt .
RUN pip install --no-cache-dir --no-index --find-links=/app/wheels_repository -r requirements.txt
COPY . .
CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-c", "gunicorn.conf.py", "app.main:app"]
`
アップストリームのGitリポジトリの変更点を丸ごとpullすると、苦労してチューニングしたローカル設定が上書きされて消えてしまいます。最適化用のブランチを分離し、必要なコミットのみをチェリーピックで取り込みます。
`bash
git remote add upstream https://github.com/opensource-ai-workspace/workspace.git
git fetch upstream
git checkout -b feature/local-mac-optimization
git cherry-pick
`
DBコネクションのボトルネック制御とネットワークの隔離ルーティング
マルチエージェントが埋め込み検索、メモリ参照、対話ログ保存を同時に実行すると、PostgreSQLのコネクションプールが瞬く間に枯渇します。8コアのApple Siliconシングルディスク環境を基準としたコネクション計算式(Nextconn=extCPUコアimes2+extスピンドル数)に合わせ、エンジン側のプールを17個以下に制限します。
`python
from sqlalchemy.ext.asyncio import create_async_engine
DATABASE_URL = "postgresql+asyncpg://postgres:password@localhost:6432/ai_workspace"
engine = create_async_engine(
DATABASE_URL,
pool_size=15,
max_overflow=5,
pool_timeout=10,
pool_recycle=300,
pool_pre_ping=True
)
`
エージェントがLLM推論の結果を待っている間にDBセッションを保持し続けてしまう現象は、PgBouncerのトランザクションプーリングによって遮断します。
`ini
[databases]
ai_workspace = host=postgres_db port=5432 dbname=ai_workspace auth_user=postgres
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = plain
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
idle_transaction_timeout = 60
max_client_conn = 1000
default_pool_size = 20
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 3
`
`
+-------------------------------------------------------------------------------+
| Docker Internal Isolated Network (internal: true) |
| |
| +--------------------+ +--------------------+ +-----------------+ |
| | AI Workspace App | ---> | PgBouncer Proxy | ---> | PostgreSQL DB | |
| | (FastAPI Backend) | | (Port 6432) | | (Port 5432) | |
| +--------------------+ +--------------------+ +-----------------+ |
| | | |
| | [pool_mode = transaction] |
| | [idle_tx_timeout = 60s] |
| v |
| +--------------------+ |
| | Local Vector DB | (外部インターネット通信遮断) |
| | (Qdrant) | |
| +--------------------+ |
+-------------------------------------------------------------------------------+
`
データ流出のリスクを排除するため、コンテナネットワークにinternal: trueを設定して外部クラウドとの通信をブロックします。ホストマシン上で直接稼働している高速なMetal Ollamaランタイムとは、host.docker.internalゲートウェイを介してのみ接続します。
`yaml
version: '3.8'
services:
backend-app:
build: .
environment:
- DB_HOST=pgbouncer
- DB_PORT=6432
- VECTOR_DB_HOST=vector-db
- OLLAMA_HOST=http://host.docker.internal:11434
networks:
- isolated_local_net
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "8000:8000"
pgbouncer:
image: edoburu/pgbouncer:latest
environment:
- DB_HOST=postgres_db
- DB_PORT=5432
- DB_USER=postgres
- DB_PASSWORD=secret
- POOL_MODE=transaction
networks:
- isolated_local_net
depends_on:
- postgres_db
postgres_db:
image: postgres:16-alpine
environment:
- POSTGRES_DB=ai_workspace
- POSTGRES_PASSWORD=secret
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- isolated_local_net
vector-db:
image: qdrant/qdrant:v1.9.0
volumes:
- qdrant_data:/qdrant/storage
networks:
- isolated_local_net
networks:
isolated_local_net:
driver: bridge
internal: true
volumes:
pgdata:
qdrant_data:
`
GPU仮想化のボトルネックをPodmanやホストブリッジでバイパスし、ワーカーの再利用とDBプロキシによってライフサイクルを制御すれば、Mシリーズチップを搭載したノートパソコンでもクラッシュしない独立した開発環境が完成します。