sue@blog ~ /posts/ollama-memory-runaway-hunt
Engineer
Business
$ cd ../ 記事一覧に戻る
Business Mode — 技術的な詳細を省略し、全体の流れと「何をすればいいか」に集中した表示です。右上のトグルでEngineerモードに切り替えられます。
$ cat post.metadata

48GBのMacがスワップ地獄に落ちた — ローカルLLMの21GB暴走を追跡して止めるまで

date: 2026-07-16 ollama Local LLM macOS Performance Troubleshooting
48GBのMacBookがスワップを使い切って停止寸前に。犯人はローカルLLMの21.5GBでした。追跡の全記録と、途中で犯した3回の勘違い、そして効く順に並べた9つの対策。

💡 ひとことで言うと
パソコンが突然重くなったので原因を調べたら、自分のPCの中で動かしていたAI(ローカルLLM)が、メモリの半分近くを1人で占領していたという話です。

面白いのは犯人よりも探し方の方で、途中で3回も見当違いの相手を疑い、1回止めたのに2回も復活されました。「原因を突き止める」という作業が実際どういう手触りなのか、その記録です。

Macが突然重くなる。カーソルがカクつき、アプリの切り替えに数秒かかる。アクティビティモニタを開こうとして、そのアクティビティモニタすら開くのが遅い——。

こういうとき、犯人は「一番派手に動いているもの」ではありません。静かに、大量に、メモリを握ったまま何もしていないものです。

この記事は、48GBのMacBookが実際にスワップを使い切って停止寸前になったときの、犯人追跡の全記録です。犯人はローカルLLM(自分のPCの中で動くAI)でした。ただし面白いのはそこではなく、追跡の途中で3回も勘違いをしたことと、1回殺しても2回復活したことです。

対象読者

📋 ざっくり言うと、起きたことはこの3つ
  1. PCのメモリ48GBのうち、AIが21.5GB(約45%)を1人で占有していた
  2. しかもそのAIは、何も計算していなかった(場所だけ取って居座っていた)
  3. 止めても2回復活した(裏で自動的に何度も呼び出す仕組みが動いていた)

症状:数字で見た「地獄」

まず、状況を数字で押さえます。感覚で「重い」と言っている限り、原因には辿り着けません。

状況
物理メモリ(RAM):  48.0 GB
スワップ使用量:      31.7GB 中 30.4GB 使用 → 残り 1.3GB
空きメモリ:          7.3 GB
Pageins(累計):     14,978,252

スワップというのは、メモリが足りなくなったときにOSがディスクを仮のメモリとして使う仕組みです。ディスクはメモリより桁違いに遅いので、スワップを使い始めた瞬間からマシン全体が遅くなります。それが31.7GB中30.4GB——ほぼ使い切り。残り1.3GB。

📚 スワップとは
机の上(メモリ)が書類でいっぱいになったとき、あふれた書類を床(ディスク)に置き始めるのがスワップです。

置くこと自体はできます。でも床の書類を使うたびに、いちいちしゃがんで探して拾い上げる必要がある。机の上なら一瞬で取れたものが、何十倍も時間がかかる。それが「PCが重い」の正体です。

今回は、その床のスペース(31.7GB)すら30.4GB埋まっていました。床にもほぼ置けない=あと少しで何も動かなくなる状態です。

つまり「重い」ではなく「あと少しで何も動かなくなる」状態でした。


犯人探し:RSSの上位を見る

プロセスがどれだけメモリを使っているかは RSS(Resident Set Size) という数字で分かります。「実際に物理メモリ上に載っているサイズ」です。

Terminal
ps -Ao pid,ppid,rss,%mem,%cpu,etime,comm | sort -k3 -rn | head -5

結果:

出力
  PID   PPID        RSS   %MEM  %CPU    ELAPSED  COMM
65851  80888   20857 MB   42.4   0.0      01:43  .../libexec/ollama runner
 3114    619     604 MB    1.2   0.1   12:49:06  Slack
44384  44331     533 MB    1.1   1.3    8:03:03  Python
40198  57472     479 MB    1.0  16.9      31:17  claude

一位が 20.8GB。二位のSlackが604MB。桁が2つ違います

しかも注目すべきは ELAPSED(起動からの経過時間)が 01:43 ——たった1分43秒。起動して2分足らずで20.8GB を確保していました。

正体は ollama runner。ollama は、ローカルでLLMを動かすためのソフトです。

🔍 この作業の意味
「PC上で動いている全部のプログラムを、メモリ使用量の多い順に並べる」というだけの作業です。犯人探しは、ここから始めるのが定石です。

結果は一目瞭然でした。1位が20.8GB、2位のSlackが0.6GB桁が2つ違うので、議論の余地なく1位が犯人です。

さらに、そいつが起動してからまだ1分43秒しか経っていない。2分足らずで20.8GBを掴んだ計算になります。

勘違い その1:「Ghosttyのプロセスが暴走している」

このとき私は「ターミナル(Ghostty)で動かしている何かが暴走している」と考えていました。実際、体感としてはそうです。

しかし親を辿ると、話が違いました

プロセスには必ず「親」がいます。PPID(Parent PID)がそれです。親を root まで遡ります。

Terminal
pid=65851
# -n "$pid" が無いと、PIDが既に死んでいるとき無限ループする(後述)
while [ -n "$pid" ] && [ "$pid" != "1" ]; do
  ps -p "$pid" -o pid=,ppid=,rss=,etime=,command=
  pid=$(ps -p "$pid" -o ppid= | tr -d ' ')
done

結果:

出力
65851  80888  20.8GB  01:43   .../libexec/ollama runner --ollama-engine --model /Volumes/NX-P4SE1TB/...
80888      1  42MB    07:56:49  /opt/homebrew/opt/ollama/bin/ollama serve

ollama serve の親が PID 1。PID 1 は launchd(macOSの最上位プロセス管理者)です。

つまり ollama はターミナルの子ではありませんでした。 launchd が管理する常駐サービスで、ターミナルからは完全に切り離されています。Ghosttyを閉じても、ターミナルのタブを全部消しても、こいつは死にません。

念のためGhostty自体も測りました:

出力
613   1  69 MB  /Applications/Ghostty.app/Contents/MacOS/ghostty
1411  1  66 MB  ghostty -e tmux attach -t dev-34057
1478  1  71 MB  ghostty -e tmux attach -t dev-29095

70MB程度。完全に無実でした。

👥 「親を辿る」とは
プログラムには必ず「誰に起動されたか」=親が記録されています。組織図をたどるようなものです。

私は最初「ターミナル(黒い画面)の中の何かが暴走している」と疑っていました。体感としてはそう見えたからです。ところが親をたどると、犯人の上司はターミナルではなくOS本体の常駐サービスでした。

これは重要です。ターミナルを閉じても犯人は死なないということだからです。疑っていたGhostty自身は70MBしか使っておらず、完全に無実でした。

ただし——直感が全部外れていたわけではありません。メモリを握っていたのはlaunchd配下のollamaだが、それを呼び出した引き金はターミナルの中にいた。これが後で分かります。「体感は正しいが、犯人の住所は違う」というパターンです。


何をロードしているのか

ollama には、いま何のモデルがメモリに載っているかを見るコマンドがあります。

Terminal
ollama ps
出力
NAME               ID              SIZE     PROCESSOR    CONTEXT    UNTIL
qwen3-coder:30b    06c1097efce0    22 GB    100% GPU     65536      3 minutes from now

読み解きます。

項目 意味
NAME qwen3-coder:30b 300億パラメータのコード特化モデル
SIZE 22 GB メモリ占有
PROCESSOR 100% GPU Apple SiliconなのでGPUメモリ=本体のRAM(ユニファイドメモリ)。逃げ場なし
CONTEXT 65536 一度に扱える文脈の長さ(64K)
UNTIL 3 minutes from now あと3分で自動解放される予定

さらにモデルの素性を調べます:

Terminal
ollama show qwen3-coder:30b
出力
  architecture        qwen3moe
  parameters          30.5B
  context length      262144
  quantization        Q4_K_M

ディスク上の実体も測りました:

出力
17.3 GB  /Volumes/NX-P4SE1TB/ollama/models/blobs/sha256-1194192cf...

ここで2つのことが分かります。

1. 17.3GBのモデルが、メモリ上では21.5GBになっている。 差分の約4.2GBは、主にコンテキストを保持するためのキャッシュ(KVキャッシュ)です。CONTEXTが65536=64Kと大きいほど、このキャッシュは膨らみます。

2. モデルの実体が外付けSSD(/Volumes/NX-P4SE1TB)にある。 17.3GBをUSB越しに読み込んでいるので、ロードが遅い。さらに外付けを抜けば当然壊れます。

📐 なぜ17.3GBが21.5GBに膨らむのか
AIの「本体」がファイルとして17.3GBあります。これは辞書のようなもので、使うには机の上に広げる必要があります。

ところが机の上では21.5GBになる。差の約4.2GBは「会話の記憶用のメモ帳」です。「一度にどれだけの文脈を覚えておくか」という設定が64K(かなり大きめ)になっていたので、その分のメモ帳を最初から広げていました。

そして本体の17.3GBは外付けSSDに置いてありました。つまり毎回USBケーブル越しに17.3GBを読み込んでいた=遅い。しかも外付けを抜いた瞬間に壊れる構成でもあります。

そして最大の事実——21.5GB ÷ 48GB = 45%。1つのプロセスが物理メモリの半分近くを単独で持っていく。そこにSlack、ChatGPT、Cursor、そして18本のClaude Codeセッションが乗れば、スワップに落ちるのは当然です。

🏢 たとえるなら
机(メモリ)が48GB。そこに1人で21.5GB=机の半分近くを占める同僚が座っている状態です。

残り半分に、Slack・ChatGPT・エディタ・18本の作業セッションが全部乗ろうとする。当然あふれる。あふれた分が床(スワップ)行きになり、PC全体が遅くなりました。

「机の広さ」は買った時点で決まっています。だから座らせる相手のサイズを先に測るしかありません。

犯人は「何もしていない」

ここで決定的な観測をします。CPUを2サンプル測りました。

Terminal
top -l 2 -s 3 -pid 65851 -stats pid,cpu,mem,th
出力
PID    %CPU  MEM  #TH
65851  0.0   21G  24

CPU 0.0%。21GBを握ったまま、何も計算していない。

これが「暴走」の正体です。 暴走というと、CPUを100%使って走り回っているイメージがありますが、実際に危険なのは逆です。仕事をせずにメモリだけ持って居座る方が、システムには致命的です。
🛋 たとえるなら
大きな会議室を予約したまま、中で何もしていない人です。

「暴走」と聞くと、走り回って騒いでいる人を想像しますよね。でも組織にとって本当に困るのはそっちではない。走り回っている人は目立つのですぐ止められます。

厄介なのは、一番広い会議室を押さえたまま、静かに、何もせずに居座っている人です。誰も気づかないし、その間ずっと他の全員が会議室難民になる。今回の犯人はまさにこれで、CPU使用率0%=完全に無活動のまま21GBを保持していました。

誰が呼んだのか:lsofで接続元を暴く

ollamaは自分で勝手に動きません。誰かがAPI(localhost:11434)を叩いたから、モデルをロードしたはずです。

lsof はネットワーク接続からプロセスを逆引きできます。

Terminal
lsof -nP -iTCP:11434 | grep -v LISTEN
出力
COMMAND    PID    FD   TYPE   NAME
opencode 76615   15u  IPv4   127.0.0.1:54837->127.0.0.1:11434 (ESTABLISHED)
opencode 76615   17u  IPv4   127.0.0.1:54839->127.0.0.1:11434 (ESTABLISHED)
ollama   80888   10u  IPv4   127.0.0.1:11434->127.0.0.1:54837 (ESTABLISHED)

opencode でした。ローカルLLMでコーディングエージェントを動かすツールです。設定を見ると:

opencode 設定
{
  "provider": {
    "ollama": {
      "npm": "@ai-sdk/openai-compatible",
      "options": { "baseURL": "http://localhost:11434/v1" },
      "models": { "qwen3-coder:30b": { "name": "Qwen3-Coder 30B (local)" } }
    }
  },
  "model": "ollama/qwen3-coder:30b"
}

"model": "ollama/qwen3-coder:30b" ——ここで30Bモデルが固定されていました。

さらに親を辿ると、全体像が見えます:

claude 72060 (--dangerously-skip-permissions)
  ↓
timeout 240 qq run --agent reviewer "Review bug.js in this folder."
  ↓
opencode 76615  ——ESTABLISHED——→  ollama:11434
                                     ↓
                                   runner = 21.6 GB

qq は opencode のラッパースクリプトでした:

qq(ラッパースクリプト)
# qq: launch opencode against the local ollama qwen3-coder:30b as an agentic wrapper.
MODEL_TAG="qwen3-coder:30b"

要するに、bug.js のレビューをローカルLLMのサブエージェントに委譲する実験を繰り返していた。そして1試行ごとに21GBがロードされていた。

📞 つまり
AIは勝手に起動しません。必ず誰かが呼んでいます。そこで「誰がこのAIに電話をかけているか」を通話記録から逆引きしました。

犯人は自分自身でした。「コードのレビューをローカルAIにやらせる実験」を繰り返し走らせていて、その1回ごとに21GBのAIが呼び出されていた。

つまり被害者だと思っていた自分が、加害者を毎回呼び出していたわけです。

勘違い その2:殺したのに、死んでいなかった

犯人が分かったので殺します。

Terminal
kill -TERM 76615   # opencode
ollama stop qwen3-coder:30b

確認:

出力
opencode 76615: 停止済み
ollama runner 65851: 停止済み
free+inactive: 7.3 GB → 22.5 GB

解放されました。一件落着——ではありませんでした

数分後、もう一度測ると:

出力
7719  80888  21.5GB  00:39  .../libexec/ollama runner

新しいrunner(PID 7719)が39秒前に起動して、また21.5GBを確保している。

lsof をもう一度叩くと:

出力
opencode 11252  ->  127.0.0.1:11434 (ESTABLISHED)

opencodeが「生きて」いました。しかしPIDが違う(76615 → 11252)。

つまり私が殺したのは「1世代前の試行」で、次の世代がもう生まれていた。 殺した直後の「停止済み ✓」は正しかったのに、結論としては間違っていた。単発のスナップショットで判断すると、ループを見逃します。

調査の教訓: 「殺した→確認した→消えていた」は、その瞬間の事実でしかありません。ループしているものを相手にするときは、時間をおいて2回測る必要があります。
🔄 なぜ「止めたのに復活した」のか
会議室を占領していた人を退室させました。確認すると確かに空いている。解決したと思いました。

ところが数分後にもう一度見ると、別の人が同じ会議室を占領していた。私が追い出したのは「1回目の実験の担当者」で、2回目の担当者がもう入室していたのです。

教訓はシンプルです。「今この瞬間、空いている」は「解決した」ではありません。 繰り返し発生するものが相手のときは、時間を置いてもう一度確認するまで結論を出してはいけない。

勘違い その3:親を殺しても止まらない(孤児プロセス)

ループ元は claude セッション(PID 72060)だと特定し、これを殺しました。

Terminal
kill -TERM 72060
出力
72060: 停止済み ✓

そして——また復活しました

出力
23541  80888  21.1GB  .../libexec/ollama runner

新しいトリガーを探します:

Terminal
ps -Ao pid,ppid,etime,args | awk '$3 ~ /^0?[0-1]:[0-9][0-9]$/'
出力
43185     1    00:39  bash -c echo start=$(date +%s) > d2_timing.txt; '/Users/.../personal-dotfiles/bin/qq' ...
43189 43185    00:39  opencode run --agent build "Delegate the review of bug.js to the reviewer subagent..."

PPID1

これが3つ目の勘違いの正体でした。このプロセスは、親を殺したから孤児になったのではなく、最初から親から切り離されて(detached)動いていた。 UNIXでは親プロセスが死ぬと、子は launchd(PID 1)に引き取られます。だから親を殺しても子は生き残る

しかも d2_timing.txt というファイル名。echo start=$(date +%s) で開始時刻を記録している。これは計測付きのベンチマークスクリプトでした。d1、d2……と、opencodeのエージェント委譲パターンを何通りか試して、所要時間を比較していた。

1パターン試すごとに、21GBのモデルがロードされる。ベンチマークが終わるまで、それが繰り返される。

「親を殺せば止まる」は、detachedなプロセスには通用しません。
🚫 なぜ「上司を止めても部下が動き続ける」のか
2回目は「指示を出している大元」を止めました。それでも復活しました。

調べると、その実験プログラムは最初から誰の部下でもない状態で動いていた。上司を経由せず、独立して走るように作られていたのです。だから上司を止めても、まったく影響を受けなかった。

正体は「複数のやり方を順番に試して所要時間を比べるベンチマーク」でした。1パターン試すごとに21GBのAIが呼ばれる。全パターン終わるまで、それが延々と繰り返される仕組みだったわけです。

最終的にベンチマークが完走し、すべてのプロセスを止めて解放できました:

出力
free+inactive: 7.3 GB → 28.5 GB
qwen runner: 停止済み
opencode:    停止済み

根本原因は3層あった

「ollamaが重い」で終わらせると再発します。原因を層に分けます。

🎯 原因は1つではありません
「AIが重かった」で片付けると必ず再発します。原因は3階建てになっていました。

1階:設定ミス — 「全AI共通で、記憶メモ帳は大きめに」という設定が全体にかかっていた
2階:サイズ選定ミス — そもそもこのPCに対してAIが大きすぎた(ここが最大の要因)
3階:止められない作り — 実験プログラムが「途中で止める」を想定せずに作られていた

どれか1つを直しても足りません。特に2階を放置すると、他を全部直しても効きません

第1層:設定 — 全モデルに64Kコンテキストを強制していた

~/Library/LaunchAgents/com.local.ollama.plist:

com.local.ollama.plist
<key>EnvironmentVariables</key>
<dict>
    <key>OLLAMA_FLASH_ATTENTION</key>   <string>1</string>
    <key>OLLAMA_KV_CACHE_TYPE</key>     <string>q8_0</string>
    <key>OLLAMA_CONTEXT_LENGTH</key>    <string>65536</string>
    <key>OLLAMA_MODELS</key>            <string>/Volumes/NX-P4SE1TB/ollama/models</string>
</dict>
<key>KeepAlive</key>
<true/>

OLLAMA_CONTEXT_LENGTH=65536グローバルに効いていました。用途に関係なく、どのモデルも64Kのコンテキストを確保しに行く。

名誉のために書いておくと、この設定はすでに手当てされている部分もありますOLLAMA_FLASH_ATTENTION=1OLLAMA_KV_CACHE_TYPE=q8_0 は、まさにKVキャッシュを節約するための設定です。これらが無ければ、もっと悲惨だった。それでも21.5GBだった、というのが実情です。

KeepAlive=true も効いています。ollama serve は殺しても launchd が即座に復活させます。

「どのAIを使うときも、記憶用のメモ帳は最大サイズで用意する」という設定が、全体に対してかかっていました。1ファイルのレビューにそんな容量は要りません。

なお、名誉のために書くと節約のための設定は既に入っていました。それが無ければもっと悲惨だった。それでも21.5GBだった、というのが実情です。

第2層:モデル選定 — 48GBの機体に30Bは重すぎた

計算
qwen3-coder:30b = 30.5B params / Q4_K_M / 実体 17.3GB → メモリ上 21.5GB
21.5GB ÷ 48GB = 45%

ここが最大の要因です。コンテキストを64Kから8Kに落としても、削れるのはキャッシュ側の数GB。17.3GBのモデル本体は削れません

第3層:プロセス設計 — 止められない作りだった


対策:効く順に

「どれか1つ」ではなく、効果の大きい順に、複数を組み合わせるのが現実的です。

📋 やることを効く順に並べると
  1. AIのサイズをPCに合わせる(最も効く・コスト最小)
  2. 記憶メモ帳のサイズを用途に合わせる(1ファイルのレビューに最大容量は不要)
  3. 使い終わったらすぐ解放する(既定では5分間も居座る)
  4. 動かす前に、空き容量が足りるか測る(これがあれば今回は起動時点で止まっていた)

案1:モデルを機体に合わせる(最も効く)

48GBのマシンで30Bを常用するのは無理があります。

モデル おおよその実体 48GB機での占有率
qwen3-coder:30b 17.3 GB 約36%(+キャッシュで45%)
14bクラス 約9 GB 約19%
7bクラス 約4.7 GB 約10%

コードレビューの委譲程度なら、14b でも実用になることが多いです。効果が最大で、コストが最小の対策はこれです。

トレードオフ: 当然、モデルの賢さは落ちます。「何に使うか」を先に決めてからサイズを選ぶべきで、逆順にすると今回のようになります。

案2:コンテキスト長を用途に合わせる

com.local.ollama.plist
<key>OLLAMA_CONTEXT_LENGTH</key>
<string>16384</string>

65536 → 16384 で、キャッシュ側が概ね1/4になります。1ファイルのレビューに64Kは要りません。

グローバルに下げたくない場合は、plistから消して呼び出し側(opencode等)でリクエストごとに指定する方が筋がいいです。「全体に効く設定」で個別事情を吸収すると、今回のように誰も意図していない場所に影響が出ます

案3:keep_aliveを短くする

com.local.ollama.plist
<key>OLLAMA_KEEP_ALIVE</key>
<string>30s</string>

デフォルトは5分。使い終わってから5分間、21GBを握り続けます。今回まさに「CPU 0%で21GB保持」の状態を作っていた設定です。

トレードオフ: 短くすると再ロードが増えます。特にモデルが外付けSSDにあると、ロードのたびに17.3GBを読み直すので体感が悪化します。案6(内蔵SSDへ移動)とセットで効きます。

案4:detachedにしない、殺すときはプロセスグループごと

ベンチマークのように複数回まわすものは、プロセスグループを作って一括で殺せるようにします。

Terminal
# 実行時: 専用のプロセスグループを作る
# set -m でジョブ制御を有効にすると、バックグラウンドジョブが
# 独自プロセスグループのリーダーになる
set -m
./bench.sh &

# $! はPIDであってPGIDとは限らない。実際のPGIDを引いて保存する
pgid=$(ps -o pgid= -p $! | tr -d ' ')
echo "$pgid" > bench.pgid

# 停止時: グループごと殺す(子・孫まで確実に届く)
kill -- -$(cat bench.pgid)

ここには罠が2つあります。

1つ目:setsid はmacOSに入っていません。 Linuxの記事をそのまま持ってくると command not found でベンチが1回も起動せず、しかも & の後ろなので失敗に気づきにくい。macOSでは set -m でジョブ制御を有効にすれば、標準機能だけで新しいプロセスグループを作れます。

2つ目:$! はPIDであって、PGIDではありません。 スクリプト(非対話シェル)ではジョブ制御が既定でオフなので、この2つは一致しません。echo $! > bench.pgid としてしまうと、kill -- -$(cat bench.pgid)別のグループを撃ちます。ベンチは生き残り、21GBを掴んだまま止まらない——この記事が扱っている事故そのものです。ps -o pgid= で実際のPGIDを引き直してください。

あるいは、そもそも & でdetachせず、フォアグラウンドで timeout に包む。「Ctrl-Cで止まらないものを作らない」 のが原則です。

案5:起動前のメモリガード

qq には既に qq doctor(ヘルスチェック)があります。ここに空きメモリの閾値チェックを足すのが自然です。

qq(メモリガード追加案)
qq_guard_memory() {
  local need_gb=22 free_gb
  free_gb=$(vm_stat | awk '/page size of/{gsub(/[^0-9]/,"",$8);ps=$8}
                           /Pages free/{gsub(/\./,"",$3);f=$3}
                           /Pages inactive/{gsub(/\./,"",$3);i=$3}
                           END{printf "%d",(f+i)*ps/1073741824}')
  if (( free_gb < need_gb )); then
    echo "qq: 空き ${free_gb}GB < 必要 ${need_gb}GB。中止します。" >&2
    return 1
  fi
}

「動かす前に測る」。これがあれば、今回は起動時点で止まっていました。

案6:モデルを内蔵SSDへ移す

com.local.ollama.plist
<key>OLLAMA_MODELS</key>
<string>/Users/xxx/.ollama/models</string>

外付け(/Volumes/NX-P4SE1TB)に17.3GBを置くと、ロードが遅く、外付けを抜いた瞬間に壊れます。案3(keep_alive短縮)と両立させるには内蔵が前提です。

トレードオフ: 内蔵SSDの容量を食います。複数モデルを持つなら悩ましいところ。

案7:ロード上限を明示する

com.local.ollama.plist
<key>OLLAMA_MAX_LOADED_MODELS</key>
<string>1</string>

複数モデルの同時ロードを防ぎます。今回は単一モデルだったので直接の原因ではありませんが、モデルを増やしたときに事故る前に入れておく価値があります。

案8:監視して自動で殺す

RSSが閾値を超えたら通知、あるいはアンロードするwatchdogをlaunchdに置く方法です。

watchdog
# runnerが複数行マッチしうるので必ず合計する(printfだけだと数字が連結される)
RSS_GB=$(ps -Ao rss,args | grep "[l]ibexec/ollama runner" | awk '{s+=$1} END{if(NR) printf "%d", s/1048576}')
if [ -n "$RSS_GB" ] && [ "$RSS_GB" -gt 18 ]; then
  ollama stop qwen3-coder:30b
  terminal-notifier -title "ollama" -message "${RSS_GB}GB 超過のためアンロードしました"
fi

細かいですが、awk は必ず合計してください。{printf "%d",$1/1048576} のままだと、runnerが2つ動いているときに数字が連結されます——21GBが2つで「43」ではなく 2121。5GBが2つなら 55 になり、実際は各5GBなのに閾値18を超えたと誤爆します。案7で触れたとおり複数ロードは起こり得るので、現実的な経路です。

ただしこれは最後の砦であって、対策ではありません。案1〜3で発生自体を防ぐのが本筋です。監視で殺すのは「事故は起きる前提」の話です。

案9:ベンチマークは直列+明示的アンロード

複数パターンを比較するなら、各試行の間に明示的に解放を挟みます。

Terminal
for pattern in d1 d2 d3; do
  run_bench "$pattern"
  ollama stop qwen3-coder:30b   # 次の試行の前に必ず返す
done

「どうせ次も使うから」と載せっぱなしにすると、試行の合間に他のアプリがスワップへ追いやられます。


この調査から持ち帰るもの

技術的な結論より、調べ方の方が汎用的です。

1. 体感の主語を疑う。 「Ghosttyが重い」は半分正しく、半分間違いでした。メモリを握っていたのはlaunchd配下のollama、引き金はターミナルの中。症状の出口と原因の入口は別の場所にあることが多い。

2. スナップショットで結論を出さない。 「殺した→消えた」を2回やって、2回とも復活しました。ループを相手にするときは、時間をおいて測り直すまで結論を出してはいけません。

3. PPID を見る。 プロセスの住所は PPID に書いてあります。PPID=1 なら、それは親から切り離されていて、親を殺しても止まりません

4. CPU 0%を安心材料にしない。 今回の犯人は終始CPU 0%でした。仕事をせずにメモリを持って居座るのが一番タチが悪い。

5. 「全体に効く設定」は、いつか誰かを殴る。 OLLAMA_CONTEXT_LENGTH=65536 は、それを書いた時点では合理的だったはずです。しかし後から来た30Bモデルと組み合わさって45%を持っていきました。グローバル設定と個別事情の掛け算は、書いた本人にも予測できません

そして最後に、一番地味で一番効く教訓を。

動かす前に、そのマシンで動くのかを測る。 48GBに21.5GBを載せると何が起きるかは、ollama showls -la で事前に分かりました。30秒のコマンド1本です。
🏁 おつかれさまでした!
技術的な結論より、調べ方の方が持ち帰る価値があります。エンジニアでない方にも効く形にすると、こうです。

1. 「体感」の犯人はたいてい間違っている — 症状が出ている場所と、原因がある場所は別です
2. 「今は大丈夫」を「解決した」と言わない — 繰り返すものは時間を置いて2回確認する
3. 静かに居座っているものが一番危ない — 派手に暴れている方は、すぐ見つかるので実は安全
4. 「全員に効く設定」は、いつか誰かを殴る — 良かれと思った共通ルールが、後から来た事情と噛み合って事故になる

そして一番地味で一番効くのが、「動かす前に、入るのかを測る」。今回それは30秒で終わる作業でした。

参考コマンド集

Terminal
# RSS上位を見る(犯人探しの起点)
ps -Ao pid,ppid,rss,%mem,%cpu,etime,comm | sort -k3 -rn | head -10

# 親を root まで遡る(-n "$pid" が無いと死んだPIDで無限ループする)
pid=<PID>; while [ -n "$pid" ] && [ "$pid" != "1" ]; do
  ps -p "$pid" -o pid=,ppid=,rss=,etime=,command=
  pid=$(ps -p "$pid" -o ppid= | tr -d ' ')
done

# ポートから接続元プロセスを逆引き
lsof -nP -iTCP:11434 | grep -v LISTEN

# 空きメモリ(free + inactive)
vm_stat | awk '/page size of/{gsub(/[^0-9]/,"",$8);ps=$8}
               /Pages free/{gsub(/\./,"",$3);f=$3}
               /Pages inactive/{gsub(/\./,"",$3);i=$3}
               END{printf "free+inactive: %.1f GB\n",(f+i)*ps/1073741824}'

# スワップ
sysctl vm.swapusage

# ollama の状態とモデルの素性
ollama ps
ollama show <model>
ollama stop <model>

# 直近2分以内に起動したプロセス(トリガー探し)
ps -Ao pid,ppid,etime,args | awk '$3 ~ /^0?[0-2]:[0-9][0-9]$/'

小ネタ:pgrep -f が嘘をつくことがある

調査中、pgrep -f ollama がこう返しました。

出力
pgrep: Regular expression evaluation error (illegal byte sequence)

非UTF-8ロケール環境で、pgrep -f がコマンドライン中のバイト列を処理できずに落ちます。厄介なのは、|| でフォールバックを書いていると「プロセスなし」と誤判定することです。実際これで一度「ollamaは死んだ」と誤読しました。

回避策:

Terminal
LC_ALL=C pgrep -f "ollama serve"
# または ps を直読みする
ps -Ao pid,args | grep "[o]llama serve"

余談ですが、この回避策は qq スクリプトの中に既にコメント付きで書かれていました。

qq(ラッパースクリプト)
# LC_ALL=C avoids "illegal byte sequence" from pgrep -f under non-UTF-8 locales.
qq_ollama_up() { LC_ALL=C pgrep -f "ollama serve" >/dev/null 2>&1; }

過去の自分が踏んだ罠に、現在の自分が同じように踏み込んでいたわけです。教訓はコードに書くだけでなく、調査手順の側にも置いておかないと再利用されません。

最後に、少し笑える話

調査中に使ったコマンドの1つが「該当なし」という嘘の結果を返し、一度「AIはもう止まっている」と誤読しました。

その回避方法は、過去に自分が書いたスクリプトの中に、コメント付きで既に書かれていました。つまり、以前の自分が同じ罠を踏んで、対策まで残していたのに、現在の自分がまったく同じ穴に落ちたわけです。

教訓を書き残す場所を間違えると、こうなります。対策はコードの中ではなく、調査の手順書の側にも置いておかないと再利用されません。

Related Posts

$ _