在容器化的 203 主机上跑过 Gitea 后,我一直想给本地数据找个稳定的网盘落点。选了 OpenList 挂 115 网盘,配一套定时备份脚本。整个方案最后跑通了,但中间的坑比预期多得多——WebDAV 权限、rclone 对 115 的兼容、115 写入限流、伪目录陷阱、同路径冷却窗口。这篇文章把完整方案和踩坑处理一次写清楚。

1. 总体方案

1.1 架构

数据从本地 203 主机出发,经 OpenList 作为中转,落到 115 网盘。备份脚本做两件事:把 gitea 和 qwenpaw 的数据打成 tar 包,再通过 WebDAV PUT 推到 115。

flowchart LR
  src[203 本地目录] --> tar[打 tar 包]
  tar --> curl[WebDAV PUT]
  curl --> ol[OpenList 容器:5244]
  ol --> webdav[WebDAV /dav/]
  webdav --> api[115 Open API]
  api --> netdisk[115 网盘]

为什么选 WebDAV 而不是直接用 115 SDK:OpenList 已经把 WebDAV 暴露成文件系统,备份脚本只需要一套通用的 HTTP 客户端就能覆盖所有网盘,不依赖 115 的逆向接口。

1.2 备份策略

服务 模式 保留
gitea tar 归档 7 份轮转
qwenpaw/working tar 归档 7 份轮转
qwenpaw/secret tar 归档 7 份轮转
qwenpaw/backups 镜像同步 与源一致

前三个是每日打包上传,远端按 服务_日期.tar.gz 命名,超过 7 份自动删旧的。第四个 qwenpaw/backups 里放的是平台升级导出包,本身不频繁变化,用镜像同步保持远端和源完全一致。

2. 部署 OpenList

2.1 环境准备

203 主机已有 docker,镜像源走自建脚本,国内可达。

services:
  openlist:
    image: openlistteam/openlist:latest
    container_name: openlist
    restart: always
    user: "0:0"
    environment:
      - UMASK=022
      - TZ=Asia/Shanghai
    ports:
      - "5244:5244"
    volumes:
      - /data/openlist:/opt/openlist/data

拉镜像遇到第一个坑:默认镜像源 docker.1ms.run 对这个镜像会卡死挂起,换 dockerproxy.net 一次成功。

2.2 115 网盘挂载

登录 http://192.168.0.203:5244,进管理页(/@manage)→ 存储 → 添加存储,选 115 Open 驱动。

访问令牌和刷新令牌需要从 https://api.oplist.org 获取:下拉选「115 验证网盘」,勾选使用内置参数,客户端 ID 和应用秘钥留空,点「获取 Token」用 115 App 扫码授权。拿到的两个令牌分别填入访问令牌和刷新令牌字段,根文件夹 ID 填 0 表示 115 根目录。

⚠️ 同一账号在同一应用最多取 2 次刷新令牌,第 3 次会让最早那个失效。令牌等于 115 账号的钥匙,泄漏了去 115 网页端「设备登录管理」解除授权。

2.3 WebDAV 权限坑

115 挂载成功,列目录能看到文件,但用 WebDAV 写操作一律 403。

根因:OpenList 新版默认给 admin 的权限位里没有「WebDAV 管理」,只有「WebDAV 读取」。

修复方法二选一:

  • 管理 UI:管理 → 用户 → admin → 权限 → 勾选「WebDAV 管理」
  • API 一次性改:把权限位加 512,例如 29183 → 29695
curl -s -X POST http://127.0.0.1:5244/api/admin/user/update \
  -H "Authorization: $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"id":1,"username":"admin","password":"","base_path":"/","role":2,"disabled":false,"permission":29695}'

判定点:原生 API /api/fs/mkdir 能成功说明令牌没问题,WebDAV 403 才是权限位的事。

3. 备份脚本设计

3.1 为什么最终用 curl 而不是 rclone

脚本第一版用 rclone copyto 上传 tar 包。本地验证全过,一上 115 就 404。排查了一整圈才发现:

  • rclone mkdir 在 115 Open API 下建的是伪目录(0 字节文件),WebDAV 不认
  • rclone copyto 遇到伪目录就 404
  • rclone sync 对 115 伪目录也 404
  • 115 空目录本身不可见,curl 删了同名文件后目录消失,rclone 又 404
  • 115 不支持覆盖同名文件,第二次 copy 同名文件 404

而 curl WebDAV PUT 能自动创建中间目录、能覆盖同名文件、HTTP 状态码清晰。curl 直传是唯一稳定方案。

3.2 脚本结构

# 任务表:服务 -> "远端路径|源路径|模式"
declare -A JOBS=(
  [gitea]="gitea|/data/gitea|tar"
  [qwenpaw-working]="qwenpaw/working|/opt/qwenpaw/working|tar"
  [qwenpaw-secret]="qwenpaw/secret|/opt/qwenpaw/working.secret|tar"
  [qwenpaw-backups]="qwenpaw/backups|/opt/qwenpaw/working.backups|sync"
)

支持无参全量、指定单服务、多个服务,未知服务直接拒绝。

/opt/openlist/backup-to-115.sh                    # 全量
/opt/openlist/backup-to-115.sh gitea              # 只备 gitea
/opt/openlist/backup-to-115.sh gitea qwenpaw-working  # 指定多个

tar 模式的核心是打包 + curl PUT + 校验:

tar czf "$tarball" -C "$(dirname "$src")" "$(basename "$src")" 2>>"$LOG"
rc=$?
[ "$rc" -gt 1 ] && { log "打包失败 (tar rc=$rc)"; return 1; }
[ "$rc" -eq 1 ] && log "打包期间源目录有写入,归档仍可用"

http_code=$(curl -s -m 300 -u "$OL_USER:$OL_PASS" \
  -T "$tarball" "http://127.0.0.1:5244/dav/$BASE/$dest/$fname" \
  -w "%{http_code}" -o /dev/null)

校验用 WebDAV PROPFIND 读 Content-Length,确认远端文件大小与本地一致:

curl -s -X PROPFIND "http://127.0.0.1:5244/dav/$BASE/$dest/$fname" \
  -H "Depth: 0" | grep -oP '(?<=<D:getcontentlength>)[0-9]+'

镜像同步模式(qwenpaw-backups)用 curl 逐文件 PUT,保持远端和源逐文件一致。

3.3 失败重试与服务间隔

115 同路径短时间内写入会 404,脚本做了两层缓解:

  • 上传失败且是 404 时,等 60 秒重试一次,共 2 轮
  • 多服务时,非最后一个服务完成后等 30 秒再进下一个,降低同令牌连续写入压力

3.4 空 targets 防护

调试阶段发现一个隐蔽 bug:无参执行时任务表里的关联数组在 bash 下没展开,脚本静默空跑、fail=0。加了防护:# targets == 0 直接 fail=1,避免静默漏备份。

3.5 本地验证纪律

脚本和策略变更的验证一律在本地做——用 rclone 的 local remote 指向 /tmp/rclone-localtest 跑通全部逻辑,包括 tar 上传、镜像同步、轮转、校验、参数化分发。本地全绿后才动 115。115 的写入能力已由首次全量备份证明,不重复消耗它的额度与带宽。

4. 踩坑记录

4.1 115 写入限流

症状:rclone/curl 写入同一路径,隔 60 秒重试仍 404,隔 5 分钟才恢复。

根因:115 对同令牌的同一路径写入有冷却窗口,比 120 秒更长。今天密集测试时新令牌额度被打光,脚本 3 轮 × 120 秒全 404,陷入死循环。

处理:把重试间隔从 120 秒改回 60 秒、轮数从 3 减到 2,避免死等;服务间加 30 秒间隔;手动触发改为只在 cron 窗口跑。

4.2 rclone 的 readonly 变量问题

症状RCLONE=/usr/local/bin/rclone 定义成 readonly 变量,脚本里 $RCLONE copyto ... 返回 rc=1 且报 404;用 /usr/local/bin/rclone 字面路径就成功。

根因:203 的 CentOS 7 上,sudo 环境下 readonly 变量引用的 rclone 行为异常。

处理:所有 rclone 调用改用字面路径,最终因 115 兼容性问题整块弃用了 rclone 上传,但这个问题值得记录。

4.3 115 伪目录陷阱

症状:用 rclone mkdir 创建的目录,rclone 能列,但 WebDAV PUT 进去就 404。

根因:115 Open API 的目录实现与 WebDAV 不一致,mkdir 建的是 0 字节文件而非真正目录。curl PUT 自动建目录时才会创建真正的 WebDAV 目录。

处理:目录创建改由 curl PUT 自动完成,脚本不再主动 mkdir。

4.4 路径拼接遗漏

症状:curl 上传返回 201,但 115 里找不到文件,rclone 列目录也看不到。

根因:curl URL 少拼了 备份/home-203/ 这一段,文件写到了 115 根目录。

处理:统一用 $BASE 常量拼装路径,dav/$BASE/$dest/$fname,避免每处手拼。

4.5 后台启动被 ssh 会话关闭连带杀死

症状:setsid nohup 启动的备份脚本,等会儿看发现根本没跑。

根因:把部署命令和后台启动写在同一条 ssh 链里,& 把整条链丢进后台,会话一断全没了。

处理:部署和启动分离到两个 ssh 调用;或者用 setsid ... < /dev/null 确保 stdin 已断开。

5. 最终交付

  • 脚本 /opt/openlist/backup-to-115.sh,700 权限,root 运行
  • cron 任务 /etc/cron.d/backup-to-115:每天 03:00 全量执行
  • 日志 /var/log/backup-to-115.log,超 10MB 自动截断保留末尾 2000 行
  • 远端目录 115:/备份/home-203/{gitea/, qwenpaw/working|secret|backups/}

⚠️ 改 OpenList 的 admin 密码必须同步更新 203 上的 rclone 配置(/root/.config/rclone/rclone.conf),否则备份会静默失败。建议给备份单独建一个受限账号,只开备份目录的 WebDAV 权限,更稳。