Macが突然重くなる。カーソルがカクつき、アプリの切り替えに数秒かかる。アクティビティモニタを開こうとして、そのアクティビティモニタすら開くのが遅い——。
こういうとき、犯人は「一番派手に動いているもの」ではありません。静かに、大量に、メモリを握ったまま何もしていないものです。
この記事は、48GBのMacBookが実際にスワップを使い切って停止寸前になったときの、犯人追跡の全記録です。犯人はローカルLLM(自分のPCの中で動くAI)でした。ただし面白いのはそこではなく、追跡の途中で3回も勘違いをしたことと、1回殺しても2回復活したことです。
ps や lsof を使ったプロセス調査を体系的にやりたい方まず、状況を数字で押さえます。感覚で「重い」と言っている限り、原因には辿り着けません。
物理メモリ(RAM): 48.0 GB
スワップ使用量: 31.7GB 中 30.4GB 使用 → 残り 1.3GB
空きメモリ: 7.3 GB
Pageins(累計): 14,978,252
スワップというのは、メモリが足りなくなったときにOSがディスクを仮のメモリとして使う仕組みです。ディスクはメモリより桁違いに遅いので、スワップを使い始めた瞬間からマシン全体が遅くなります。それが31.7GB中30.4GB——ほぼ使い切り。残り1.3GB。
つまり「重い」ではなく「あと少しで何も動かなくなる」状態でした。
プロセスがどれだけメモリを使っているかは RSS(Resident Set Size) という数字で分かります。「実際に物理メモリ上に載っているサイズ」です。
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を動かすためのソフトです。
このとき私は「ターミナル(Ghostty)で動かしている何かが暴走している」と考えていました。実際、体感としてはそうです。
しかし親を辿ると、話が違いました。
プロセスには必ず「親」がいます。PPID(Parent PID)がそれです。親を root まで遡ります。
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程度。完全に無実でした。
ただし——直感が全部外れていたわけではありません。メモリを握っていたのはlaunchd配下のollamaだが、それを呼び出した引き金はターミナルの中にいた。これが後で分かります。「体感は正しいが、犯人の住所は違う」というパターンです。
ollama には、いま何のモデルがメモリに載っているかを見るコマンドがあります。
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分で自動解放される予定 |
さらにモデルの素性を調べます:
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越しに読み込んでいるので、ロードが遅い。さらに外付けを抜けば当然壊れます。
そして最大の事実——21.5GB ÷ 48GB = 45%。1つのプロセスが物理メモリの半分近くを単独で持っていく。そこにSlack、ChatGPT、Cursor、そして18本のClaude Codeセッションが乗れば、スワップに落ちるのは当然です。
ここで決定的な観測をします。CPUを2サンプル測りました。
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を握ったまま、何も計算していない。
ollamaは自分で勝手に動きません。誰かがAPI(localhost:11434)を叩いたから、モデルをロードしたはずです。
lsof はネットワーク接続からプロセスを逆引きできます。
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でコーディングエージェントを動かすツールです。設定を見ると:
{
"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モデルが固定されていました。
さらに親を辿ると、全体像が見えます:
qq は opencode のラッパースクリプトでした:
# qq: launch opencode against the local ollama qwen3-coder:30b as an agentic wrapper.
MODEL_TAG="qwen3-coder:30b"
要するに、bug.js のレビューをローカルLLMのサブエージェントに委譲する実験を繰り返していた。そして1試行ごとに21GBがロードされていた。
犯人が分かったので殺します。
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世代前の試行」で、次の世代がもう生まれていた。 殺した直後の「停止済み ✓」は正しかったのに、結論としては間違っていた。単発のスナップショットで判断すると、ループを見逃します。
ループ元は claude セッション(PID 72060)だと特定し、これを殺しました。
kill -TERM 72060
72060: 停止済み ✓
そして——また復活しました。
23541 80888 21.1GB .../libexec/ollama runner
新しいトリガーを探します:
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..."
PPID が 1。
これが3つ目の勘違いの正体でした。このプロセスは、親を殺したから孤児になったのではなく、最初から親から切り離されて(detached)動いていた。 UNIXでは親プロセスが死ぬと、子は launchd(PID 1)に引き取られます。だから親を殺しても子は生き残る。
しかも d2_timing.txt というファイル名。echo start=$(date +%s) で開始時刻を記録している。これは計測付きのベンチマークスクリプトでした。d1、d2……と、opencodeのエージェント委譲パターンを何通りか試して、所要時間を比較していた。
1パターン試すごとに、21GBのモデルがロードされる。ベンチマークが終わるまで、それが繰り返される。
最終的にベンチマークが完走し、すべてのプロセスを止めて解放できました:
free+inactive: 7.3 GB → 28.5 GB
qwen runner: 停止済み
opencode: 停止済み
「ollamaが重い」で終わらせると再発します。原因を層に分けます。
~/Library/LaunchAgents/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=1 と OLLAMA_KV_CACHE_TYPE=q8_0 は、まさにKVキャッシュを節約するための設定です。これらが無ければ、もっと悲惨だった。それでも21.5GBだった、というのが実情です。
KeepAlive=true も効いています。ollama serve は殺しても launchd が即座に復活させます。
「どのAIを使うときも、記憶用のメモ帳は最大サイズで用意する」という設定が、全体に対してかかっていました。1ファイルのレビューにそんな容量は要りません。
なお、名誉のために書くと節約のための設定は既に入っていました。それが無ければもっと悲惨だった。それでも21.5GBだった、というのが実情です。
qwen3-coder:30b = 30.5B params / Q4_K_M / 実体 17.3GB → メモリ上 21.5GB
21.5GB ÷ 48GB = 45%
ここが最大の要因です。コンテキストを64Kから8Kに落としても、削れるのはキャッシュ側の数GB。17.3GBのモデル本体は削れません。
「どれか1つ」ではなく、効果の大きい順に、複数を組み合わせるのが現実的です。
48GBのマシンで30Bを常用するのは無理があります。
| モデル | おおよその実体 | 48GB機での占有率 |
|---|---|---|
| qwen3-coder:30b | 17.3 GB | 約36%(+キャッシュで45%) |
| 14bクラス | 約9 GB | 約19% |
| 7bクラス | 約4.7 GB | 約10% |
コードレビューの委譲程度なら、14b でも実用になることが多いです。効果が最大で、コストが最小の対策はこれです。
トレードオフ: 当然、モデルの賢さは落ちます。「何に使うか」を先に決めてからサイズを選ぶべきで、逆順にすると今回のようになります。
<key>OLLAMA_CONTEXT_LENGTH</key>
<string>16384</string>
65536 → 16384 で、キャッシュ側が概ね1/4になります。1ファイルのレビューに64Kは要りません。
グローバルに下げたくない場合は、plistから消して呼び出し側(opencode等)でリクエストごとに指定する方が筋がいいです。「全体に効く設定」で個別事情を吸収すると、今回のように誰も意図していない場所に影響が出ます。
<key>OLLAMA_KEEP_ALIVE</key>
<string>30s</string>
デフォルトは5分。使い終わってから5分間、21GBを握り続けます。今回まさに「CPU 0%で21GB保持」の状態を作っていた設定です。
トレードオフ: 短くすると再ロードが増えます。特にモデルが外付けSSDにあると、ロードのたびに17.3GBを読み直すので体感が悪化します。案6(内蔵SSDへ移動)とセットで効きます。
ベンチマークのように複数回まわすものは、プロセスグループを作って一括で殺せるようにします。
# 実行時: 専用のプロセスグループを作る
# 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で止まらないものを作らない」 のが原則です。
qq には既に qq doctor(ヘルスチェック)があります。ここに空きメモリの閾値チェックを足すのが自然です。
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
}
「動かす前に測る」。これがあれば、今回は起動時点で止まっていました。
<key>OLLAMA_MODELS</key>
<string>/Users/xxx/.ollama/models</string>
外付け(/Volumes/NX-P4SE1TB)に17.3GBを置くと、ロードが遅く、外付けを抜いた瞬間に壊れます。案3(keep_alive短縮)と両立させるには内蔵が前提です。
トレードオフ: 内蔵SSDの容量を食います。複数モデルを持つなら悩ましいところ。
<key>OLLAMA_MAX_LOADED_MODELS</key>
<string>1</string>
複数モデルの同時ロードを防ぎます。今回は単一モデルだったので直接の原因ではありませんが、モデルを増やしたときに事故る前に入れておく価値があります。
RSSが閾値を超えたら通知、あるいはアンロードするwatchdogをlaunchdに置く方法です。
# 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で触れたとおり複数ロードは起こり得るので、現実的な経路です。
複数パターンを比較するなら、各試行の間に明示的に解放を挟みます。
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%を持っていきました。グローバル設定と個別事情の掛け算は、書いた本人にも予測できません。
そして最後に、一番地味で一番効く教訓を。
ollama show と ls -la で事前に分かりました。30秒のコマンド1本です。
# 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は死んだ」と誤読しました。
回避策:
LC_ALL=C pgrep -f "ollama serve"
# または ps を直読みする
ps -Ao pid,args | grep "[o]llama serve"
余談ですが、この回避策は 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はもう止まっている」と誤読しました。
その回避方法は、過去に自分が書いたスクリプトの中に、コメント付きで既に書かれていました。つまり、以前の自分が同じ罠を踏んで、対策まで残していたのに、現在の自分がまったく同じ穴に落ちたわけです。
教訓を書き残す場所を間違えると、こうなります。対策はコードの中ではなく、調査の手順書の側にも置いておかないと再利用されません。