自宅のサーバーでは三十数個のコンテナが動いています。このブログ、NAS のダッシュボード、監視、いくつかのセルフホストなサービス。それに加えて、普段の開発そのものも VS Code Remote-SSH でそこに繋いでやっていました。ワークスペースは 36 GB、ファイル数は四十万。

七月半ばのある午後、そのマシンが焼き切れて応答しなくなりました。

三日かけて追った結果、犯人はエディタでした。三十数個のコンテナのどれでもなく。

症状:90°C に張り付いたまま四十分#

もっと前の電源断を追っていたときに、CPU 温度と load を毎分記録するスクリプトを仕込んでありました。それが捉えたのがこれです。

console
15:12  cpu=90.1C  load=8.65
15:22  cpu=90.0C  load=32.53
15:31  cpu=90.0C  load=84.42
15:43  cpu=90.0C  load=156.54
15:50  cpu=90.0C  load=196.01   ← cron がもうスケジュールされない

温度は 90°C に張り付いたまま、load は 8 から 196 まで駆け上がり、最後は毎分実行の cron すら順番が回ってこなくなりました。それが四十分続いて、マシン全体が応答を返さなくなります。

死ぬ直前の journal は四秒ごとにこれを吐いていました。

console
amdgpu 0000:79:00.0: amdgpu: SMU: I'm not done with your previous command

はチップ内で電源と温度を管理している小さなコントローラです。それすらスケジュールされないということは、CPU は限界まで忙しいということになります。

電源は先に除外できます。このマシンは UPS の下にあり、UPS は全期間 OL、商用電源は安定、バッテリー満充電。停電ではありません。

先に結論#

犯人は三つ。すべて VS Code Remote-SSH の一式から出ています。

犯人占有対処
rust-analyzer がコンパイル不可能なプロジェクトに対して cargo check を無限に回す8.3 コアjobs を制限し allTargets を切る
Foam が定期的に ripgrep で四十万ファイルを走査ピーク 245 個の rg拡張ごと無効化
RustDesk が毎秒 ps aux を fork してポーリングマシン全体の 1.5%放置。ノイズでしかない

以下はどうやってそこに辿り着いたかです。

この件が厄介なのは、手がかりが嘘をつくから#

温度は突然跳ねたのではなく、じわじわ上がっていました。

日付85°C 超過の分数最高温
07-16083.9°C
07-1716796.2°C
07-1839796.4°C
07-198194.0°C
07-2061796.1°C

07-16 は完全に無事で、07-17 から変わりました。何かが 07-17 に入ってきて、そのまま居座っている。

問題は、次に出てくる三つの手がかりがどれも説得力があり、そしてどれも間違いだったことです。

罠その一:センサーが嘘をついていた#

当時読めていたのはこの数字でした。

console
Tctl: 94.6°C   PPT: 5-22W   load: 10-13

8700G が 5 から 22W、つまりアイドル相当で回っているなら 45 から 55°C のはずです。それが 94°C。消費電力がそれだけ低いのにここまで熱いなら、結論の向きは一つしかありません。熱が逃げていない。グリスが乾いたか、クーラーがきちんと乗っていないか。時期も噛み合っていました。七月初めに電源断を追って筐体きょうたいを開けているので、そのときに CPU ファンのケーブルを外していた可能性は十分あります。

なので当時の判断はグリスの塗り直しでした。私は手をつけませんでした。 単純に面倒だったからです。乗っているのはで、デュアルタワーを外して付け直すのは手間ですし、そもそもグリスをどこにしまったか探すところからでした。

手をつけなかったのが結果的に正解でした。再起動後、同じマシン、同じクーラーで:

再起動前再起動後
CPU94.9°C54.5°C
GPU61°C38°C
NVMe57°C42°C

しかもこれは負荷がかかった状態、load 12 でコンテナが全部動いている状態で 55°C です。

WARNING

センサーが壊れているとき、読める数字は作り話になる あの「PPT はたった 5 から 22W」という値はそもそも成立していません。その時点で SMU はすでにハングしており、amdgpu が四秒ごとに応答が返らないと文句を言っていたのがまさにそれです。SMU が落ちた後、そこを経由するすべての値は信用できません。 消費電力も含めて。実際には CPU は本当にフルロードで発熱していて、熱は問題なく逃げていました。

壊れた計測器の数字から推論するのは、数字がまったくないより危険です。出力が証拠のように見えてしまうからです。

罠その二:再起動したら直った#

再起動後にプロセス一覧を見直すと、何日も CPU を食っていたものが見つかりました。

console
msedge   395% CPU   ← 4 コア分

因果関係はその場で組み上がります。Edge のどこかのタブが暴走 → CPU が長時間フルロード → Tjmax に張り付いて激しくクロックダウン → 処理が滞留 → load が 8、32、84、196 → SMU が枯渇 → システムが固まる。しかも再起動で Edge が消えた後は正常なので、検証済みのように見えます。

ただ、私は自分で中を見ていました。RustDesk でサーバーに繋いだとき、Edge は起動しておらず、CPU 使用率も 30 から 50% しかありませんでした

Edge がなくても熱いなら、Edge は主因ではありません。「再起動したら直った」が証明できるのは犯人が常駐サービスではないことだけで、犯人が誰かは何も言っていない。 それを因果の検証として扱ったのが、この件で道を間違えかけた最大の瞬間でした。

罠その三:CPU が 30 から 50% では過熱に見えない#

これが一番直感に反しますが、実はこれが答えです。後で sar を使って分解します。

すでに終わった事象をどう追うか#

事象は過ぎ去り、プロセスもとっくに死んでいます。そして再現するまで待って捕まえる気はありませんでした。幸い、履歴を残してくれていたものが二つありました。

sar:過去の CPU 内訳が一分刻みでディスクに残っている

top は現在しか見せてくれません。ただこのマシンには が入っています。平穏だった 07-16 と、爆発した 07-20 を並べると:

console
07-16(無事)    %user  7%   %system  3%   %idle 87%
07-20(94°C)   %user 46%   %system 18%   %idle 32%   runq-sz 20-24   blocked 0

私が見た 30 から 50% は %user の 46% でした。そこに %system の 18% を足すと、CPU は実際には 68% 燃えていて、しかもそれが何時間も続いていたことになります。

8700G において 68% の持続負荷は、およそ十一本のスレッドが止まらず計算している状態に相当します。数字としては高く感じませんが、持続負荷としては APU を温度上限まで押し上げるのに十分です。一瞬 100% が十秒なら何ともありませんが、68% が三時間続けば Tjmax に届きます。

iowait 0.04%blocked 0runq-sz 20-24 の三つを合わせて見ると、I/O で詰まっていたのではないことも確定します。プロセスが に溜まっている場合も load は高くなりますが、その手の高 load では CPU は暇です。ここでは本当に二十数本の実行可能スレッドが並んでいました。

コンテナは全部無罪

再起動後、三十五個のコンテナは全部戻ってきましたが、%user は 6.9% しかありません。平穏だった日とまったく同じです。

あの 46% を食っていたのはどのコンテナでもない。 再起動後に自力で戻ってこないもの、つまりデスクトップセッションの中のプログラムです。

netdata の apps.plugin は遡れる

このマシンでは netdata が動いていて、その が per-process の履歴を保持していました。「熱かった窓」と「静かな窓」で各 app group を引き算します。

console
差分         熱窓        静窓   app
+829.3     834.7        5.4   sshd     ← 8.3 コア
  +5.0      45.5       40.4   dockerd
  +4.7       9.0        4.3   rustdesk

答えは最初からディスクの上に転がっていました。 再現を待つ必要はありません。

sshd が 5.4% から 834.7% まで跳ね上がっていますが、これは SSH サービス自体に問題があるという意味ではありませんapps.plugin は名前を認識できないプロセスを親のグループに集計するので、VS Code Remote-SSH で繋いでいる私の場合はこうなります。

text
sshd
└─ code-server
   ├─ bootstrap-fork --type=fileWatcher
   └─ bootstrap-fork --type=extensionHost
      ├─ tsserver.js ×2
      ├─ typingsInstaller.js
      ├─ jsonServerMain
      └─ claude ×2

netdata から見れば、VS Code Remote-SSH 一式はまるごと sshd です。 ユーザー別で見ても一致します。私のアカウントが 74% から 928.7% になっている一方、root はほとんど動いていません。時系列も噛み合います。

時刻sshd グループ対応する出来事
07-16 18:5412.7%平穏な日、63°C
07-17 13:18227.8%温度が初めて 85°C を超えたのが 13:05
07-18 16:25380.1%この日は 397 分が 85°C 超
07-19 15:10308.7%15:43 の load=196
07-19 16:3811.5%再起動後に下がる
07-20 17:480.0%シャットダウン後にゼロ

この二つはどちらもずっと前に何となく入れたもので、普段まったく見ていません。再現を待たずに事象を復元できたのは完全にこの二つのおかげでした。sysstat が CPU の内訳を、netdataapps.plugin が per-process の履歴を残していたということです。

犯人その一:rust-analyzer が cargo check を無限に回す#

VS Code 一式まで絞れたので、そのログを直接読みました。一番大きいファイルは rust-analyzer のもので、中身はこれで埋まっていました。

console
ERROR Flycheck failed to run: cargo check --workspace
error: failed to run custom build command for `soup3-sys`
error: failed to run custom build command for `javascriptcore-rs-sys`
error: failed to run custom build command for `gobject-sys` / `glib-sys` / `gio-sys`

完全なコマンドはこれです。

bash
cargo check --workspace --keep-going --all-targets \
  --manifest-path ~/Server/tauri-plugin-sidecar/Cargo.toml

これは のプラグインプロジェクトで、Tauri は Linux 上ではシステムの GTK と WebKit を使ってウィンドウを描画します。ビルド時、*-sys クレートがそれらのライブラリの開発用ファイルをシステム上に探しに行きます。

ところがこのマシンはヘッドレスサーバーで、デスクトップ環境を入れたことが一度もありません。つまりどれ一つとして存在しない。ログに出ていた不足分がこれです。

不足しているライブラリ何を担当するか
gdk-3.0ウィンドウと描画の基盤
javascriptcoregtk-4.1libsoup-3.0WebKit の JS エンジンと HTTP 層
glib-2.0gobject-2.0gio-2.0GTK のオブジェクトシステムと I/O
cairopangoベクター描画とテキストレイアウト
gdk-pixbuf-2.0atk画像デコードとアクセシビリティ

そして同じ失敗の組がログの中で三十五回繰り返されています。それが再試行の回数です。

つまりこの cargo check時間がかかるのではなく、永遠に終わらない。毎回まったく同じ場所で倒れ、倒れたらまた最初からやり直します。ループはこう閉じます。

CAUTION

--keep-going --all-targets は致命的な組み合わせ --keep-going は「エラーが出ても止まらず他を全部ビルドし続ける」、--all-targets は「lib、bin、test、bench、example を全部ビルドする」という意味です。

二つ合わせると、コンパイルできないと分かっていながらワークスペース全体の全ターゲットを頭から最後まで走らせる、ということになります。しかも失敗した build script はキャッシュを残さないので、再試行のたびに完全な再ビルドになり、rustc が全コアを埋め尽くします。

これで、それまで説明のつかなかった二点も片付きます。なぜ 07-17 以降にしか起きないのか(その日からその Tauri プロジェクトを触り始めた)、そしてなぜ再起動すると必ず直るのか(VS Code のセッションが消え、rust-analyzer も一緒に消える)。

どう直すか#

根本的には該当のシステム依存を入れるか、そもそもサーバーでそのプロジェクトを開かないことです。ただそれ以上に大事なのは、次にまたループが起きてもマシンを焼けないようにする上限を置くことでした。

toml
# ~/.cargo/config.toml
[build]
jobs = 6
json
// rust-analyzer: test/bench/example はビルドせず、スレッド数も制限する
{
  "rust-analyzer.check.allTargets": false,
  "rust-analyzer.numThreads": 4
}

上限を実際に保証できるのは jobs の行だけです。他は対症療法になります。

ついでに書いておくと、files.watcherExclude は元からきちんと設定してあり、大きなディレクトリは除外済みでした。つまりファイル監視は今回の原因ではなく、純粋に cargo です。これはわざわざ言う価値があります。「VS Code は重い」という議論では、ファイル監視が唯一の容疑者として扱われがちだからです。

犯人その二:Foam 拡張が ripgrep を二百個以上起動する#

これで終わったと思っていたら、数日後にまた CPU が上下し始めました。 のプロセス数を連続でサンプリングすると:

console
16 → 36 → 6 → 5 → 15 → ... → 245

出どころを辿ると、どれもこの指紋でした。

console
rg (--files ... .foam ...)
   └── PID 1220855  = VS Code がたった今再起動した新しい ext host

が定期的に rg --files でワークスペース全体を走査していました。そして私のワークスペースはこの規模です。

36GB
ワークスペース容量
405k
ファイル数
16
node_modules
ほかに Rust の target/ が 5 個

WARNING

プロセスを殺しても直らない Foam を載せている ext host を殺すと、VS Code は即座に新しいものを立ち上げ、Foam も一緒に復活し、rg がまた噴き出しました。このループを目の前で一度再現しました。

唯一効くのは拡張そのものを無効にすることです。しかもディスク上でフォルダ名を変えた後、Reload Window が必要です。すでにメモリに読み込まれた分は、ファイル名を変えたところで消えません。

無効化した後は rg がゼロになり、温度は 90°C から 69°C まで下がりました。

この症状は私だけのものではありません。VS Code には rg が 900% CPU に達する issue や rg が長時間高 CPU になる issue があり、Cursor のコミュニティにも Cursor spawns hundreds of rg processes というスレッドがあります。症状は私の 245 個とほぼ同じです。

それらの議論に共通しているのは誰も根本原因を指摘していないことで、返信の多くは ripgrep 自体が遅くなったのではと推測しています。しかし rg は壊れていません。何度も何度も呼ばれているだけです。CPU を燃やしているのは rg で、ボタンを押し続けている手は別のところにあります。 この種の問題で探すべきは呼び出し側であって、呼ばれている側ではありません。

犯人その三:RustDesk、ただしノイズ#

三十秒のプロセス生成キャプチャを取り、新しく現れたものを全部記録しました。

console
11× sh   ← rustdesk が起動
 7× ps   ← rustdesk が起動

RustDesk 1.3.1 は毎秒 1 から 2 個の sh を fork して ps aux を実行し、自分の子プロセス管理とユーザーセッション検出に使っています。四日で積み上がった CPU 時間は十九時間、誰も接続していなくても動き続けます。再起動しても変わりません。

十六コアのマシン全体で換算すると 1.5%。CPU を沸騰させるには到底足りませんし、リモートデスクトップで中を見るのに必要なので残してあります。ここに書いたのは netdata の差分表で三番目に来たからで、除外するのに使った時間のほうが割に合わなかったからです。

事後に足した二つの保険#

温度上限を 85°C まで下げた#

BIOS に入り、CPU の温度上限をデフォルトの 95°C 付近から 85°C まで下げました。

理由は冷却が足りないからではなく、「何かが無限ループして全コアを埋める」という事態が起こりうると今回で証明されたからです。温度上限の役割はその状況で早めにクロックを落とすことにあります。マシンは遅くなりますが、全コア全開のまま 90°C 超で四十分粘ることはなくなります。

設定場所は です。普段の使用ではまったく違いが分かりません。そもそも 85°C に届かないからで、処理後に測った基準値は温度 72.6°C・ピーク 83°C、消費電力は平均 40W・ピーク 64W(上限はおよそ 88W)、全コア 4.6GHz・シングル 4.9GHz。すべて正常です。

さらに踏み込むなら Curve Optimizer で電圧を下げる手もあり、より涼しく省電力で性能も落ちません。ただこれはデータを置いているマシンで、不安定な低電圧化はランダムなフリーズや segfault として現れます。極限より安定を取るので、これはやりません。

リモート開発の使い方を変えた#

直した後も作業のやり方は変えました。この二つの犯人はどちらも事故ではなく、リモート開発というアーキテクチャの必然だからです。言語サーバー、ファイルインデックス、拡張機能、検索。そのすべてが、デスクトップより遥かに熱的余裕のないマシンの上で動きます。ワークスペースが大きいほど重くなり、私のものは 36 GB・四十万ファイルで、ほぼ最悪の組み合わせでした。

今の分け方はこうです。自宅で LAN 経由なら VS Code Remote-SSH。マシンのすぐ横にいるので、燃え始めても見えますし止められます。外から長時間繋ぐときは Zed。上の三つの犯人はどれも「繋いでいるうちに勝手に燃えてくる」タイプで、その場にいないのがまさに最悪の状況だからです。

參考連結