pve_snapshot_recover
PVE 实例快照回滚工具
一键脚本
bash <(curl -sL gitee.com/meimolihan/linux-command_sh/raw/master/pve_snapshot_recover.sh)
效果预览
补充说明
该脚本用于在 Proxmox VE(PVE)平台上将 QEMU 虚拟机和 LXC 容器回滚到指定快照状态,基于 qm rollback 和 pct rollback 命令实现,适合在系统故障、配置回退等需要恢复至历史状态的运维场景。
功能特点
- 双模式回滚:同时支持 QEMU 虚拟机(
qm rollback)和 LXC 容器(pct rollback)快照回滚 - 实例自动筛选:仅列出拥有快照的 VM 和 CT 实例,无快照的实例自动隐藏
- 快照列表展示:选择实例后动态获取其所有快照,并识别当前运行节点
- 双重确认机制:选择快照后要求输入
yes确认回滚,防止误操作 - 运行中实例自动停止:回滚前检测实例状态,先停止再回滚,完成后自动恢复运行
- 彩色输出:不同类型和状态使用不同颜色区分
输出说明
脚本交互过程包含以下信息:
| 阶段 | 说明 |
|---|---|
| 实例列表 | 按序号列出所有有快照的 VM 和 CT 及其名称 |
| 快照列表 | 选中实例后展示其所有快照(含描述和当前标识) |
| 双重确认 | 输入 yes 确认回滚,取消则输入 no |
| 回滚结果 | 回滚成功或失败的状态提示 |
参数示例
# 交互式模式(列出有快照的实例 → 选择 → 选快照 → 确认 → 回滚)
bash pve_snapshot_recover.sh
关联工具
| 工具 | 说明 |
|---|---|
pve_snapshot_new.sh |
创建 VM/CT 快照 |
pve_snapshot_list.sh |
查看所有实例快照一览 |
pve_vm_start.sh |
批量启动 VM/CT 实例 |
pve_vm_stop.sh |
批量停止 VM/CT 实例 |
pve_vm_reboot.sh |
批量重启 VM/CT 实例 |
pve_vm_status.sh |
查看 VM/CT 运行状态 |
注意事项
- 需要在 PVE 节点上以 root 权限执行
- 快照回滚将丢弃回滚点之后的所有改动,操作不可逆,请谨慎操作
- 回滚前建议先为当前状态创建一个新快照,以便需要时恢复
- 运行中的实例会自动停止后再回滚,回滚完成后自动启动
- 回滚操作会短暂中断实例运行,请安排在维护窗口执行
脚本源码
#!/bin/bash
list_color_init() {
export gl_hui=$'\033[38;5;59m'
export gl_hong=$'\033[38;5;9m'
export gl_lv=$'\033[38;5;10m'
export gl_huang=$'\033[38;5;11m'
export gl_lan=$'\033[38;5;32m'
export gl_bai=$'\033[38;5;15m'
export gl_zi=$'\033[38;5;13m'
export gl_bufan=$'\033[38;5;14m'
export reset=$'\033[0m'
}
list_color_init
log_info() { echo -e "${gl_lan}[信息]${gl_bai} $*"; }
log_ok() { echo -e "${gl_lv}[成功]${gl_bai} $*"; }
log_warn() { echo -e "${gl_huang}[警告]${gl_bai} $*"; }
log_error() { echo -e "${gl_hong}[错误]${gl_bai} $*" >&2; }
sleep_fractional() {
local seconds=$1
if sleep "$seconds" 2>/dev/null; then return 0; fi
if command -v perl >/dev/null 2>&1; then perl -e "select(undef, undef, undef, $seconds)"; return 0; fi
if command -v python3 >/dev/null 2>&1; then python3 -c "import time; time.sleep($seconds)"; return 0; fi
if command -v python >/dev/null 2>&1; then python -c "import time; time.sleep($seconds)"; return 0; fi
local int_seconds=$(echo "$seconds" | awk '{print int($1+0.999)}')
sleep "$int_seconds"
}
break_end() {
echo -e "${gl_lv}操作完成${gl_bai}"
echo -e "${gl_bai}按任意键继续${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai} \c"
read -r -n 1 -s -r -p ""
echo ""
clear
}
root_use() {
clear
if [ "$EUID" -ne 0 ]; then
echo -e "\n${gl_zi}>>> ROOT登录检查 ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}"
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
echo -e "${gl_huang}提示: ${gl_bai}该功能需要root用户才能运行!"
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
break_end
mobufan
return 1
fi
return 0
}
exit_animation() {
echo -ne "${gl_lv}即将退出 ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
sleep_fractional 0.5
echo -ne "${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
sleep_fractional 0.6
echo ""
clear
}
exit_script() {
echo ""
echo -ne "${gl_hong}感谢使用,再见!${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
sleep_fractional 0.5
echo -ne "${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}\c"
sleep_fractional 0.6
clear
exit 0
}
list_vms_with_snapshots() {
local vms=$(qm list 2>/dev/null | awk 'NR>1 {print $1}' | sort -n)
local result=()
for vmid in $vms; do
local snap_count=$(qm listsnapshot "$vmid" 2>/dev/null | grep -vc "current\|You are here")
if [ "$snap_count" -gt 0 ]; then
local vm_name=$(qm config "$vmid" 2>/dev/null | grep "^name:" | awk '{print $2}')
result+=("$vmid:$vm_name")
fi
done
printf '%s\n' "${result[@]}"
}
list_cts_with_snapshots() {
local cts=$(pct list 2>/dev/null | awk 'NR>1 {print $1}' | sort -n)
local result=()
for ctid in $cts; do
local snap_count=$(pct listsnapshot "$ctid" 2>/dev/null | grep -vc "current\|You are here")
if [ "$snap_count" -gt 0 ]; then
local ct_name=$(pct config "$ctid" 2>/dev/null | grep "^hostname:" | awk '{print $2}')
result+=("$ctid:$ct_name")
fi
done
printf '%s\n' "${result[@]}"
}
get_snapshot_names() {
local type=$1
local id=$2
if [ "$type" = "vm" ]; then
qm listsnapshot "$id" 2>/dev/null | awk 'NR>1 && !/current/ {print $2}'
else
pct listsnapshot "$id" 2>/dev/null | awk 'NR>1 && !/current/ {print $2}'
fi
}
get_snapshot_description() {
local type=$1
local id=$2
local snapname=$3
if [ "$type" = "vm" ]; then
qm listsnapshot "$id" 2>/dev/null | awk -v snap="$snapname" '$2 == snap {desc=""; for(i=4;i<=NF;i++){if($i!="no-description"&&$i!="current"){desc=(desc?desc" ":"")$i}}; if(desc) print desc}'
else
pct listsnapshot "$id" 2>/dev/null | awk -v snap="$snapname" '$2 == snap {desc=""; for(i=4;i<=NF;i++){if($i!="no-description"&&$i!="current"){desc=(desc?desc" ":"")$i}}; if(desc) print desc}'
fi
}
is_current_snapshot() {
local type=$1
local id=$2
local snapname=$3
if [ "$type" = "vm" ]; then
qm listsnapshot "$id" 2>/dev/null | awk -v snap="$snapname" '$2 == snap {for(i=1;i<=NF;i++) if($i=="current") print "yes"}'
else
pct listsnapshot "$id" 2>/dev/null | awk -v snap="$snapname" '$2 == snap {for(i=1;i<=NF;i++) if($i=="current") print "yes"}'
fi
}
rollback_vm() {
local vmid=$1
local snapname=$2
local was_running=false
local current_status=$(qm status "$vmid" 2>/dev/null | awk '{print $2}')
if [ "$current_status" = "running" ]; then
was_running=true
log_warn "虚拟机 $vmid 正在运行,即将停止 ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}"
if ! qm stop "$vmid" &>/dev/null; then
log_error "无法停止虚拟机 $vmid"
return 1
fi
log_ok "虚拟机 $vmid 已停止"
fi
log_info "正在回滚虚拟机 ${gl_bufan}$vmid${gl_bai} 到快照 ${gl_bufan}$snapname${gl_bai}"
if qm rollback "$vmid" "$snapname" 2>&1; then
log_ok "虚拟机 $vmid 回滚到快照 $snapname 成功"
if [ "$was_running" = true ]; then
log_info "正在重新启动虚拟机 $vmid ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}"
qm start "$vmid" &>/dev/null && log_ok "虚拟机 $vmid 已启动" || log_warn "虚拟机 $vmid 启动失败,请手动启动"
fi
return 0
else
log_error "虚拟机 $vmid 回滚失败"
if [ "$was_running" = true ]; then
log_warn "虚拟机 $vmid 回滚前已停止,请手动启动"
fi
return 1
fi
}
rollback_ct() {
local ctid=$1
local snapname=$2
local was_running=false
local current_status=$(pct status "$ctid" 2>/dev/null | awk '{print $2}')
if [ "$current_status" = "running" ]; then
was_running=true
log_warn "容器 $ctid 正在运行,即将停止 ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}"
if ! pct stop "$ctid" &>/dev/null; then
log_error "无法停止容器 $ctid"
return 1
fi
log_ok "容器 $ctid 已停止"
fi
log_info "正在回滚容器 ${gl_bufan}$ctid${gl_bai} 到快照 ${gl_bufan}$snapname${gl_bai}"
if pct rollback "$ctid" "$snapname" 2>&1; then
log_ok "容器 $ctid 回滚到快照 $snapname 成功"
if [ "$was_running" = true ]; then
log_info "正在重新启动容器 $ctid ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}"
pct start "$ctid" &>/dev/null && log_ok "容器 $ctid 已启动" || log_warn "容器 $ctid 启动失败,请手动启动"
fi
return 0
else
log_error "容器 $ctid 回滚失败"
if [ "$was_running" = true ]; then
log_warn "容器 $ctid 回滚前已停止,请手动启动"
fi
return 1
fi
}
handle_invalid_input() {
echo -ne "\r${gl_huang}无效的输入,请重新输入! ${gl_zi} 1 ${gl_huang} 秒后返回"
sleep 1
echo -e "\r${gl_lv}无效的输入,请重新输入! ${gl_zi}0${gl_lv} 秒后返回"
sleep 0.5
}
main() {
while true; do
clear
root_use
if ! command -v qm &> /dev/null; then
echo -e ""
echo -e "${gl_huang}>>> PVE 虚拟机/容器快照回滚${gl_bai}"
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
log_error "未检测到Proxmox VE环境,请确保脚本在PVE节点上运行"
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
break_end
return 1
fi
echo -e ""
echo -e "${gl_zi}>>> PVE 虚拟机/容器快照回滚 ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}"
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
local instance_list=()
local index=1
local vm_list=$(list_vms_with_snapshots)
if [ -n "$vm_list" ]; then
while IFS=: read -r vmid vmname; do
instance_list+=("vm:$vmid")
if [ -n "$vmname" ]; then
echo -e "${gl_bufan}$index. ${gl_bai}VM $vmid - $vmname"
else
echo -e "${gl_bufan}$index. ${gl_bai}VM $vmid"
fi
((index++))
done <<< "$vm_list"
fi
local ct_list=$(list_cts_with_snapshots)
if [ -n "$ct_list" ]; then
while IFS=: read -r ctid ctname; do
instance_list+=("ct:$ctid")
if [ -n "$ctname" ]; then
echo -e "${gl_bufan}$index. ${gl_bai}CT $ctid - $ctname"
else
echo -e "${gl_bufan}$index. ${gl_bai}CT $ctid"
fi
((index++))
done <<< "$ct_list"
fi
if [ ${#instance_list[@]} -eq 0 ]; then
echo -e "${gl_huang}没有拥有快照的虚拟机或容器${gl_bai}"
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
break_end
return 1
fi
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
read -r -e -p "$(echo -e "${gl_bai}请选择要回滚快照的实例序号 (${gl_hong}0${gl_bai} 退出): ")" choice
if [ "$choice" = "0" ]; then
exit_script
fi
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#instance_list[@]}" ]; then
local selected="${instance_list[$((choice-1))]}"
local type="${selected%%:*}"
local id="${selected#*:}"
clear
echo -e ""
echo -e "${gl_zi}>>> 快照列表 ${gl_hong}.${gl_huang}.${gl_lv}.${gl_bai}"
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
echo -e "${gl_bai}实例: ${gl_bufan}$id${gl_bai}"
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
local snap_names=()
local snap_index=1
while IFS= read -r snapname; do
[ -z "$snapname" ] && continue
snap_names+=("$snapname")
local desc=$(get_snapshot_description "$type" "$id" "$snapname")
local current_mark=$(is_current_snapshot "$type" "$id" "$snapname")
if [ -n "$current_mark" ]; then
echo -e "${gl_bufan}$snap_index. ${gl_lv}$snapname ${gl_huang}(当前)${gl_bai}"
elif [ -n "$desc" ]; then
echo -e "${gl_bufan}$snap_index. ${gl_bai}$snapname - $desc"
else
echo -e "${gl_bufan}$snap_index. ${gl_bai}$snapname"
fi
((snap_index++))
done <<< "$(get_snapshot_names "$type" "$id")"
if [ ${#snap_names[@]} -eq 0 ]; then
log_info "该实例没有可回滚的快照"
break_end
continue
fi
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
read -r -e -p "$(echo -e "${gl_bai}请选择要回滚的快照序号 (${gl_hong}0${gl_bai} 取消): ")" snap_choice
if [ "$snap_choice" = "0" ]; then
continue
fi
if [[ "$snap_choice" =~ ^[0-9]+$ ]] && [ "$snap_choice" -ge 1 ] && [ "$snap_choice" -le "${#snap_names[@]}" ]; then
local snapname="${snap_names[$((snap_choice-1))]}"
echo -e ""
echo -e "${gl_hong}⚠ 危险操作!回滚将丢失所有后续更改,且不可逆!${gl_bai}"
echo -e "${gl_huang}即将回滚实例 ${gl_bufan}$id${gl_huang} 到快照 ${gl_bufan}$snapname${gl_huang}${gl_bai}"
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
read -r -e -p "$(echo -e "${gl_bai}请输入 ${gl_hong}yes${gl_bai} 确认回滚 (输入 ${gl_lv}no${gl_bai} 取消): ")" confirm
if [ "$confirm" != "yes" ]; then
log_info "已取消回滚操作"
break_end
continue
fi
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
if [ "$type" = "ct" ]; then
rollback_ct "$id" "$snapname"
else
rollback_vm "$id" "$snapname"
fi
echo -e "${gl_bufan}————————————————————————————————————————————————${gl_bai}"
break_end
return 1
else
handle_invalid_input
fi
else
handle_invalid_input
fi
done
}
main
创建本地脚本
new_script="pve_snapshot_recover.sh"
cat > "$new_script" << 'EOF'
#!/bin/bash
# 粘贴脚本源码
EOF
chmod +x "$new_script" && ./"$new_script" && rm -f "$new_script"