Compare commits
10 Commits
6644724218
...
7b6bed439b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6bed439b | ||
|
|
0ce006b6de | ||
|
|
79efc73897 | ||
|
|
aba4a352e4 | ||
|
|
4170ef96ed | ||
|
|
737cd84e39 | ||
|
|
a34548446d | ||
|
|
c463226ce0 | ||
|
|
927e725b91 | ||
|
|
38f62c4c50 |
199
vps-snapshot.sh
199
vps-snapshot.sh
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
#===============================================================================
|
||||
# VPS 快照备份脚本 v3.10.6
|
||||
# VPS 快照备份脚本 v3.15
|
||||
# 支持: Ubuntu, Debian, CentOS, Alpine
|
||||
# 功能: 智能识别应用 + Docker迁移 + 数据备份 + Telegram通知
|
||||
#===============================================================================
|
||||
@@ -19,7 +19,7 @@ LOG_FILE="/var/log/vps-snapshot.log"
|
||||
print_banner() {
|
||||
echo -e "${BLUE}"
|
||||
echo "╔═══════════════════════════════════════════════════════════╗"
|
||||
echo "║ VPS 快照备份脚本 v3.10 ║"
|
||||
echo "║ VPS 快照备份脚本 v3.15 ║"
|
||||
echo "║ 智能识别 + Docker迁移 + 数据备份 ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
@@ -54,7 +54,7 @@ install_deps() {
|
||||
check_deps() {
|
||||
local missing=""
|
||||
command -v rsync &>/dev/null || missing+=" rsync"
|
||||
command -v sshpass &>/dev/null || missing+=" sshpass"
|
||||
# sshpass is optional when using key auth
|
||||
command -v jq &>/dev/null || missing+=" jq"
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
@@ -271,9 +271,63 @@ backup_app_data() {
|
||||
|
||||
local backup_paths=""
|
||||
|
||||
#---------------------------------------------------------------------------
|
||||
# 智能扫描系统目录(自动识别有数据的目录)
|
||||
#---------------------------------------------------------------------------
|
||||
log "🔍 智能扫描系统目录..."
|
||||
|
||||
# 核心配置目录 - 必备
|
||||
[ -d /etc ] && backup_paths+=" /etc"
|
||||
|
||||
# 用户目录
|
||||
[ -d /root ] && backup_paths+=" /root"
|
||||
[ -d /home ] && [ "$(ls -A /home 2>/dev/null)" ] && backup_paths+=" /home"
|
||||
|
||||
# 手动安装的程序和数据
|
||||
[ -d /usr/local/bin ] && [ "$(ls -A /usr/local/bin 2>/dev/null)" ] && backup_paths+=" /usr/local/bin"
|
||||
[ -d /usr/local/sbin ] && [ "$(ls -A /usr/local/sbin 2>/dev/null)" ] && backup_paths+=" /usr/local/sbin"
|
||||
[ -d /usr/local/etc ] && [ "$(ls -A /usr/local/etc 2>/dev/null)" ] && backup_paths+=" /usr/local/etc"
|
||||
|
||||
# /usr/local 下的应用目录(x-ui, xray 等)
|
||||
if [ -d /usr/local ]; then
|
||||
for dir in /usr/local/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
local dirname=$(basename "$dir")
|
||||
# 跳过标准目录(bin/sbin/etc/lib/share/include/man/src/games)
|
||||
case "$dirname" in
|
||||
bin|sbin|etc|lib|lib64|share|include|man|src|games|libexec) continue ;;
|
||||
esac
|
||||
backup_paths+=" $dir"
|
||||
done
|
||||
fi
|
||||
|
||||
# /opt 应用目录(排除 containerd)
|
||||
if [ -d /opt ]; then
|
||||
for dir in /opt/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
local dirname=$(basename "$dir")
|
||||
# 跳过 containerd(Docker 内部数据)
|
||||
[ "$dirname" = "containerd" ] && continue
|
||||
backup_paths+=" $dir"
|
||||
done
|
||||
fi
|
||||
|
||||
# /var/www 网站目录
|
||||
[ -d /var/www ] && [ "$(ls -A /var/www 2>/dev/null)" ] && backup_paths+=" /var/www"
|
||||
|
||||
# 显示扫描结果
|
||||
log "📋 将备份以下目录:"
|
||||
for p in $backup_paths; do
|
||||
local size=$(du -sh "$p" 2>/dev/null | cut -f1)
|
||||
echo " $p ($size)"
|
||||
done
|
||||
|
||||
#---------------------------------------------------------------------------
|
||||
# 特定应用数据导出(数据库等需要特殊处理)
|
||||
#---------------------------------------------------------------------------
|
||||
|
||||
# Nginx
|
||||
[ -d /etc/nginx ] && backup_paths+=" /etc/nginx"
|
||||
[ -d /var/www ] && backup_paths+=" /var/www"
|
||||
[ -d /etc/nginx ] && log " ✓ Nginx 配置"
|
||||
|
||||
# MySQL
|
||||
if [ -d /var/lib/mysql ]; then
|
||||
@@ -325,25 +379,24 @@ backup_app_data() {
|
||||
fi
|
||||
|
||||
# 1Panel
|
||||
[ -d /opt/1panel ] && backup_paths+=" /opt/1panel"
|
||||
[ -d /opt/1panel ] && log " ✓ 1Panel"
|
||||
|
||||
# 宝塔
|
||||
[ -d /www ] && backup_paths+=" /www"
|
||||
[ -d /www ] && backup_paths+=" /www" && log " ✓ 宝塔面板"
|
||||
|
||||
# 通用数据目录
|
||||
[ -d /opt ] && backup_paths+=" /opt"
|
||||
[ -d /home ] && backup_paths+=" /home"
|
||||
[ -d /root/.config ] && backup_paths+=" /root/.config"
|
||||
|
||||
# 打包(排除快照目录)
|
||||
# 打包(排除快照目录和无用文件)
|
||||
local snap_dir="${LOCAL_DIR:-/var/snapshots}"
|
||||
if [ -n "$backup_paths" ]; then
|
||||
log "打包数据目录..."
|
||||
log "📦 打包数据目录..."
|
||||
tar --exclude='*.sock' --exclude='*.pid' --exclude='node_modules' \
|
||||
--exclude='.npm' --exclude='.cache' --exclude='__pycache__' \
|
||||
--exclude='*.log' --exclude='/var/log/*' \
|
||||
--exclude='/var/cache/*' --exclude='/tmp/*' \
|
||||
--exclude="$snap_dir" --exclude='/var/snapshots' \
|
||||
--exclude='/root/.cache' --exclude='/root/.local/share/Trash' \
|
||||
-czf "$output_dir/app-data.tar.gz" $backup_paths 2>/dev/null || true
|
||||
info "数据已保存: $output_dir/app-data.tar.gz"
|
||||
local data_size=$(du -h "$output_dir/app-data.tar.gz" 2>/dev/null | cut -f1)
|
||||
info "数据已保存: $output_dir/app-data.tar.gz ($data_size)"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -438,9 +491,9 @@ if [ -d /tmp/restore_data/volumes ]; then
|
||||
fi
|
||||
|
||||
# 恢复应用数据
|
||||
if ls /tmp/restore_data/app-data_*.tar.gz &>/dev/null; then
|
||||
if ls /tmp/restore_data/app-data.tar.gz &>/dev/null; then
|
||||
echo "恢复应用数据..."
|
||||
tar -xzf /tmp/restore_data/app-data_*.tar.gz -C / 2>/dev/null || true
|
||||
tar -xzf /tmp/restore_data/app-data.tar.gz -C / 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 恢复 NPM 全局包
|
||||
@@ -562,7 +615,7 @@ cleanup_local() {
|
||||
local vps_name="${VPS_NAME:-snapshot}"
|
||||
|
||||
# 删除临时的app-data文件
|
||||
rm -f "$snap_dir"/app-data_*.tar.gz 2>/dev/null
|
||||
rm -f "$snap_dir"/app-data.tar.gz 2>/dev/null
|
||||
|
||||
# 只统计VPS名称开头的快照
|
||||
local count=$(ls -1 "$snap_dir"/${vps_name}_*.tar.gz 2>/dev/null | wc -l)
|
||||
@@ -578,8 +631,7 @@ cleanup_remote() {
|
||||
local remote_path="${REMOTE_DIR:-/backup}/${VPS_NAME:-$(hostname)}"
|
||||
|
||||
log "🧹 清理远程旧快照 (保留${days}天)..."
|
||||
sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no \
|
||||
-p "${REMOTE_PORT:-22}" "${REMOTE_USER:-root}@$REMOTE_IP" \
|
||||
_ssh_wrap ssh "${REMOTE_USER:-root}@$REMOTE_IP" \
|
||||
"find $remote_path -name '*.tar.gz' -mtime +$days -delete 2>/dev/null" || true
|
||||
}
|
||||
|
||||
@@ -622,7 +674,11 @@ do_full_restore() {
|
||||
# 解压到临时目录
|
||||
local tmp_dir="/tmp/restore_$$"
|
||||
mkdir -p "$tmp_dir"
|
||||
tar -xzf "$snap_file" -C "$tmp_dir"
|
||||
if ! tar -xzf "$snap_file" -C "$tmp_dir" 2>/dev/null; then
|
||||
error "快照文件损坏或解压失败"
|
||||
rm -rf "$tmp_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 读取快照时的已安装包列表
|
||||
if [ -f "$tmp_dir/installed-packages.txt" ]; then
|
||||
@@ -686,9 +742,18 @@ do_full_restore() {
|
||||
# 删除快照后新增的 systemd 服务
|
||||
if [ -f "$tmp_dir/systemd-services.txt" ]; then
|
||||
log "清理新增的 systemd 服务..."
|
||||
# 只清理用户安装的服务,不动系统服务
|
||||
# 只检查 /etc/systemd/system (用户服务),不检查 /usr/lib/systemd/system (系统服务)
|
||||
for f in /etc/systemd/system/*.service; do
|
||||
[ -e "$f" ] || continue
|
||||
local fname=$(basename "$f")
|
||||
# 跳过系统核心服务和模板服务
|
||||
case "$fname" in
|
||||
systemd-*|dbus*|ssh*|cron*|rsyslog*|networking*|getty*|serial-getty*) continue ;;
|
||||
cloud-*|apt-*|apparmor*|console-*|keyboard-*|grub-*) continue ;;
|
||||
docker*|containerd*|snap*|snapd*) continue ;;
|
||||
*@*) continue ;; # 跳过所有模板服务
|
||||
esac
|
||||
if ! grep -qx "$fname" "$tmp_dir/systemd-services.txt" 2>/dev/null; then
|
||||
log "停止并删除服务: $fname"
|
||||
systemctl stop "$fname" 2>/dev/null || true
|
||||
@@ -739,9 +804,9 @@ do_full_restore() {
|
||||
fi
|
||||
|
||||
# 恢复应用数据
|
||||
if ls "$tmp_dir"/app-data_*.tar.gz &>/dev/null; then
|
||||
if ls "$tmp_dir"/app-data.tar.gz &>/dev/null; then
|
||||
log "恢复应用数据..."
|
||||
tar -xzf "$tmp_dir"/app-data_*.tar.gz -C / 2>/dev/null || true
|
||||
tar -xzf "$tmp_dir"/app-data.tar.gz -C / 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 启动Docker容器
|
||||
@@ -762,18 +827,37 @@ do_data_restore() {
|
||||
# 解压到临时目录
|
||||
local tmp_dir="/tmp/restore_$$"
|
||||
mkdir -p "$tmp_dir"
|
||||
tar -xzf "$snap_file" -C "$tmp_dir"
|
||||
if ! tar -xzf "$snap_file" -C "$tmp_dir" 2>/dev/null; then
|
||||
error "快照文件损坏或解压失败"
|
||||
rm -rf "$tmp_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 导入Docker
|
||||
# 导入Docker镜像
|
||||
if [ -f "$tmp_dir/docker-images.tar.gz" ]; then
|
||||
log "导入 Docker 镜像..."
|
||||
gunzip -c "$tmp_dir/docker-images.tar.gz" | docker load
|
||||
fi
|
||||
|
||||
# 恢复 Docker Volumes
|
||||
if [ -d "$tmp_dir/volumes" ]; then
|
||||
log "恢复 Docker Volumes..."
|
||||
for vol_file in "$tmp_dir/volumes"/*.tar.gz; do
|
||||
[ -f "$vol_file" ] || continue
|
||||
local vol_name=$(basename "$vol_file" .tar.gz)
|
||||
docker volume create "$vol_name" 2>/dev/null || true
|
||||
docker run --rm -v "$vol_name:/data" -v "$tmp_dir/volumes:/backup" \
|
||||
alpine sh -c "cd /data && tar -xzf /backup/$(basename $vol_file)"
|
||||
log " 恢复 Volume: $vol_name"
|
||||
done
|
||||
fi
|
||||
|
||||
# 恢复应用数据
|
||||
if ls "$tmp_dir"/app-data_*.tar.gz &>/dev/null; then
|
||||
if [ -f "$tmp_dir/app-data.tar.gz" ]; then
|
||||
log "恢复应用数据..."
|
||||
tar -xzf "$tmp_dir"/app-data_*.tar.gz -C / 2>/dev/null || true
|
||||
if ! tar -xzf "$tmp_dir/app-data.tar.gz" -C / 2>/dev/null; then
|
||||
warn "应用数据恢复部分失败"
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -rf "$tmp_dir"
|
||||
@@ -798,8 +882,7 @@ do_restore_config_remote() {
|
||||
echo ""
|
||||
|
||||
# 获取远程快照列表
|
||||
local snap_list=$(sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no \
|
||||
-p "${REMOTE_PORT:-22}" "${REMOTE_USER:-root}@$REMOTE_IP" \
|
||||
local snap_list=$(_ssh_wrap ssh "${REMOTE_USER:-root}@$REMOTE_IP" \
|
||||
"ls -t $remote_path/*.tar.gz 2>/dev/null")
|
||||
|
||||
[ -z "$snap_list" ] && { error "无远程快照"; return 1; }
|
||||
@@ -810,8 +893,7 @@ do_restore_config_remote() {
|
||||
local snaps=()
|
||||
while read -r snap; do
|
||||
local name=$(basename "$snap")
|
||||
local size=$(sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no \
|
||||
-p "${REMOTE_PORT:-22}" "${REMOTE_USER:-root}@$REMOTE_IP" \
|
||||
local size=$(_ssh_wrap ssh "${REMOTE_USER:-root}@$REMOTE_IP" \
|
||||
"ls -lh '$snap' 2>/dev/null | awk '{print \$5}'" 2>/dev/null)
|
||||
echo " $i) $name ($size)"
|
||||
snaps+=("$snap")
|
||||
@@ -831,8 +913,7 @@ do_restore_config_remote() {
|
||||
|
||||
log "下载快照: $(basename "$selected")"
|
||||
local local_file="/tmp/remote_snapshot_$$.tar.gz"
|
||||
sshpass -p "$REMOTE_PASS" scp -o StrictHostKeyChecking=no \
|
||||
-P "${REMOTE_PORT:-22}" "${REMOTE_USER:-root}@$REMOTE_IP:$selected" "$local_file"
|
||||
_ssh_wrap scp "${REMOTE_USER:-root}@$REMOTE_IP:$selected" "$local_file"
|
||||
|
||||
[ ! -f "$local_file" ] && { error "下载失败"; return 1; }
|
||||
|
||||
@@ -848,9 +929,9 @@ do_restore_config_remote() {
|
||||
fi
|
||||
|
||||
# 恢复应用数据
|
||||
if ls "$tmp_dir"/app-data_*.tar.gz &>/dev/null; then
|
||||
if ls "$tmp_dir"/app-data.tar.gz &>/dev/null; then
|
||||
log "恢复应用数据..."
|
||||
tar -xzf "$tmp_dir"/app-data_*.tar.gz -C / 2>/dev/null || true
|
||||
tar -xzf "$tmp_dir"/app-data.tar.gz -C / 2>/dev/null || true
|
||||
fi
|
||||
|
||||
rm -rf "$tmp_dir" "$local_file"
|
||||
@@ -894,9 +975,9 @@ do_restore_remote() {
|
||||
fi
|
||||
|
||||
# 恢复应用数据
|
||||
if ls "$tmp_dir"/app-data_*.tar.gz &>/dev/null; then
|
||||
if ls "$tmp_dir"/app-data.tar.gz &>/dev/null; then
|
||||
log "恢复应用数据..."
|
||||
tar -xzf "$tmp_dir"/app-data_*.tar.gz -C / 2>/dev/null || true
|
||||
tar -xzf "$tmp_dir"/app-data.tar.gz -C / 2>/dev/null || true
|
||||
fi
|
||||
|
||||
rm -rf "$tmp_dir" "$local_file"
|
||||
@@ -927,13 +1008,11 @@ do_sync_remote() {
|
||||
log "📤 同步到远程: $REMOTE_IP:$remote_path"
|
||||
|
||||
# 创建远程目录(以VPS名称命名)
|
||||
sshpass -p "$REMOTE_PASS" ssh -o StrictHostKeyChecking=no \
|
||||
-p "${REMOTE_PORT:-22}" "${REMOTE_USER:-root}@$REMOTE_IP" \
|
||||
_ssh_wrap ssh "${REMOTE_USER:-root}@$REMOTE_IP" \
|
||||
"mkdir -p $remote_path"
|
||||
|
||||
# 同步到VPS专属目录
|
||||
sshpass -p "$REMOTE_PASS" rsync -avz --progress \
|
||||
-e "ssh -o StrictHostKeyChecking=no -p ${REMOTE_PORT:-22}" \
|
||||
_ssh_wrap rsync -avz --progress \
|
||||
"$latest" "${REMOTE_USER:-root}@$REMOTE_IP:$remote_path/"
|
||||
|
||||
# 清理远程旧快照
|
||||
@@ -1055,6 +1134,22 @@ load_config() {
|
||||
[ -f "$CONFIG_FILE" ] && source "$CONFIG_FILE"
|
||||
}
|
||||
|
||||
# SSH/SCP/rsync 前缀:有key用key,有密码用sshpass,都没有走默认
|
||||
_ssh_wrap() {
|
||||
local mode="$1"; shift
|
||||
local key="${REMOTE_KEY:-}" pass="${REMOTE_PASS:-}"
|
||||
local ssh_opts="-o StrictHostKeyChecking=no -p ${REMOTE_PORT:-22}"
|
||||
[ -n "$key" ] && [ -f "$key" ] && ssh_opts="$ssh_opts -i $key"
|
||||
local ssh_cmd="ssh $ssh_opts"
|
||||
local prefix=""
|
||||
[ -z "$key" ] || [ ! -f "$key" ] && [ -n "$pass" ] && prefix="sshpass -p $pass"
|
||||
case "$mode" in
|
||||
ssh) $prefix $ssh_cmd "$@" ;;
|
||||
scp) $prefix scp -o StrictHostKeyChecking=no ${key:+-i "$key"} -P "${REMOTE_PORT:-22}" "$@" ;;
|
||||
rsync) $prefix rsync -e "$ssh_cmd" "$@" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
save_config() {
|
||||
cat > "$CONFIG_FILE" << EOF
|
||||
VPS_NAME="$VPS_NAME"
|
||||
@@ -1064,6 +1159,7 @@ REMOTE_IP="$REMOTE_IP"
|
||||
REMOTE_PORT="$REMOTE_PORT"
|
||||
REMOTE_USER="$REMOTE_USER"
|
||||
REMOTE_PASS="$REMOTE_PASS"
|
||||
REMOTE_KEY="$REMOTE_KEY"
|
||||
REMOTE_DIR="$REMOTE_DIR"
|
||||
REMOTE_KEEP_DAYS="$REMOTE_KEEP_DAYS"
|
||||
TG_BOT_TOKEN="$TG_BOT_TOKEN"
|
||||
@@ -1094,8 +1190,9 @@ do_setup() {
|
||||
REMOTE_PORT=${REMOTE_PORT:-22}
|
||||
read -p "远程用户 [root]: " REMOTE_USER
|
||||
REMOTE_USER=${REMOTE_USER:-root}
|
||||
read -s -p "远程密码: " REMOTE_PASS
|
||||
read -s -p "远程密码 (key认证留空): " REMOTE_PASS
|
||||
echo ""
|
||||
read -p "远程密钥路径 (密码认证留空): " REMOTE_KEY
|
||||
read -p "远程目录 [/backup]: " REMOTE_DIR
|
||||
REMOTE_DIR=${REMOTE_DIR:-/backup}
|
||||
read -p "远程保留天数 [30]: " REMOTE_KEEP_DAYS
|
||||
@@ -1204,6 +1301,23 @@ case "${1:-}" in
|
||||
migrate) do_migrate ;;
|
||||
docker-export) docker_export "${2:-/var/snapshots}" ;;
|
||||
docker-import) docker_import "${2:-/var/snapshots}" ;;
|
||||
restore)
|
||||
# 命令行恢复: restore <快照文件> [full|data]
|
||||
load_config 2>/dev/null || true
|
||||
_snap_file="${2:-}"
|
||||
_mode="${3:-data}"
|
||||
if [ -z "$_snap_file" ]; then
|
||||
error "用法: $0 restore <快照文件> [full|data]"
|
||||
echo " full = 完整恢复 (删除后来安装的软件)"
|
||||
echo " data = 仅恢复数据 (默认)"
|
||||
exit 1
|
||||
fi
|
||||
[ ! -f "$_snap_file" ] && { error "文件不存在: $_snap_file"; exit 1; }
|
||||
case "$_mode" in
|
||||
full) do_full_restore "$_snap_file" ;;
|
||||
data|*) do_data_restore "$_snap_file" ;;
|
||||
esac
|
||||
;;
|
||||
help|--help|-h)
|
||||
echo "用法: $0 [命令]"
|
||||
echo ""
|
||||
@@ -1212,6 +1326,7 @@ case "${1:-}" in
|
||||
echo " scan 扫描已安装应用"
|
||||
echo " snapshot 创建本地快照"
|
||||
echo " snapshot-sync 创建快照并同步远程"
|
||||
echo " restore 恢复快照 (restore <文件> [full|data])"
|
||||
echo " migrate 一键迁移"
|
||||
echo " docker-export 导出 Docker"
|
||||
echo " docker-import 导入 Docker"
|
||||
|
||||
Reference in New Issue
Block a user