Homelab 断电保护全攻略:两台 APC UPS + NUT + PVE 优雅关机脚本

random-pic-api

前言

“咔哒”一声,屋里一片黑。

家里一楼二楼总电闸在夏天经常跳,一开始只有一台 NAS 的时候无所谓,硬关机就硬关机,顶多 DSM 开机之后跑一次文件系统检查。但自从 Homelab 一点点堆起来、PVE 集群 + Ceph 上线之后,每次停电我都要紧张一阵子 —— 不是怕硬件坏,是怕突然断电的 Ceph OSD 可能不一致,或者某台 LXC 里的数据库日志写坏了

修好电闸只是小事,事后收拾集群这些”副作用”才是真正烦。

所以我给家里上了两台 APC UPS,再花了个周末搭了一套 NUT + 自定义关机脚本,让家里的每台机器都能在停电时优雅下班。这篇把整个方案从硬件选择到脚本逐行解释写清楚,谁来抄都能用。

硬件:两台二手 APC

UPS #1:APC BK650M2-CH(跟 DS923+)

这台是最早的那台,很早以前给 DS923+ 配的。

  • 容量:390W / 650VA
  • 价格:闲鱼 200 出头
  • 电池:自带,7Ah 铅酸,免维护几年基本没衰减

插上 DS923+ + 一个千兆交换机之后,650VA 的余量刚刚好够跑 NAS 撑 10~15 分钟,足够 DSM 做一次完整的关机。跟着 NAS 一起已经扛住了好多波意外断电,一次没掉链子。

UPS #2:APC Back-UPS RS 1500G(跟 DS218+)

最近新增了 4 台 PVE 小主机,再加上原来的 DS218+ 和两台 Mac mini,功率加起来 500W 出头,BK650 那台完全带不动,得再上一台大的。

还是闲鱼捡垃圾:

  • 机器本体:APC Back-UPS RS 1500G,闲鱼 300 左右;
  • 电池:不带,需要另购(两块 12V 9Ah 铅酸串联),100 块左右;
  • 一套下来:接近 500 块搞定。

1500G 的标称是 865W / 1500VA,带我这一堆机器满载估计能撑 810 分钟,空闲状态 20 分钟以上 —— 再结合后面的自动关机脚本,基本上”从断电到全部关机完毕”只需要 35 分钟,时间绰绰有余。

整体方案:群晖当 NUT 广播源

最初我想的是给每台机器都接一根 USB 到 UPS,但 APC 这俩 UPS 一台只有一个 USB 口,显然不够。折腾了一会儿发现,群晖 DSM 自带了 Network UPS Tools (NUT) server,是一个现成的 UPS 广播源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌─────────────────────┐              ┌─────────────────────┐
│ APC BK650 │ USB │ DS923+ (NUT server)│
│ ─── 给 DS923+ 供电 │─────────────▶│ 192.168.31.2 │───┐
└─────────────────────┘ └─────────────────────┘ │
│ 网络广播
┌─────────────────────┐ ┌─────────────────────┐ │ UPS 状态
│ APC RS 1500G │ USB │ DS218+ (NUT server)│ │
│ ─── 给 4 台小主机+ │─────────────▶│ 192.168.31.3 │───┤
│ DS218+ + Mac mini │ └─────────────────────┘ │
└─────────────────────┘ │

┌─────────────────────────────────────┐
│ NUT clients: │
│ - 4 台 PVE 小主机 (nut-client) │
│ - 2 台 Mac mini (可选) │
│ - 其他 Linux / LXC │
└─────────────────────────────────────┘

好处是:

  1. USB 口问题解决:只要 UPS 插到一台 NAS 上,它就能把 UPS 状态通过网络广播给整个局域网;
  2. 群晖 UI 就能查电量 / 续航:不用 SSH 也能看;
  3. 客户端只要是 Linux,装个 nut-client 就完事,配置极简。

DSM 里开启 NUT server 的路径:控制面板 → 硬件和电源 → UPS,打勾”启用 UPS 支持”,选”启用网络 UPS 服务器”,就能看到默认用户名 monuser / 默认密码 secret强烈建议改一下)。

NUT 客户端配置(PVE / 普通 Linux)

以下配置适用于所有非群晖的机器,我的 4 台 PVE 小主机都是这个配置。

安装

1
sudo apt install nut

配置为 netclient 模式

NUT 有三种模式:none / standalone / netclient。我们这里只作为客户端听服务端广播,选 netclient

1
sudo vim /etc/nut/nut.conf

MODE=none 改成:

1
MODE=netclient

配置监听哪台 UPS 服务器

1
sudo vim /etc/nut/upsmon.conf

在末尾加上:

1
2
3
4
5
6
7
8
9
10
# MONITOR <system> <powervalue> <username> <password> ("master"|"slave")
# <system> = ups@<NAS-IP>,群晖的 UPS 命名默认就是 ups
# <powervalue> = 这台客户端从这个 UPS 获得几路电源,普通机器填 1
# <username> = 群晖的默认是 monuser,建议改
# <password> = 群晖的默认是 secret,建议改
# master/slave = 客户端统一填 slave,真正控制 UPS 的是群晖
MONITOR ups@192.168.31.3 1 monuser secret slave

# 当 UPS 进入关机流程时,执行我们自定义的脚本
SHUTDOWNCMD "/mnt/pve/cephfs/scripts/pve/ups-shutdown-notify.sh"

💡 我把脚本放在 cephfs 上,所有 PVE 节点共用一份,修改一处全集群生效。

启动 & 验证

1
2
3
4
5
systemctl restart nut-client
systemctl enable nut-client

# 验证:这条命令能拉到 UPS 信息就算配通了
upsc ups@192.168.31.3

正常输出大概长这样:

1
2
3
4
battery.charge: 100
battery.runtime: 2160
ups.status: OL
ups.load: 28

重点看这三个:

  • battery.charge —— 剩余电量百分比;
  • battery.runtime —— 预估能撑多少秒;
  • ups.status —— OL 表示市电正常 (On Line),OB 表示已切到电池 (On Battery),LB 表示电量低 (Low Battery)。

PVE 优雅关机脚本(核心)

重头戏来了。默认的 NUT 关机行为就是直接 shutdown -h now,对于普通机器没问题,但对 PVE 节点来说等于硬关整台虚拟化宿主机,上面跑的 VM 和 LXC 全都相当于硬断电,对 Ceph 和某些数据库非常不友好。

我写了一个增强版的 SHUTDOWNCMD,流程是:

  1. 采集信息:UPS 电量、运行中的 VM / LXC 列表、系统负载、网络状态;
  2. 发邮件通知:让我知道”是真的停电了”还是”误触发”,顺便记录当时的集群状态;
  3. 优雅关 VM 和 LXC:用 qm shutdown / pct shutdown,给每个实例 20 秒超时;
  4. 强杀残留:超时之后还没关掉的,用 qm stop / pct stop 强杀;
  5. 关主机:最后 shutdown -h now

完整脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
#!/bin/bash
# 增强版断电关机脚本 v1.3
# 功能:安全关闭虚拟机 + LXC 容器 → 发送通知 → 记录日志 → 关闭主机

# ========================
# 配置区(按你自己环境改)
# ========================
RECIPIENT="you@example.com" # 通知邮箱,多个用逗号分隔
BACKUP_IP="192.168.21.1" # 用于探测网络状态的 IP(比如主路由)
LOG_FILE="/var/log/ups_shutdown.log"
SHUTDOWN_TIMEOUT=20 # 单实例优雅关机超时(秒)
FORCE_SHUTDOWN_DELAY=10 # 强制关闭前再等多少秒
UPS_HOST="ups@192.168.31.3" # NUT server 地址

# ========================
# 工具函数
# ========================
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a $LOG_FILE
}

check_command() {
command -v $1 &>/dev/null || { log "错误:未找到命令 $1"; exit 1; }
}

# ========================
# 预检
# ========================
for cmd in mail uptime qm pct upsc; do
check_command $cmd
done

[ "$(id -u)" -ne 0 ] && { log "必须以 root 运行"; exit 1; }

# ========================
# 信息采集
# ========================
log "开始采集系统信息..."

HOSTNAME=$(hostname)
UPTIME=$(uptime -p)
LOAD_AVG=$(cat /proc/loadavg | awk '{print $1,$2,$3}')
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S%z')

# 网络状态
NETWORK_STATUS=$(ping -c 3 $BACKUP_IP >/dev/null 2>&1 && echo "正常" || echo "异常")

# UPS 状态
UPS_INFO=$(upsc $UPS_HOST 2>/dev/null | grep -E 'battery.charge|battery.runtime|ups.status')
BATTERY_CHARGE=$(echo "$UPS_INFO" | grep battery.charge | awk '{print $2}')
BATTERY_RUNTIME=$(echo "$UPS_INFO" | grep battery.runtime | awk '{print $2}')
UPS_STATUS=$(echo "$UPS_INFO" | grep ups.status | awk '{print $2}')

# 运行中的 VM / LXC
VM_LIST=$(qm list | awk 'NR>1 && $3=="running" {print $1 ":" $2}')
ACTIVE_VM_COUNT=$(echo "$VM_LIST" | grep -c ":")
LXC_LIST=$(pct list | awk 'NR>1 && $2=="running" {print $1 ":" $3}')
ACTIVE_LXC_COUNT=$(echo "$LXC_LIST" | grep -c ":")

# ========================
# 通知
# ========================
log "准备发送通知邮件..."
MAIL_BODY=$(cat <<EOF
【紧急关机通知】

主机名称: $HOSTNAME
事件时间: $TIMESTAMP

当前状态:
- 运行时间: $UPTIME
- 系统负载: $LOAD_AVG
- 活动虚拟机: $ACTIVE_VM_COUNT 台
- 活动 LXC: $ACTIVE_LXC_COUNT 台

UPS 状态:
- 剩余电量: ${BATTERY_CHARGE}%
- 预估续航: ${BATTERY_RUNTIME}s
- UPS 状态: $UPS_STATUS

网络检测:
- 到 $BACKUP_IP: $NETWORK_STATUS

即将执行:
1. 优雅关闭所有 VM 与 LXC(超时 ${SHUTDOWN_TIMEOUT}s)
2. 强杀残留实例(延迟 ${FORCE_SHUTDOWN_DELAY}s)
3. 关闭主机
EOF
)

echo "$MAIL_BODY" | mail -s "[CRITICAL] $HOSTNAME 断电关机预警" $RECIPIENT

# ========================
# 关机
# ========================
log "开始关闭虚拟机..."
for vm in $VM_LIST; do
vm_id=$(echo $vm | cut -d: -f1)
log "关闭 VM #$vm_id ..."
qm shutdown $vm_id --timeout $SHUTDOWN_TIMEOUT || log "VM #$vm_id 优雅关闭失败"
done

log "开始关闭 LXC..."
for lxc in $LXC_LIST; do
lxc_id=$(echo $lxc | cut -d: -f1)
log "关闭 LXC #$lxc_id ..."
pct shutdown $lxc_id --timeout $SHUTDOWN_TIMEOUT || log "LXC #$lxc_id 优雅关闭失败"
done

log "等待 ${FORCE_SHUTDOWN_DELAY}s 后检查残留实例..."
sleep $FORCE_SHUTDOWN_DELAY

# 强杀
REMAIN_VM=$(qm list | awk 'NR>1 && $3=="running" {print $1}')
REMAIN_LXC=$(pct list | awk 'NR>1 && $2=="running" {print $1}')

[ -n "$REMAIN_VM" ] && { log "强杀 VM: $REMAIN_VM"; for v in $REMAIN_VM; do qm stop $v; done; }
[ -n "$REMAIN_LXC" ] && { log "强杀 LXC: $REMAIN_LXC"; for l in $REMAIN_LXC; do pct stop $l; done; }

log "执行主机关机..."
/usr/sbin/shutdown -h now "紧急断电关机,由 UPS 触发"

# 理论上执行不到这里
log "关机流程异常!"
exit 1

脚本里几个我踩过坑的细节

1. 一定要在脚本里先探测网络

我的 PVE 节点监控的是群晖上的 UPS。如果只是”NUT client 这台到群晖的网络断了”,也会触发 SHUTDOWNCMD —— 你会发现明明没停电整个集群却自动关机了,我第一次踩到这个坑的时候一脸懵。

所以脚本里 ping 一次主路由(192.168.31.1 或者你自己定义的 BACKUP_IP),如果能 ping 通、UPS 状态又是 OB/LB,才是真正的停电。

2. qm list / pct list 的列位置会变

原来我草稿里用的是 awk 'NR>1 {print $1 ":" $2}',实际上 qm list 第二列是 NAME,第三列才是 STATUS。更安全的写法是过滤 running 状态

1
2
qm list | awk 'NR>1 && $3=="running" {print $1 ":" $2}'
pct list | awk 'NR>1 && $2=="running" {print $1 ":" $3}'

不然会把已经是 stopped 状态的实例也当成”运行中”,去 qm shutdown 个已经停了的 VM 一堆报错。

3. SHUTDOWNCMD 里不能放太慢的操作

整个 SHUTDOWNCMD 必须在 UPS 电量耗尽之前跑完,所以脚本里所有 “网络请求”(邮件、ping)都要带超时。mail 这个命令在 MTA 连不上 SMTP 的时候会挂很久,建议配一个本地的 SMTP relay(比如 msmtp),或者干脆把通知发到一个已经缓存到本地的 webhook 上(Bark / Gotify)。

Web UI 监控(可选但很香)

NUT 本身自带一个 CGI 网页:

1
systemctl restart apache2

访问:

1
http://<nut-server-ip>:82/cgi-bin/nut/upsstats.cgi

能看到实时电量、负载、波形图,长得很”2005 年”,但胜在轻量:

方案是否好看资源占用备注
upsstats.cgi⭐️NUT 自带,开箱即用
Netdata + apcupsd⭐️⭐️⭐️需要装 apcupsd,Dashboard 很漂亮
Grafana + NUT exporter⭐️⭐️⭐️⭐️配合其他指标一起看,我最终用的方案

我现在用的是最后一种 —— 直接走 PVE 集群里已经部署好的 Prometheus + Grafana,加一个 nut_exporter,所有 UPS 的电量、负载、温度一张图里全部可见。停电了手机 Grafana App 弹通知,比任何别的方案都直接。

跑了一年的真实体验

方案上线之后大概扛过 4 次真实停电 + 3 次跳闸,总结几点:

  1. 真的能帮你抢救到心态平稳。一次凌晨两点停电,我在床上看着 Grafana 上的电量曲线一路下滑,到 40% 的时候集群陆续开始自动关机,到我起床排查电路的时候所有机器已经干净地躺在那儿等我,一个 OSD 异常都没有。
  2. 二手电池比机器本体更容易坏。APC 这俩机器本体质量相当好,但 RS 1500G 那块铅酸电池一年下来续航打了 6 折。做好心理准备两三年换一次电池,或者直接上 LiFePO4(寿命长得多,但要改装)。
  3. 关机阈值设在电量 40% 就够了,别等到 20%。因为 SHUTDOWNCMD 跑完到机器完全断电还要 1~2 分钟的缓冲,留得多一点安全。群晖里的阈值可以在”UPS 支持”里调。
  4. 别忘了 mac mini。我 2 台 Mac mini 是直接接 UPS #2 的插座的,但 macOS 没装 NUT client。早期几次停电我以为 UPS 撑得住,结果低电量保护启动后 Mac mini 还是会突然断电 —— 后来给它们也装了 nut-client (通过 Homebrew),走和 PVE 一样的那套 upsmon.conf,才真正做到”全屋机器一起体面下班”。

最后聊两句

UPS 这个东西很像家里的灭火器

  • 平时花几百块买回来放那儿,100% 会觉得”这钱花得真亏”;
  • 等真用上那一次,又 100% 会觉得”这钱花得真值”。

尤其是家里装了 Homelab、NAS、跑了一堆有状态服务的朋友,强烈建议先上 UPS,再考虑更多设备。不然有一天你会发现,你花了三万块搭的 Ceph 集群,被一次价值 3 毛钱的跳闸干趴下半天。

这套方案跑了一年多,目前挺稳的。如果你也在配 UPS,直接抄这一篇就行,脚本拿去改改邮箱和 IP 就能用。


参考