日志是排查问题的第一手证据,也是泄露风险最高的数据之一。一个日 PV 几千的站点,访问日志里就可能同时包含用户手机号、邮箱、订单号的片段,接口日志里可能有完整的 Authorization 头,错误日志里甚至会把数据库密码打进堆栈。等到「日志被误传出去」或「审计要求提供脱敏证明」时再回头处理,成本会高十倍。
这篇讲两件事:一是日志怎么脱敏(在哪个环节做、用什么规则、怎么验证),二是备份怎么从「有备份」升级为「确认能恢复」——后者是绝大多数站长以为自己做完了、实际从没做对的一件事。
一、日志里到底有哪些敏感字段
先按类型列一遍,比凭印象处理可靠:
- 身份类:手机号、身份证号、邮箱、用户名、IP(部分场景属于个人信息);
- 凭证类:Authorization 头、Cookie、URL 里的 token 参数、数据库连接串、短信/邮件的 API key;
- 业务类:订单号、支付流水、地址、设备号;
- 间接泄露:完整堆栈里的文件路径与 SQL 语句(能推出表结构和字段名)。
最危险的不是表单字段,而是「URL 里的 token」和「错误页里的 SQL」。前者会被 Nginx access log 原样记下来,后者会被框架的异常处理器写进 error log,两者都是默认行为,不需要任何人犯错就会发生。
二、脱敏应该发生在哪一层
常见做法是在日志传出去之后做「离线脱敏」,这基本没有意义 —— 已经落盘的原文仍然可以泄露。正确的原则是「敏感数据不落盘」,在写日志的那一刻就处理掉。分三层:
第一层:应用层(最可控)
在代码里统一封装日志函数,禁止直接用 error_log 或 console.log 打印整个请求对象。典型写法:
function mask(array $data): array {
$keys = ['password','passwd','token','secret','authorization','cookie','phone','mobile','idcard'];
foreach ($data as $k => $v) {
if (is_array($v)) { $data[$k] = mask($v); continue; }
$lk = strtolower((string)$k);
foreach ($keys as $needle) {
if (strpos($lk, $needle) !== false) { $data[$k] = '[REDACTED]'; break; }
}
}
return $data;
}
按「键名包含匹配」而不是精确匹配,是因为实际字段名千变万化(access_token、userPhone、X-Api-Secret)。代价是会多遮一些无关字段,但这对排查影响很小,因为排查真正依赖的字段(id、状态码、耗时)不会被遮。
手机号和邮箱这类「需要保留部分信息用于排查」的字段,用部分遮盖而不是整体打码:
$phone = preg_replace('/(\d{3})\d{4}(\d{4})/', '$1****$2', $phone);
$email = preg_replace('/(.{2}).*(@.*)/', '$1***$2', $email);
第二层:Web 服务器层
Nginx 默认的 $request 会记录完整 URL,包括 query string 里的 token。两个改法,二选一:
# 方案 A:自定义日志格式,去掉 query string
log_format main_clean '$remote_addr - $remote_user [$time_local] '
'"$request_method $uri $server_protocol" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" $request_time';
access_log /var/log/nginx/access.log main_clean;
# 方案 B:保留 query,但对敏感参数做遮盖(nginx 需 http_sub 或 Lua,实际操作更常用 A + 应用层遮盖)
丢掉 query string 会损失部分排查能力(比如按 ?page=3 分析分页),折中做法是保留白名单参数:用 map 只提取需要的键。对绝大多数站点来说,先丢 query 再在应用层按需记录更划算。
第三层:日志采集与传输
如果用 Filebeat / Vector / Fluent Bit 采集日志,可以在管道里加一层脱敏处理器,作为「应用漏遮」的兜底。但要清楚它的定位是兜底,不是主防线 —— 一旦原文已落盘,兜底的价值有限。
三、验证:脱敏必须可测
写完脱敏函数不验证,等于没写。最省事的验证是「用假数据走一遍真实链路」:构造一条包含手机号、token、Authorization 头的请求打到测试环境,然后 grep 日志:
grep -E '1[3-9][0-9]{9}|Bearer [A-Za-z0-9._-]{20,}' /var/log/app/*.log
没有输出才算过。这条检查建议做成发布前的一个固定脚本,因为脱敏逻辑最容易被「新加的一个接口」绕过 —— 新接口直接 error_log(json_encode($request)),脱敏就形同虚设。更好的做法是把「禁止直接调用底层日志函数」写进代码检查规则(grep 级别的 lint 就够)。
四、日志保留多久、放在哪
脱敏之后仍然要管保留期。原则是「够排查、够合规、不多留」:
- 访问日志 7~14 天足够定位绝大多数问题,保留 30 天已偏长;
- 错误日志留 30 天(偶发问题的复现周期长);
- 审计类日志(登录、后台操作)留 90 天以上,且与业务日志分开存储;
- 磁盘占用用 logrotate 控制,注意
copytruncate与create的选择:用copytruncate时写入进程不需要重启,但截断瞬间可能丢几行;用create则必须让进程重新打开文件(kill -USR1或systemctl reload)。
/var/log/app/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
su www-data www-data
}
delaycompress 的意义是「本轮不压缩、下轮再压」,避免压缩刚写完还在被追加的文件。这条对于「压缩后日志里出现半截行」的诡异问题几乎必治。
五、备份:从「有备份」到「确认能恢复」
备份出问题的形态几乎只有一种:备份任务一直在跑,文件也一直在生成,但从来没人恢复过。等到需要恢复时才发现备份是空的、是损坏的、或者只备了数据库没备上传目录。
最小可用的备份结构(以站点为例):
# 数据库:逻辑备份 + 压缩 + 日期后缀
mysqldump --single-transaction --quick --default-character-set=utf8mb4 \
-u backup_user -p"$PW" zz1984 | gzip > /data/backup/db/zz1984_$(date +%F).sql.gz
# 站点文件:只备必要目录,排除缓存
tar czf /data/backup/files/uploads_$(date +%F).tar.gz \
--exclude='*/cache/*' --exclude='*.log' /home/wwwroot/www.zz1984.com/usr/uploads
--single-transaction 保证 InnoDB 表的一致性快照且不锁表;--quick 避免把大表全部读进内存。这两个参数对线上库几乎必加。
更推荐「增量 + 硬链接快照」的做法,用 rsync 的 --link-dest:每天生成一个完整目录,未变化的文件与前一天共享 inode,磁盘占用接近增量,但恢复时每个目录都是完整可用的一份,不需要「第 1 全量 + 第 2~N 增量」的还原链。
rsync -a --delete --link-dest=/data/backup/current /home/wwwroot/site/ /data/backup/snap_$(date +%F)/
rm -f /data/backup/current && ln -s snap_$(date +%F) /data/backup/current
六、恢复演练:把「验证」变成固定动作
备份的价值只有一个判据:能不能在另一台机器上恢复出一个可访问的站点。建议每月做一次,流程固定下来:
- 找一台空闲机器(或临时容器),装同版本 PHP / MySQL / Nginx;
- 把最新的库和文件备份拉过去,按顺序恢复;
- 改 hosts 指向临时机器,确认首页、列表页、详情页、后台登录都能打开;
- 记录耗时(这是真正出事时最需要的数字)和遇到的每一个问题。
演练最容易暴露的三类问题:备份里缺了 usr/uploads 之外的目录(比如主题的自定义文件)、数据库字符集不一致导致中文乱码、备份账号权限过大或过小(SHOW DATABASES 看不到目标库)。每次演练完更新一份不超过半页的「恢复手册」,写清命令顺序和每步校验点 —— 真出事的时候没人有空读文档。
七、自动化与告警
备份任务的结果必须能被外部看到,否则「跑失败三个月没人发现」是必然的。最低限度做三件事:
- 备份脚本最后输出一行汇总(大小、耗时、文件数),写进日志;
- 用
find /data/backup -name '*.gz' -mtime +1 -size -1k每天检查是否出现「新但很小」的文件(备份失败的典型形态); - 恢复演练的完成时间记进日历,逾期提醒。
一个廉价而有效的完整性检查是「gzip 测试 + 文件头验证」:gzip -t file.sql.gz 能发现截断,head -c 500 file.sql.gz | gunzip -c | head 能确认里面真的是 SQL 而不是一个错误页 HTML。
八、上传目录与附件:备份里最容易漏的一块
数据库恢复得再完美,上传目录缺一半,页面上的图片就全是破图。附件目录的三个特点是「大、碎、变更频繁」,处理方式与数据库完全不同:
- 不要每天全量打包:十万个小文件的 tar 每次都要几十分钟,还吃满 IO。用前面说的
rsync --link-dest做硬链接快照,只有新增文件占空间。 - 按年月分目录:
uploads/2026/10/这种结构让「只备份最近三个月」成为一条find命令,也让目录内文件数可控(单目录文件过多会拖慢ls和备份扫描)。 - 定期对账:数据库里记录的附件路径数量应与磁盘上的文件数量大致相等,差得太多说明有孤儿文件(数据库删了、文件没删)或丢失文件(文件没了、数据库还引用)。一条 SQL 加一次
find就能算出这个差值,是发现「备份不完整」最灵敏的指标。
# 磁盘侧文件数
find /home/wwwroot/site/usr/uploads -type f | wc -l
# 数据库侧引用数(示例表结构)
mysql -N -e "select count(*) from zz1984.typecho_contents where text like '%/usr/uploads/%'"
两者相差 5% 以内通常正常(缩略图、临时文件、已清理的附件),相差几倍就说明某一边出了问题,此时再去做备份恢复演练,会发现缺的正是那一批。
九、把「恢复手册」写成可以照着念的样子
恢复手册最常见的失败形态是「写成了架构说明」:讲了一堆服务器角色和备份策略,真出事时看的人只想知道第一条命令是什么。有效的写法是「编号 + 命令 + 期望输出」:
- 第 1 步:装依赖。
apt install nginx php8.1-fpm mysql-server,期望nginx -v有版本输出; - 第 2 步:恢复数据库。
gunzip -c db.sql.gz | mysql zz1984,期望无报错,接一句mysql -N -e "select count(*) from typecho_contents"核对行数; - 第 3 步:恢复文件。
rsync -a snap_latest/ /home/wwwroot/site/,期望du -sh与源站相差 10% 以内; - 第 4 步:改配置。数据库连接、域名、缓存目录权限,期望
php -r "new PDO(...)"能连通; - 第 5 步:验收。首页、列表页、详情页、后台登录四个 URL,期望都是 200 且页面有内容。
每一步都写「期望输出」,是因为恢复过程中最难判断的是「这一步到底成功了没有」。有期望输出,任何人都能照着走完;只有命令,只有原作者敢用。
十、小结:先做对小概率但致命的那一半
日志脱敏和备份恢复是同一类工作:平时看不出收益,出事时决定生死。优先级建议按「不可逆」排序 —— 先做「日志里不落敏感数据」(一次配置长期有效),再做「备份能在别的机器恢复」(每月演练),最后才是保留期策略和自动化告警等优化项。做完这三步,站点在「数据泄露」和「灾难恢复」两个方向上的不确定性就基本消除了。