feat: simplify production backups with SCP

This commit is contained in:
openclaw 2026-08-08 01:14:19 +08:00
parent 99933da2c8
commit 0c5c6040bc
6 changed files with 128 additions and 31 deletions

View File

@ -19,8 +19,12 @@ TEST_MODE=false
POSTGRES_DB=cents
POSTGRES_USER=cents
POSTGRES_PASSWORD=change-me
# Host uid/gid used by the backup container when writing ./backups/db.
BACKUP_UID=1001
BACKUP_GID=1001
# Optional off-site backup over SCP. Leave BACKUP_SSH_HOST empty to disable.
# Each PostgreSQL dump is copied to the remote with its timestamped filename.
# The current host user's ~/.ssh is mounted read-only for key authentication.
BACKUP_SSH_HOST=
BACKUP_SSH_USER=backup
BACKUP_SSH_PORT=22
BACKUP_SSH_DIR=
APP_TIME_ZONE=Asia/Shanghai
EXCHANGE_RATE_API_URL=https://api.frankfurter.dev

View File

@ -85,9 +85,34 @@ docker compose --env-file .env -f compose.pro.yaml exec api \
## 生产数据库备份
`backup` 容器每天北京时间 `03:00` 将 PostgreSQL 备份到生产 clone 目录下的 `./backups/db`。备份容器使用宿主机普通用户运行,不会以 root 保存备份文件。备份采用 PostgreSQL custom format并在写入后校验超过 30 天的备份会自动删除。
`backup` 容器每天北京时间 `02:00` 使用 PostgreSQL custom format 执行一次逻辑备份,写入生产 clone 目录下的 `./backups/db`。备份先写入临时文件能够读取归档目录后再改为正式文件dump 权限为 `0600`,超过 30 天自动删除。
`BACKUP_UID``BACKUP_GID` 应设置为生产宿主机运行 Cents 的普通用户 UID/GID默认值为 `1001`
当前应用的业务数据全部存放在 PostgreSQL 中,因此不备份运行中的 Docker volume。证书可以重新签发代码由 Git 保存。若将来增加上传文件等数据库外的持久化数据,需要再单独备份对应目录。
备份成功后会更新 `./backups/.last-success`。容器启动后的前 26 小时是健康检查宽限期,此后若定时器未运行,或超过 30 小时没有完整成功的备份,`backup` 容器会变为 unhealthy。
`.env` 不随数据库 dump 备份,其中的部署参数应另行保存在密码管理器等安全位置。
### 远程备份(可选)
远程服务器地址配置在生产部署使用的 `.env` 中。配置后,每次本地备份成功都会通过 SCP 上传本次新 dump未配置 `BACKUP_SSH_HOST` 时只保留本地备份。
```text
BACKUP_SSH_HOST=backup.example.com # 置空则禁用远程备份
BACKUP_SSH_USER=backup # 建议使用只管理备份目录的非 root 用户
BACKUP_SSH_PORT=22
BACKUP_SSH_DIR=/home/backup/cents # 必须是非根绝对路径,不能包含空格
```
SCP 使用生产宿主机当前用户的 `~/.ssh`,该目录以只读方式挂载到容器。建议为备份创建专用 SSH 密钥,并通过 `~/.ssh/config` 为备份主机指定该密钥。身份验证强制使用 SSH key禁止密码登录。
首次启动备份容器前,先在宿主机手动连接一次,以核对并记录主机指纹:
```bash
ssh -p 22 backup@backup.example.com true
```
远端文件先以隐藏的临时文件上传,完成后再改为正式名称。远端 dump 同样保留 30 天;不会镜像删除本地目录中的其他内容。
查看调度和备份日志:
@ -107,3 +132,14 @@ docker compose --env-file .env -f compose.pro.yaml exec backup \
```text
./backups/db/cents-YYYYMMDD-HHMMSS.dump
```
建议在首次部署及之后偶尔将最新 dump 恢复到临时数据库,确认备份实际可用:
```bash
docker compose --env-file .env -f compose.pro.yaml exec db \
createdb -U cents cents_restore
docker compose --env-file .env -f compose.pro.yaml exec backup \
pg_restore --dbname=cents_restore /backups/db/cents-YYYYMMDD-HHMMSS.dump
docker compose --env-file .env -f compose.pro.yaml exec db \
dropdb -U cents cents_restore
```

View File

@ -35,27 +35,37 @@ services:
- app
backup:
image: postgres:17-alpine
image: cents-backup
build:
context: .
dockerfile_inline: |
FROM postgres:17-alpine
RUN apk add --no-cache openssh-client
restart: unless-stopped
user: "${BACKUP_UID:-1001}:${BACKUP_GID:-1001}"
environment:
TZ: Asia/Shanghai
PGHOST: db
PGDATABASE: ${POSTGRES_DB:-cents}
PGUSER: ${POSTGRES_USER:-cents}
PGPASSWORD: ${POSTGRES_PASSWORD}
BACKUP_SSH_HOST: ${BACKUP_SSH_HOST:-}
BACKUP_SSH_USER: ${BACKUP_SSH_USER:-backup}
BACKUP_SSH_PORT: ${BACKUP_SSH_PORT:-22}
BACKUP_SSH_DIR: ${BACKUP_SSH_DIR:-}
entrypoint: ["/usr/local/bin/cents-backup"]
volumes:
- ./backups/db:/backups
- ./backups:/backups
- ~/.ssh:/root/.ssh:ro
- ./deploy/postgres-backup.sh:/usr/local/bin/cents-backup:ro
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "pgrep crond >/dev/null"]
test: ["CMD", "/usr/local/bin/cents-backup", "health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 26h
networks:
- app

View File

@ -1,9 +0,0 @@
# Copy this to the public VPS and adapt it for frps.
bindPort = 7000
vhostHTTPPort = 80
vhostHTTPSPort = 443
# If your frps requires authentication, configure it on both frps and frpc.
# auth.method = "token"
# auth.token = "replace-with-a-secret-token"

View File

@ -3,13 +3,59 @@
set -eu
backup_dir=${BACKUP_DIR:-/backups}
ssh_key_dir=/root/.ssh
success_marker="$backup_dir/.last-success"
ssh_host=${BACKUP_SSH_HOST:-}
ssh_user=${BACKUP_SSH_USER:-backup}
ssh_port=${BACKUP_SSH_PORT:-22}
ssh_dir=${BACKUP_SSH_DIR:-}
ssh_opts="-o BatchMode=yes -o PasswordAuthentication=no -o StrictHostKeyChecking=yes"
copy_remote() {
target=$1
[ -n "$ssh_host" ] || return 0
[ -d "$ssh_key_dir" ] || {
printf 'SSH key directory %s not found\n' "$ssh_key_dir" >&2
return 1
}
case "$ssh_port" in
''|*[!0-9]*) printf 'BACKUP_SSH_PORT must be numeric\n' >&2; return 1 ;;
esac
case "$ssh_user" in
''|*[!A-Za-z0-9_.-]*) printf 'BACKUP_SSH_USER contains unsupported characters\n' >&2; return 1 ;;
esac
case "$ssh_dir" in
/|''|*[!A-Za-z0-9_./-]*)
printf 'BACKUP_SSH_DIR must be a non-root absolute path without spaces\n' >&2
return 1
;;
/*) ;;
*)
printf 'BACKUP_SSH_DIR must be an absolute path\n' >&2
return 1
;;
esac
filename=$(basename "$target")
remote="$ssh_user@$ssh_host"
remote_target="$ssh_dir/$filename"
remote_temporary="$ssh_dir/.$filename.tmp"
ssh $ssh_opts -p "$ssh_port" "$remote" "mkdir -p '$ssh_dir'" >/dev/null
scp $ssh_opts -P "$ssh_port" "$target" "$remote:$remote_temporary"
ssh $ssh_opts -p "$ssh_port" "$remote" \
"mv '$remote_temporary' '$remote_target' && find '$ssh_dir' -maxdepth 1 -type f -name 'cents-*.dump' -mtime +29 -delete"
printf 'Backup copied to %s:%s\n' "$remote" "$remote_target"
}
run_backup() {
umask 077
mkdir -p "$backup_dir"
mkdir -p "$backup_dir/db"
timestamp=$(date +%Y%m%d-%H%M%S)
target="$backup_dir/cents-$timestamp.dump"
target="$backup_dir/db/cents-$timestamp.dump"
temporary="$target.tmp"
trap 'rm -f "$temporary"' EXIT INT TERM
@ -18,8 +64,21 @@ run_backup() {
mv "$temporary" "$target"
trap - EXIT INT TERM
find "$backup_dir" -maxdepth 1 -type f -name 'cents-*.dump' -mtime +29 -delete
printf 'Backup created: %s\n' "$target"
copy_remote "$target"
find "$backup_dir/db" -maxdepth 1 -type f -name 'cents-*.dump' -mtime +29 -delete
touch "$success_marker"
printf 'Backup completed successfully\n'
}
check_health() {
pgrep crond >/dev/null || return 1
[ -f "$success_marker" ] || return 1
now=$(date +%s)
last_success=$(stat -c %Y "$success_marker")
age=$((now - last_success))
[ "$age" -ge 0 ] && [ "$age" -le 108000 ]
}
case "${1:-cron}" in
@ -27,13 +86,14 @@ case "${1:-cron}" in
run_backup
;;
cron)
cron_dir=/tmp/cents-crontabs
mkdir -p "$cron_dir"
printf '%s\n' \
'0 3 * * * /usr/local/bin/cents-backup run >> /proc/1/fd/1 2>> /proc/1/fd/2' \
> "$cron_dir/$(id -u)"
printf 'Backup scheduler started: daily at 03:00 %s, retaining 30 days\n' "${TZ:-local time}"
exec crond -f -l 2 -c "$cron_dir"
'0 2 * * * /usr/local/bin/cents-backup run >> /proc/1/fd/1 2>> /proc/1/fd/2' \
> /etc/crontabs/root
printf 'Backup scheduler started: daily at 02:00 %s, retaining 30 days\n' "${TZ:-local time}"
exec crond -f -l 2
;;
health)
check_health
;;
*)
printf 'Unknown command: %s\n' "$1" >&2

View File

@ -19,10 +19,6 @@
- VPS 开放 `80`、`443` 和 `7000`
- VPS 的 `frps` 配置了 HTTP/HTTPS vhost 端口。
参考配置:
- `deploy/frp/frps.example.toml`
## 本机配置
调试模式不读取 `.env`,配置直接写在 `compose.dev.yaml`