From 0c5c6040bc4ce0342e7adc28b69245da9ce42501 Mon Sep 17 00:00:00 2001 From: openclaw Date: Sat, 8 Aug 2026 01:14:19 +0800 Subject: [PATCH] feat: simplify production backups with SCP --- .env.example | 10 +++-- README.md | 40 +++++++++++++++++- compose.pro.yaml | 18 +++++++-- deploy/frp/frps.example.toml | 9 ----- deploy/postgres-backup.sh | 78 +++++++++++++++++++++++++++++++----- docs/内网穿透调试.md | 4 -- 6 files changed, 128 insertions(+), 31 deletions(-) delete mode 100644 deploy/frp/frps.example.toml diff --git a/.env.example b/.env.example index 1fc39c9..5a1021a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index a7bbb5f..e611752 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/compose.pro.yaml b/compose.pro.yaml index 2bf3cd4..7c7007c 100644 --- a/compose.pro.yaml +++ b/compose.pro.yaml @@ -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 diff --git a/deploy/frp/frps.example.toml b/deploy/frp/frps.example.toml deleted file mode 100644 index 4d24540..0000000 --- a/deploy/frp/frps.example.toml +++ /dev/null @@ -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" - diff --git a/deploy/postgres-backup.sh b/deploy/postgres-backup.sh index f83f832..6197c0a 100755 --- a/deploy/postgres-backup.sh +++ b/deploy/postgres-backup.sh @@ -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 diff --git a/docs/内网穿透调试.md b/docs/内网穿透调试.md index a8c5415..3067b93 100644 --- a/docs/内网穿透调试.md +++ b/docs/内网穿透调试.md @@ -19,10 +19,6 @@ - VPS 开放 `80`、`443` 和 `7000`。 - VPS 的 `frps` 配置了 HTTP/HTTPS vhost 端口。 -参考配置: - -- `deploy/frp/frps.example.toml` - ## 本机配置 调试模式不读取 `.env`,配置直接写在 `compose.dev.yaml`: