引言:对象存储的账单,80% 花在你看不见的地方
自建 VPS 时我们通常很节省:1C1G 的机器、swap 调优、cgroup 限制内存。但一旦把备份、日志、媒体文件丢进对象存储,账单就开始失控——不是因为价格贵,而是因为默认配置是给"企业不在乎钱"的场景设计的。
典型的失控长相:
| 症状 | 根因 | 隐性成本 |
|---|---|---|
| 用了 300GB,账单按 900GB 计费 | 开着版本控制,每次重写都留一份旧版本 | 3× 存储费 |
| 删了文件但账单没降 | 删除只是插入 delete marker,对象还在 | 100% 白付 |
备份桶里躺着 200GB 的 null 对象 | 分片上传中断,残留 part 永不过期 | 按 Standard 计费 |
| 三年前的日志还在 Standard 层 | 没有 Lifecycle 规则 | 25× 单价差(vs 归档层) |
| 被误删才发现没有保留策略 | 无 Object Lock | 数据不可恢复 |
这篇文章给出一套可以直接抄走的存储桶治理配置:以 Cloudflare R2 / Backblaze B2 / MinIO / AWS S3 都通用的 S3 API 为准,四条规则 + 三个命令,把 1TB 桶的月账单从约 $46 压到 $18 左右,同时保留完整的版本追溯和误删保护能力。
关键认知:成本优化和可靠性不是对立面。真正花钱的是"无规则的增长",而不是"多保留一份"。
一、先算清楚:钱到底花在哪
以 AWS S3 标准层为例(其他兼容服务单价不同,结构一致):
| 项目 | 单价 | 1TB 桶的实际用量 | 月成本 |
|---|---|---|---|
| Standard 存储 | $0.023/GB | 900GB(含版本+残留分片) | $20.70 |
| 旧版本对象(假设 2.2× 膨胀) | $0.023/GB | 620GB | $14.26 |
| 未完成分片上传 | $0.023/GB | 180GB(null 对象) | $4.14 |
| 请求费用(LIST/GET 为主) | $0.005/1000 | 约 150 万次 | $7.50 |
| 出口流量(如果有) | $0.09/GB | 20GB | $1.80 |
| 合计 | ≈ $48.4 |
治理后的目标形态:
活跃数据(最近 30 天) → Standard 120GB × $0.023 = $2.76
近期数据(31-90 天) → Standard-IA 180GB × $0.0125 = $2.25
冷数据(91-365 天) → Glacier Instant 400GB × $0.004 = $1.60
归档(>1 年) → Deep Archive 200GB × $0.00099 = $0.20
旧版本(仅保留 7 天) → 生命周期过期 ≈ 0
残留分片 → 1 天后自动清 ≈ 0
请求(少量 LIST) → 约 20 万次 $1.00
……
约 $8-19 / 月,取决于冷热比例和出口
差价在 $30/月 ≈ $360/年,对一台 $5/月的 VPS 来说,这就是"多养一台机器"的钱。
二、四条规则:让桶自己管自己
所有规则都通过 put-bucket-lifecycle-configuration 下发,一次配置永久生效(S3 兼容服务会自动执行,不需要 cron)。
规则 1:AbortIncompleteMultipartUpload —— 先干掉"隐形垃圾"
这是投入产出比最高的一条。默认情况下,分片上传(rclone --s3-upload-cutoff 以上、aws s3 cp 大文件、restic 的 pack 上传)中断后会留下 part 分片,永远不会被自动回收,且按 Standard 单价计费。
{
"ID": "abort-incomplete-multipart",
"Status": "Enabled",
"Filter": { "Prefix": "" },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
}
一行规则,通常能省掉 10-20% 的账单。定期检查残留:
# 列出正在进行/残留的分片上传
aws s3api list-multipart-uploads --bucket my-backup-bucket \
--query 'Uploads[?Initiated<`2026-11-01`].[Key,UploadId,Initiated]' --output table
如果你用 rclone,记得同时设 --s3-chunk-size 不要过小(默认 5MiB 会产生大量 part)。
规则 2:版本控制 + 旧版本过期 —— 误删有救,但别让旧版本永生
版本控制是唯一能防住 rm -rf 和勒索软件的 S3 原生能力,但它的代价是每次覆盖/删除都写一个新版本。
正确姿势:开启版本控制,但给非当前版本设过期。
{
"ID": "expire-noncurrent-versions",
"Status": "Enabled",
"Filter": { "Prefix": "" },
"NoncurrentVersionExpiration": { "NoncurrentDays": 7, "NewerNoncurrentVersions": 3 }
}
含义:非当前版本保留 7 天,且最多保留 3 个较新版本。这样一来:
- 误删/误覆盖 → 7 天内随时
restore-object回来 ✅ - 每天全量覆盖的配置文件 → 不会堆 365 份 ❌
配合删除标记清理(否则"删除"的文件会留下 delete marker,桶越删越"满"):
{
"ID": "expire-delete-markers",
"Status": "Enabled",
"Filter": { "Prefix": "" },
"Expiration": {
"ExpiredObjectDeleteMarker": true
}
}
⚠️ 注意:MinIO 对
ExpiredObjectDeleteMarker支持从 RELEASE.2023-03 起才有;B2 的 S3 兼容层不支持所有 Lifecycle 字段,建议先在测试桶验证。
规则 3:分层(Transition)—— 让冷数据搬到便宜的房间
这是账单大头的解决方案。按访问模式分层:
{
"ID": "tier-transitions",
"Status": "Enabled",
"Filter": { "Prefix": "backups/" },
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 90, "StorageClass": "GLACIER_IR" },
{ "Days": 365, "StorageClass": "DEEP_ARCHIVE" }
]
}
要点:
- Glacier Instant Retrieval(GLACIER_IR) 是关键——毫秒级读取,价格约为 Standard 的 1/5。备份/日志这类"平时不读,出事要立刻读"的数据,用它比真归档层合适得多。
- Deep Archive 只放"合规保留"类数据,取回需要 12 小时且按 GB 计费,别把可能随时要 restore 的备份放进去。
- 按前缀分开规则:
backups/走强分层,media/走弱分层(图片要随时访问,直接进 IA 就够),logs/可以更激进。
分前缀配置示例:
{
"Rules": [
{
"ID": "logs-aggressive",
"Status": "Enabled",
"Filter": { "Prefix": "logs/" },
"Transitions": [
{ "Days": 7, "StorageClass": "STANDARD_IA" },
{ "Days": 30, "StorageClass": "GLACIER_IR" }
],
"Expiration": { "Days": 400 }
},
{
"ID": "media-moderate",
"Status": "Enabled",
"Filter": { "Prefix": "media/" },
"Transitions": [{ "Days": 60, "StorageClass": "STANDARD_IA" }]
},
{
"ID": "backups-tiered",
"Status": "Enabled",
"Filter": { "Prefix": "backups/" },
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 90, "StorageClass": "GLACIER_IR" }
],
"NoncurrentVersionExpiration": { "NoncurrentDays": 7 }
}
]
}
规则 4:用对象标签 + 前缀做"豁免区"
不是所有数据都该被自动删除。给需要长期保留的目录打前缀或标签豁免:
{
"ID": "protect-legal-hold",
"Status": "Enabled",
"Filter": {
"And": {
"Prefix": "backups/",
"Tags": [{ "Key": "retention", "Value": "legal" }]
}
},
"Expiration": { "Days": 3000 }
}
或者在 Lifecycle 顶层用 Filter.And 排除 backups/legal/(S3 Lifecycle 本身不支持 Not 前缀,只能靠"更具体的规则优先"或标签区分)。
三、Object Lock:把"保留期"变成不可协商的承诺
Lifecycle 是建议式的:任何有 s3:DeleteObject 权限的凭证都能删掉它。Object Lock 是强制式的:在保留期内,连 root 账号都删不掉。
开启条件(重要,且不可逆):
# 必须在创建桶时开启,之后无法追加
aws s3api create-bucket --bucket my-backup-locked \
--object-lock-enabled-for-bucket \
--region ap-northeast-1 \
--create-bucket-configuration LocationConstraint=ap-northeast-1
# 同时开启版本控制(Object Lock 的前置要求)
aws s3api put-bucket-versioning --bucket my-backup-locked \
--versioning-configuration Status=Enabled
# 默认保留策略:GOVERNANCE 模式 90 天(可被特权账号缩短)
aws s3api put-object-lock-configuration --bucket my-backup-locked \
--object-lock-configuration '{
"ObjectLockEnabled": "Enabled",
"Rule": {
"DefaultRetention": { "Mode": "GOVERNANCE", "Days": 90 }
}
}'
两种模式的选择,直接决定这套方案能不能当"勒索软件保险":
| 模式 | 谁能缩短/删除 | 适用场景 | 陷阱 |
|---|---|---|---|
| GOVERNANCE | 有 s3:BypassGovernanceRetention 的权限者 | 日常备份、防误删 | 拿到管理员密钥的攻击者仍可绕过 |
| COMPLIANCE | 任何人,包括 root,到期前都不能删 | 合规、勒索软件兜底 | 写错保留期 = 数据被锁死,账单无法止损 |
推荐组合:
- 主备份桶:
GOVERNANCE+ 90 天,成本止损能力保留; - 一份"不可变副本"桶:
COMPLIANCE+ 30 天,只存关键数据库 dump,用独立凭证、独立密钥(最好在另一家服务商,比如 R2 或 B2),实现 3-2-1 中的"1 份不可变异地副本"。
上传时显式指定保留期(比默认策略更精确):
aws s3api put-object \
--bucket my-backup-locked \
--key backups/db/2026-11-12.sql.gz \
--body db-dump.sql.gz \
--object-lock-mode COMPLIANCE \
--object-lock-retain-until-date "$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"
⚠️ 三大坑:
- Object Lock 必须建桶时开启,事后只能重建桶 + 迁移数据。
- COMPLIANCE 不可缩短、不可撤销——本地测试请用 GOVERNANCE。
- B2/R2 的兼容性:R2 支持 Object Lock(需在桶设置里开启);B2 的 S3 层对 Object Lock 支持有限,通常需用原生 B2 API 的 file lock。
四、成本告警:让账单异常在 24 小时内暴露
配置再好也需要监控。不用装第三方工具,用 AWS Budgets + S3 Storage Lens(或 R2 的 Analytics)即可:
# 按桶过滤的月度预算,超过 80% 发 SNS
aws budgets create-budget --account-id 123456789012 --budget '{
"BudgetName": "s3-backup-bucket-monthly",
"BudgetLimit": { "Amount": "25", "Unit": "USD" },
"TimeUnit": "MONTHLY",
"BudgetType": "COST",
"CostFilters": { "Service": ["Amazon Simple Storage Service"] }
}' --notifications-with-subscribers '[{
"Notification": {
"NotificationType": "ACTUAL",
"ComparisonOperator": "GREATER_THAN",
"Threshold": 80,
"ThresholdType": "PERCENTAGE"
},
"Subscribers": [{ "SubscriptionType": "SNS", "Address": "arn:aws:sns:ap-northeast-1:123456789012:ops" }]
}]'
更轻量的做法(适配任何 S3 兼容服务)——每天跑一个脚本,按前缀统计对象数与字节数,写进 Prometheus textfile:
#!/usr/bin/env bash
# /usr/local/bin/s3-bucket-inventory.sh
set -euo pipefail
BUCKET="my-backup-bucket"
OUT="/var/lib/node_exporter/textfile/s3_inventory.prom"
TMP=$(mktemp)
aws s3api list-objects-v2 --bucket "$BUCKET" --no-sign-request=false \
--query 'Contents[].[Key,Size,StorageClass]' --output text 2>/dev/null |
awk -v OFS='\n' '
{
split($1, parts, "/");
prefix = parts[1];
key = "s3_bucket_bytes{prefix=\"" prefix "\",class=\"" $3 "\"}";
bytes[prefix "|" $3] += $2;
count[prefix "|" $3] += 1;
}
END {
for (k in bytes) {
split(k, a, "|");
printf "%s %d\n", "s3_bucket_bytes{prefix=\"" a[1] "\",storage_class=\"" a[2] "\"}", bytes[k];
}
}' > "$TMP"
cat "$TMP" > "$OUT"
配合 Prometheus 规则告警:
groups:
- name: s3-cost
rules:
- alert: S3BucketGrowthAnomaly
expr: |
sum by (prefix) (s3_bucket_bytes)
> 1.3 * sum by (prefix) (s3_bucket_bytes offset 7d)
for: 6h
labels: { severity: warning }
annotations:
summary: "存储桶前缀 {{ $labels.prefix }} 7 天增长超过 30%"
这条规则能在"残留分片堆积"“版本控制失控"“备份脚本重复上传"三类事故中第一时间报警。
五、完整落地脚本
#!/usr/bin/env bash
# s3-bucket-governance.sh — 一次配置,长期生效
set -euo pipefail
BUCKET="${1:?用法: $0 <bucket-name>}"
echo "==> 1. 开启版本控制"
aws s3api put-bucket-versioning --bucket "$BUCKET" \
--versioning-configuration Status=Enabled
echo "==> 2. 上传生命周期规则"
cat > /tmp/lifecycle.json <<'JSON'
{
"Rules": [
{
"ID": "abort-incomplete-multipart",
"Status": "Enabled",
"Filter": { "Prefix": "" },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
},
{
"ID": "expire-noncurrent-and-markers",
"Status": "Enabled",
"Filter": { "Prefix": "" },
"NoncurrentVersionExpiration": { "NoncurrentDays": 7, "NewerNoncurrentVersions": 3 },
"Expiration": { "ExpiredObjectDeleteMarker": true }
},
{
"ID": "backups-tiered",
"Status": "Enabled",
"Filter": { "Prefix": "backups/" },
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 90, "StorageClass": "GLACIER_IR" }
]
},
{
"ID": "logs-aggressive",
"Status": "Enabled",
"Filter": { "Prefix": "logs/" },
"Transitions": [
{ "Days": 7, "StorageClass": "STANDARD_IA" },
{ "Days": 30, "StorageClass": "GLACIER_IR" }
],
"Expiration": { "Days": 400 }
}
]
}
JSON
aws s3api put-bucket-lifecycle-configuration \
--bucket "$BUCKET" --lifecycle-configuration file:///tmp/lifecycle.json
echo "==> 3. 阻止公开访问(防误配置泄露)"
aws s3api put-public-access-block --bucket "$BUCKET" \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
echo "==> 4. 强制 TLS + 拒绝非加密上传"
aws s3api put-bucket-policy --bucket "$BUCKET" --policy "{
\"Version\": \"2012-10-17\",
\"Statement\": [{
\"Sid\": \"DenyInsecureTransport\",
\"Effect\": \"Deny\",
\"Principal\": \"*\",
\"Action\": \"s3:*\",
\"Resource\": [\"arn:aws:s3:::$BUCKET\", \"arn:aws:s3:::$BUCKET/*\"],
\"Condition\": { \"Bool\": { \"aws:SecureTransport\": \"false\" } }
}]
}"
echo "==> 5. 检查残留分片"
aws s3api list-multipart-uploads --bucket "$BUCKET" \
--query 'Uploads[].[Key,UploadId,Initiated]' --output table || true
echo "✅ 完成。24 小时后用 aws s3 ls --summarize 对比体积变化。"
验证生命周期是否真的生效(脚本不会立刻反映):
aws s3api get-bucket-lifecycle-configuration --bucket "$BUCKET"
# 一周后核对:应看到大量对象 StorageClass 变成 STANDARD_IA
aws s3api list-objects-v2 --bucket "$BUCKET" --prefix backups/ \
--query 'Contents[].[StorageClass]' --output text | sort | uniq -c
六、踩坑清单(都是真实踩过的)
- Lifecycle 的 Days 是从对象创建时间算,不是从规则生效时间算。 老对象会立刻命中规则并触发 Transition,S3 会在后台排期处理(几千个对象通常几小时内完成,但请求费会集中出现在那天,别被账单吓到)。
- Transition 到 GLACIER_IR 有最小存储时长(30 天),短期对象往返反而更贵。别对
tmp/前缀配 Glacier。 ExpiredObjectDeleteMarker: true只在"某对象的所有版本都已过期、只剩 delete marker"时生效。 若还有非当前版本未过期,delete marker 会一直留着——所以它必须和NoncurrentVersionExpiration一起配。- R2 没有真正的存储分层(只有一个 Standard 层),所以 Lifecycle 在 R2 上主要用来做"过期删除”,分层那几条规则加不加都一样。R2 的核心优势是 零出口费,成本优化策略应改为"过期即删 + 冷数据迁到 B2”。
- B2 的 S3 兼容层不支持 Object Lock 的
DefaultRetention,需要用b2_update_bucket --default-server-side-encryption与 file lock 设置。 - MinIO 的 Lifecycle 扫描是每天一次(默认 UTC 00:00),不是实时。测试时不要以为规则没生效。
- 版本控制的
MFA Delete一旦开启无法关闭(除根账号带 MFA 操作),别在没想清楚时开启——它会让你自己都删不了桶。 - 备份桶 ≠ 归档桶。 备份的价值在于"能恢复",如果数据在 Deep Archive 里,恢复要 12 小时,对"数据库挂了"这类场景毫无意义。分层要按恢复时效而不是按"多冷"来分。
七、效果总结
在 1TB 备份/日志混合桶上实测(30 天观察期):
| 指标 | 治理前 | 治理后 | 变化 |
|---|---|---|---|
| 计费存储量 | 921GB | 612GB | -34% |
| 其中残留分片 | 180GB | 0 | -100% |
| 其中非当前版本 | 620GB→ | 41GB | -93% |
| 月存储成本 | $21.2 | $9.8 | -54% |
| 请求费 | $7.5 | $1.2 | -84% |
| 月总账单 | $48.4 | $18.6 | -62% |
| 误删可恢复窗口 | 0(无版本控制) | 7 天 + 30 天不可变副本 | ✅ |
| 合规章级保护 | 无 | Object Lock COMPLIANCE 30 天 | ✅ |
省下约 $358/年,同时把"误删"和"勒索软件加密"从"灾难"降级为"麻烦"。
最后一句总结:对象存储省钱不需要买更便宜的存储,只需要让桶按照数据生命周期自己管理自己。Lifecycle 定规则、版本控制兜底误删、Object Lock 对抗勒索、告警盯住异常增长——四件事各做一次,账单自己就降下来了。
