搬完家的第一周,伺服器开始每天半夜自己关机。

不是当机、不是重开,是断电式的死亡:回来开机一定跑 ext4 recovery,uptime 归零,前一秒还在跑的东西没留下任何遗言。而且它挑时间,几乎都在清晨。

我第一时间想的是「线松了」,去压紧,隔天照死。想到搬家后冷气和热水器可能跟它共用回路,关掉试,照死。量温度,50~60°C,正常。最后怀疑是搬家把 PSU 震坏了——那是一颗买不到一年的振华白金牌,我不太想相信。

问题是这些都是猜测,而每次验证要等一整天。查了几天之后我意识到真正缺的不是假设,是证据:我根本不知道死掉的那一瞬间,送进机器的电是什么样子。

所以我去买了一台 UPS,一半是为了保护,一半是把它当量测仪器用

这篇是后来的完整过程:UPS 怎么选、NUT 从零设定到自动安全关机、BIOS 那个一定要设对的选项,以及最后靠 UPS 的 log 破了案——凶手是一条被猫咬过的电源线。

为什么一台 UPS 能定案#

先讲清楚它为什么是仪器,因为这决定了整篇的逻辑。

UPS 量测的是它自己输出端的状态,而它会用几个代号回报自己现在在干嘛。整篇会一直看到,先讲清楚:

状态意思
OLOn Line,市电正常,UPS 只是在旁边待命
OBOn Battery,市电没了,正在放电池
LBLow Battery,电池快没了,该关机了

有了这三个代号,只要 UPS 持续记录,下次再死就能一刀切开两种可能:

  • 死亡当下记到 OB 或电压掉 → 问题在 UPS 上游,市电有事。
  • 死亡当下全程 OL、电压稳、电池满 → UPS 送出去的电是干净的,问题在它下游,也就是电源线、PSU、主机板这一段。

这条分界线把搜寻范围砍掉一半,而且不用再靠猜。

选型:1500VA / 纯正弦波 / 有 AVR#

这颗 CyberPower CP1500PFCLCD 有三个对这次诊断有意义的点:

PFC 纯正弦波输出

现在的主机 PSU 几乎都是主动式 PFC。这类 PSU 遇到便宜 UPS 的,在切换到电池的瞬间可能直接关机——那会制造出跟我正在追的症状一模一样的新问题。诊断期间最不需要的就是这个。

内建 AVR

直接处理掉一个假设:如果清晨真的有市电电压跳动,它会当场压平。压平之后如果还死,那市电这条就能排除。

NUT 完整支援

Linux 上的 usbhid-ups 驱动就能读它,不需要厂商的专属软体。USB 插上去 lsusb 会看到:

console
Bus 001 Device 005: ID 0764:0601 Cyber Power System, Inc.

NOTE

网路线不用接 这台 UPS 同时有 USB 和序列埠,而 NUT 走 USB 就够了。网路那条是给有网管卡的机种或多机共享用的,单机监控用不到。

NUT:从零到自动安全关机#

NUT 的设定档散在好几个地方,而且权限没设对服务就起不来。我把整套写成一支脚本,重灌或换机时直接重跑。

先搞懂 NUT 的三层#

设定档之所以看起来很乱,是因为 NUT 不是一支程式,是三支各司其职的程式。搞懂谁是谁,设定档就自己对号入座了:

  • driver(我这台是 usbhid-ups)——真正跟 UPS 讲话的那支,把厂商的协定翻成 NUT 的资料。
  • upsd——把 driver 拿到的资料开成一个服务,让别人来问。
  • upsmon——用户端。它一直跟 upsd 要资料,看到电量低就执行关机。

所以那五个设定档是这样分的:

bash
/etc/nut/nut.conf      # 这台扮演什么角色(单机还是主从)
/etc/nut/ups.conf      # driver:用哪个驱动、接哪台 UPS
/etc/nut/upsd.conf     # upsd:资料服务听哪个位址
/etc/nut/upsd.users    # upsd:谁能读、谁能下指令
/etc/nut/upsmon.conf   # upsmon:什么情况下关机、怎么通知

单机监控用 standalone:

ini
# /etc/nut/nut.conf
MODE=standalone
ini
# /etc/nut/ups.conf
maxretry = 3
pollinterval = 5

[cyberpower]
    driver = usbhid-ups
    port = auto
    desc = "CyberPower CP1500PFCLCD"
ini
# /etc/nut/upsd.conf —— 只听 localhost,不对外
LISTEN 127.0.0.1 3493

WARNING

LISTEN 预设值要改 NUT 的 upsd 如果听在 0.0.0.0,区网任何人都能查询你的 UPS 状态、甚至在权限没设好时下指令。单机监控没有任何理由对外,绑 127.0.0.1 就好。密码我是用 openssl rand -hex 16 随机产生后写进档案,反正不用人记。

权限也要对,不然 driver 读不到设定:

bash
chown root:nut /etc/nut/*.conf /etc/nut/upsd.users
chmod 640 /etc/nut/*.conf /etc/nut/upsd.users

安全关机是怎么发生的#

重点在 upsmon.conf 这几行:

ini
MONITOR cyberpower@localhost 1 upsmon <password> primary
MINSUPPLIES 1
SHUTDOWNCMD "/sbin/shutdown -h +0"
POWERDOWNFLAG /etc/killpower
POLLFREQ 5
POLLFREQALERT 5
FINALDELAY 5

一行一行看:

指令在做什么
MONITOR要监看哪台 UPS、用哪组帐密。结尾的 primary 代表这台机器有权在关机前叫 UPS 一起断电
MINSUPPLIES至少要有几组电源还活着才继续跑。单电源主机填 1
SHUTDOWNCMD决定关机时执行什么
POWERDOWNFLAG关机流程留下的旗标档,下面会专门讲
POLLFREQ / POLLFREQALERT平常几秒问一次 upsd、出事后几秒问一次
FINALDELAY送出关机指令前最后缓冲几秒

实际流程是这样,而最后一段是最多人漏掉的:

POWERDOWNFLAG 指到的是一个旗标档upsmon 决定要关机时会先建立它,而系统关机脚本跑到最后会看一眼:档案在,就顺便叫 UPS 把自己的输出也切断。

不这样做的话,机器关了但 UPS 还在放电,电池会被榨到 0,而铅酸电池深度放电一次就折寿一次。

BIOS 自动开机那一格如果没设,前面全部白做——机器会干净地关机,然后停在那里等人按电源键。

关机门槛的取舍#

触发点是 UPS 自己认定的低电量。我这台的原厂值是:

console
battery.charge.low: 10        # 电量剩 10%
battery.runtime.low: 300      # 或估计剩余 300 秒

先用原厂值是刻意的,因为它最不会误判。跑一阵子有真实数字之后再决定要不要调早。我现在的实测是:

12%
UPS 负载
额定 1000W
61分钟
估计续航
113V
输入电压

负载 12%、续航一小时,剩 5 分钟才开始关机的缓冲完全够。但如果你的机器把 UPS 吃到 60%、续航只剩十几分钟,那 5 分钟就有点紧,可以用 upssched 加一道更早的触发(例如「吃电池满 90 秒」就先关)。

BIOS:那个不能选错的选项#

这是整套自动复原里最容易踩雷的一格。

CAUTION

Restore after AC Power Loss 要选 On,不能选 Last State Last State 的意思是「回到断电前的状态」。而 NUT 低电量关机之后,机器的状态就是关机——市电回来时 BIOS 会判定「它本来就是关的」,于是保持关机。你做了一整套自动复原,结果卡在最后一步。 选 On 才是「只要有电就开机」,不管之前是开是关。

我的板子是 MSI MAG X870E TOMAHAWK,路径是:

text
开机按 Del → F7 切到 Advanced Mode
  → SETTINGS → Advanced → Power Management Setup
    → Restore after AC Power Loss = On

BIOS 在作业系统之前,SSH 和远端桌面都看不到,一定要接实体萤幕键盘。想省去狂按 Del 可以先下:

bash
sudo systemctl reboot --firmware-setup

它会直接重开进 BIOS 设定画面,但人还是得在萤幕前。

把 UPS 状态接出来#

设定完之后 upsc 随时查得到,但我不想每次都 SSH 进去打指令。

每分钟留一笔纪录#

这支脚本挂 cron 每分钟跑一次,它同时做两件事:写一行人看的 log,和一份给程式读的 JSON。

bash
DATA=$(upsc cyberpower 2>/dev/null) || exit 0
g() { grep -m1 "^$1:" <<<"$DATA" | cut -d' ' -f2-; }

echo "$(date '+%F %T') status=$(g ups.status) in=$(g input.voltage)V \
out=$(g output.voltage)V load=$(g ups.load)% batt=$(g battery.charge)% \
runtime=$(g battery.runtime)s" >> "$OUT"

那行 log 长这样,而它就是后来破案的东西:

console
2026-07-05 00:12:01 status=OL in=113.0V out=113.0V load=12% batt=100% runtime=3675s
2026-07-05 00:13:01 status=OL in=113.0V out=113.0V load=12% batt=100% runtime=3675s
2026-07-05 00:14:01 status=OL in=113.0V out=113.0V load=12% batt=100% runtime=3675s
                    ↑ 机器就在这一分钟之后硬断电

事件推到 Discord#

upsmon.conf 支援对每种事件挂外部指令。做法是加一行 NOTIFYCMD,再把要通知的事件旗标加上 +EXEC:

ini
NOTIFYCMD /path/to/ups-discord-notify.sh
NOTIFYFLAG ONBATT   SYSLOG+WALL+EXEC
NOTIFYFLAG LOWBATT  SYSLOG+WALL+EXEC
NOTIFYFLAG ONLINE   SYSLOG+WALL+EXEC

推的事件有六种:切换到电池、电量低即将关机、触发系统关机、市电恢复、电池老化该换、与 UPS 失联。每则附上即时电量、剩余分钟、负载和市电电压。

Webhook 档案要放在 upsmon 读得到的地方,权限是 root:nut 640——这是我第一次弄的时候卡住的点,放在家目录底下 upsmon 读不到。

不想再开一个子网域#

原本想过用 Home Assistant 的 NUT 整合,或架个 peaNUT 之类的面板。但我的子网域已经一堆了,为了看一颗 UPS 再开一个网站很多余

所以改成把它塞进我自己的 NAS 面板。这里有个限制:后端跑在 docker bridge 网路里,连不到 host 的 upsd:3493。与其把 upsd 对外暴露,不如让 host 写档、容器读档:

JSON 就是上面那支脚本顺手产的:

json
{
  "status": "OL",
  "online": true,
  "battery_charge": 100,
  "battery_runtime": 3675,
  "ups_load": 12,
  "input_voltage": 113.0,
  "model": "CP1500PFCLCDa TW",
  "updated_at": "2026-08-04T13:55:01+0800"
}

这个模式跟后端本来就在读挂载档案的做法一致,不用为了一个唯读状态去开网路权限。

NOTE

顺带发现的一件事 弄到一半才发现手机上看不到监控,不是 PWA 的问题——那个 app 根本没有 PWA,没有 manifest 也没有 service worker。手机看到的是完全独立的行动版版面,而监控页只挂在桌面版的 Dock 里。所以不是「PWA 没带到」,是行动版根本没放。这种「以为是 A 问题结果是 B」在这整件事里出现了不只一次。

破案:一条被猫咬过的线#

UPS 上线几天后,它又死了一次。

我去翻 log,死亡前后全程 OL、市电 113~115V 稳、电池 100%、没有任何 OB 事件,nut-monitor 也没有任何纪录。但重开机又是 ext4 recovery。

市电干净,机器却硬断电。问题确定在 UPS 下游。

范围缩到电源线、PSU、主机板之后,我把机壳打开重压所有接头,顺便换了一条电源线。原本那条是 12A 125V,而它上面有猫咬出来的洞。

那条线的位置正好是 UPS 输出 → PSU 入口,完全在 UPS 的视线之外。一条铜芯半断的线会间歇性接触,一点震动或热胀冷缩就通、断、通——那正是「随机、瞬间、无预兆」的断电。

而且它解释了所有先前讲不通的地方:

现象猫咬线怎么解释
UPS log 全程干净断点在 UPS 量测点的下游,它看不到
idle 也会死跟负载无关,是机械性接触不良
搬家后才开始搬运的震动让半断的铜芯变得更容易分离
挑清晨温度最低,热胀冷缩让接触点收缩

换上的新线是 10A 125V。看起来规格降了,但 10A × 125V = 1250W,而整台满载大约 350~400W、换算不到 3A,余裕非常大。

~1
换线前
平均死一次的间隔
10.8
换线后
连续运行

从「几乎每天死」变成「十天才死一次」,频率掉了十倍。

那次差点被误导的复发#

换线后第十一天,它又死了。而且我的游戏机也重开了

两台一起出事,结论很明显:真的停电了。于是接下来的推论一路展开——真停电时 UPS 应该要撑住 server 才对,既然没撑住,那一定是电源线插在「Surge Only」那排而不是「Battery Backup」那排,CyberPower 背后那两排插座长得几乎一样,插错太常见了。

整套推论写得煞有介事,直到我去查游戏机为什么重开。

是 Windows Update。

前提垮掉,整串推论作废。回头重看 log,那次跟前面四次一模一样:OL 全程、市电稳、电池满、无 OB——不是停电,也不是插错排,是同一种「UPS 下游瞬断」,只是频率已经低了十倍。

TIP

两个独立事件同时发生,是最容易让人推错的情况 「两台一起挂」看起来是强得不能再强的共因证据,但它只是巧合。当一个新证据让你的假设突然变得非常有说服力时,那正是最该回头验证那个证据本身的时候。我当时如果没去查 Windows Update 的纪录,大概会花一整晚去研究 UPS 的插座分组。

那这次到底是什么?老实说没有查出来。

当下能确定的只有排除法的结果:不是市电(UPS log 全程干净)、也不是过热(死的当下 CPU 85% idle、load 1~3、零温度告警、温度正常)。剩下最可能的是新线某一端没插到底,或某个内部接头又松了一点——C13/C14 那种「感觉插好了其实没到底」非常常见。我把两端重压了一次,然后就没有下文了。

没有下文的原因不是我放弃追,是四天后它又死了一次,而那次是完全不同的死法。从那之后注意力整个转过去,这条残留再也没有复发到足以验证的程度。所以诚实的结论是:猫咬线把频率降了十倍,剩下的那一次至今无解。

不过那天我多做了一件事。既然「过热」这条被排除得有点快,我顺手加了一支温度记录器,每分钟记一笔 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,而 UPS 全程正常。跟电力完全无关,是另一个故事。

NOTE

每一次追查都留下一个量测工具,而下一个案子是被上一个留下的仪器破的 买 UPS 是为了查断电,而它的 log 破了猫咬线这案。加温度记录器是为了排除过热,而它四天后抓到了热爆炸的现行犯。这不是运气好,是因为每次「我不确定」的时候,加的不是猜测而是一支记录器——那些记录器会在你还不知道自己需要它的时候就开始累积证据。

写这篇的时候顺手查了现况#

journalctl --list-boots 可以看出每次重开机是「干净关机」还是「硬断」——有 systemd-shutdown 收尾的是前者,什么都没留下的是后者。

console
07-14 → 07-15 20:21   无关机纪录        ← 电力残留那次
07-16 → 07-19 15:56   无关机纪录        ← 热爆炸那次
07-19 → 07-20 17:15   systemd-shutdown
07-20 → 之后八次       systemd-shutdown  ← 全部是我自己重开的

最后一次非计划性死亡是 07-19,之后十六天没有任何一次硬断。

但这条电力残留还不能算结案。这段期间我因为各种原因重开过八次,单次连续运行最长只有七天,而上次产生电力故障的间隔是十点八天。也就是说,到目前为止还没有任何一段连续运行超过上次的发作间隔——它不是被证明修好了,只是还没有机会出现。

要真的宣告结案,得先让它安静地连续跑超过十一天。这个标准是我自己订的,写出来是因为「后来就再也没发生了」这句话如果不附上时间尺度,基本上不构成证据。

一页式清单#

UPS + NUT 自架检查清单
  1. UPS 选型看三件事:PFC 纯正弦波(主动式 PFC 的 PSU 会挑波形)、有 AVR(电压波动不用动电池)、NUT 支援(不必装厂商软体)。
  2. UPS 也是量测仪器:每分钟记一次 ups.status / input.voltage / battery.charge,下次出事就能一刀切开「市电问题」和「UPS 下游问题」。
  3. upsd127.0.0.1,单机监控没有理由对外。
  4. 设定档权限 root:nut 640,权限不对 driver 起不来。
  5. POWERDOWNFLAG 不能漏,否则 OS 关机后电池会被榨到 0。
  6. BIOS 选 On 不是 Last State,选错的话 NUT 干净关机后市电回来机器不会自己开。
  7. 关机门槛先用原厂值,跑出真实续航数字再决定要不要用 upssched 提早。
  8. Webhook 档案放 /etc/nut 底下,upsmon 读不到家目录。
  9. 状态要给容器看就用档案,host 写 JSON、bind-mount 进去,比把 upsd 对外暴露干净。
  10. 电线收进走线槽或套防咬管,这条跟伺服器无关,是为了猫。
  11. 排除掉一个假设之后顺手加一支记录器,下一次出事你就有它了。
參考連結