Seafile 云盘备份到本地磁盘:从 SeaDrive+rsync 踩坑到年度快照方案

Seafile 云盘备份到本地磁盘:从 SeaDrive+rsync 踩坑到年度快照方案

背景

一直以来我用 SeaDrive + rsync 的组合来备份 Seafile 云盘数据到本地磁盘。但用久了发现这个方案有一个根本性问题,值得写出来给同样在用 Seafile 的朋友避坑。

问题:SeaDrive 是虚拟盘,rsync 备份的可能只是"影子"

SeaDrive 的工作模式是 on-demand(按需下载):本地默认只保存"影子文件"(占位符),真正的文件内容要在被访问时才会从服务器拉取下来。

这意味着直接对 SeaDrive 挂载目录跑 rsync,会遇到两难:

  • 要么触发大量按需下载:rsync 遍历目录时把所有影子文件的实际内容都拉取一遍,速度慢、流量大,尤其是移动网络或跨境访问服务器时体验很差;
  • 要么根本没备份到完整内容:如果不触发下载,rsync 复制到的可能只是占位符,不是真实文件,备份形同虚设。

这也是我决定换方案的直接原因。

候选方案对比

方案说明适用场景
seaf-cli官方命令行同步客户端,本地是真实文件,非虚拟盘命令行环境、树莓派等 headless 设备
seafile-client(同步模式)官方桌面客户端,选"同步"而非"虚拟盘"有图形界面的常驻电脑
rclone + WebDAV利用 Seafile 的 WebDAV 接口做同步/备份想要多目标备份(同时备份到 S3、OneDrive 等)的场景
强制落盘后 rsync先遍历触发 SeaDrive 全量下载,再 rsync不推荐,效率低、逻辑 tricky

最终选择基于官方 seaf-cli,因为它本身就是同步工具,不需要再叠加 rsync 这一层,依赖轻,适合脚本化和定时任务。

我的场景:另一台电脑 + 移动硬盘,每年备份一次

具体需求是:

  • 不影响 Seafile 服务器端的数据;
  • 用一台额外的电脑,接上移动硬盘做本地留档;
  • 备份频率很低——一年一次

关键坑:官方客户端默认是双向同步

无论是 seafile-client 桌面版还是 seaf-cli,默认都是双向同步。这意味着如果备份电脑上误删了文件,或者移动硬盘故障导致文件丢失/损坏,这个"删除"或"损坏"状态会被同步推回服务器,造成反向污染——这是这套方案里最容易被忽视、也最危险的一点。

解决思路:单向"快照式"备份

不做长期挂载的双向同步,而是把每次备份当作一次性的"拉取快照":

# 每年备份时执行
seaf-cli sync -l <repo-id> -s https://your-server -u user -p pass -d /path/to/backup

# 同步完成后立刻断开
seaf-cli desync -d /path/to/backup

这样处理后,移动硬盘在平时(也就是一年中的绝大部分时间)是完全离线、与服务器无任何连接的状态,即使备份电脑本身出问题(误操作、勒索软件等),也不会波及服务器数据。风险被压缩在"一年一次、几十分钟到几小时"的备份窗口内。

完整脚本:轮询等待 + 超时保护 + 重试机制

考虑到大库同步可能要跑很久,简单的 sleep N 猜测等待时间并不可靠,所以脚本里加了几个健壮性设计:

  • 轮询检测同步状态,而不是固定等待时间
  • 超时保护(默认 6 小时),避免异常情况下无限挂起
  • 失败自动重试(默认 3 次)
  • 信号捕获:即使中途 Ctrl+C 或脚本崩溃,也会自动断开同步、清理现场,不会留下"半连接"状态
  • 密码运行时输入,不写死在脚本里
  • 按年份建目录,历史快照互不覆盖,方便追溯
#!/bin/bash
#
# Seafile 年度备份脚本(带轮询等待 + 超时保护 + 重试机制)
# 用法: ./seafile_yearly_backup.sh
#
# 设计原则:
#   1. 每次运行只做"拉取快照",同步完成后立即 desync + stop
#   2. 平时(脚本运行窗口之外)本地目录与服务器完全无连接
#   3. 按年份建目录,历史快照互不覆盖
#

set -euo pipefail

# ============ 配置区(根据实际情况修改) ============

SERVER="https://your-seafile-server"
USER="your@email.com"
BACKUP_BASE_DIR="/Volumes/BackupDisk"          # 移动硬盘挂载路径
CONF_DIR="$HOME/.seafile-backup-conf"           # seaf-cli 配置/状态目录

# 要备份的资料库 repo-id 列表(用 seaf-cli list-remote 获取)
REPOS=(
  "repo-id-1"
  "repo-id-2"
)

# 轮询设置
POLL_INTERVAL=30        # 每次检查间隔(秒)
MAX_WAIT_SECONDS=21600  # 最长等待时间,默认6小时,超时则报警退出
MAX_RETRIES=3           # 单个库同步失败后的最大重试次数

# ============ 配置区结束 ============

YEAR=$(date +%Y)
BACKUP_ROOT="${BACKUP_BASE_DIR}/seafile-backup-${YEAR}"
LOG_FILE="${BACKUP_ROOT}/backup_log_$(date +%Y%m%d_%H%M%S).txt"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

fail() {
    log "❌ 错误: $*"
    cleanup_and_exit 1
}

cleanup_and_exit() {
    local exit_code=$1
    log "开始清理:断开所有已同步的库并停止 seaf-cli..."
    for REPO in "${REPOS[@]}"; do
        local target_dir="${BACKUP_ROOT}/${REPO}"
        if [ -d "$target_dir" ]; then
            seaf-cli desync -d "$target_dir" 2>/dev/null || true
        fi
    done
    seaf-cli stop 2>/dev/null || true
    log "清理完成,退出码: $exit_code"
    exit "$exit_code"
}

# 捕获中断信号(Ctrl+C、异常退出等),确保不会留下挂着的同步连接
trap 'log "收到中断信号"; cleanup_and_exit 1' INT TERM

# ------------ 预检查 ------------

if ! command -v seaf-cli &> /dev/null; then
    echo "错误: 未找到 seaf-cli,请先安装 (apt install seafile-cli 或 brew install seaf-cli)"
    exit 1
fi

if [ ! -d "$BACKUP_BASE_DIR" ]; then
    echo "错误: 移动硬盘路径不存在: $BACKUP_BASE_DIR"
    echo "请确认硬盘已挂载"
    exit 1
fi

mkdir -p "$BACKUP_ROOT"
touch "$LOG_FILE"

log "========================================="
log "Seafile 年度备份开始"
log "备份目录: $BACKUP_ROOT"
log "========================================="

# 密码运行时输入,不写死在脚本中
if [ -z "${SEAFILE_PASSWORD:-}" ]; then
    read -rsp "请输入 Seafile 密码: " SEAFILE_PASSWORD
    echo
fi

mkdir -p "$CONF_DIR"
seaf-cli init -d "$CONF_DIR" 2>/dev/null || true
seaf-cli start
sleep 2

# ------------ 轮询等待单个库同步完成 ------------
# 返回值: 0=成功, 1=超时, 2=同步出错
wait_for_sync() {
    local repo_id=$1
    local target_dir=$2
    local elapsed=0

    while [ "$elapsed" -lt "$MAX_WAIT_SECONDS" ]; do
        local status_output
        status_output=$(seaf-cli status 2>/dev/null || echo "")

        # 检查该库是否还在同步中
        if echo "$status_output" | grep -q "$repo_id"; then
            local repo_line
            repo_line=$(echo "$status_output" | grep "$repo_id" || true)

            if echo "$repo_line" | grep -qiE "synchronized|up to date|up-to-date"; then
                return 0
            elif echo "$repo_line" | grep -qiE "error|failed"; then
                log "  ⚠ 检测到同步错误状态: $repo_line"
                return 2
            fi
            # 否则认为仍在同步中(如 "syncing", "downloading" 等),继续等待
        else
            # seaf-cli status 中已找不到该库条目,通常表示同步完成或已完成初始化
            # 做一次额外确认:目录是否存在且非空
            if [ -d "$target_dir" ] && [ "$(ls -A "$target_dir" 2>/dev/null)" ]; then
                return 0
            fi
        fi

        sleep "$POLL_INTERVAL"
        elapsed=$((elapsed + POLL_INTERVAL))
        log "  ...同步中,已等待 ${elapsed}s / ${MAX_WAIT_SECONDS}s"
    done

    return 1  # 超时
}

# ------------ 主同步流程(带重试) ------------

FAILED_REPOS=()
SUCCESS_REPOS=()

for REPO in "${REPOS[@]}"; do
    TARGET_DIR="${BACKUP_ROOT}/${REPO}"
    log "-----------------------------------------"
    log ">>> 开始同步库: $REPO"
    log "    目标目录: $TARGET_DIR"

    ATTEMPT=1
    REPO_OK=false

    while [ "$ATTEMPT" -le "$MAX_RETRIES" ]; do
        log "  第 $ATTEMPT 次尝试..."

        if seaf-cli sync -l "$REPO" -s "$SERVER" -u "$USER" -p "$SEAFILE_PASSWORD" -d "$TARGET_DIR" 2>>"$LOG_FILE"; then

            wait_for_sync "$REPO" "$TARGET_DIR"
            RESULT=$?

            if [ "$RESULT" -eq 0 ]; then
                log "  ✅ 库 $REPO 同步成功"
                REPO_OK=true
                break
            elif [ "$RESULT" -eq 1 ]; then
                log "  ⏱ 库 $REPO 同步超时(超过 ${MAX_WAIT_SECONDS}s)"
            else
                log "  ⚠ 库 $REPO 同步过程中报错"
            fi
        else
            log "  ⚠ seaf-cli sync 命令执行失败"
        fi

        # 失败后先 desync 清理,再决定是否重试
        seaf-cli desync -d "$TARGET_DIR" 2>/dev/null || true
        ATTEMPT=$((ATTEMPT + 1))
        [ "$ATTEMPT" -le "$MAX_RETRIES" ] && sleep 10
    done

    if [ "$REPO_OK" = true ]; then
        SUCCESS_REPOS+=("$REPO")
        # 成功后立即断开,缩短联网窗口
        seaf-cli desync -d "$TARGET_DIR" 2>/dev/null || true
        log "  已断开 $REPO 的同步连接"
    else
        FAILED_REPOS+=("$REPO")
        log "  ❌ 库 $REPO$MAX_RETRIES 次尝试后仍然失败"
    fi
done

# ------------ 收尾 ------------

seaf-cli stop 2>/dev/null || true
unset SEAFILE_PASSWORD

log "========================================="
log "备份任务结束"
log "成功: ${#SUCCESS_REPOS[@]} 个库 (${SUCCESS_REPOS[*]:-})"
log "失败: ${#FAILED_REPOS[@]} 个库 (${FAILED_REPOS[*]:-})"
log "备份位置: $BACKUP_ROOT"
log "日志文件: $LOG_FILE"
log "========================================="

if [ "${#FAILED_REPOS[@]}" -gt 0 ]; then
    echo ""
    echo "⚠ 部分库备份失败,请检查日志: $LOG_FILE"
    exit 1
fi

echo ""
echo "✅ 全部备份成功完成"
exit 0

使用方法

1. 安装 seaf-cli

# Debian/Ubuntu
sudo apt install seafile-cli

# macOS
brew install seafile-client seaf-cli

2. 获取要备份的资料库 repo-id

seaf-cli list-remote -s https://your-seafile-server -u your@email.com

3. 修改脚本配置区

  • SERVERUSER:Seafile 服务器地址和账号
  • BACKUP_BASE_DIR:移动硬盘挂载路径(Linux 类似 /media/username/BackupDisk,macOS 是 /Volumes/BackupDisk
  • REPOS:填入上一步获取到的 repo-id 列表

4. 赋予执行权限并运行

chmod +x seafile_yearly_backup.sh
./seafile_yearly_backup.sh

密码会在运行时以隐式方式输入,不落盘。如果想跳过交互(比如接入 cron),可以提前 export SEAFILE_PASSWORD=xxx

小结

要点说明
别用虚拟盘做 rsync 备份源SeaDrive 的"影子文件"机制会让 rsync 备份不完整或效率低下
优先用官方同步工具seaf-cli / seafile-client 的同步模式本地就是真实文件
警惕默认的双向同步备份电脑上的误删/损坏可能被同步回服务器
低频备份用"快照式"而非长期挂载同步完立即 desync,缩短暴露窗口,离线时零风险
脚本要考虑异常情况超时保护、重试、信号捕获,避免"半成品"备份或悬挂状态

这套方案目前用在我自己的场景里:一台闲置电脑 + 移动硬盘,每年跑一次,跑完硬盘直接拔下离线保存。如果你的备份频率更高(比如每周/每天),更适合用**方案 A:只读同步(download-only)**长期挂载,而不是这种一次性快照方式,具体可以根据自己的风险偏好选择。