tva
← 洞察

自托管服务的灾难恢复:我们的备份策略

自托管赋予您托管服务无法提供的控制权:您可以选择硬件、网络、数据驻留位置和成本结构。但实际上,这种控制也伴随着一项通常由托管服务在背后承担的责任:出了问题,就得完全由您解决。对于在单台服务器上运行多个生产服务——Supabase、CRM、DAM 和 Git 服务器——的环境,备份与恢复策略不是次要问题。它决定了一次事件能否恢复,还是会造成永久数据丢失。

本文记录了我们在自托管生产环境中使用的备份策略。涉及的服务包括 Supabase 应用栈(以 PostgreSQL 作为主要数据存储)、自托管 CRM(Twenty,同样运行 PostgreSQL)、Gitea Git 服务器和数字资产管理系统。它们全部以 Docker 容器运行在同一台 Hetzner VPS 上。备份策略必须覆盖所有服务,同时避免造成不可接受的高负载时段,也不能引入复杂的编排依赖。

为什么单服务器环境的风险更高

单服务器环境没有内置冗余。没有备用副本,没有跨可用区故障转移,也没有通过健康检查自动触发的数据库恢复。服务器一旦故障——无论原因是硬件损坏、升级失误、存储问题还是安全事件——唯一的恢复途径就是备份。如果备份不完整、不够新,或未经测试,恢复能力也会相应受限。

这种威胁模型与托管云服务不同:在托管云服务中,基础设施提供商会处理故障转移,因此硬件故障通常对用户不可见。而在单台 VPS 上,您必须明确规划如何应对那些被托管服务屏蔽掉的故障模式。规划本身并不特别复杂,但必须在事件发生之前完成,而不是等出事时才做。

哪些数据需要备份

每项服务的数据特点都不同。了解哪些数据无法替代,以及它们存放在哪里,是设计备份策略的前提。

Supabase 将数据存储在由 Supabase 应用栈管理的 PostgreSQL 实例中。数据库包含所有应用数据、用户记录,以及 Supabase 自身的认证和存储元数据。存储桶中的文件上传内容保存在磁盘上,需要与数据库备份分开处理。

Twenty CRM 使用自己的 PostgreSQL 实例,与 Supabase 的实例相互独立。它包含联系人记录、商机数据和工作流状态。这个数据库的数据量较小,但高度敏感——丢失 CRM 数据对运营的影响,比丢失可以重新生成的应用数据更大。

Gitea 将仓库存储在磁盘上,采用裸 Git 对象的形式。原则上,仓库数据可以从开发人员的工作站重建,因为完整的非浅克隆包含已获取的历史记录,但不包含 Git LFS 对象或从未获取的引用;但问题跟踪数据、拉取请求评论和团队配置只存在于 Gitea 的数据库中,不包含在任何克隆里。因此,Git 对象和 Gitea 数据库都需要备份。

DAM(数字资产管理)存储原始文件、处理后的衍生文件和元数据。原始文件是无法替代的部分,衍生文件则可以重新生成。元数据保存在数据库中,用于记录文件关系、标签和使用权。

pg_dump 编排

PostgreSQL 的 pg_dump 是进行数据库逻辑备份的合适工具。它将数据库导出为 SQL 形式,可以恢复到任何兼容的 PostgreSQL 版本,因此比物理备份更便于迁移;物理备份要求 PostgreSQL 版本和二进制布局相同。

每个 PostgreSQL 实例的备份脚本都遵循相同模式:

#!/usr/bin/env bash
set -euo pipefail

TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DB_NAME="$1"
CONTAINER_NAME="$2"
BACKUP_DIR="/opt/backups/postgres"
OUTPUT_FILE="${BACKUP_DIR}/${DB_NAME}_${TIMESTAMP}.sql.gz"

mkdir -p "${BACKUP_DIR}"

docker exec "${CONTAINER_NAME}"   pg_dump -U postgres -d "${DB_NAME}"   | gzip -9 > "${OUTPUT_FILE}"

echo "Backup complete: ${OUTPUT_FILE} ($(du -sh "${OUTPUT_FILE}" | cut -f1))"

脚本开头的 set -euo pipefail 很重要:任何命令失败时,包括管道中的命令失败,脚本都会立即退出。如果没有 pipefail,失败的 pg_dump 后面接着成功的 gzip,就会留下一个空的或被截断的压缩文件(错误消息发送到 stderr,而不是写入文件),但脚本仍会报告成功;问题直到恢复时才会暴露。

输出直接通过 gzip -9 在导出过程中压缩,而不是事后再压缩。这能减少磁盘占用,避免先将未压缩的导出文件写入磁盘;对于可用空间需要严格管理的服务器,这一点很重要。

错峰调度

所有备份任务同时运行,会形成磁盘 I/O 争用时段,可能降低被备份服务的性能。已经承受负载的 Supabase 实例,不应该再与 pg_dump 争抢磁盘 I/O。解决方法是错峰调度——让每个备份任务在不同时间开始,并留出足够间隔,使前一个任务能在下一个任务开始前完成。

我们使用的调度计划,以 cron 条目表示如下:

# Supabase PostgreSQL backup — 02:00 daily
0 2 * * * /opt/scripts/pg-backup.sh supabase supabase-db

# Twenty CRM PostgreSQL backup — 02:30 daily
30 2 * * * /opt/scripts/pg-backup.sh twenty twenty-db

# Gitea database backup — 03:00 daily
0 3 * * * /opt/scripts/pg-backup.sh gitea gitea-db

# DAM database backup — 03:30 daily
30 3 * * * /opt/scripts/pg-backup.sh dam dam-db

# Gitea repository objects — 04:00 daily
0 4 * * * /opt/scripts/git-objects-backup.sh

# Storage files (Supabase + DAM originals) — 04:30 daily
30 4 * * * /opt/scripts/files-backup.sh

任务之间间隔 30 分钟,是较为保守的安排——对于这种规模的数据库,多数数据库备份几分钟就能完成。但间隔也考虑了每次备份之后的上传步骤:压缩文件会在下一个任务开始前上传至对象存储,避免本地磁盘同时积压多天的备份。

对象存储上传与保留策略

如果备份只存放在它所保护的服务所在的同一台服务器上,那就不能算备份——它只是快照,会在毁掉原始数据的同一次事件中一起丢失。每份备份都必须上传到独立的存储位置,备份过程才算完成。

我们使用一家兼容 S3 的对象存储提供商,与 VPS 提供商相互独立,并使用 rclone 上传。它会自动处理重试和传输后的校验和验证:

rclone copy "${OUTPUT_FILE}" "backup-remote:tva-backups/postgres/${DB_NAME}/"   --checksum   --transfers 1   --log-level INFO

--checksum 标志使 rclone 按哈希值和大小比较文件(兼容 S3 的远程存储使用 MD5),而不是按修改时间和大小比较。结合 rclone 默认执行的传输后哈希校验,只要远程存储保存了可用的哈希值,就能检测传输过程中的数据损坏。

保留策略由一个每周运行的独立清理任务执行。具体策略是:每日备份保留七天,每周备份(由每周任务重命名的星期日每日备份)保留四周,每月备份(每月第一个星期日的备份)保留六个月。这为发现不易立即察觉的数据丢失留出了合理窗口——三周前出现的损坏记录,仍可从每周备份中恢复。

保留期清理使用 rclone delete,按修改时间进行筛选(--min-age),而不是仅依赖文件名。当文件命名约定不完全一致时,这样更可靠:

rclone delete "backup-remote:tva-backups/postgres/"   --min-age 7d   --include "*_daily_*"   --dry-run

测试清理任务时使用 --dry-run 标志。只有确认筛选模式恰好匹配应删除的文件后,才能移除该标志。--include 模式假定每日备份的文件名包含 _daily_ 标记;但上面的备份脚本将文件命名为 DB_TIMESTAMP.sql.gz,因此需要先调整匹配模式或文件名,使两者一致。

恢复测试

未经测试的备份,只能算一个假设。要知道备份是否能恢复,唯一的方法就是实际恢复。我们每月使用与生产应用栈隔离的临时 Docker 容器,对每个数据库进行一次恢复测试:

#!/usr/bin/env bash
set -euo pipefail

BACKUP_FILE="$1"
ROLES_FILE="${2:-}"
TEST_CONTAINER="restore-test-$(date +%s)"

# Start a temporary PostgreSQL container
docker run -d   --name "${TEST_CONTAINER}"   -e POSTGRES_PASSWORD=testpass   -e POSTGRES_DB=testdb   postgres:15-alpine

# Remove the container on exit, also when a step fails
trap 'docker rm -f "${TEST_CONTAINER}" > /dev/null' EXIT

# Wait for PostgreSQL to be ready
for _ in $(seq 1 30); do
  docker exec "${TEST_CONTAINER}" pg_isready -h 127.0.0.1 -U postgres -q && break
  sleep 1
done

# pg_dump omits roles: restore them first from a pg_dumpall --roles-only file (optional 2nd argument)
if [ -n "${ROLES_FILE}" ]; then
  docker exec -i "${TEST_CONTAINER}"   psql -U postgres -d testdb -q < "${ROLES_FILE}"
fi

# Restore the backup
zcat "${BACKUP_FILE}" | docker exec -i "${TEST_CONTAINER}"   psql -U postgres -d testdb -v ON_ERROR_STOP=1

# Verify row counts match expectations
docker exec "${TEST_CONTAINER}"   psql -U postgres -d testdb   -c "SELECT schemaname, relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"

行数核验并不是全面验证——它检查主要表的估算行数是否合理(n_live_tup 是统计估算值,并非精确计数),而不是逐行确认数据正确。更完整的测试应该针对恢复后的数据库运行应用自身的健康检查。但行数检查可以发现最常见的故障模式:备份被截断、恢复失败导致数据库为空,或版本不兼容造成静默数据丢失。恢复时启用了 ON_ERROR_STOP=1,因此第一条失败的语句就会使测试失败,而 trap 无论结果如何都会删除容器。pg_dump 不包含角色:如果转储引用了 postgres 以外的角色,请将源服务器上 pg_dumpall --roles-only 的输出作为第二个参数传入(这一步中针对已存在的 postgres 角色报错属于预期情况)。测试镜像应与源库的 PostgreSQL 主版本一致。Supabase 的转储还引用了普通 postgres 镜像中没有的 Supabase 专用 schema 和扩展,因此需要按照 Supabase 自身的恢复流程,恢复到一个 Supabase 实例中进行测试。

实际恢复过程是什么样的

备份策略的价值,取决于使用它的恢复流程。恢复运维手册以 Markdown 文档的形式保存在服务器配置代码库中,记录了在一台全新 VPS 上恢复每项服务的确切操作顺序。

整台服务器故障后的恢复顺序是:创建新 VPS,从配置代码库安装 Docker 和应用栈,从对象存储下载最新备份文件,按依赖顺序恢复各数据库(先 Supabase,再 CRM,然后是其他数据库),恢复文件存储,更新 DNS,最后验证每项服务。如果所有备份都足够新,并且严格按照运维手册执行、无需临场调整,预计完整恢复耗时不到两小时。

这个估算中的关键是“严格按手册执行、无需临场调整”。运维手册应该足够具体,让一个从未接触过该系统的人也能成功执行。命令应该能够直接复制粘贴,而不是只用文字描述。每一步都应该有验证检查。在实际事件的压力下,恢复运维手册中的歧义会不断放大风险。

我们每年在预发布环境中执行一次完整恢复,以测试运维手册。每次测试通常都能发现至少一项自上次编写手册以来发生变化的步骤——Docker 镜像版本发生变动、配置键被重命名,或新增了环境变量。在计划好的测试中发现这些问题,远好于在实际恢复时才发现。

相关洞察

延伸阅读