在 PVE 上给软路由虚拟机加一个网络 Watchdog

1913 字
10 分钟
在 PVE 上给软路由虚拟机加一个网络 Watchdog

很多 Homelab 会把软路由跑在 PVE 里的虚拟机上,例如 iKuai、OpenWrt、RouterOS 等。这样做很灵活:网卡直通、快照、备份、迁移都方便。但它也带来一个问题:如果这台“网络出口虚拟机”异常了,整个内网可能都会断网,甚至你连 PVE Web UI 都打不开。

这篇文章记录一个保守的自愈方案:在 PVE 宿主机上运行一个 watchdog,每分钟检测一次外网连通性。如果连续失败,就先重启软路由虚拟机;如果软路由连续自愈多次仍无效,再重启 PVE 宿主机。

注意:下面的 IP、VMID、路径都是示例值。不要把自己的真实公网地址、PVE SSH 别名、真实内网网段写进公开文章或公开仓库。

设计目标#

这个 watchdog 的目标不是“网络一断就马上重启”,而是尽量避免误判:

  • 单次 ping 失败只记录状态,不做动作。
  • 连续失败达到阈值后,先恢复软路由虚拟机。
  • 软路由恢复动作有冷却时间,避免反复重启。
  • 连续多次恢复软路由仍失败,才升级为重启 PVE。
  • 所有阈值都放在配置文件里,不写死在脚本里。
  • 只写 systemd journal 日志,不依赖外部通知服务。

判断链路#

最小可用检测可以只 ping 一个外网域名:

Terminal window
ping -c 3 -W 2 www.baidu.com

如果你的环境更复杂,可以同时检测多个目标,例如 DNS、HTTP、上游网关等。本文为了保持脚本简单,只把 www.baidu.com 作为主检测目标,并额外 ping 一下软路由 LAN IP,用于日志诊断。

日志里的含义大致是:

  • 软路由 LAN IP 可达,但外网不可达:更可能是拨号、DNS、运营商或上游出口问题。
  • 软路由 LAN IP 不可达,外网也不可达:更可能是软路由 VM、虚拟网桥、直通网卡或宿主机网络问题。

配置文件#

先创建 /etc/default/pve-net-watchdog

Terminal window
# pve-net-watchdog configuration
# 软路由虚拟机 VMID,按你的 PVE 环境修改。
VMID=103
# 软路由 LAN IP,使用示例地址,部署时替换成自己的网关地址。
ROUTER_IP=192.168.1.1
# 主连通性检测目标。
PING_TARGET=www.baidu.com
PING_COUNT=3
PING_TIMEOUT_SECONDS=2
# 连续故障达到多少分钟后,开始恢复软路由虚拟机。
ROUTER_RESTART_AFTER_MINUTES=5
# 两次软路由恢复动作之间的冷却时间。
ROUTER_COOLDOWN_MINUTES=10
# 软路由连续恢复多少次仍失败后,升级为重启 PVE。
ROUTER_MAX_RESTARTS_BEFORE_PVE=3
# 是否允许自动重启 PVE。建议先设为 0 观察日志,确认无误后再改成 1。
ENABLE_PVE_REBOOT=1
# 第三次软路由恢复后,继续等待多少分钟仍失败才重启 PVE。
PVE_REBOOT_AFTER_LAST_ROUTER_MINUTES=5
# 避免 PVE 重启循环。
PVE_REBOOT_COOLDOWN_HOURS=24
STATE_DIR=/var/lib/pve-net-watchdog
LOCK_FILE=/run/pve-net-watchdog.lock

如果你还在调试阶段,建议先把 ENABLE_PVE_REBOOT=0,观察几天日志后再打开。

Watchdog 脚本#

把下面的脚本保存为 /usr/local/sbin/pve-net-watchdog,并设置为可执行:

#!/usr/bin/env bash
set -euo pipefail
: "${CONFIG_FILE:=/etc/default/pve-net-watchdog}"
set_defaults() {
: "${VMID:=103}"
: "${ROUTER_IP:=192.168.1.1}"
: "${PING_TARGET:=www.baidu.com}"
: "${PING_COUNT:=3}"
: "${PING_TIMEOUT_SECONDS:=2}"
: "${ROUTER_RESTART_AFTER_MINUTES:=5}"
: "${ROUTER_COOLDOWN_MINUTES:=10}"
: "${ROUTER_MAX_RESTARTS_BEFORE_PVE:=3}"
: "${ENABLE_PVE_REBOOT:=0}"
: "${PVE_REBOOT_AFTER_LAST_ROUTER_MINUTES:=5}"
: "${PVE_REBOOT_COOLDOWN_HOURS:=24}"
: "${STATE_DIR:=/var/lib/pve-net-watchdog}"
: "${LOCK_FILE:=/run/pve-net-watchdog.lock}"
: "${DRY_RUN:=0}"
}
load_config() {
if [[ -r "$CONFIG_FILE" ]]; then
# shellcheck source=/dev/null
source "$CONFIG_FILE"
fi
set_defaults
STATE_FILE="$STATE_DIR/state"
}
log() {
local level="$1"
shift
local message="$*"
printf '%s [%s] %s\n' "$(date -Is)" "$level" "$message"
logger -t pve-net-watchdog "[$level] $message"
}
ping_host() {
local host="$1"
ping -c "$PING_COUNT" -W "$PING_TIMEOUT_SECONDS" "$host" >/dev/null 2>&1
}
run_cmd() {
if [[ "$DRY_RUN" == "1" ]]; then
log info "DRY_RUN would run: $*"
return 0
fi
"$@"
}
init_state() {
FAIL_COUNT=0
FIRST_FAILURE_EPOCH=0
ROUTER_RESTART_COUNT=0
LAST_ROUTER_RESTART_EPOCH=0
LAST_PVE_REBOOT_EPOCH=0
}
ensure_state_dir() {
mkdir -p "$STATE_DIR"
chmod 0700 "$STATE_DIR"
}
load_state() {
init_state
if [[ -r "$STATE_FILE" ]]; then
# shellcheck source=/dev/null
source "$STATE_FILE"
fi
}
save_state() {
local tmp="$STATE_FILE.tmp.$$"
umask 077
{
printf 'FAIL_COUNT=%s\n' "$FAIL_COUNT"
printf 'FIRST_FAILURE_EPOCH=%s\n' "$FIRST_FAILURE_EPOCH"
printf 'ROUTER_RESTART_COUNT=%s\n' "$ROUTER_RESTART_COUNT"
printf 'LAST_ROUTER_RESTART_EPOCH=%s\n' "$LAST_ROUTER_RESTART_EPOCH"
printf 'LAST_PVE_REBOOT_EPOCH=%s\n' "$LAST_PVE_REBOOT_EPOCH"
} >"$tmp"
mv "$tmp" "$STATE_FILE"
}
elapsed_minutes_since() {
local start_epoch="$1"
local now="$2"
if [[ "$start_epoch" -le 0 ]]; then
echo 0
return
fi
echo $(((now - start_epoch) / 60))
}
minutes_have_passed() {
local last_epoch="$1"
local now="$2"
local minutes="$3"
[[ "$last_epoch" -le 0 ]] && return 0
[[ $((now - last_epoch)) -ge $((minutes * 60)) ]]
}
hours_have_passed() {
local last_epoch="$1"
local now="$2"
local hours="$3"
[[ "$last_epoch" -le 0 ]] && return 0
[[ $((now - last_epoch)) -ge $((hours * 3600)) ]]
}
reset_failure_state() {
FAIL_COUNT=0
FIRST_FAILURE_EPOCH=0
ROUTER_RESTART_COUNT=0
LAST_ROUTER_RESTART_EPOCH=0
}
recover_router_vm() {
local status
status="$(qm status "$VMID" 2>/dev/null || true)"
if ! grep -q 'status: running' <<<"$status"; then
log warning "VM $VMID is not running; starting it"
run_cmd qm start "$VMID"
return
fi
log warning "VM $VMID is running but connectivity is still down; rebooting it"
if ! run_cmd qm reboot "$VMID"; then
log error "qm reboot $VMID failed; resetting VM"
run_cmd qm reset "$VMID"
fi
}
maybe_recover_router() {
local now="$1"
local failure_minutes="$2"
if [[ "$failure_minutes" -lt "$ROUTER_RESTART_AFTER_MINUTES" ]]; then
return
fi
if [[ "$ROUTER_RESTART_COUNT" -ge "$ROUTER_MAX_RESTARTS_BEFORE_PVE" ]]; then
log warning "router restart limit reached: $ROUTER_RESTART_COUNT/$ROUTER_MAX_RESTARTS_BEFORE_PVE"
return
fi
if ! minutes_have_passed "$LAST_ROUTER_RESTART_EPOCH" "$now" "$ROUTER_COOLDOWN_MINUTES"; then
log warning "connectivity still down; router restart cooldown is active"
return
fi
recover_router_vm
ROUTER_RESTART_COUNT=$((ROUTER_RESTART_COUNT + 1))
LAST_ROUTER_RESTART_EPOCH="$now"
log warning "recorded router recovery attempt $ROUTER_RESTART_COUNT/$ROUTER_MAX_RESTARTS_BEFORE_PVE"
}
maybe_reboot_pve() {
local now="$1"
if [[ "$ENABLE_PVE_REBOOT" != "1" ]]; then
return
fi
if [[ "$ROUTER_RESTART_COUNT" -lt "$ROUTER_MAX_RESTARTS_BEFORE_PVE" ]]; then
return
fi
if ! minutes_have_passed "$LAST_ROUTER_RESTART_EPOCH" "$now" "$PVE_REBOOT_AFTER_LAST_ROUTER_MINUTES"; then
log warning "PVE reboot threshold reached, waiting for post-router-restart grace period"
return
fi
if ! hours_have_passed "$LAST_PVE_REBOOT_EPOCH" "$now" "$PVE_REBOOT_COOLDOWN_HOURS"; then
log error "PVE reboot suppressed by cooldown"
return
fi
LAST_PVE_REBOOT_EPOCH="$now"
save_state
log error "connectivity still down after $ROUTER_RESTART_COUNT router recoveries; rebooting PVE host"
run_cmd systemctl reboot
}
run_once() {
load_config
ensure_state_dir
load_state
local now
now="$(date +%s)"
if ping_host "$PING_TARGET"; then
if [[ "$FAIL_COUNT" -gt 0 || "$ROUTER_RESTART_COUNT" -gt 0 ]]; then
log info "connectivity restored via $PING_TARGET; resetting failure state"
fi
reset_failure_state
save_state
return
fi
if [[ "$FIRST_FAILURE_EPOCH" -le 0 ]]; then
FIRST_FAILURE_EPOCH="$now"
fi
FAIL_COUNT=$((FAIL_COUNT + 1))
local failure_minutes
failure_minutes="$(elapsed_minutes_since "$FIRST_FAILURE_EPOCH" "$now")"
if ping_host "$ROUTER_IP"; then
log warning "connectivity check failed for $PING_TARGET; router $ROUTER_IP is reachable; failure_minutes=$failure_minutes fail_count=$FAIL_COUNT"
else
log warning "connectivity check failed for $PING_TARGET; router $ROUTER_IP is not reachable; failure_minutes=$failure_minutes fail_count=$FAIL_COUNT"
fi
maybe_recover_router "$now" "$failure_minutes"
maybe_reboot_pve "$now"
save_state
}
main() {
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run)
DRY_RUN=1
shift
;;
*)
echo "unknown argument: $1" >&2
exit 2
;;
esac
done
load_config
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log warning "another watchdog run is active; skipping"
exit 0
fi
run_once
}
main "$@"

授权:

Terminal window
chmod 0755 /usr/local/sbin/pve-net-watchdog

systemd service 和 timer#

创建 /etc/systemd/system/pve-net-watchdog.service

[Unit]
Description=PVE network watchdog
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
EnvironmentFile=-/etc/default/pve-net-watchdog
ExecStart=/usr/local/sbin/pve-net-watchdog

创建 /etc/systemd/system/pve-net-watchdog.timer

[Unit]
Description=Run PVE network watchdog every minute
[Timer]
OnBootSec=2min
OnUnitActiveSec=60s
AccuracySec=5s
Persistent=true
Unit=pve-net-watchdog.service
[Install]
WantedBy=timers.target

启用:

Terminal window
systemctl daemon-reload
systemctl enable --now pve-net-watchdog.timer

验证#

先做语法检查:

Terminal window
bash -n /usr/local/sbin/pve-net-watchdog

再做一次 dry-run:

Terminal window
/usr/local/sbin/pve-net-watchdog --dry-run

确认 timer 状态:

Terminal window
systemctl is-enabled pve-net-watchdog.timer
systemctl is-active pve-net-watchdog.timer
systemctl list-timers --all pve-net-watchdog.timer --no-pager

查看日志:

Terminal window
journalctl -u pve-net-watchdog.service -n 100 --no-pager

实时跟踪:

Terminal window
journalctl -u pve-net-watchdog.service -f

查看状态文件:

Terminal window
cat /var/lib/pve-net-watchdog/state

正常情况下状态大概是:

Terminal window
FAIL_COUNT=0
FIRST_FAILURE_EPOCH=0
ROUTER_RESTART_COUNT=0
LAST_ROUTER_RESTART_EPOCH=0
LAST_PVE_REBOOT_EPOCH=0

触发流程#

按上面的配置,完整动作链路是:

  1. 每分钟 ping 一次 www.baidu.com
  2. 连续失败 5 分钟后,恢复软路由虚拟机。
  3. 如果虚拟机已停止,执行 qm start <vmid>
  4. 如果虚拟机正在运行,优先执行 qm reboot <vmid>
  5. 如果软重启失败,再执行 qm reset <vmid>
  6. 每次恢复后等待 10 分钟冷却。
  7. 连续恢复软路由 3 次仍然不通,再等待 5 分钟。
  8. 如果仍然不通,并且 PVE 重启冷却期已过,执行 systemctl reboot

这个流程的关键是“逐级升级”:不要把宿主机重启作为第一动作。

临时停用#

如果你要维护网络、升级 PVE、调整虚拟网桥或直通网卡,建议先停掉 timer:

Terminal window
systemctl disable --now pve-net-watchdog.timer

维护完成后再启用:

Terminal window
systemctl enable --now pve-net-watchdog.timer

生产环境建议#

自动重启 PVE 是高风险动作,建议至少做三点保护:

  • 初次部署先把 ENABLE_PVE_REBOOT=0,观察几天日志。
  • 确认软路由 VM 能稳定启动,并且设置了开机自启。
  • 如果 PVE 上有备份、迁移、快照、升级任务,脚本里应该额外检查这些任务,避免在关键操作中重启宿主机。

如果你还有另一台常在线设备,例如 NAS、旁路由、树莓派,也可以把外部探针加入判断。由 PVE 自己判断“我是否断网”已经能解决大部分场景,但外部视角更适合判断“整个家庭网络是否真的不可达”。

这套脚本不复杂,真正重要的是阈值和冷却策略。网络自愈系统宁可慢一点,也不要因为一两次临时丢包就把关键设备重启掉。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

在 PVE 上给软路由虚拟机加一个网络 Watchdog
https://blog.sephy.top/posts/在-pve-上给软路由虚拟机加一个网络-watchdog/
作者
虾米
发布于
2026-06-14
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
虾米
coder
分类
标签
站点统计
文章
61
分类
4
标签
52
总字数
64,663
运行时长
0
最后活动
0 天前
站点信息
构建平台
GitHub Actions
博客版本
Firefly v6.13.9
文章许可
CC BY-NC-SA 4.0