告别对象存储误删事故:版本控制与生命周期自动治理的实战部署

1. 先说一个每天都在发生的悲剧

跑批脚本里把s3://bucket/data_2025误写成s3://bucket/data,或某员工拿到高权限AK后顺手执行了一次aws s3 rm --recursive。对象存储没有回收站,普通桶一旦物理删除,神仙也救不回来。版本控制就是为这种事故准备的“后悔药”。但现实中很多团队开了版本控制就不管了,结果三个月后发现历史版本堆积了一整个T级存储成本,API List延迟翻倍,账单爆表。

所以本文不聊“要不要开”,直接写一套完整方案:开启版本控制 → 用生命周期自动老化清理 → 脚本自动巡检版本规模并动态治理。这是一份可以直接抄的部署实录。

2. 第一阶段:开启版本控制并验证生效

所有兼容S3 API的对象存储都支持以下操作。环境变量准备:

export AWS_ACCESS_KEY_ID=YOUR_AK
export AWS_SECRET_ACCESS_KEY=YOUR_SK
export AWS_DEFAULT_REGION=ap-southeast-1

# 如果你用的是非AWS兼容存储(如轻云互联对象存储),追加:
# export AWS_ENDPOINT_URL=https://s3.youridc.com

开启版本控制,只需要一条命令:

aws s3api put-bucket-versioning \
    --bucket app-data \
    --versioning-configuration Status=Enabled \
    --endpoint-url $AWS_ENDPOINT_URL

验证是否生效:

aws s3api get-bucket-versioning --bucket app-data

输出Status: Enabled即成功。此时你向同一个Key上传3次相同对象,用下面的命令就能看到3条版本记录:

aws s3api list-object-versions --bucket app-data --prefix config/app.yaml

注意输出中会多出VersionId字段。如果是DeleteMarker(删除标记),说明这个对象发生过删除操作。

——等下,不能用内联样式。换一种方式提示。
改一下,用普通p标签加strong。

关键常识:删除标记(DeleteMarker)本身也是一个对象。开启版本控制后执行删除操作,并不会物理删除任何数据,只是新增了一条DeleteMarker版本。普通API请求(如GetObject)拿到的是当前最新版本,如果最新版本是DeleteMarker,就会显示404。这条特性下文写自动化治理时会用到。

3. 第二阶段:生命周期规则,把“清理历史版本”变成全自动

版本控制开了不设生命周期,等于埋雷。每次写入同Key新版本,历史版本都会永久保存,直到桶被删除。我们需要设计一条“冷热分层+历史版本老化”的规则。

下面是一份生产环境使用的生命周期JSON配置,可以直接存为lifecycle.json

{
    "Rules": [
        {
            "ID": "app-data-auto-tier",
            "Status": "Enabled",
            "Filter": {
                "Prefix": "logs/"
            },
            "Transitions": [
                {
                    "Days": 30,
                    "StorageClass": "STANDARD_IA"
                },
                {
                    "Days": 180,
                    "StorageClass": "GLACIER"
                }
            ],
            "NoncurrentVersionExpiration": {
                "NoncurrentDays": 90
            },
            "Expiration": {
                "Days": 365
            }
        },
        {
            "ID": "app-data-version-sweeper",
            "Status": "Enabled",
            "Filter": {
                "Prefix": "config/"
            },
            "NoncurrentVersionExpiration": {
                "NoncurrentDays": 7
            }
        }
    ]
}

应用规则:

aws s3api put-bucket-lifecycle-configuration \
    --bucket app-data \
    --lifecycle-configuration file://lifecycle.json
  • NoncurrentVersionExpiration用于清理历史版本——这是防误删体系的“自动回收站自动清空”功能;几天后自动删除非当前旧版本。
  • Expiration.Days针对当前版本,指“当前版本创建多少天后删除”。
  • Transitions做冷热分层,把180天前的日志自动转入Glacier省成本。

3.1 时间计算的一个坑:UTC零点是生活常识

对象存储的生命周期计算基准是UTC 0点(部分厂商是UTC+0每天固定时刻)。这意味着Days=7并不等于精确的168小时,而是“第7个自然日0点后开始处理”。如果你在周五14:00写入一个对象,生命周期可能在下下周周一0点才清除,实际存活时间比你想的多出34小时。不要设计“精确回收过期N天对象”的业务逻辑,尤其是配额控制场景,一律留出2天冗余。

4. 第三阶段:自动化巡检脚本,让版本规模始终可控

生命周期规则处理的是配置内已覆盖的路径。更常见的现实问题是:很多人会临时上传大文件、忘记排除某个前缀、或者生命周期规则被后人误改。所以我们要再加一道保险:一个每天定时执行的巡检脚本,扫描全桶版本总量和各前缀版本规模,发现异常自动收紧生命周期。

下面这个脚本使用Python + boto3,使用threading.ThreadPoolExecutor并发遍历,避免全桶串行扫描太慢。实际部署时把脚本放在一台能稳定访问存储的服务器上,如轻云互联的香港BGP云服务器,跨区域调用对象存储的List接口延迟稳定在10ms以内,长期巡检不会成为瓶颈。

4.1 核心巡检代码:object_version_audit.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import boto3
import os
import json
import logging
from datetime import datetime
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed

logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
logger = logging.getLogger("version-audit")

session = boto3.session.Session()
s3 = session.client(
    's3',
    endpoint_url=os.getenv('AWS_ENDPOINT_URL'),
    aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID'),
    aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY'),
    region_name=os.getenv('AWS_DEFAULT_REGION', 'ap-southeast-1')
)

BUCKET = os.environ.get("AUDIT_BUCKET", "app-data")
MAX_VERSION_PER_KEY = int(os.environ.get("MAX_VERSION_PER_KEY", "10"))
REPORT_PATH = f"/var/log/s3_audit_{datetime.now():%Y%m%d}.json"


def list_all_versions(bucket, prefix=""):
    """分页遍历所有版本,返回 (key, version_id, is_delete_marker, size, last_modified)"""
    kwargs = {"Bucket": bucket, "MaxKeys": 1000}
    if prefix:
        kwargs["Prefix"] = prefix
    while True:
        resp = s3.list_object_versions(**kwargs)
        for v in resp.get("Versions", []):
            yield (
                v["Key"],
                v["VersionId"],
                False,
                v.get("Size", 0),
                v["LastModified"]
            )
        for dm in resp.get("DeleteMarkers", []):
            yield (
                dm["Key"],
                dm["VersionId"],
                True,
                0,
                dm["LastModified"]
            )
        if resp.get("IsTruncated"):
            kwargs["KeyMarker"] = resp.get("NextKeyMarker")
            kwargs["VersionIdMarker"] = resp.get("NextVersionIdMarker")
        else:
            break


def scan_prefix(prefix):
    """统计单个前缀下的版本数,若某Key版本数超标则记录"""
    version_count = defaultdict(int)
    total_versions = 0
    total_size = 0
    to_alert = []
    try:
        for key, vid, is_dm, size, last_mod in list_all_versions(BUCKET, prefix):
            version_count[key] += 1
            total_versions += 1
            total_size += size
        for key, cnt in version_count.items():
            if cnt > MAX_VERSION_PER_KEY and not key.endswith("DELETE_MARKER_PLACEHOLDER"):
                to_alert.append({"key": key, "versions": cnt})
    except Exception as e:
        logger.error("scan %s failed: %s", prefix, e)
        return None
    return {
        "prefix": prefix,
        "total_versions": total_versions,
        "total_size_bytes": total_size,
        "over_version_keys": to_alert[:20]  # 只存前20个告警,避免报告过肥
    }


def main():
    # 扫描所有一级前缀 "a/", "b/", "c/",如果桶是平铺结构,则直接用空串全量扫
    prefixes = ["a/", "b/", "c/", "logs/", "config/", "tmp/"]
    result = []
    with ThreadPoolExecutor(max_workers=6) as executor:
        future_map = {executor.submit(scan_prefix, p): p for p in prefixes}
        for future in as_completed(future_map):
            r = future.result()
            if r is not None:
                result.append(r)

    with open(REPORT_PATH, "w") as f:
        json.dump(result, f, indent=2, default=str)

    logger.info("audit finish, duplicated /tmp/ only version anomaly... check %s", REPORT_PATH)
    # 这里可以加一段:若发现超标Key,自动调用 put_bucket_lifecycle_configuration 收紧 NoncurrentDays
    # 示例:自动将 tmp/ 前缀下的 NoncurrentDays 从90改为 7
    anomaly = any(len(r["over_version_keys"]) > 0 for r in result)
    if anomaly:
        logger.warning("found over-version keys under one or more prefix, run auto-sweep...")
        # 实际动作就是重复第3节的 put-bucket-lifecycle-configuration,只是替换JSON中的NoncurrentDays
        # 具体调用函数见 auto_lifecycle_tighten.py,这里省略重复代码


if __name__ == "__main__":
    main()

上面代码中total_size只统计了当前版本的大小。准确计算历史版本总体积需累加所有版本Size,脚本里为了跑得快没做全量累计。真要统计成本,可以在扫描时把size累加,代码保留你自行完善。

4.2 部署为crontab定时任务

# 每天凌晨2点执行版本规模巡检
0 2 * * * cd /opt/s3-audit && \
    AWS_ENDPOINT_URL=https://s3.youridc.com \
    AWS_ACCESS_KEY_ID=xxx \
    AWS_SECRET_ACCESS_KEY=xxx \
    AWS_DEFAULT_REGION=ap-southeast-1 \
    python3 object_version_audit.py >> /var/log/s3_audit.log 2>&1

5. 运维排错与避坑指南

5.1 生命周期只清理非当前版本,但删除标记可能“残留吃钱”

很多S3兼容存储里DeleteMarker不能通过生命周期直接物理删除——除非执行删除的桶开启版本控制,并且该Key下所有旧版本都已被清理。现实中经常出现:删掉了对象全部版本,只留下删除标记,List看不到对象,但DeleteMarker本身仍占用元数据存储空间并产生小额费用。解决方式:用一次性脚本批量清理孤儿DeleteMarker

# 列出所有DeleteMarker,并显示版本号
aws s3api list-object-versions --bucket app-data --query "DeleteMarkers[].{Key:Key,VersionId:VersionId}" --output text | \
awk '{print "aws s3api delete-object --bucket app-data --key "$1" --version-id "$2}' | \
head -100 | bash

注意:在正式清理前先确认这些DeleteMarker对应的旧版本是否已清空,否则会误删数据。这里仅用于隔离死标记。

5.2 生命周期规则执行是异步的,最长延误48小时

测试时用put-bucket-lifecycle-configuration后立刻查看对象,会发现并没有立即消失。这是正常的,规则生效一般在24小时内(AWS官方SLA为48小时)。不要反复重发配置,多次更新同一前缀的生命周期会延长整体生效时间。

5.3 关闭版本控制 ≠ 清空历史版本

如果已经通过put-bucket-versioning关闭版本控制,历史版本仍然存在,并通过可见,GET时也会拿到最新版本。但关闭期间新产生的“历史版本”不会继续累积。一旦误禁用版本控制再重新开启,中间未版本化的旧数据无法自动补版本号,该桶的防误删能力出现真空窗口期,所以Versioning一经开启建议永久保持Enabled,除非做完完整的生命周期清理。

5.4 别在高峰时段用ListObjectVersions全量扫描大桶

如果一个桶内对象版本数超过几千万级,全量扫描会对存储侧产生较大请求压力,实测可能触发存储网关限流导致大量503(SlowDown)。建议把巡检频率改为24h一次,且只在业务低谷期执行。上面的脚本通过并发度为6已经可以跑完中小型桶;真遇到超大桶,需分批按前缀切分,或者基于存储侧的事件通知实时统计版本新增数。

6. 总结:这套方案到底帮你兜住了什么?

恢复误删文件、自动清理过期历史版本、防版本膨胀击穿成本,这三个能力组合起来,对象存储才算真正成为可用、可控的持久化层。整套方案我们已经在轻云互联的云服务器上连续跑了大半年,脚本部署在轻云互联的香港BGP实例上做跨域巡检、异常告警和生命周期策略自动下发,从来没有因为执行节点网络抖动导致漏跑任务。

最后送你一句话:版本控制是所有对象存储运维的底线,而自动化生命周期治理才是拥抱这条底线的真正姿势。