我那台家用伺服器上跑著三十幾個容器:部落格、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 check8.3 顆核心限制 jobs、關掉 allTargets
Foam 擴充週期性叫 ripgrep 掃四十萬個檔案尖峰 245 個 rg停用擴充
RustDesk 每秒 fork ps aux 輪詢整機 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   ← 四顆核心

因果鏈當場就成立了:Edge 某個分頁跑飛 → CPU 長期滿載 → 頂到 Tjmax 狂降頻 → 工作積壓 → load 8、32、84、196 → SMU 被搞當 → 系統卡死。而且重開機把 Edge 清掉之後一切正常,看起來像驗證過了。

但我後來自己進去看過。用 RustDesk 連進 server,沒有開 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% 的東西不是任何一個容器,而是某個重開機後不會自己回來的東西——也就是桌面 session 裡的程式。

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

整包 VS Code Remote-SSH 在 netdata 眼裡全部算 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 這包之後,直接翻它自己的 log。最大的那個檔案是 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 crate 會去系統上找對應函式庫的開發檔案。

問題是這台是無頭伺服器,從來沒裝過桌面環境,所以那些東西一個都不在。log 裡缺的是這些:

缺的函式庫它負責什麼
gdk-3.0視窗與繪圖的底層
javascriptcoregtk-4.1libsoup-3.0WebKit 的 JS 引擎與 HTTP 層
glib-2.0gobject-2.0gio-2.0GTK 的物件系統與 I/O
cairopango向量繪圖與文字排版
gdk-pixbuf-2.0atk圖片解碼與無障礙介面

而同一組失敗在 log 裡重複了三十五次——那就是它重試的次數。

換句話說,這個 cargo check 不是編很久,是永遠編不完:每一輪都在同一個地方倒下,倒完再從頭來。迴圈是這樣成立的:

CAUTION

--keep-going --all-targets 是致命的一組 --keep-going 是「遇到錯誤不要停,繼續編其他所有東西」,--all-targets 是「lib、bin、test、bench、example 全部都編」。

兩個加起來就是:明知道編不過,還是把整個 workspace 的每一種 target 從頭到尾跑一遍。而 build script 失敗不會產生快取,所以每一次重試都是完整重編,rustc 直接吃滿全部核心。

這也解釋了兩件先前講不通的事:為什麼只有 07-17 之後才發生(那天開始弄那個 Tauri 專案),以及為什麼一重開機就好(VS Code session 沒了,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
外加 5 個 Rust target/

WARNING

砍行程治不好這個 我砍掉載著 Foam 的 ext host,VS Code 立刻自動補一個新的,Foam 跟著復活,rg 又開始噴。這個迴圈在我眼前重現了一次。

唯一有效的是停用擴充本身。而且磁碟上把資料夾改名之後還要 Reload Window,已經載進記憶體的那份不會因為檔案改名就消失。

停用之後 rg 歸零,溫度從 90°C 掉回 69°C。

這個症狀不是我獨有的。VS Code 有 rg 吃到 900% CPUrg 長期高 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 每秒 fork 一到兩個 sh 去跑 ps aux,用來管理自己的子行程和偵測使用者 session。四天累積燒掉十九小時 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、四十萬個檔案,正好是最壞的組合。

現在的分法是:在家走內網用 VS Code Remote-SSH,人就在機器旁邊,真的燒起來我看得到也關得掉;在外面長時間連就換 Zed,因為上面那三個兇手全都是「連著連著它自己燒起來」的類型,人不在現場正是最糟的狀況。

參考連結