日志留存这件事,平时看不出价值,出事那天就是唯一的证据链。勒索软件的第一批动作往往是清空 /var/log,内鬼删库前也会先处理审计日志。把日志写到同一台服务器、同一个可写目录,等于把证据和嫌疑人放在一个房间。
本文用 S3 Object Lock 做 WORM(Write Once Read Many,一次写入多次读取)归档:日志落进对象存储后,在保留期内任何人都无法修改或删除,连存储管理员也不行。整套方案基于 MinIO 与 mc 客户端,等保要求的六个月留存可以直接按这条链路落地。
适用场景
三种情况必须上 WORM,普通备份解决不了:
- 合规审计要求:等保 2.0 三级明确要求网络日志留存不少于六个月,且要防止未预期的删除、修改。备份可以被删,WORM 在保留期内删不掉,这是两者最本质的差别。
- 勒索软件防护:攻击者拿到 root、拿到云账号都不怕,只要对象处于合规保留期,删请求会被存储侧直接拒绝。这是最后一道止损线。
- 安全事件取证:入侵时间线重建依赖原始日志的完整性,任何一次「顺手清理磁盘」都可能毁掉关键证据。WORM 保证取证时拿到的就是当时写下的。
反过来说,业务日志、调试日志不需要 WORM。给它们加锁只会推高存储成本,还会让排障变得麻烦。分层:审计日志上锁,业务日志正常轮转。
前置条件
- MinIO RELEASE.2023 之后的版本,或 AWS S3、阿里云 OSS 等支持 Object Lock 的兼容存储。老版本 MinIO 不支持锁定桶。
mc客户端已配置别名,具备s3:PutObjectRetention、s3:PutObjectLegalHold权限。- 服务器已用 logrotate 管理日志,能按天切割并压缩。
- 服务器与对象存储之间时钟同步。保留期的
Retain-Until由服务端时间计算,客户端时钟偏差会影响签名,但不影响锁定本身。 - 一个独立的审计账号,日常运维账号不应具备修改保留策略的权限。
原理说明
Object Lock 依赖版本控制
Object Lock 不是给对象打一个标记,而是给对象的某个版本锁定保留期。没有版本控制就没有「版本」这个概念,所以开启锁定的桶必须同时开启 versioning,且只能在建桶时一次性开启,已有桶无法补开。这一点是实施时最常见的卡点。
锁定后的对象仍然可以被覆盖写入,但覆盖会产生新版本,旧版本依旧保留到期满。这就是 WORM 的实际语义:数据不会被销毁,只是多了一个新版本。读取时默认取最新版本,取证时用 --version-id 取指定版本。
GOVERNANCE 与 COMPLIANCE 的区别
两种保留模式的行为差异极大,选错了没有回头路:
- GOVERNANCE:具备
s3:BypassGovernanceRetention权限的账号可以提前解除保留。适合演练、测试、以及需要保留应急通道的场景。 - COMPLIANCE:任何人都不能缩短或解除保留期,包括 root 账号。保留期只能延长,不能缩短。删库跑路、账号被劫持都改不了这个状态。
落地建议:先在测试桶用 GOVERNANCE 跑两周,确认日志量、保留天数、成本都符合预期,再切 COMPLIANCE。反过来操作几乎没有补救手段,只能等期满。
Legal Hold 与 Retention 的叠加
Legal Hold(法律保留)是独立于保留期的开关,打上之后对象永久不可删,直到手动解除。它常用于「已知有诉讼或调查,需要无限期保全」的场景。两者可以叠加,取最严格的那个生效。
操作步骤
步骤一:创建带锁定的存储桶
--with-lock 是关键参数,漏掉它后面所有 retention 命令都会报 Object Lock configuration is not enabled。桶建好后立刻确认版本控制状态。
mc alias set audit https://minio.example.com:9000 auditadmin 'StrongPassw0rd!'
mc mb --with-lock audit/audit-logs
mc version info audit/audit-logs
# 预期输出包含 versioning enabled
步骤二:设置桶级默认保留策略
桶级默认策略让新写入的对象自动获得保留期,不依赖上传脚本显式设置。这一步是整个方案的可靠性来源:脚本可能漏跑,桶策略不会。
# 先设 180 天,等保要求的六个月
mc retention set --default COMPLIANCE 180d audit/audit-logs
# 查看桶级默认策略
mc retention info --default audit/audit-logs
步骤三:配置日志轮转与上传
用 logrotate 按天切割,postrotate 里触发上传。注意 delaycompress 必须开,否则刚切出来的日志还在写就被压缩,会出现截断。
sudo tee /etc/logrotate.d/nginx-audit >/dev/null <<'EOF'
/var/log/nginx/access.log /var/log/nginx/error.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0640 www-data adm
sharedscripts
postrotate
/usr/local/bin/push-audit-log.sh
endscript
}
EOF
上传脚本按日期分层,避免同名覆盖。任务里同时显式设一次 retention,形成桶策略加对象级的双保险;--preserve 保留原始修改时间,取证时时间戳才有意义。
#!/usr/bin/env bash
set -euo pipefail
D=$(date -u +%Y/%m/%d)
PREFIX="nginx/$D"
mc mirror --overwrite --preserve /var/log/nginx "audit/audit-logs/$PREFIX"
# 对象级保留:与桶默认策略叠加,取更严格者
mc retention set --recursive COMPLIANCE 180d "audit/audit-logs/$PREFIX" || true
# 生成完整性清单
find /var/log/nginx -maxdepth 1 -type f -name '*.gz' -exec sha256sum {} \; \
> "/tmp/manifest-$D.txt"
mc cp "/tmp/manifest-$D.txt" "audit/audit-logs/$PREFIX/manifest.sha256"
步骤四:对关键日志加法律保留
发生安全事件时,把涉事时段的日志目录整体打上 legal hold,防止保留期到期后被生命周期规则清掉。这一步应当在事件定级后立刻执行,不要等取证结束。
mc legalhold set --recursive "audit/audit-logs/nginx/2026/11/06"
mc legalhold info --recursive "audit/audit-logs/nginx/2026/11/06" | head -20
步骤五:冷热分层控制成本
WORM 不等于全部放热存储。前 30 天高频查询放标准层,之后转低频或归档层,对象锁的保留期跟随对象走,转换存储类别不会解除锁定。
cat > /tmp/lifecycle.json <<'EOF'
{
"Rules": [
{
"ID": "audit-tiering",
"Status": "Enabled",
"Filter": { "Prefix": "nginx/" },
"Transition": { "Days": 30, "StorageClass": "GLACIER" },
"Expiration": { "Days": 220 }
}
]
}
EOF
mc ilm import audit/audit-logs < /tmp/lifecycle.json
mc ilm ls audit/audit-logs
配置验证
验证一:确认对象已被锁定
mc stat 输出里必须有 Lock Status: enabled 与 Retain-Until 时间。只看到版本号没有锁状态,说明桶默认策略没生效。
mc stat "audit/audit-logs/nginx/2026/11/07/access.log.gz"
mc retention info --recursive "audit/audit-logs/nginx/2026/11/07/" | head
验证二:删除请求必须失败
这是整个方案的核心验证。用最强权限的账号尝试删除,如果删成功,说明锁定根本没生效,前面的配置全部白做。
mc rm "audit/audit-logs/nginx/2026/11/07/access.log.gz"
# 预期:AccessDenied / Object is under retention
mc rm --recursive --force "audit/audit-logs/nginx/2026/11/07/"
# 预期:同样被拒绝,不得出现任何删除成功提示
验证三:覆盖写入产生新版本而非销毁
同名覆盖后查版本列表,应当能看到旧版本仍在,且旧版本的 Retain-Until 未被重置。
mc cp /etc/hostname "audit/audit-logs/nginx/2026/11/07/access.log.gz"
mc ls --versions "audit/audit-logs/nginx/2026/11/07/access.log.gz"
验证四:完整性清单可校验
定期把清单拉回来比对,能发现「对象还在但内容被替换」的情况。这也是审计时向检查方证明日志未被篡改的直接证据。
mc cp "audit/audit-logs/nginx/2026/11/07/manifest.sha256" /tmp/
cd /var/log/nginx && sha256sum -c /tmp/manifest.sha256
常见问题
Q1:已有桶开启 Object Lock 报错,无法配置
Object Lock 只能在建桶时开启,已有桶没有补救路径。正确做法是新建一个 --with-lock 的桶,把历史日志 mc mirror 过去,然后改上传脚本的目标前缀。历史日志的保留期从复制完成时刻开始计算,无法回溯到日志产生时间,这一点在合规说明里要写清楚。
如果必须让保留期与日志时间对齐,只能在复制后手动按目录设置不同的 Retain-Until 日期,工作量较大,建议只在审计明确要求时做。
Q2:COMPLIANCE 模式设了 10 年,能改短吗
不能。COMPLIANCE 模式下保留期只允许延长,任何缩短请求都会被存储侧拒绝,root 账号也一样。这是设计目标而不是缺陷,正因为无法撤销,攻击者拿到管理员权限也毁不掉证据。
所以策略必须先在 GOVERNANCE 桶演练:用真实日志量跑两周,算出存储成本与查询频率,确认 180 天确实够用,再切 COMPLIANCE。真要长期保存,用生命周期转归档层降成本,而不是缩短保留期。
Q3:上传脚本报 AccessDenied,但对象已经存在
检查是否在覆盖一个仍处于保留期的对象。虽然版本控制允许写新版本,但部分 S3 兼容实现对带锁对象的覆盖有额外限制,尤其是启用了 legal hold 的对象。解决方式是按日期分路径,保证同一路径不重复写入,本文脚本已经用 date -u +%Y/%m/%d 做到这一点。
另一个常见原因是最小权限策略缺 s3:GetObjectRetention。脚本里调用了 mc retention set,读取当前策略这一步会先发一个 GET,缺权限就在这里失败,报错信息却常常只显示 AccessDenied,容易误判成写入权限问题。
总结
日志留存做到「防篡改」,要同时满足四个条件,缺一条链路就断:
- 写入即锁定:桶级默认保留策略保证脚本漏跑也不会留下未锁对象。
- 权限分离:上传账号只有写权限,保留策略由独立审计账号管理,运维账号不具备 bypass 能力。
- 路径分层:按 UTC 日期分区,避免覆盖写入引发的版本混乱。
- 可验证:定期跑删除测试与清单校验,把「以为锁上了」变成「验证过锁上了」。
最后一条最容易被忽略。见过把 retention 命令写进文档、实际从未执行的环境,也见过桶建对了但上传走的是另一个前缀。上线后第一周一定要亲手删一次、看它失败,这个动作比任何配置检查都可靠。