feat: simplify production backups with SCP
This commit is contained in:
parent
99933da2c8
commit
0c5c6040bc
10
.env.example
10
.env.example
|
|
@ -19,8 +19,12 @@ TEST_MODE=false
|
||||||
POSTGRES_DB=cents
|
POSTGRES_DB=cents
|
||||||
POSTGRES_USER=cents
|
POSTGRES_USER=cents
|
||||||
POSTGRES_PASSWORD=change-me
|
POSTGRES_PASSWORD=change-me
|
||||||
# Host uid/gid used by the backup container when writing ./backups/db.
|
# Optional off-site backup over SCP. Leave BACKUP_SSH_HOST empty to disable.
|
||||||
BACKUP_UID=1001
|
# Each PostgreSQL dump is copied to the remote with its timestamped filename.
|
||||||
BACKUP_GID=1001
|
# 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
|
APP_TIME_ZONE=Asia/Shanghai
|
||||||
EXCHANGE_RATE_API_URL=https://api.frankfurter.dev
|
EXCHANGE_RATE_API_URL=https://api.frankfurter.dev
|
||||||
|
|
|
||||||
40
README.md
40
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
|
```text
|
||||||
./backups/db/cents-YYYYMMDD-HHMMSS.dump
|
./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
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -35,27 +35,37 @@ services:
|
||||||
- app
|
- app
|
||||||
|
|
||||||
backup:
|
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
|
restart: unless-stopped
|
||||||
user: "${BACKUP_UID:-1001}:${BACKUP_GID:-1001}"
|
|
||||||
environment:
|
environment:
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
PGHOST: db
|
PGHOST: db
|
||||||
PGDATABASE: ${POSTGRES_DB:-cents}
|
PGDATABASE: ${POSTGRES_DB:-cents}
|
||||||
PGUSER: ${POSTGRES_USER:-cents}
|
PGUSER: ${POSTGRES_USER:-cents}
|
||||||
PGPASSWORD: ${POSTGRES_PASSWORD}
|
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"]
|
entrypoint: ["/usr/local/bin/cents-backup"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./backups/db:/backups
|
- ./backups:/backups
|
||||||
|
- ~/.ssh:/root/.ssh:ro
|
||||||
- ./deploy/postgres-backup.sh:/usr/local/bin/cents-backup:ro
|
- ./deploy/postgres-backup.sh:/usr/local/bin/cents-backup:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pgrep crond >/dev/null"]
|
test: ["CMD", "/usr/local/bin/cents-backup", "health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 26h
|
||||||
networks:
|
networks:
|
||||||
- app
|
- app
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
||||||
|
|
@ -3,13 +3,59 @@
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
backup_dir=${BACKUP_DIR:-/backups}
|
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() {
|
run_backup() {
|
||||||
umask 077
|
umask 077
|
||||||
mkdir -p "$backup_dir"
|
mkdir -p "$backup_dir/db"
|
||||||
|
|
||||||
timestamp=$(date +%Y%m%d-%H%M%S)
|
timestamp=$(date +%Y%m%d-%H%M%S)
|
||||||
target="$backup_dir/cents-$timestamp.dump"
|
target="$backup_dir/db/cents-$timestamp.dump"
|
||||||
temporary="$target.tmp"
|
temporary="$target.tmp"
|
||||||
trap 'rm -f "$temporary"' EXIT INT TERM
|
trap 'rm -f "$temporary"' EXIT INT TERM
|
||||||
|
|
||||||
|
|
@ -18,8 +64,21 @@ run_backup() {
|
||||||
mv "$temporary" "$target"
|
mv "$temporary" "$target"
|
||||||
trap - EXIT INT TERM
|
trap - EXIT INT TERM
|
||||||
|
|
||||||
find "$backup_dir" -maxdepth 1 -type f -name 'cents-*.dump' -mtime +29 -delete
|
|
||||||
printf 'Backup created: %s\n' "$target"
|
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
|
case "${1:-cron}" in
|
||||||
|
|
@ -27,13 +86,14 @@ case "${1:-cron}" in
|
||||||
run_backup
|
run_backup
|
||||||
;;
|
;;
|
||||||
cron)
|
cron)
|
||||||
cron_dir=/tmp/cents-crontabs
|
|
||||||
mkdir -p "$cron_dir"
|
|
||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
'0 3 * * * /usr/local/bin/cents-backup run >> /proc/1/fd/1 2>> /proc/1/fd/2' \
|
'0 2 * * * /usr/local/bin/cents-backup run >> /proc/1/fd/1 2>> /proc/1/fd/2' \
|
||||||
> "$cron_dir/$(id -u)"
|
> /etc/crontabs/root
|
||||||
printf 'Backup scheduler started: daily at 03:00 %s, retaining 30 days\n' "${TZ:-local time}"
|
printf 'Backup scheduler started: daily at 02:00 %s, retaining 30 days\n' "${TZ:-local time}"
|
||||||
exec crond -f -l 2 -c "$cron_dir"
|
exec crond -f -l 2
|
||||||
|
;;
|
||||||
|
health)
|
||||||
|
check_health
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
printf 'Unknown command: %s\n' "$1" >&2
|
printf 'Unknown command: %s\n' "$1" >&2
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,6 @@
|
||||||
- VPS 开放 `80`、`443` 和 `7000`。
|
- VPS 开放 `80`、`443` 和 `7000`。
|
||||||
- VPS 的 `frps` 配置了 HTTP/HTTPS vhost 端口。
|
- VPS 的 `frps` 配置了 HTTP/HTTPS vhost 端口。
|
||||||
|
|
||||||
参考配置:
|
|
||||||
|
|
||||||
- `deploy/frp/frps.example.toml`
|
|
||||||
|
|
||||||
## 本机配置
|
## 本机配置
|
||||||
|
|
||||||
调试模式不读取 `.env`,配置直接写在 `compose.dev.yaml`:
|
调试模式不读取 `.env`,配置直接写在 `compose.dev.yaml`:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue