Paperless-ngx 云端备份方案:从定时任务到 webhook 触发

我将所有重要的(以及许多并不那么重要的)文档都存储在我的家用实验室中运行的 无纸化-ngx 实例里。鉴于这些文件的重要性,我希望能为它们再增加一份备份,同时保留相应的元数据。

虽然将文件存储在云端并非完全理想,但这样一来,如果因各种原因无法直接访问文件,我依然可以轻松地获取这些文档。

请跳转到前面部分,查看 计划备份 或 换钱时的后备 的配置信息。

我选择使用 Dropbox 作为备份工具,因为多年前发送邀请时就已拥有充足的空间,并且该服务会自动同步我的文件至笔记本电脑,这样我也能在笔记本上获得额外的备份。

下面介绍的方法同样可以轻松应用于其他云存储解决方案。

最初,我想到的解决办法是:在家中实验室中运行 Dropbox 守护进程,并创建一个符号链接,指向 Paperless 存储文件的路径。然而,我很快发现,这一方案存在几个问题:

  1. Dropbox 守护进程不支持 Arm 架构,而我的家用实验室则是一台树莓派。
  2. 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”。
Screenshot of paperless edit workflow screen, with triggers on “Document Added” and “Document Updated” both with filename filters of “*” and content matching disabled. There’s also a webhook action with the url “http://float:41232”

在 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)
	}
}
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论