我那台家用伺服器上跑着三十几个容器:部落格、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,因为上面那三个凶手全都是「连着连着它自己烧起来」的类型,人不在现场正是最糟的状况。

參考連結