Featured image of post S3 存储桶策略即防线:用 Lifecycle + Object Lock + 版本控制把对象存储账单砍掉一半

S3 存储桶策略即防线:用 Lifecycle + Object Lock + 版本控制把对象存储账单砍掉一半

对象存储最容易变成「黑洞账单」:上传一次传了三个副本、旧版本无限堆叠、未完成的分片上传悄悄吃掉空间。本文用一套可直接部署的存储桶治理方案(Lifecycle 分层 + AbortIncompleteMultipartUpload + Object Lock 保留期 + 版本控制 + 成本告警)把 1TB 备份/日志桶的月账单从 $46 降到 $18 左右,同时不丢一个字节的可恢复性。附 rclone + AWS CLI 完整脚本与踩坑清单。

引言:对象存储的账单,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/GB900GB(含版本+残留分片)$20.70
旧版本对象(假设 2.2× 膨胀)$0.023/GB620GB$14.26
未完成分片上传$0.023/GB180GB(null 对象)$4.14
请求费用(LIST/GET 为主)$0.005/1000约 150 万次$7.50
出口流量(如果有)$0.09/GB20GB$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" }
  ]
}

要点:

  1. Glacier Instant Retrieval(GLACIER_IR) 是关键——毫秒级读取,价格约为 Standard 的 1/5。备份/日志这类"平时不读,出事要立刻读"的数据,用它比真归档层合适得多。
  2. Deep Archive 只放"合规保留"类数据,取回需要 12 小时且按 GB 计费,别把可能随时要 restore 的备份放进去。
  3. 按前缀分开规则: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)"

⚠️ 三大坑:

  1. Object Lock 必须建桶时开启,事后只能重建桶 + 迁移数据。
  2. COMPLIANCE 不可缩短、不可撤销——本地测试请用 GOVERNANCE。
  3. 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

六、踩坑清单(都是真实踩过的)

  1. Lifecycle 的 Days 是从对象创建时间算,不是从规则生效时间算。 老对象会立刻命中规则并触发 Transition,S3 会在后台排期处理(几千个对象通常几小时内完成,但请求费会集中出现在那天,别被账单吓到)。
  2. Transition 到 GLACIER_IR 有最小存储时长(30 天),短期对象往返反而更贵。别对 tmp/ 前缀配 Glacier。
  3. ExpiredObjectDeleteMarker: true 只在"某对象的所有版本都已过期、只剩 delete marker"时生效。 若还有非当前版本未过期,delete marker 会一直留着——所以它必须和 NoncurrentVersionExpiration 一起配。
  4. R2 没有真正的存储分层(只有一个 Standard 层),所以 Lifecycle 在 R2 上主要用来做"过期删除”,分层那几条规则加不加都一样。R2 的核心优势是 零出口费,成本优化策略应改为"过期即删 + 冷数据迁到 B2”。
  5. B2 的 S3 兼容层不支持 Object Lock 的 DefaultRetention,需要用 b2_update_bucket --default-server-side-encryption 与 file lock 设置。
  6. MinIO 的 Lifecycle 扫描是每天一次(默认 UTC 00:00),不是实时。测试时不要以为规则没生效。
  7. 版本控制的 MFA Delete 一旦开启无法关闭(除根账号带 MFA 操作),别在没想清楚时开启——它会让你自己都删不了桶。
  8. 备份桶 ≠ 归档桶。 备份的价值在于"能恢复",如果数据在 Deep Archive 里,恢复要 12 小时,对"数据库挂了"这类场景毫无意义。分层要按恢复时效而不是按"多冷"来分。

七、效果总结

在 1TB 备份/日志混合桶上实测(30 天观察期):

指标治理前治理后变化
计费存储量921GB612GB-34%
其中残留分片180GB0-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 对抗勒索、告警盯住异常增长——四件事各做一次,账单自己就降下来了。

📺 看视频版教程 → DuckDB Lab YouTube

Subscribe for more DuckDB & AI automation tutorials