Paperless-ngx 云端备份方案:从定时任务到 webhook 触发
我将所有重要的(以及许多并不那么重要的)文档都存储在我的家用实验室中运行的 无纸化-ngx 实例里。鉴于这些文件的重要性,我希望能为它们再增加一份备份,同时保留相应的元数据。
虽然将文件存储在云端并非完全理想,但这样一来,如果因各种原因无法直接访问文件,我依然可以轻松地获取这些文档。
请跳转到前面部分,查看 计划备份 或 换钱时的后备 的配置信息。
我选择使用 Dropbox 作为备份工具,因为多年前发送邀请时就已拥有充足的空间,并且该服务会自动同步我的文件至笔记本电脑,这样我也能在笔记本上获得额外的备份。
下面介绍的方法同样可以轻松应用于其他云存储解决方案。
最初,我想到的解决办法是:在家中实验室中运行 Dropbox 守护进程,并创建一个符号链接,指向 Paperless 存储文件的路径。然而,我很快发现,这一方案存在几个问题:
- Dropbox 守护进程不支持 Arm 架构,而我的家用实验室则是一台树莓派。
- Paperless 并未以一种便于备份的方式存储元数据。
为了解决第 1 个问题,我花了一些时间研究了适用于树莓派的 Dropbox 同步脚本,随后自己编写了一段脚本来实现同样的功能,不过我对这些脚本都不太满意。
经过一番研究,我突然想起 Rclone 这一工具——它比这些脚本都要强大得多,而且能够实现单向同步,这正是我所需的最佳备份方式。然而,这也带来了一个新的问题:Rclone 并不是以守护进程的形式运行,而是需要通过某种方式触发才能完成同步。
要解决第 2 个问题,我选择了 无纸化文档导出脚本。这是 Paperless 推荐的备份解决方案,它能完整保存所有元数据,并且在需要恢复备份时,还提供了一套对应的导入脚本。
几乎完美!不过,它也和 Rclone 一样,需要通过某种方式被触发。
现在,我们已经有了两段脚本,需要按顺序运行,且频率要足够高,以确保我的备份始终处于最新状态。我想,现在也是时候提一下:我正在使用 Docker 运行 Paperless,并打算继续沿用提供的镜像,因此备份任务应该由我的 Docker Compose 文件来完成。
定时备份
简单的定时计划是进行备份的经典方式,这也是我最初的选择。我使用官方的 Rclone Docker 镜像和 奥菲莉娅 来触发文档导出操作,然后通过 Rclone 进行同步。
下面是一个简化的 Docker Compose 文件,其中加入了每小时一次的备份任务。完整的版本可以在 给你 中找到,不过如果你打算使用这个方案,我建议以 来自Paperless的更现代的写作文件 为基础进行开发。
services:
broker: ...
db: ...
webserver:
...
volumes:
- export:/usr/src/paperless/export
...
labels:
ofelia.enabled: "true"
ofelia.job-exec.export-job.schedule: "@hourly"
ofelia.job-exec.export-job.command: "document_exporter --no-thumbnail ../export"
rclone:
container_name: rclone
image: rclone/rclone:latest
profiles: ["rclone"]
volumes:
- export:/data
- ./rclone/config:/root/.config/rclone
labels:
ofelia.enabled: "true"
command: "copy /data "
ofelia:
image: mcuadros/ofelia:latest
restart: unless-stopped
depends_on:
- webserver
command: daemon --docker
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
# 备份任务将在每小时的 5 分钟后执行,以便给导出操作预留充足的时间
ofelia.job-run.backup.schedule: "0 5 * * * *"
ofelia.job-run.backup.container: "rclone"
volumes:
export:
要设置 Rclone 远程仓库,请运行以下命令并按照向导步骤操作:
$ docker compose run -ti --entrypoint="rclone config" rclone
请将“dropbox:paperless”替换为你为远程仓库指定的名称,以及你希望备份文件存储的目录。我使用的是 dropbox:paperless。不过,Dropbox 并非唯一的选择;Rclone 可以与众多不同的云存储服务进行同步,具体详情可在 他们的文档页面 中查阅。
与其在文档导出器完成工作后才启动 Rclone,不如让其在 5 分钟后自动运行,这种方式效果相当不错。我使用这一设置大约 6 个月,期间从未遇到过任何问题。
不过,我在 Paperless 中更新文档的频率其实并不高,大概一周一两次而已,因此感觉每小时运行一次备份似乎有些多余。但另一方面,我又不想让文件长时间处于未备份的状态。
理想情况下,备份应该在我在 Paperless 中真正执行某些操作时自动触发。
变更时备份
Paperless 提供了在文档新增或更新时发送 Webhook 的功能,这正是他们 工作流程功能 的一部分。因此,我的下一个想法是利用这一功能,代替每小时触发一次文档导出与同步任务。
为此,我创建了 漂浮。其核心理念是:为 Ofelia 提供类似的功能,只不过通过 Webhook 来触发,而非按计划执行。
services:
broker: ...
db: ...
webserver:
...
volumes:
- export:/usr/src/paperless/export
...
rclone:
container_name: rclone
image: rclone/rclone:latest
profiles: ["rclone"]
volumes:
- export:/data
- ./rclone/config:/config/rclone
command: "copy /data "
float:
container_name: float
image: ghcr.io/sams96/float:latest
ports:
- "41232:41232"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
FLOAT_CMD: "docker exec -d paperless-webserver document_exporter /usr/src/paperless/export && docker start rclone"
volumes:
export:
同样需要使用 如上所述 设置 Rclone 环境,接下来,你还需要在 Paperless 中创建一个工作流,用于发送 Webhook。下图中附上了我所使用的设置截图。
Float 会对请求进行去抖动处理,以避免每次变更时都触发备份。默认的去抖动时间为 15 分钟,可以通过环境变量 FLOAT_DEBOUNCE_TIME 来进行配置,该变量采用 Go 时间包中的时间格式字符串,被指定 也正是基于此设计:
时间格式字符串由若干个十进制数字组成,可带小数部分及单位后缀,例如 “300ms”、“-1.5h” 或 “2h45m”。有效的时间单位包括 “ns”、“us”(或 “µs”)、“ms”、“s”、“m”、“h”。

在 Float 中
Float 的源代码非常短小,因此我直接将其完整地收录在下方。相比使用 Ofelia 一样的 Docker API 和标签,我决定直接传入一条运行命令,并将容器基于 Docker 基础镜像 进行构建,这样容器就能直接访问 Docker。
不过,我认为 Ofelia 的标签化配置要更加优雅,所以如果我有更多动力来改进 Float 的复制功能,那无疑是我想要实现的改进之一。此外,我还希望 Float 能够支持多条命令,并通过独立的 Webhook 和去抖动计时器来处理任务,不过目前我还没有真正用到这些功能。
package main
import (
"log"
"net/http"
"os"
"os/exec"
"sync"
"time"
)
var debounceDefault = 15 * time.Minute
func main() {
debounceStr := os.Getenv("FLOAT_DEBOUNCE_TIME")
debounceDur, err := time.ParseDuration(debounceStr)
if err != nil {
log.Println("未能解析去抖动时间;使用默认值:", debounceDefault.String())
debounceDur = debounceDefault
}
c := os.Getenv("FLOAT_CMD")
debounced := debouncer(debounceDur, func() {
cmd := exec.Command("/bin/sh", "-c", c)
cmd.Stdout, cmd.Stderr = log.Writer(), log.Writer()
err := cmd.Run()
if err != nil {
log.Println(err)
}
})
log.Println("float 已启动,去抖动时间:", debounceDur.String(), "命令:", c)
log.Fatal(http.ListenAndServe("0.0.0.0:41232",
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println("收到请求")
debounced()
w.WriteHeader(http.StatusNoContent)
})),
)
}
// 感谢 https://github.com/bep/debounce 的贡献
func debouncer(after time.Duration, f func()) func() {
d := &struct {
mu sync.Mutex
after time.Duration
timer *time.Timer
}{
after: after,
}
return func() {
d.mu.Lock()
defer d.mu.Unlock()
if d.timer != nil {
d.timer.Reset(d.after)
return
}
d.timer = time.AfterFunc(d.after, f)
}
}