数据库备份是服务器运维中最基础、也最容易被忽视的一项工作。很多个人项目和小型业务系统使用 Docker 部署 PostgreSQL,平时运行稳定,但一旦出现误删数据、磁盘损坏、服务器故障或升级失败,如果没有可靠的备份机制,恢复起来就会非常被动。
本文以 Docker 部署的 PostgreSQL 为例,实现一套自动化备份方案:
- 每天定时备份指定业务数据库,并将备份文件发送到指定邮箱。
- 每周执行一次全库备份,包含所有数据库以及数据库角色等全局对象,并发送邮件。
- 自动检查备份文件是否生成、备份格式是否有效。
- 记录执行日志,备份失败时发送告警。
- 自动清理过期备份,避免占满服务器磁盘。
- 提供数据恢复命令,确保备份文件真正可用。
整体使用 Bash、Docker、PostgreSQL 官方工具和 Python 标准库,不依赖额外的 Python 邮件第三方包。
一、设计备份方案
假设 PostgreSQL 已经通过 Docker 部署,容器名称为 postgres,其中运行着多个数据库,例如:
PostgreSQL
├── postgres
├── app_db
├── blog_db
└── analytics_db
其中 app_db 是日常最重要的业务数据库,需要每天备份;其余数据库则通过每周一次的全库备份进行额外保护。
备份流程设计如下:
Linux 定时任务
│
┌───────────┴───────────┐
│ │
每天 02:00 每周日 03:00
│ │
▼ ▼
备份指定数据库 备份整个集群
pg_dump pg_dumpall
│ │
▼ ▼
生成 .dump 文件 生成 .sql.gz 文件
│ │
└───────────┬───────────┘
│
▼
检查备份文件
│
▼
发送邮件
│
▼
清理过期备份
这里需要区分两个概念:
单库备份使用 pg_dump,只导出指定数据库,适合每天进行,文件相对较小,恢复也更加灵活。
全库备份使用 pg_dumpall,导出 PostgreSQL 集群中的所有数据库,并包含数据库角色、表空间等全局对象。它适合每周执行一次,作为额外的恢复保障。
需要注意,pg_dumpall 导出的是逻辑备份,并不是 PostgreSQL 数据目录的完整物理副本。它也不包含 PostgreSQL 配置文件、Docker Compose 文件、外部表空间目录本身等所有运行环境信息。因此,重要业务还应单独保护配置文件和其他必要资源。
二、准备 Docker 环境与备份目录
假设现有 PostgreSQL 容器名称为 postgres,可以先检查容器状态:
docker ps --filter name=postgres
查看 PostgreSQL 版本:
docker exec postgres psql -U postgres -d postgres -c "SELECT version();"
这里的 postgres 是示例容器名和数据库管理员用户名,需要根据实际环境修改。
建议在宿主机上建立独立的备份目录:
sudo mkdir -p /opt/pg-backup
sudo mkdir -p /var/backups/postgres
sudo mkdir -p /var/log/pg-backup
目录职责如下:
| 目录 | 用途 |
|---|---|
/opt/pg-backup | 备份脚本、配置文件和邮件发送程序 |
/var/backups/postgres | 保存生成的备份文件 |
/var/log/pg-backup | 保存备份执行日志 |
不要直接把备份文件保存在 PostgreSQL 容器的可写层中。容器重建、清理或迁移时,这些文件可能随之丢失。
另外,备份文件可能包含用户信息、业务数据和敏感字段,应当限制目录权限:
sudo chmod 700 /opt/pg-backup
sudo chmod 700 /var/backups/postgres
sudo chmod 700 /var/log/pg-backup
后续可以使用专门的 Linux 用户运行备份任务,并将相应目录的所有权授予该用户。
三、解决数据库认证问题
自动备份最大的一个实际问题,是脚本不能像人工执行命令那样等待输入数据库密码。
PostgreSQL 客户端支持通过密码文件进行认证。这里采用 .pgpass 方式,避免将数据库密码直接写进备份脚本。
由于示例使用 docker exec 在 PostgreSQL 容器内部运行备份命令,我们需要确保容器内部能够访问密码文件。
如果容器已经挂载了密码文件,可以直接使用现有路径。否则,建议将一个专用密码文件以只读方式挂载到容器中,例如:
services:
postgres:
image: postgres:16
container_name: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
- /opt/pg-backup/pgpass:/run/secrets/pgpass:ro
volumes:
postgres_data:
这是 Docker Compose 配置片段,实际使用时应合并到已有配置中,不要直接覆盖原来的数据卷、网络和其他参数。修改挂载配置后,需要按实际部署方式重建容器,并确认数据库数据卷没有改变。
创建密码文件:
127.0.0.1:5432:*:postgres:替换为实际数据库密码
保存到:
/opt/pg-backup/pgpass
设置权限:
sudo chmod 600 /opt/pg-backup/pgpass
如果容器内以 postgres 用户运行备份命令,密码文件必须满足 PostgreSQL 对密码文件权限的要求,并且该用户需要能够读取文件。可以根据容器用户和文件挂载方式调整文件所有者或权限,但不要为了方便而将密码文件开放给所有用户。
.pgpass 每行的格式是:
hostname:port:database:username:password
这里使用 127.0.0.1,是因为后续备份命令会明确指定连接地址为 127.0.0.1。密码文件中的主机字段必须与客户端实际使用的连接方式匹配。
测试认证是否正常:
docker exec \
-e PGPASSFILE=/run/secrets/pgpass \
postgres \
psql -h 127.0.0.1 -p 5432 \
-U postgres -d postgres \
-c "SELECT current_database(), current_user;"
如果这条命令可以正常执行,说明连接和密码文件基本配置正确。
生产环境中更推荐创建专用备份账号,并按备份范围授予必要权限。全库备份涉及全局对象和多个数据库,通常需要更高权限;如果为了方便直接使用超级用户,应严格限制凭据的访问范围。
四、配置邮件发送功能
备份文件生成后,需要将文件作为附件发送到指定邮箱。
邮件发送可以通过 msmtp、mailx 等系统工具实现,也可以直接使用 Python 标准库中的 smtplib 和 email 模块。为了减少额外依赖,本文使用 Python 实现附件发送。
先创建邮件配置文件:
sudo nano /opt/pg-backup/backup.env
写入以下内容:
SMTP_HOST="smtp.example.com"
SMTP_PORT="465"
SMTP_USER="backup@example.com"
SMTP_PASSWORD="替换为邮箱SMTP授权码"
MAIL_FROM="backup@example.com"
MAIL_TO="admin@example.com,ops@example.com"
需要替换为实际的 SMTP 地址、邮箱账号和授权码。很多邮箱要求使用 SMTP 授权码,而不是直接使用登录密码。
限制配置文件权限:
sudo chmod 600 /opt/pg-backup/backup.env
接下来创建邮件发送脚本:
sudo nano /opt/pg-backup/send_mail.py
完整代码如下:
#!/usr/bin/env python3
import os
import smtplib
import sys
from email.message import EmailMessage
from pathlib import Path
def main():
if len(sys.argv) < 3:
print(
"Usage: send_mail.py SUBJECT BODY [ATTACHMENT]",
file=sys.stderr,
)
return 2
subject = sys.argv[1]
body = sys.argv[2]
attachment = Path(sys.argv[3]) if len(sys.argv) > 3 else None
smtp_host = os.environ["SMTP_HOST"]
smtp_port = int(os.environ.get("SMTP_PORT", "465"))
smtp_user = os.environ["SMTP_USER"]
smtp_password = os.environ["SMTP_PASSWORD"]
sender = os.environ.get("MAIL_FROM", smtp_user)
recipients = [
item.strip()
for item in os.environ["MAIL_TO"].split(",")
if item.strip()
]
if not recipients:
raise RuntimeError("MAIL_TO 未配置收件人")
message = EmailMessage()
message["Subject"] = subject
message["From"] = sender
message["To"] = ", ".join(recipients)
message.set_content(body)
if attachment:
if not attachment.is_file():
raise FileNotFoundError(
f"附件不存在: {attachment}"
)
with attachment.open("rb") as file:
content = file.read()
message.add_attachment(
content,
maintype="application",
subtype="octet-stream",
filename=attachment.name,
)
with smtplib.SMTP_SSL(
smtp_host,
smtp_port,
timeout=30,
) as smtp:
smtp.login(smtp_user, smtp_password)
smtp.send_message(message)
print("邮件发送成功")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc:
print(
f"邮件发送失败: {exc}",
file=sys.stderr,
)
sys.exit(1)
这里使用 SMTP SSL 连接,默认端口为 465。如果邮箱服务商要求通过 STARTTLS 连接,则需要相应调整连接代码和端口。
这个脚本支持两种用法:
python3 /opt/pg-backup/send_mail.py \
"备份成功" \
"数据库备份任务已完成。"
发送带附件的邮件:
python3 /opt/pg-backup/send_mail.py \
"数据库备份" \
"请查收备份文件。" \
"/var/backups/postgres/app_db_20261010.dump"
需要注意,这个实现会把附件完整读入内存。对于几百 MB 甚至数 GB 的备份文件,不建议直接通过邮件附件发送。邮箱通常存在附件大小限制,过大的邮件也可能发送失败或被拒收。
如果数据库比较大,更推荐将备份上传到对象存储或其他独立存储,再通过邮件发送文件信息、校验值和受控下载地址。
五、编写自动备份脚本
接下来实现整个方案的核心脚本:每天备份指定数据库,每周执行一次全库备份,并通过邮件通知执行结果。
创建:
sudo nano /opt/pg-backup/backup.sh
写入以下完整脚本:
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
BASE_DIR="/opt/pg-backup"
BACKUP_DIR="/var/backups/postgres"
LOG_DIR="/var/log/pg-backup"
CONTAINER="postgres"
PG_USER="postgres"
PG_PORT="5432"
# 每天需要重点备份的数据库
DAILY_DATABASE="app_db"
# 日备份保留天数
DAILY_RETENTION_DAYS=14
# 全库备份保留天数
FULL_RETENTION_DAYS=56
MODE="${1:-daily}"
DATE="$(date '+%Y%m%d_%H%M%S')"
HOSTNAME="$(hostname)"
mkdir -p "$BACKUP_DIR" "$LOG_DIR"
chmod 700 "$BACKUP_DIR" "$LOG_DIR"
LOG_FILE="$LOG_DIR/backup.log"
LOCK_FILE="$BASE_DIR/backup.lock"
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "已有备份任务运行,跳过本次执行"
exit 1
fi
# 加载 SMTP 配置
set -a
source "$BASE_DIR/backup.env"
set +a
TEMP_FILE=""
BACKUP_FILE=""
BACKUP_TYPE=""
BACKUP_STARTED=0
log() {
echo "[$(date '+%F %T')] $*" | tee -a "$LOG_FILE"
}
send_mail() {
local subject="$1"
local body="$2"
local attachment="${3:-}"
if [[ -n "$attachment" ]]; then
python3 "$BASE_DIR/send_mail.py" \
"$subject" "$body" "$attachment"
else
python3 "$BASE_DIR/send_mail.py" \
"$subject" "$body"
fi
}
cleanup_temp() {
if [[ -n "$TEMP_FILE" && -f "$TEMP_FILE" ]]; then
rm -f "$TEMP_FILE"
fi
}
on_error() {
local exit_code=$?
trap - ERR
cleanup_temp
if [[ "$BACKUP_STARTED" -eq 1 ]]; then
local body
body="PostgreSQL 备份失败。
服务器:$HOSTNAME
类型:$BACKUP_TYPE
数据库:$DAILY_DATABASE
时间:$(date '+%F %T')
退出码:$exit_code
请检查日志:
$LOG_FILE"
log "备份失败,退出码:$exit_code"
send_mail \
"[失败] PostgreSQL 备份异常 - $HOSTNAME" \
"$body" \
|| log "失败告警邮件发送失败"
fi
exit "$exit_code"
}
trap on_error ERR
trap cleanup_temp EXIT
case "$MODE" in
daily)
BACKUP_TYPE="每日单库备份"
BACKUP_FILE="$BACKUP_DIR/${DAILY_DATABASE}_${DATE}.dump"
BACKUP_STARTED=1
log "开始备份数据库:$DAILY_DATABASE"
TEMP_FILE="$(mktemp "$BACKUP_DIR/.daily.XXXXXX")"
docker exec \
-e PGPASSFILE=/run/secrets/pgpass \
"$CONTAINER" \
pg_dump \
-h 127.0.0.1 \
-p "$PG_PORT" \
-U "$PG_USER" \
-d "$DAILY_DATABASE" \
-Fc \
> "$TEMP_FILE"
# 检查自定义格式备份是否可以读取目录信息
docker exec \
-e PGPASSFILE=/run/secrets/pgpass \
-i "$CONTAINER" \
pg_restore --list \
< "$TEMP_FILE" \
> /dev/null
;;
full)
BACKUP_TYPE="每周全库备份"
BACKUP_FILE="$BACKUP_DIR/full_${DATE}.sql.gz"
BACKUP_STARTED=1
log "开始执行 PostgreSQL 全库备份"
TEMP_FILE="$(mktemp "$BACKUP_DIR/.full.XXXXXX")"
docker exec \
-e PGPASSFILE=/run/secrets/pgpass \
"$CONTAINER" \
pg_dumpall \
-h 127.0.0.1 \
-p "$PG_PORT" \
-U "$PG_USER" \
| gzip -c > "$TEMP_FILE"
# 检查 gzip 文件完整性
gzip -t "$TEMP_FILE"
;;
*)
echo "用法:$0 {daily|full}" >&2
exit 2
;;
esac
# 拒绝空文件
if [[ ! -s "$TEMP_FILE" ]]; then
log "备份文件为空"
exit 1
fi
# 只有验证通过后才将临时文件改为正式备份文件
mv "$TEMP_FILE" "$BACKUP_FILE"
TEMP_FILE=""
BACKUP_SIZE="$(du -h "$BACKUP_FILE" | awk '{print $1}')"
BACKUP_SHA256="$(sha256sum "$BACKUP_FILE" | awk '{print $1}')"
log "备份生成成功:$BACKUP_FILE"
log "文件大小:$BACKUP_SIZE"
log "SHA256:$BACKUP_SHA256"
# 发送备份邮件
MAIL_BODY="PostgreSQL 备份成功。
服务器:$HOSTNAME
备份类型:$BACKUP_TYPE
数据库:$DAILY_DATABASE
时间:$(date '+%F %T')
文件:$(basename "$BACKUP_FILE")
大小:$BACKUP_SIZE
SHA256:$BACKUP_SHA256
备份文件已通过基础完整性检查。
建议定期进行独立恢复验证。"
send_mail \
"[成功] $BACKUP_TYPE - $HOSTNAME" \
"$MAIL_BODY" \
"$BACKUP_FILE"
log "备份邮件发送成功"
# 备份和邮件发送成功后,才清理过期文件
find "$BACKUP_DIR" \
-maxdepth 1 \
-type f \
-name "${DAILY_DATABASE}_*.dump" \
-mtime +"$DAILY_RETENTION_DAYS" \
-delete
find "$BACKUP_DIR" \
-maxdepth 1 \
-type f \
-name "full_*.sql.gz" \
-mtime +"$FULL_RETENTION_DAYS" \
-delete
log "过期备份清理完成"
log "本次任务结束"
保存后赋予执行权限:
sudo chmod 700 /opt/pg-backup/backup.sh
sudo chmod 700 /opt/pg-backup/send_mail.py
上面的脚本展示了完整的备份流程,不过部署前需要根据实际环境调整容器名、数据库名、数据库账号和密码文件路径。
另外,这个示例假定全库备份和单库备份都使用有权限执行相应操作的数据库账号。若实际账号权限不足,脚本会失败并尝试发送失败告警。不要因为权限不足就直接关闭错误检查。
为什么单库备份使用 -Fc
这里使用:
pg_dump -Fc
-Fc 表示使用 PostgreSQL 自定义归档格式。它适合通过 pg_restore 恢复,可以选择恢复部分对象,也可以调整恢复顺序。该格式自带压缩能力,通常不需要再额外使用 gzip。
为什么全库备份使用 pg_dumpall | gzip
pg_dumpall 输出的是 SQL 文本,包含集群中的所有数据库以及全局对象。通过 gzip 压缩后,可以减少磁盘占用和邮件附件大小。
这两种文件格式不同,恢复命令也不同,不能把 .dump 文件直接交给 psql 执行,也不能把 .sql.gz 文件直接交给 pg_restore。
脚本需要注意的几个细节
第一,脚本使用 flock 避免两个备份任务同时执行,降低重复任务和磁盘竞争风险。
第二,先生成临时文件,再检查文件,最后通过 mv 生成正式文件,避免任务中断后留下一个看起来像完整备份的正式文件。
第三,脚本在备份失败时尝试发送邮件告警,但如果 SMTP 服务本身也不可用,邮件同样可能发送失败。因此,关键业务不应该只依赖邮件这一种告警通道。
第四,备份成功后再清理旧文件。这样可以避免新备份失败时,旧备份已经被提前删除。
第五,pg_restore --list 和 gzip -t 只是基础完整性检查,并不等于完整恢复测试。只有在独立环境中实际恢复成功,才能更有把握地确认备份可用。
六、配置每天与每周的定时任务
Linux 可以通过 Cron 执行定时任务。
编辑当前用户的 Crontab:
sudo crontab -e
添加:
# 每天凌晨 02:00 备份指定数据库
0 2 * * * /opt/pg-backup/backup.sh daily >> /var/log/pg-backup/cron.log 2>&1
# 每周日凌晨 03:00 执行全库备份
0 3 * * 0 /opt/pg-backup/backup.sh full >> /var/log/pg-backup/cron.log 2>&1
这样,任务就会按照以下计划执行:
| 时间 | 任务 | 邮件内容 |
|---|---|---|
| 每天 02:00 | 备份 app_db | 单库备份文件 |
| 每周日 03:00 | 备份全部数据库及全局对象 | 全库 SQL 压缩文件 |
注意,Cron 使用服务器本地时区。如果服务器采用 UTC,而你希望按北京时间执行,就需要先确认系统时区,或者相应调整任务时间。
可以检查系统时区:
timedatectl
检查定时任务:
sudo crontab -l
在正式启用 Cron 之前,建议先手动执行一次每日备份:
sudo /opt/pg-backup/backup.sh daily
再手动执行一次全库备份:
sudo /opt/pg-backup/backup.sh full
确认两类邮件都能正常收到、备份文件正常生成后,再依赖定时任务运行。
七、恢复数据:备份系统必须包含恢复方案
备份最重要的价值在于恢复。因此,必须提前明确每种备份文件如何使用。
1. 恢复单个数据库
假设每天生成的文件为:
app_db_20261010_020000.dump
首先将备份文件复制到需要恢复的环境,并确认目标 PostgreSQL 版本与备份格式兼容。
如果目标数据库不存在,可以先创建数据库:
docker exec postgres \
createdb -U postgres -h 127.0.0.1 \
-T template0 app_db_restore
然后执行恢复:
docker exec \
-e PGPASSFILE=/run/secrets/pgpass \
-i postgres \
pg_restore \
-h 127.0.0.1 \
-p 5432 \
-U postgres \
-d app_db_restore \
--exit-on-error \
< app_db_20261010_020000.dump
这里恢复到新的数据库 app_db_restore,而不是直接覆盖生产数据库。
这种方式适合先验证备份是否能够恢复,再决定是否执行生产环境恢复。
如果数据库使用了自定义角色、扩展或特定权限设置,恢复前还需要准备相应的角色、扩展和依赖环境。单库 pg_dump 不包含集群级角色定义,因此不能假设它能独立还原整个 PostgreSQL 集群的所有权限配置。
2. 恢复每周全库备份
全库备份文件例如:
full_20261011_030000.sql.gz
先检查压缩文件:
gzip -t full_20261011_030000.sql.gz
然后在隔离的 PostgreSQL 实例或测试环境中恢复:
gunzip -c full_20261011_030000.sql.gz \
| docker exec -i postgres \
psql -X -U postgres -d postgres
这里的 postgres 是连接数据库的入口数据库。pg_dumpall 生成的 SQL 脚本会包含相应的数据库创建和切换命令。
全库恢复涉及角色、权限、数据库和其他全局对象,通常需要较高权限。恢复到已有实例时,还可能出现角色已存在、数据库已存在等冲突。不要直接对生产实例执行全量恢复命令,尤其不要在没有确认影响范围的情况下使用会删除现有对象的选项。
3. 为什么不能只检查备份文件大小
一个备份文件即使大小正常,也可能由于权限、版本、扩展或数据对象问题而无法完整恢复。
建议至少做到:
- 每次备份后检查命令退出码。
- 对自定义格式文件运行
pg_restore --list。 - 对 gzip 文件运行
gzip -t。 - 每隔一段时间在独立环境中执行完整恢复。
- 对关键业务表执行记录数、关键字段或业务一致性检查。
例如,可以在恢复测试后查询关键表:
SELECT COUNT(*) FROM users;
如果是业务系统,还可以校验关键数据是否完整、外键关系是否正常,以及应用能否成功连接并完成基本业务操作。
八、生产环境还需要完善哪些地方
上面的方案适合个人项目和中小型服务作为起点,但如果数据库承载关键业务,还需要进一步完善。
1. 不要只把备份放在同一台服务器
如果数据库、备份文件和 Docker 数据卷都在同一块磁盘上,磁盘损坏或服务器被误删时,可能同时失去数据库和备份。
建议将备份同步到独立存储,例如另一台 NAS、对象存储或其他服务器。对于重要数据,还应考虑加密、访问控制和异地备份。
2. 邮件附件并不适合无限增长的数据库
随着数据库增长,备份文件可能超过邮箱附件大小限制。此时可以改为:
- 备份文件先写入本地磁盘。
- 计算 SHA-256 校验值。
- 上传到对象存储或独立备份服务器。
- 检查上传结果。
- 通过邮件发送备份时间、文件大小、校验值和受控下载地址。
这样比直接发送大型附件更容易扩展,也能减少 SMTP 发送失败的概率。
3. 注意数据库备份工具的版本兼容性
pg_dump 和 pg_dumpall 是 PostgreSQL 客户端工具。实际使用时应关注客户端版本与服务器版本的兼容性,尤其是跨大版本迁移和恢复场景。
建议记录服务器版本和备份工具版本,并在升级 PostgreSQL 前完成一次恢复测试。
4. 明确备份保留策略
本文设置了每日备份保留 14 天、每周全库备份保留 56 天。这只是示例值,并不适合所有业务。
保留周期应根据数据重要性、恢复点目标(RPO)、恢复时间目标(RTO)、磁盘容量以及法规要求决定。关键业务可以采用每日备份、每周全量备份、异地存储和定期恢复演练相结合的策略。
还要注意,Cron 任务每天执行并不代表每天都有备份。如果服务器关机、磁盘满、数据库不可用或者邮件失败,任务可能无法正常完成。因此应持续监控最后一次成功备份的时间,并在超过预期时间没有新备份时告警。
5. 逻辑备份不等于时间点恢复
pg_dump 和 pg_dumpall 适合逻辑备份,但如果需要把数据库恢复到某个具体时间点,仅靠这两类定期导出通常不够。
对于更严格的恢复要求,可以结合基础备份与 WAL 归档,使用 PostgreSQL 的时间点恢复(PITR)能力。这样能够在具备完整 WAL 归档链的情况下,将数据库恢复到指定时间点。
九、总结
通过 Docker、PostgreSQL 官方备份工具、Cron 和 SMTP 邮件发送,可以建立一套相对完整的自动化备份流程:
- 每天使用
pg_dump -Fc备份指定业务数据库。 - 每周使用
pg_dumpall备份所有数据库及全局对象。 - 使用密码文件避免在脚本中明文写入数据库密码。
- 使用临时文件、完整性检查和执行日志提升可维护性。
- 通过邮件发送备份文件或任务结果。
- 使用定时清理控制磁盘占用。
- 定期在独立环境中执行恢复演练。
真正可靠的备份方案,不能只看脚本是否按时运行,还需要考虑备份是否完整、是否能够恢复、备份是否存放在独立介质,以及失败时是否能够及时发现。
对于个人服务器和小型业务系统,可以先把这套方案部署起来;随着数据库规模和业务重要性增加,再逐步引入对象存储、异地备份、WAL 归档、监控告警和自动化恢复测试。
