永不入睡的节点

一、现状

此刻,ms-01-1 的二十个线程全部满载空转,CPU 温度稳定在七十七度。它没有在计算任何东西。它只是在忙着保持清醒。这些核心本该在空闲时睡去,此刻却被一个忙等循环占满,唯一的目的,是让 CPU 永远不进入休眠状态。

另一台机器则在网络的另一头盯着它。一旦它僵死,那台机器就远程切断它的电源,再重新上电。

ms-01-1 是我家里那个小集群中的一个节点,一台 Minisforum 的 MS-01 迷你主机。此刻它在正常服务:上面的虚拟机照常运行,它承载的数据在线,整个集群健康。那个忙等循环并不是我额外跑的程序,也不和虚拟机抢 CPU:它只占着本该空闲的时间,真正的负载一到就立刻让位。它同时也是一台我已经放弃真正修复的机器。

它的故障出在硬件上。能确定的是,不在内存条本身,而在机器某个无法更换的地方。我最接近的判断,是 CPU 和内存之间传递地址与数据的那条链路:它偶尔会在半路上把一位翻错。照这个判断,内存单元自始至终完好,错只错在传输的那一瞬,下一次再读,它便又自己「愈合。而这台机器用的是非 ECC 内存1,本就没有检错、报告这套机制,于是这种错误连一行日志、一个计数器都不会留下,对系统彻底隐形。它要么什么都不发生,要么让一个进程因段错误骤然崩溃,要么让整台机器毫无征兆地僵死。

不让它睡觉,是把所有办法都试过之后,唯一还管用的一个。换内存条、换电源、刷 BIOS、降频率,逐一试过,没有一样奏效。修得动的话,我早修了;可它既过了保修期,坏的地方又没法单独更换,怎么算都不划算。于是它留在原地,靠这一行让它整日发烫的设置维持运转,再有对面那台机器上的看门狗作为最后的保险。

最后救了它的那个办法——别让 CPU 睡觉——我很早就想到了。只是没有第一时间去追它。

它怎么走到这一步,要从一个月前的一次跳闸讲起。

二、跳闸

一个月前的一个深夜,家里跳闸了。七台服务器同时硬断电。偏偏我那台 UPS 没有联网,报不了警,更谈不上触发优雅关机,我是第二天醒来才发现的。

这七台是我在家里搭的一个小集群:用 Proxmox VE 做虚拟化、Ceph 做存储,以超融合(计算和存储跑在同一批机器上)的方式部署,上面是我自己的 GitLab、三个 Kubernetes 集群、内网 DNS、单点登录,还有我 Mac 的 Time Machine 备份等一系列服务。

我跑这套东西,看重的不是上面的服务有多要紧。几乎一切都是 cattle——运维里所谓的「牲口而非宠物,坏了不照料、直接重建——配置都在 Git 里,整个 GitLab 我都从零重建过一次,备份还另有备份。当初打的旗号当然冠冕堂皇:数据主权,把自己的数据握在自己手里。可几年下来,平心而论,真正让我着迷的是折腾基础设施这件事本身,而且是软件那一层:声明式、Nix、GitOps、Terraform 才是我的乐趣;至于那些本该承载「数据主权」的服务,真正上线、有人用的并没有几个。硬件则更不在我的兴趣之内。也正因为上面的负载坏了无所谓,我并不在意内存有没有 ECC——况且这类迷你主机本来也不支持——正因为它足够便宜,买这样一台机器才成了我能接受的取舍。

ms-01-1 之前就偶尔出现段错误,零零星星,不影响使用,我也就没怎么在意。那次断电之后,症状重了一个量级:接下来那两周,段错误从偶尔一次变成每次开机几十次,它在存储里承担的那部分无法启动,整机也开始每隔几天就毫无征兆地僵死一次。崩掉的来来回回总是那几个进程,可那时这并不显眼。我没法证明是那次断电把它弄坏的。毕竟它本来就有毛病,我能确定的只有时间上的先后。我一直拖着没认真处理,直到半个月前才正式着手。

蹊跷就蹊跷在这里:这七台里有两台一模一样的 MS-01,CPU、BIOS、内存型号、配置全都相同,连购入批次都相邻。另一台叫 ms-01-2,自始至终一次没出过问题。那晚两台一同断电,型号一致,内存同样不带 ECC,出问题的却只有 ms-01-1。

三、存疑

我很早就猜到了大致的方向:C-state——CPU 闲下来时,单个核心各自进入的省电休眠档,越深越省电、唤醒也越慢。睡的是核心,不是整机:只要某个核心一时没活干,它就自行睡下,而机器照常运转、照常服务。

这台机器的崩溃有个规律:空闲或轻载时发作,满载反而几乎不出事。这种「越闲越容易出事」的模式,很自然指向这些省电休眠档。何况 MS-01 这款机器早有先例:早期 BIOS(1.22 前后)有过一个和 C-state 等省电机制相关的稳定性问题,后来的版本才修掉。两条迹象叠在一起,方向是一致的。

但知道一个可能的开关,和伸手去拨它,是两回事。我没有去拨。

道理很简单:某个改动恰好让它不再崩溃,并不等于我找到了原因。这改动完全可能只是压住了症状,真正出问题的地方原封不动地藏在底下。对发生率稳定的故障「改完就好」还勉强算一点证据;可这台机器的故障率本身在漂移:同样的配置,这次开机不到百分之一,过些时候又能漂到一半以上。在这种背景噪声里,改完之后崩不崩,其实都说明不了问题:没崩,可能是修好了,也可能只是赶上一段低发期;崩了,读法同样有两种,谁也压不倒谁。一个任何结果都解释得通的测试,等于没做。

所以,比起让它表面上不再崩,我更在意它究竟坏在哪里。归结成一句话:只要还没把其他可能逐一排除,这台机器在我眼里就是带病运转。严格说,这不是机器的病,而是我的心病,可只要这判断还在,我就当它有病。

这句话决定了后面整件事的走向。接下来一连串看似徒劳的测试,都是有计划的逐项排除。和那个我没去拨的省电开关不同,这些测试每一个都可能给出对我不利的答案:内存条换到双胞胎上,故障可能跟着内存条走;memtest 可能真的报错;某一档频率可能正是病根。正因为它们能失败,结果才算数。

而这套排除之所以成立,靠的是那台健康的双胞胎 ms-01-2 作对照。一台坏、一台好,其余全同。它让我绕开那个答不上来的问题——我这一改到底有没有用——换上一个答得上来的:把一个部件在两台机器间对调,故障是跟着部件走,还是留在原机。这个答得上,因为我只看故障停在哪一边,与它此刻漂到多高无关。

四、逐项排除

最先怀疑的,自然是内存条本身。

排查内存的标准做法,是跑 memtest86+,让它在操作系统之外,把每一个内存单元反复读写、比对。光是把它启动起来,就耗去我一个通宵:关掉 Secure Boot,从 GRUB 把它引导起来,中间还卡在开机自检上好几次,屏幕一片黑,只能靠远程把电源硬切、重来。真正跑起来后,我不敢只跑两轮,经验上至少要十轮。于是它跑了二十一小时四十八分,整整十轮,温度一路顶在九十度上下。

结果是零错误。

这是整场排查里第一次落空。一项测试干干净净地通过,却什么也没排除,至多把「内存单元有硬损坏」这一种可能压得很低。memtest 反复读写、比对的,是数据有没有存对,而这台机器的问题不在那里。

同一类工具我还试了第二把:stressapptest,把内存带宽压满,专门逼高速信号上的毛病现形。四个小时,搬运七百多 TB 数据,又是零事件。两把工具,同一种干净。

干净,机器却照旧崩。我退后一步,开始怀疑前提本身:也许根本不是硬件,而是软件,内核的某个 bug。这台机器当时用的内核版本,确实背着几个和内存管理相关的已知回归。我把它升到了修掉那些回归的新版本。那天傍晚,复现脚本跑了两百多轮,一次未崩,我几乎以为就此结束,便让它过夜接着跑。第二天早上,脚本跑到第一千两百多轮、连续两小时五十分之后,整机毫无征兆地僵死在那里。

后来才明白,这一步和别的排查项不是一类。新内核确实修掉了一个真实存在、却完全是另一回事的软件 bug。它发作得又快又频,把所有崩溃的账都揽了过去,我便始终以为崩溃是软件的事;它一被修掉,藏在它后面、发作慢得多的硬件故障才显出原形。这个软件 bug 从头到尾都不是故障本身,而是遮在硬件故障前面的一道烟幕。升级并没有修好任何东西,只是把烟幕吹散了。那两百多轮干净,正是我一直最警惕的那种「改完不崩,而且是最难识破的一种:别的排查项至多给我一个没用的干净,这一次却差点成了一个假的终点。少了那道烟幕,又恰好撞上硬件故障的一段低发期,两样凑在一起,险些让我就此收手、认定它已经修好。

当时我自然没看这么透。内核既然没能了结,我的怀疑还在硬件之外打转,而固件也属于这一类。我把 BIOS 刷到最新版。前面那条 1.22 的 C-state 旧事,本来也指着这个方向。刷完,依旧冻结;更新说明里和内存、DDR5、内存控制器相关的条目一条都没有。那条旧事也跟着不成立:社区那个固件级、影响所有机器的 C-state bug,和我这台的问题不是一回事;双胞胎用着同一版 BIOS,却安然无恙。到这一步,键盘前不拆机能做的,都做尽了:软件、固件都已排除,验内存的几把工具也跑干净了。故障确实长在这台机器的硬件里,而硬件不是一整块。

要分清坏的究竟是哪一块,剩下的只能动手。在此之前,刷 BIOS、引导 memtest、跑那些复现脚本,我都不必离开书房:这几台机器虽没有 BMC2,却各接了一台 IP-KVM(把画面和键鼠经网络接出来远程操控;硬断电重启则靠机柜里一个交换式 PDU(可联网远程开关的电源插排。这样人坐在键盘前就能做完。拆装却是头一件 IP-KVM 帮不上的事。它能把键盘和画面送到我面前,却没法替我拧一颗螺丝。这一步我一直拖到最后,因为它在我这儿的成本高得惊人。这台机器和另外三台迷你主机,挤在同一副我自己 3D 打印的机架套件上,每台背后是四根光纤、一根电源、一根网线、一根 HDMI、一根 USB。要动其中任何一台的硬件,我得打开机柜侧面板、戴上头灯,先把每一根线的位置记下来,再一根根拔掉,然后用电动螺丝刀把整副套件从机架上卸下来。套件卡得很紧,装回去比拆下来更费力。这样一趟下来,不止一个小时。

第一件必须靠拆装才能分辨的,是内存条坏,还是机器本身坏。我把两台 MS-01 的内存对调了过来:ms-01-1 的内存条插进健康的 ms-01-2,ms-01-2 的插进 ms-01-1。逻辑很简单:故障若是跟着内存条去了 ms-01-2,便是内存条的问题;若留在 ms-01-1,便是机器本身的问题。结果,故障留在了 ms-01-1:换上那对健康内存条,它依旧崩溃。所以不是内存条,是机器本身:板子上的供电、CPU 里的内存控制器、CPU 插槽这一类东西,要么焊死在板上,要么无法靠换件解决。既然拆装如此折磨,这次对调我索性没再换回,就一直保持原样。

电源是下一个。按说查到机器本身,它也算其中一部分,可它还没单独验证过,而怀疑它不无道理:故障专挑空闲时发作,恰好和「从空闲骤然转入活跃、供电没跟上」的毛病吻合。于是我又拆了一次,把 ms-01-1 接到 ms-01-2 那个确认无碍的电源适配器上,照原样再测。它仍以几乎一样的频率崩溃,电源没问题。这一拆本可以省下,反正已经查到是机器本身;可若是省了它,电源就始终没真正验过,那这台机器在我眼里仍是带病运转。

最后是内存频率。我把它从 5600 一路降到 4400,又试了 5200。表面上各档的数字有高有低,可一旦把那个会自己漂移的故障率算进去,几档之间分辨不出真实差别。频率也不是能拨动结果的那个旋钮。

数据存得对不对、是不是硬件之外的软件与固件、又是机器里的哪一块,这三层我逐一问过。但每一层至多问出「可能性很低,问不出「不可能:测试只把某一种坏法压到可以搁置,并没有谁被真正排除。唯一的例外是换内存条:故障没跟着它走,这一条是确凿的。剩下还立着的,是一处确实长在机器本身、却没有哪一项测试真正「看见」过的故障。

五、隐形的故障

那些给出「零错误」的测试,之所以一个接一个干净通过,是因为它们本是同一类测试,而这一类测试,天生就难以看见这种故障。

要看清这一点,先得分开两种坏法。一种,是值存进内存之后,存储单元本身出了错,写进去的和读出来的对不上;常规内存测试设想的,正是这一种。这台机器不属于这种:内存单元自始至终是好的。我能给出的最好解释,是错出在「搬运」的那一程,而不在「存放」的那一处:值在 CPU 与内存之间往返,偶尔在途中有一位走样,可只要落定,存着的就是对的,再读也读得回。若真如此,这个错便只发生在往返的途中,两头都干净:写下去的对,读回来的也对。

memtest、stressapptest 这一类工具,本质上都在做同一件事:写一个值进去,过一会儿再读回来,比对前后一致不一致。可这种「写进去、读回来、对一遍」的做法,恰好会漏掉一个传输瞬间出错、读回时已经复原的故障:等回头一比对,它早已恢复,数据里一丝痕迹都不留。它们还有个共同点:都让内存和 CPU 满负荷运转,而这故障偏偏在机器空闲时才最活跃。一种天生看不见它、又恰好压住了它触发条件的测试,自然什么都查不到。

我甚至用 Claude Code 专门写了一个小工具,并将其命名为 memchase,想把它逼出来。这工具别无他用:在内存里建起一条巨大的指针环,再以最高的密度一路追着走。每读出一个指针,当即拿它跳到下一跳,是真正的「读一个、用一个;每一跳都和一张独立的表即时核对,在途中翻错一位,就会被当场逮住。为凑齐「空闲才发作」的条件,它还在每段追逐之间留出空隙,让核心睡下去再醒来。指针密度顶满、读了就用、空闲也有,它跑了八亿多步,一次没响。这一次「什么都没抓到」反而最有分量:连一个专门冲它来、又确实睡过觉、把追指针做到极致的工具都逼不出它。它没能证明什么,却把「坏在存储单元」这条路压到极窄:故障多半不在存住的数据里,而在数据送达的那一路上。至于为什么把指针追到极致仍不够,要等我想起另一件事才明白。

最后让它现形的,不是又一件工具,是我回想起的一个细节。

我注意到,崩溃似乎总发生在我运行某一类命令时:Proxmox VE(命令行下简称 PVE)那套管理工具,qmpctpveversion 之类。它们有一个共同点:全是 Perl 脚本。

这正是 memchase 漏掉的那一样。关键从来不在「指针多,那一点 memchase 已经做到顶,也没用;要紧的是 Perl 每跑一次,都要把自己那套结构从头重建一遍。Perl 解释器本就一刻不停地解引用指针、顺着往里跳;而这套 PVE 工具每调用一次,都要重新 fork(开一个新进程、重新加载一大批模块,在刚分配的内存里现搭起大片指针结构,转头就用。memchase 追的是一条建好就再不变的环,Perl 却在永不停歇地制造新的、转瞬即用的指针:值刚送到、还没来得及自愈,它就已经跳了进去。指针只要在这一跳被翻错一位,它立刻冲进一个垃圾地址,当即崩溃。这样一种负载,不断新建、即刻就用,其间又被一次次 fork 切出空闲,恰好同时踩中了故障的触发条件和它最致命的用法。

所以最后的复现器,根本算不上内存测试。它无非是把这样一个 Perl 工具放进循环,一遍遍地调用,数它崩了几次:

i=0; while :; do i=$((i+1)); pveversion >/dev/null 2>&1 || echo "FAIL $i"; done

pveversion 是那类 Perl 工具里最简单的一个,这段脚本不过是把它无限运行下去、一旦崩溃就打印一行。在 ms-01-1 上,它在第二轮就崩溃,此后稳定地反复崩溃,大约每两次就有一次失败。在健康的双胞胎 ms-01-2 上,连续调用三百次,一次未崩。比之前任何一项内存测试都快上好几个数量级。

同一个故障,后果却分作几档,取决于那个被翻错的指针落在何处。落在一个用户进程里,那个进程因段错误崩溃、退出,机器还活着,这是最常见的一档。落在内核的指针上,内核自身 oops 一次(内核态的一次崩溃,留下一个怎么也杀不掉的任务。若落得再深一些,落在调度器或某个全局锁上,整台机器就毫无征兆地僵死,没有日志,没有 panic,什么都不留。

那种僵死还有个古怪的细节:机器明明已经死透,却还能 ping 通。我去 ping 它,ICMP 在一毫秒内便有回应;可与此同时,SSH 连不上,甚至直连它的 SSH 端口也超时,显示器上的画面也是凝固的。它还在响应 ICMP,再往上的一切却都没了反应。这底下究竟是哪一层还在动,我没有深究。

到这一步,它是什么、如何把它逼出来、它会造成什么,都已清楚。可这些,没有一样是修复。

六、跛行

最后真正压住它的,正是我一开始就指着的那个地方:C-state。绕了半个月,答案和第一小时的猜测落回了同一个点上。

但「猜到」和「能正确地动手」是两回事,这中间的距离,几乎就是整场排查本身。

最省事的做法,是直接禁掉那些深度休眠,把 CPU 钉在最浅的一档睡眠上。我试了。头两个小时,它表现得完美无缺,一次未崩。当时若就此收手,这篇文章便到此为止。可我让它继续跑,新的一轮里,它再度僵死。之前那两小时的「完美,不过是赶上了故障率自己漂到的一段低谷,正是开头我拒绝踏入的那个陷阱。这一次,它原样上演了一遍。

再往下查,机制才清楚。把上限卡在最浅那一档,核心还是会睡,只是不睡深了,可它照样冻结。可见触发故障的,是核心「睡」这个动作本身,与睡多深无关。只要它还会阖眼,哪怕最轻的一盹,故障就仍有机会犯。能彻底避开它的,就只剩一条路:根本不让核心睡。我把内核的空闲策略换成 idle=poll,让每个核心闲下来也不休眠,而是空转一个忙等循环,整颗 CPU 一刻不停地醒着。这并没有修好什么,坏的硬件原封未动,只是把那个会触发故障的动作整个拿掉了。一颗 CPU 就这么永远空转发烫,谈不上优雅,但管用。这和前面那个复现器恰好相反:复现器靠不停 fork,让大半核心进进出出地空闲;这里是让所有核心始终保持清醒,一丝空闲都不留。

这一次,故障率降到了零。这个零,我比之前那两小时的太平更信得过:浅睡那次只是恰好没犯,核心照旧在睡,风险一直都在;而现在核心根本不再阖眼,那个会触发故障的动作从源头就没了。但跑再多次都是零,也只是没见它发生,证明不了它绝不发生。

代价是发热。让所有核心二十四小时空转,这台塞在小机箱里的 i9 便会发烫,于是我给整颗 CPU 的功耗设了上限,把稳态温度压在七十七度,离过热降频的红线留出十几度余量。满载,发烫,永不入睡。

正因为没法担保它绝不会再发生,才有了最后那道保险。一台真僵死的机器,需要有人把它的电源硬切一次才能救回。而这台机器没有带外管理2,唯一能从远端硬重启它的,是机柜里那个交换式 PDU。所以我在另一台机器上架了一个看门狗,盯着 ms-01-1。当初那个我没去深究的古怪细节,到这里成了一条设计约束:僵死的它还能 ping 通,可 ping 得通并不等于它还活着。看门狗也就不靠 ping,而是同时探两层:SSH 能否登入、22 端口能否裸连;两层都没有回应,才判定它真正死亡,再通过 PDU 切电、上电,把它救回。

到这一步,能做的也就这些。能 RMA,我早就 RMA 了,可它已过保;剩下唯一「真正的修法,是把整块主板送去第三方维修,并不划算。于是它就停在这个状态里:一个仍在生产中服役的节点,旁边另一台机器随时准备切断它的电源、再重新接通。

七、确定

我从这半个月的弯路里,没有换来一个治好它的办法。换来的是确定:确定问题就在机器本身,不是别的;确定没有哪一次换件、刷写、降频会管用;确定这就是它真实的样子。对一台坏了也无所谓的机器而言,这份确定,恰恰是我自始至终真正想要的。

摊开来看,这件事是不成比例的:整整半个月的硬件取证(双胞胎对照、交叉换件、专门写的工具、十几次重启,花在一个彻头彻尾是 cattle 的节点上。可这种不成比例,大概正是某一类 homelab 玩家的常态:较真的一向是折腾本身,方法上一丝不苟,至于上面跑什么、坏不坏,反倒无所谓。也正是这种偏好,让我先挑了这台不带 ECC 的迷你主机、给它埋下病根,又让我事后甘愿花上半个月,只为把这病根查到底。

换作别人的机器,我大概会劝他算了。

这台是我自己的。

Footnotes

  1. ECC:Error-Correcting Code,带纠错的内存。它在每次读写时校验数据,能发现并纠正单比特错误,再向系统报告。消费级平台和这台迷你主机用的都是不带 ECC 的普通内存:没有这套校验,错误既不会被纠正,也不会被记录,对上层完全隐形。 ↩︎

  2. BMC(Baseboard Management Controller)/ 带外管理:一块独立于操作系统、单独供电的管理芯片,机器死了也能远程开关机、看控制台、装系统。正经服务器大多带,这几台迷你主机没有,所以它们一旦僵死,只能从电源那头硬切。 ↩︎ ↩︎