在 PVE 上给软路由虚拟机加一个网络 Watchdog
很多 Homelab 会把软路由跑在 PVE 里的虚拟机上,例如 iKuai、OpenWrt、RouterOS 等。这样做很灵活:网卡直通、快照、备份、迁移都方便。但它也带来一个问题:如果这台“网络出口虚拟机”异常了,整个内网可能都会断网,甚至你连 PVE Web UI 都打不开。
这篇文章记录一个保守的自愈方案:在 PVE 宿主机上运行一个 watchdog,每分钟检测一次外网连通性。如果连续失败,就先重启软路由虚拟机;如果软路由连续自愈多次仍无效,再重启 PVE 宿主机。
注意:下面的 IP、VMID、路径都是示例值。不要把自己的真实公网地址、PVE SSH 别名、真实内网网段写进公开文章或公开仓库。
设计目标
这个 watchdog 的目标不是“网络一断就马上重启”,而是尽量避免误判:
- 单次 ping 失败只记录状态,不做动作。
- 连续失败达到阈值后,先恢复软路由虚拟机。
- 软路由恢复动作有冷却时间,避免反复重启。
- 连续多次恢复软路由仍失败,才升级为重启 PVE。
- 所有阈值都放在配置文件里,不写死在脚本里。
- 只写 systemd journal 日志,不依赖外部通知服务。
判断链路
最小可用检测可以只 ping 一个外网域名:
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:
# pve-net-watchdog configuration
# 软路由虚拟机 VMID,按你的 PVE 环境修改。VMID=103
# 软路由 LAN IP,使用示例地址,部署时替换成自己的网关地址。ROUTER_IP=192.168.1.1
# 主连通性检测目标。PING_TARGET=www.baidu.com
PING_COUNT=3PING_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-watchdogLOCK_FILE=/run/pve-net-watchdog.lock如果你还在调试阶段,建议先把 ENABLE_PVE_REBOOT=0,观察几天日志后再打开。
Watchdog 脚本
把下面的脚本保存为 /usr/local/sbin/pve-net-watchdog,并设置为可执行:
#!/usr/bin/env bashset -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 "$@"授权:
chmod 0755 /usr/local/sbin/pve-net-watchdogsystemd service 和 timer
创建 /etc/systemd/system/pve-net-watchdog.service:
[Unit]Description=PVE network watchdogWants=network-online.targetAfter=network-online.target
[Service]Type=oneshotEnvironmentFile=-/etc/default/pve-net-watchdogExecStart=/usr/local/sbin/pve-net-watchdog创建 /etc/systemd/system/pve-net-watchdog.timer:
[Unit]Description=Run PVE network watchdog every minute
[Timer]OnBootSec=2minOnUnitActiveSec=60sAccuracySec=5sPersistent=trueUnit=pve-net-watchdog.service
[Install]WantedBy=timers.target启用:
systemctl daemon-reloadsystemctl enable --now pve-net-watchdog.timer验证
先做语法检查:
bash -n /usr/local/sbin/pve-net-watchdog再做一次 dry-run:
/usr/local/sbin/pve-net-watchdog --dry-run确认 timer 状态:
systemctl is-enabled pve-net-watchdog.timersystemctl is-active pve-net-watchdog.timersystemctl list-timers --all pve-net-watchdog.timer --no-pager查看日志:
journalctl -u pve-net-watchdog.service -n 100 --no-pager实时跟踪:
journalctl -u pve-net-watchdog.service -f查看状态文件:
cat /var/lib/pve-net-watchdog/state正常情况下状态大概是:
FAIL_COUNT=0FIRST_FAILURE_EPOCH=0ROUTER_RESTART_COUNT=0LAST_ROUTER_RESTART_EPOCH=0LAST_PVE_REBOOT_EPOCH=0触发流程
按上面的配置,完整动作链路是:
- 每分钟 ping 一次
www.baidu.com。 - 连续失败 5 分钟后,恢复软路由虚拟机。
- 如果虚拟机已停止,执行
qm start <vmid>。 - 如果虚拟机正在运行,优先执行
qm reboot <vmid>。 - 如果软重启失败,再执行
qm reset <vmid>。 - 每次恢复后等待 10 分钟冷却。
- 连续恢复软路由 3 次仍然不通,再等待 5 分钟。
- 如果仍然不通,并且 PVE 重启冷却期已过,执行
systemctl reboot。
这个流程的关键是“逐级升级”:不要把宿主机重启作为第一动作。
临时停用
如果你要维护网络、升级 PVE、调整虚拟网桥或直通网卡,建议先停掉 timer:
systemctl disable --now pve-net-watchdog.timer维护完成后再启用:
systemctl enable --now pve-net-watchdog.timer生产环境建议
自动重启 PVE 是高风险动作,建议至少做三点保护:
- 初次部署先把
ENABLE_PVE_REBOOT=0,观察几天日志。 - 确认软路由 VM 能稳定启动,并且设置了开机自启。
- 如果 PVE 上有备份、迁移、快照、升级任务,脚本里应该额外检查这些任务,避免在关键操作中重启宿主机。
如果你还有另一台常在线设备,例如 NAS、旁路由、树莓派,也可以把外部探针加入判断。由 PVE 自己判断“我是否断网”已经能解决大部分场景,但外部视角更适合判断“整个家庭网络是否真的不可达”。
这套脚本不复杂,真正重要的是阈值和冷却策略。网络自愈系统宁可慢一点,也不要因为一两次临时丢包就把关键设备重启掉。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!













