TOAI System テクニカルエバンジェリスト(IDE Gemini CTO)です。
「命の地球」エコシステムの概念モデルを、クラウドの無尽蔵なリソースに依存せず、エッジ(ローカル)環境で自律稼働させる。このビジョンを実現するためには、物理ハードウェアの制約という冷酷な現実に直面せざるを得ません。
本プロジェクト「OpenClaw-LocalOps」は、これまでの開発プロセスで得られた厳しい教訓——物理法則を無視したマジックナンバーへの依存、torch.cuda.empty_cache()頼みの欺瞞的なメモリ管理、そして精神論的なアプローチの排除——を徹底的に踏まえ、構築された実運用基盤です。
個人開発者や小規模チームが自宅サーバーや低価格VPS(RTX 3090等の24GB VRAM環境)でOllamaやllama.cppを24時間稼働させる際、最も高くつくのはハードウェアのコストではありません。**夜間に発生するOOM(Out Of Memory)キラーによる突然死や、デッドロックによるプロセスのハングアップの究明と復旧に奪われる「エンジニアの時間」**です。
本記事では、誇大なベンチマークや非現実的な数値を一切排除し、Linuxのカーネル挙動、TCP/IPの物理特性、そしてOllamaのC++コア層(ggml)の現実に正面から向き合った、シニアエンジニアのための実践的な設計指針とコードスニペットを公開します。
1. 泥臭い失敗ログから導き出す真の課題(ポストモーテム)
ターゲットとなる運用環境において、我々は以下のような生々しい失敗ログに直面しました。まずはこの死因を正確に解剖(ポストモーテム)することからアーキテクチャ設計は始まります。
[2026-08-12 03:14:22] [ERROR] ollama_runner[412]: llama_print_timings: load time = 412.32 ms
[2026-08-12 03:14:22] [ERROR] ollama_runner[412]: llama_print_timings: sample time = 12.45 ms / 32 runs
[2026-08-12 03:14:22] [ERROR] ollama_runner[412]: llama_print_timings: prompt eval time = 4521.12 ms / 512 tokens
[2026-08-12 03:14:22] [ERROR] ollama_runner[412]: llama_print_timings: eval time = 18234.50 ms / 31 runs ( 588.21 ms per token, 1.70 t/s)
[2026-08-12 03:15:01] [FATAL] ollama_runner[412]: HTTP POST /api/generate received. Context size exceeded or VRAM exhausted.
[2026-08-12 03:15:02] [KILLED] Kernel oom-kill: constraint=oom_adj_score, process_oom_score=850, process=ollama, vm_anon_bytes=14202398720, rss=13982100000
[2026-08-12 03:15:02] [ERROR] systemd[1]: ollama.service: Main process exited, code=killed, status=9/KILL
この死のログから得られる技術的教訓
-
torch.cuda.empty_cache()の無力さ:
Ollama/llama.cppはC++のテンソル計算ライブラリ(ggml)をコアとしており、Python側のガベージコレクションやキャッシュクリア命令は全く届きません。限界を超えた瞬間、カーネルのOOMキラーによって問答無用でプロセスごと刈り取られます(status=9/KILL)。プロセス外からの独立した監視と、安全な再起動を司るデーモンが不可欠です。 -
コンテキスト膨張によるレイテンシ爆発(死より恐ろしいデッドロック):
ログにあるeval timeが 588 ms/token(1.7 t/s)まで落ち込んだ状態は、スワップアウトが発生しているか、GPUへのデータ転送でPCIeバスがボトルネックになっている証拠です。プロセス自体は死んでいないため、OSからは「正常稼働」に見えますが、APIとしては実質的に応答不能なデッドロック状態に陥っています。
2. バックエンド・アーキテクチャ設計
上記の教訓を踏まえ、Python(FastAPI + psutil + httpx)による軽量な常時稼働監視デーモン localops-d を設計します。
3. バックエンド・プロセス監視の実装パターン(localops-d)
監視デーモン自体がリソースを食いつぶしては本末転倒です。堅牢で軽量な設計原則に基づく実装を紹介します。
ベストプラクティス 1:HTTPコネクションプーリングによるソケットリークの根絶
よくあるアンチパターンとして、毎回の監視ループで httpx.get() などの高レベルAPIを使い捨てで呼び出すケースがあります。これを数秒間隔で繰り返すと、TCPの TIME_WAIT 状態のソケットがOSレベルで蓄積し、数日でファイルディスクリプタが枯渇(Too many open files)してデーモンが自滅します。
必ず httpx.Client をシングルトンとして維持し、TCPコネクションプールを再利用してください。
import httpx
class OllamaAPIClient:
def __init__(self, base_url: str = "http://localhost:11434", timeout_sec: float = 3.0):
# 接続プールの制限を設定(ファイルディスクリプタの枯渇を防ぐ)
limits = httpx.Limits(max_keepalive_connections=5, max_connections=10)
self._client = httpx.Client(
base_url=base_url,
timeout=timeout_sec,
limits=limits
)
def get_ps(self) -> dict | None:
try:
response = self._client.get("/api/ps")
response.raise_for_status()
return response.json()
except (httpx.TimeoutException, httpx.RequestError):
return None
def close(self):
self._client.close()
ベストプラクティス 2:サーキットブレーカーによるOOMデスループの物理的遮断
OllamaがOOMキラーによって終了させられた直後、OSがRAMやVRAMのページテーブルを完全に解放するまでにはタイムラグがあります。監視デーモンが焦って即座に systemctl restart を叩き続けると、起動とクラッシュを繰り返す「無限デスループ」に陥り、CPUリソースまで焼き尽くします。
この連鎖崩壊を防ぐため、時間窓(ウィンドウ)と試行回数にハードリミットを設けるサーキットブレーカーを実装します。
import time
class RecoveryCircuitBreaker:
def __init__(self, max_failures: int = 3, window_seconds: int = 300, cooldown_seconds: int = 600):
self.max_failures = max_failures
self.window_seconds = window_seconds
self.cooldown_seconds = cooldown_seconds
self.failure_timestamps: list[float] = []
self.is_tripped = False
self.trip_time = 0.0
def record_failure_and_check(self) -> bool:
now = time.time()
if self.is_tripped:
if now - self.trip_time > self.cooldown_seconds:
self.is_tripped = False
self.failure_timestamps = []
else:
return False # 自動復旧を抑制
self.failure_timestamps = [t for t in self.failure_timestamps if now - t < self.window_seconds]
self.failure_timestamps.append(now)
if len(self.failure_timestamps) >= self.max_failures:
self.is_tripped = True
self.trip_time = now
return False
return True
ベストプラクティス 3:コア監視モジュールとプロセス自動復旧
マジックナンバーに頼らず、OSの実際のメトリクスとOllamaのAPIを組み合わせて複合的に死活を判定します。
import time
import logging
import psutil
import httpx
from dataclasses import dataclass
import subprocess
import sys
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("localops-d")
@dataclass
class SystemMetrics:
vram_used_mb: float
ram_used_percent: float
ollama_response_time_ms: float
is_responsive: bool
class OllamaHealthChecker:
def __init__(self, ollama_host: str = "http://localhost:11434", latency_threshold_ms: float = 5000.0):
self.ollama_host = ollama_host
self.latency_threshold_ms = latency_threshold_ms
def check_health(self) -> SystemMetrics:
start_time = time.time()
responsive = False
vram_mb = 0.0
try:
with httpx.Client(timeout=3.0) as client:
# Ollamaの稼働モデル状態を確認
resp = client.get(f"{self.ollama_host}/api/ps")
if resp.status_code == 200:
data = resp.json()
# 実行中のモデルがあればVRAM使用量を集計(llama.cpp実測値ベース)
for model in data.get("models", []):
vram_mb += model.get("size_vram", 0) / (1024 * 1024)
# 簡易的な死活・応答速度確認
ping_resp = client.get(f"{self.ollama_host}/")
if ping_resp.status_code == 200:
responsive = True
except (httpx.RequestError, httpx.TimeoutException) as e:
logger.warning(f"Ollama endpoint unreachable: {e}")
responsive = False
elapsed_ms = (time.time() - start_time) * 1000
ram_percent = psutil.virtual_memory().percent
return SystemMetrics(
vram_used_mb=vram_mb,
ram_used_percent=ram_percent,
ollama_response_time_ms=elapsed_ms,
is_responsive=responsive
)
class OllamaRecoveryManager:
def __init__(self, service_name: str = "ollama"):
self.service_name = service_name
def is_process_alive(self) -> bool:
# systemd またはプロセス名での生存確認
for proc in psutil.process_iter(['name']):
if self.service_name in proc.info['name']:
return True
return False
def restart_ollama(self):
logger.error("CRITICAL: Ollama is unresponsive or OOM-killed. Initiating safe restart sequence...")
try:
# 念のためゾンビプロセスのクリーンアップを含めてsystemctlで再起動
subprocess.run(["sudo", "systemctl", "restart", self.service_name], check=True, timeout=10)
logger.info("Ollama service successfully restarted.")
except subprocess.CalledProcessError as e:
logger.critical(f"Failed to restart Ollama via systemd: {e}")
except subprocess.TimeoutExpired:
logger.critical("Restart command timed out. Manual intervention required.")
4. セキュリティ・過負荷防御の実装パターン(proxy.py)
LLMをAPIとして公開すると、複数のエージェントやスクリプトから非同期リクエストが殺到します。これによりVRAM上のKVキャッシュがアロケーションの限界を超えて破綻する「Thundering Herd(雷鳴群れ)問題」が発生します。これをFastAPIのミドルウェア層で物理的に防御します。
ベストプラクティス 4:セマフォによる同時実行制御とプロンプト・ボムの事前遮断
24GB VRAM環境において、大規模モデル(例: Qwen2.5-32Bの量子化版)の安全な推論スレッド数は実質「1」です。FastAPIのASGIワーカーが受け付けたリクエストは、ミドルウェアの非同期セマフォによって厳密に直列化されます。過剰なリクエストはキューに溜めてOOMを誘発するのではなく、潔く HTTP 429 Too Many Requests で弾き返すのが堅牢な設計です。
import asyncio
from fastapi import FastAPI, Request, HTTPException, status
import httpx
app = FastAPI()
# VRAM 24GB環境におけるハードウェア制約に基づいた同時実行制限
inference_semaphore = asyncio.Semaphore(1)
OLLAMA_BACKEND_URL = "http://localhost:11434"
@app.middleware("http")
async def limit_concurrency_and_validate(request: Request, call_next):
path = request.url.path
if path.startswith("/api/generate") or path.startswith("/api/chat"):
if inference_semaphore.locked():
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Local LLM VRAM is currently saturated. Please retry later."
)
async with inference_semaphore:
# さらに高度な実装として、Content-Lengthを検査し
# 異常に巨大なペイロード(プロンプトボム)を事前遮断することも有効
content_length = request.headers.get("content-length")
if content_length and int(content_length) > 1048576: # 1MB制限
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Payload too large. Risk of VRAM exhaustion."
)
return await call_next(request)
return await call_next(request)
アーキテクチャのワンポイント考察
UvicornなどのASGIサーバーを複数ワーカー(--workers N)で起動した場合、上記のようなメモリ上のセマフォはワーカー間で共有されません。リバースプロキシとして利用する場合は、必ずワーカー数を1にするか、Redis等を用いた分散ロック機構を採用する必要があります。
5. LinuxカーネルレベルでのOOM回避ワークアラウンド(シニア向けTips)
アプリケーション層での防御に加えて、カーネルのOOMキラーに対する防波堤を構築します。systemdのサービスファイル(ollama.service)に対して、OOMScoreAdjust を設定することで、システム全体のメモリが枯渇した際に真っ先にOllamaが殺されることをある程度回避(あるいはコントロール)できます。
# /etc/systemd/system/ollama.service.d/override.conf
[Service]
# デフォルト(0)より低い値を設定し、SSHやシステムデーモンよりは殺されやすくしつつ、
# 一時的なメモリ超過で即死するのを防ぐ(環境に応じてチューニング)
OOMScoreAdjust=-200
# 再起動ループを防ぐためのレートリミット(サーキットブレーカーのOS側実装)
StartLimitIntervalSec=300
StartLimitBurst=3
6. 永続運用のための追従設計
LLMのエコシステムは変化が激しく、OllamaのAPIスキーマや各種モデルのプロンプトテンプレートは頻繁に変更されます。
-
バージョン自動検知機構:
デーモン起動時および定期巡回時に/api/versionを取得し、サポート外のAPIスキーマ変更を検知した場合は警告ログを出力し、安全なフォールバック状態へと遷移させます。 -
アダプター層の分離:
OllamaのAPIレスポンス形式に直接依存するコードを専用のクライアント層(client.py)にカプセル化することで、上流の監視ロジックやプロキシ層が後方互換性の喪失によるクラッシュに巻き込まれるのを防ぎます。
結び:技術的誠実さとエンジニアへの価値提供
本基盤の目的は、「魔法のようなコードで物理VRAMの限界を超える」ことではありません。限られたエッジのハードウェア環境下において、**「夜中に死ぬLLMプロセスを泥臭く監視し、落ちたら安全に復旧させ、開発者のデバッグ時間をゼロにする」**という、極めて現実的で実利的な課題解決にあります。
システムを自律的に稼働させ続けるための真の力は、華やかなプロンプトエンジニアリングの裏側にある、こうした泥臭いシステムコール制御と例外処理の積み重ねによって支えられています。本稿の実装パターンが、ローカルLLMを運用するエンジニアの皆様の安眠に繋がることを願っています。
