WADA-DEV(7) $ /ja/blog/home-server-random-freeze-part4-proving-the-repair/

NAME

home-server-random-freeze-part4-proving-the-repair — 自宅サーバがランダムフリーズした話 (4) ─ 交換した RAM を信じない

SYNOPSIS

初期不良交換で新品メモリが届いた。だが「部品を替えた」は「直った」ではない。memtest・stress-ng・実 LLM 負荷・70B ロードの多層検証で無罪を証明し、最後に破損 RAM が残した亡霊を 1 匹見つけた話。

DESCRIPTION

これまで

数日に一度フリーズする自宅 AI サーバを、journal 法医学で「DRAM ビット化け」と見立て、memtestが開始 1 分で 705 エラーを叩き出して不良メモリを確定させた。ベンダーに初期不良を申告し、送付準備まで済ませたところで話は止まっていた。

続きはあっけなかった。ベンダーが初期不良対応として新品メモリ 2 枚を先送りしてくれた。本体送付は不要、DIMM 交換だけで決着。届いた箱を開け、旧 DIMM を抜いて新品を挿す。ここまでは 5 分の作業だ。

問題はここから。「部品を替えた」は「直った」ではない。 前回の教訓⑤——緑の memtest は無罪放免にならないが、赤は一発で有罪——を、今度は自分が証明する側で使う番になった。

まず memtest、旧環境と同じ土俵で

交換直後、OS を起動する前に memtest86+ を回す。前回とまったく同じ条件で叩けば、答え合わせになる。

結果は 1 周完走・Errors 0。前回 705 エラーを吐いた 50GB 超の帯も、今回は無傷で通過した。前回は同じ場所で 1 分持たなかったのだから、これは圧倒的に有意だ。

とはいえ、これで安心しきれないのも前回学んだ通り。memtest は OS を積まない合成負荷で、実運用の発熱・電圧を再現しない。「memtest は緑なのに実負荷で落ちる」型——IMC の電圧余裕不足など——を見逃す。不良 DIMM は確定して交換済みだから線は薄いが、証明するなら詰めきりたい。

OS の上で、実発熱の下で叩く

そこで OS 起動状態で 2 本のストレステストを走らせた。

Test A ─ memtest の上位互換

まずは stress-ng のメモリ検証。memtest と同じ「パターンを書いて即読み返して照合」を、CPU 32 スレッド全開の実発熱・実電圧下でやる。

stress-ng --vm 12 --vm-bytes 4G --vm-method all --verify --timeout 1800s

システム RAM 48GB——前回の故障帯 50GB 付近を含む——を、全パターンで 30 分。Tctl はピーク 84°C まで上がった。memtest が踏まなかった熱と電圧のストレスを乗せて、それでもビット破損が出るかを見る。

結果:検証操作 11.9 億回で mismatch 0、failed 0

Test B ─ 犯人を再現する

もう 1 本は、前回クラッシュしていた実ワークロードそのもの。クラッシュはすべて LLM 推論中に起きていたので、同じ状況を意図的に作る。ollama で num_gpu を絞り、モデルの大半を GPU ではなくシステム RAM へ押し出し(spill)、CPU 主体の推論を 30 分連続で回した。

# num_gpu を絞って重みをシステム RAM 側に落とす
curl http://localhost:11434/api/generate -d '{
"model": "qwen3:32b",
"options": { "num_gpu": 8, "num_ctx": 16384, "num_predict": 900 }
}'

合成ツール(Test A)が緑でも、実アプリが長時間安定して回るかは別問題だ。リクエスト 6 件すべて正常応答、エラー 0。load average は 30 分ずっと 16 前後に張り付いていた。

ダメ押しに 70B

最後に、このマシンで一番重いメモリ配置をわざと作った。42GB の 70B モデル(Q4)は 32GB の VRAM に収まらない。必然的に ~12GB がシステム RAM へ spill する——VRAM と RAM にまたがって 42GB を展開する構図だ。前回ならこの配置は即死案件だった。

ロードは通り、生成も走った。速度の話は次回に譲るが、落ちずに完走したという 1 点が、ここでは証拠になる。

判定

全テストを通して、カーネルの MM 系エラー(bad page / GPF / list_del / hard LOCKUP)はゼロ。btrfs corruption_errs はリセット後 0 のまま。ハードウェア watchdog の自動 reboot もゼロ。

前回は同じ負荷帯で数分〜数十分で落ち、6 月だけで watchdog reboot 8 回・BTRFS corruption カウンタ 1744 を計上していた。今回はそのすべてを無傷で通過した。メモリ実不良は解消、と言い切っていい。

亡霊 ─ 破損はディスクにも指紋を残していた

証明は済んだ。……はずだった。ここで一つ気になることがある。

破損した RAM が生きていた 3 週間、その裏で pull したモデルたちは無事なのか?

ビット化けは「メモリ上のデータ」を汚す。ダウンロードしたモデルの重みは、ディスクに書かれる前に一度メモリを通る。もしその瞬間に化けたら、化けたままディスクに落ちる。しかも厄介なことに、ファイルシステム(BTRFS)の scrub は緑を返す——化けたデータに対して「正しい」チェックサムを計算して保存してしまうからだ。scrub が保証するのは「書いた後に変わっていない」であって、「書いた内容が正しい」ではない。

だが ollama のモデルは content-addressed で、ファイル名がそのまま中身の SHA-256 になっている。上流の正解ハッシュだ。つまり全 blob のハッシュを取り直してファイル名と照合すれば、scrub には見えない「書き込み時点の化け」だけを炙り出せる。

for f in /path/to/ollama/blobs/sha256-*; do
expect=$(basename "$f" | sed 's/^sha256-//')
actual=$(sha256sum "$f" | cut -d' ' -f1)
[ "$expect" != "$actual" ] && echo "CORRUPT: $f"
done

完成済み blob のうち、1 件が化けていた。 ある 27B モデルの 17GB レイヤ。破損 RAM 期の産物が、事件の物証として 1 つ残っていた。診断のすべてと辻褄が合う。

修復にも小さな罠があった。素朴に ollama pull し直しても、数秒で success を返すだけで直らない。ollama は既存 blob を「ファイル名」だけで信用し、中身を再検証しないからだ。壊れた blob を手で削除してから pull し直して、ようやく 17GB を再取得。ハッシュ一致を確認した。

rm /path/to/ollama/blobs/sha256-<digest> # 化けた blob を消してから
ollama pull <model> # 初めて再ダウンロードされる

まとめ

  • 交換は終点ではなく起点。 部品を替えたら、替える前と同じ土俵で証明する。旧環境の故障モード(50GB 帯・1 分で 705 エラー)を再現条件として使えば、合否は「その帯を無傷で通れるか」に落ちる。
  • 合成テストと実ワークロードは別の質問に答える。 memtest / stress-ng は「ビットは化けるか」、ollama 実負荷は「アプリは落ちずに回るか」。両方要る。
  • stress-ng --vm --verify は OS 上版 memtest。 ビット破損を直接検出しつつ、合成ツールが再現しない実発熱・実電圧・IMC 負荷を乗せられる。
  • チェックサムが緑でも中身が正しいとは限らない。 BTRFS scrub は「書いた後の変化」しか見ない。書き込み時点の化けは、content-addressed なデータ(ollama blob の SHA-256)でしか捕まえられなかった。
  • メモリの故障は、メモリだけを汚すわけではない。 ビット化けはページキャッシュを経由してディスクにもモデルにも指紋を残す。壊れた部品を替えた後、その部品が触ったデータの点検までやって、ようやく本当の終わりだ。

次回は、健全化した RAM でやっと解禁された実験——壊れていた頃は spill した瞬間に即死していた CPU オフロードを、性能の観点から測る話。「半分 spill すれば半分の速度」ではないことを、実測データが教えてくれる。

SEE ALSO

COMMENTS