备份这件事,我认识的站长里九成以上都在做,但只有很少人认真想过备份文件本身是不是安全的。你的 mysqldump 每天凌晨三点跑,扔到对象存储,看起来万无一失。可那个 dump 文件里有什么?有全部用户的邮箱和密码哈希、有后台管理员的会话表、有订单和支付记录、有私信内容。它躺在云端一个桶里,权限是「私有」,加密方式是……服务商默认的。
这就引出一个很实际的问题:备份是数据的副本,而副本往往比原数据保存得更久、访问更随意、审计更松散。生产库有防火墙、有审计日志、有最小权限账号;而备份桶可能是一个 API Key 就能读全部。真出事(比如员工笔记本被偷、对象存储 AccessKey 泄露、或者你的 VPS 被人拿到 root 后翻了下 /root/backup),丢的就不是一台机器,而是十年的数据。
这篇讲我在自己站上做的备份加密:加密什么、怎么加密、密钥放哪、以及恢复时怎么验证——最后一条最容易漏,也最容易在真需要的时候翻车。
一、先想清楚要防谁
加密不是「加了就安全」,不同的攻击场景需要不同的方案。我一般把威胁分四类:
第一类,存储介质失窃或被回收。硬盘退役、云盘快照泄露、备份 U 盘丢失。这类威胁防御成本最低,只要密钥不在同一介质上,全盘或者文件级加密都能挡住。
第二类,对象存储/云服务被入侵。AccessKey 泄露、桶权限配错变成公开读(这个事故每年都有几十起)。防御的关键是「即使拿到对象也解不开」——所以加密必须在上传之前在本地完成,绝不能依赖服务端的「服务端加密」选项。
第三类,主机被入侵。攻击者拿到你服务器的 root,能读到 /root/.backup-key。这类威胁靠加密防不住(密钥和密文都在同一台机器上),能做的只是增加难度——比如把密钥放在另一台机器或者密码管理器里,让攻击者拿不到。要诚实承认:如果 root 都被拿了,加密的收益很有限。
第四类,勒索软件。这是近几年个人站遇到最多的。攻击者加密你的数据,如果备份也在线可写,一起被加密。防御靠的是「离线/不可变」(对象存储的 Object Lock、或者拔下来的硬盘),而不是加密本身。
所以一个完整的方案要同时做到三件事:本地加密再上传(防第二类)、密钥与备份分离存放(防第一、三类)、备份不可变或离线(防第四类)。下面按这个顺序讲。
二、备份加密的三种层次
层次一:传输加密(不算加密)
scp、rsync over ssh、aws s3 cp 走 HTTPS,这些都只是传输加密。数据到了对端是明文的。很多人以为「我用了 SFTP 所以是安全的」,这是个误解。传输加密解决的是「路上别被截」,不解决「存着别被看」。
层次二:压缩 + 对称加密文件
最常见的做法:dump 出来,gzip 压一下,然后 gpg --symmetric 或者 openssl enc 加密,再上传。优点是简单、任何环境都能恢复;缺点是每个文件一个密码,密码管理容易出问题,而且单个文件大时(比如 30GB 的附件包)加密要额外一遍读写。
层次三:备份工具的加密仓库(推荐)
用 restic、borg、duplicity 这类工具,它们自带加密仓库:内容分块、每块加密、仓库有独立密码、支持快照和去重。这是我目前用的方式,也是我建议个人站用的方式——加密和去重增量是同一个机制带来的,不用额外维护。
但要注意:这些工具的密码一旦丢,备份就是一堆废字节,没有任何后门。所以密码管理比加密本身更重要,后面单独讲。
三、restic 仓库的实际配置
假设我用 restic 备份数据库 dump 和上传目录:
export RESTIC_REPOSITORY="s3:https://s3.example.com/zz-backup"
export RESTIC_PASSWORD_FILE="/etc/restic/password"
export AWS_ACCESS_KEY_ID="..."
export AWS_SECRET_ACCESS_KEY="..."
restic init
restic init 时若仓库已存在会报错,脚本里要做判断。密码文件的权限必须是 600,且属主是执行备份的用户,不要用 root 跑完之后留一个所有人可读的密码文件。
备份脚本的核心:先 dump 到本地临时目录,再让 restic 读文件。不要把 dump 直接管道给 restic,因为 dump 失败时管道另一头仍然会「成功」——这是管道最典型的陷阱。
#!/bin/bash
set -u
set -o pipefail
BACKUP_DIR=/var/backups/zz
DAY=$(date +%Y%m%d)
DUMP="$BACKUP_DIR/db-$DAY.sql.gz"
mkdir -p "$BACKUP_DIR"
mysqldump --single-transaction --quick --routines --triggers \
--default-character-set=utf8mb4 zz \
| gzip -6 > "$DUMP"
if ! gzip -t "$DUMP"; then
echo "dump 校验失败,中止备份" >&2
exit 1
fi
grep -q "Dump completed" <(zcat "$DUMP" | tail -3) || {
echo "dump 不完整(缺少结尾标记)" >&2
exit 1
}
restic backup "$DUMP" /var/www/zz/uploads \
--tag daily --host zz-web \
--exclude '*.log' --exclude 'cache' || exit 1
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
rm -f "$DUMP"
几个细节值得说:
--single-transaction 保证 InnoDB 表的一致性快照,不锁表;但它只对 InnoDB 有效,如果有 MyISAM 表,那些表会被锁住,而且 dump 出来的内容和其他表不是同一时刻的。检查用 SELECT table_name, engine FROM information_schema.tables WHERE table_schema='zz' AND engine<>'InnoDB'。
grep -q "Dump completed" 这一步是我加了很多次才加上的。mysqldump 在遇到某些错误(比如磁盘满、连接被中断)时不一定返回非零退出码,但会缺少结尾的 -- Dump completed on ... 注释。没有这一步,你可能得到一堆看起来正常、实际截断的 dump 文件,直到恢复时才崩溃。
restic forget ... --prune 是为了控制存储增长,但要注意 --prune 是有写放大的:它会重新打包数据,产生大量请求。对 S3 这类按请求计费的对象存储,每天 prune 一次的成本可能比存储费还高。我的做法是每周 prune 一次,日常只做 forget。
四、密钥到底放哪儿
这是整套方案里最难的一环,也是唯一没有标准答案的一环。
方案 A:本地文件 + 异地密码管理器。密钥文件放 /etc/restic/password(600 权限),同时把人可读的密码记在 Bitwarden 或者 1Password 里(或者写在纸上放抽屉)。这个方案的逻辑是:机器被入侵时攻击者能拿到密钥,但机器整个丢了/被回收时你还能从密码管理器恢复。
方案 B:密钥只在管理员笔记本上,服务器上不留。备份脚本从一台独立的机器通过 SSH 触发,密钥通过 agent 转发或者环境变量传进去。安全性最高,但复杂度也最高,机器换人接手时很容易断。
方案 C:GPG 非对称加密,公钥在服务器,私钥只在本地。这个方案我特别喜欢用于「只备份一次、极少恢复」的场景(比如年度归档)。服务器上只放公钥,加密后的文件即使泄露也解不开;私钥存在本地 GPG 里,或者导出来离线存到 U 盘。
# 本地生成密钥对
gpg --full-generate-key
# 导出公钥,拷到服务器
gpg --export -a backup@example.com > backup-pub.asc
# 服务器上导入公钥
gpg --import backup-pub.asc
# 加密(服务器端,只需要公钥)
gpg --batch --yes --trust-model always \
--recipient backup@example.com \
--output "$DUMP.gpg" --encrypt "$DUMP"
# 恢复(本地,需要私钥)
gpg --decrypt db-20261024.sql.gz.gpg | gunzip | mysql zz
非对称加密的代价是慢(实际上 GPG 内部还是生成一个对称密钥用公钥加密,所以性能损失不大),以及一旦私钥丢失就永远解不开。所以私钥至少两份:一份在本地 GPG keyring,一份导出成 .key 文件放在离线介质里,并且记下生成日期和过期策略。
无论用哪个方案,有一条铁律:密钥和密文不能存在同一个可以被一次性拿走的系统里。如果你的备份在 S3,密钥也在同一台服务器的 /root 下,那这套加密的意义只是「防存储服务商偷看」,不防主机被入侵。想清楚这一点,再决定要不要为了「更方便」把密钥放回服务器上。
五、恢复验证:唯一真正的验收
加密之后有个新问题:解密的正确性没法靠「文件存在」来证明。备份文件大小正常、上传成功、restic 的 check 通过——这些都不能证明「密码是对的、数据是完整可用的」。
所以必须做定期的恢复演练。我给自己定的规则是每个月第一个周六,做一次完整恢复,并且记录耗时和结果。
# 1. 校验仓库结构
restic check --read-data-subset=5%
# 2. 恢复到一个临时目录
restic restore latest --target /tmp/restore-test \
--include /var/backups/zz/db-20261001.sql.gz
# 3. 解密并导入一个空的测试库
createdb zz_restore_test
gunzip -c /tmp/restore-test/var/backups/zz/db-20261001.sql.gz | mysql zz_restore_test
# 4. 关键表行数比对
mysql -e "SELECT COUNT(*) FROM zz_restore_test.typecho_contents"
mysql -e "SELECT COUNT(*) FROM zz.typecho_contents"
restic check --read-data-subset=5% 表示只抽查 5% 的数据块做完整性校验,这在几十 GB 的仓库上能在几分钟内跑完;全量 --read-data 建议每季度做一次。
第 4 步很重要——很多人恢复成功了,但没有验证内容。我曾经遇到过「恢复出来的表结构和生产环境差一个字段」的情况(因为备份脚本用的是三个月前的 dump 模板),如果只看「导入成功」就发现不了。
另外别忘了验证恢复的时间。备份的目的不是「有文件」,是「能在可接受的时间内把服务拉起来」。如果一次恢复要 6 小时,而你的业务能容忍的停机窗口只有 2 小时,那这个备份方案实际上是不达标的。这个数字要在演练里量出来,不要猜。
六、几个容易忽略的边界
一是加密后的文件不能再做服务端去重。如果你把每个 dump 单独用 GPG 加密再上传,服务商看到的全是随机字节,无法去重,存储成本会翻几倍。所以要么用 restic 这类「先分块再加密」的工具(去重在加密之前完成),要么接受成本。
二是不要把密码写在 cron 行里。*/1 * * * * RESTIC_PASSWORD=xxx /usr/local/bin/backup.sh 这种写法,密码会出现在 /proc/1/environ 之外的地方(不同 cron 实现有差异),更重要的是会出现在你的版本控制、文档、截图里。用 PasswordFile 或者 EnvironmentFile(systemd unit 里,权限 600)。
三是日志里别打印环境变量。加 set -x 调试时,脚本里的 export RESTIC_PASSWORD=... 会被原样打进日志,而日志通常会被 logrotate 保留很久,或者被同步到集中日志。调试完记得关掉。
四是多密钥轮换。restic 支持 restic key add、restic key remove,但必须先加新密钥、验证能解锁、再删除旧密钥,顺序反了就跑不了了。我自己踩过一次(脚本写成先 remove 再 add),幸好当时仓库还有另一把密钥没有删。
五是容器化环境。如果备份脚本跑在容器里,密钥通过 volume 挂进来,要注意容器重启后 volume 是否还在、以及容器日志是否会带出密钥。用 docker secret 或者宿主机的 EnvironmentFile 都比写进 Dockerfile 里强。
说到底,备份加密不是一个技术选型问题,是一个「你愿不愿意为最坏情况做一点麻烦事」的问题。加密那几行脚本花不了半小时,密钥管理花的心思是几倍,而恢复演练花的时间最多——但这三件事缺任何一件,你的备份都不能算「有」。我现在的做法很简单:每月第一个周六固定两小时做恢复演练,演练记录写进同一份 runbook 里,包含日期、恢复耗时、行数比对结果、遇到的问题。这份记录本身,比任何加密配置都更能说明这个站的备份是靠得住的。