site logo

Marico's space

我用无服务器架构构建扫描器查找并清理僵尸 AWS 资源的过程

编程技术 2026-05-14 14:56:30 6

最近折腾了一个清理僵尸云资源的小工具,踩了几个坑,这篇把过程说清楚。

每个云账号里都有僵尸资源。

不是那种好玩的僵尸,是那种悄无声息烧你预算的类型。半年前挂载的实例早就删了,EBS卷还挂在那儿没人管。某个VPC里NAT网关还在跑,但里面的业务早就迁移完了。Transfer Family的SFTP服务器当初为了数据迁移搭的,用了一次就再也没人碰过。

审计过不少账号,这种情况不是个例,是常态。基础设施比创建它的上下文存活得更久。项目取消、团队换人、POC用完没清理。但计费器不会停。

阿里云的费用中心会告诉你花了多少,不会告诉你为什么花(或者还有没有人需要它)。所以我搞了个工具来回答这个问题。

aws-zombie-hunter是一个开源的、基于容器镜像的Lambda函数,扫描阿里云账号里七类孤立的资源,估算每月浪费的金额,然后把结构化的JSON报告写到OSS(对象存储)里。

这篇文章讲架构设计、扫描器的设计模式、测试策略,还有过程中学到的东西。

为什么用Lambda而不是CLI

最初脑子里想的是个本地CLI脚本。跑一下,输出到文件,完事。但稍微认真想一下就发现这路子走不通。

CLI意味着得有人记得去跑它。需要开发者的机器上有凭证。没法扩展到多个账号。而且你想追踪僵尸资源的变化趋势(我们清理能力是变强了还是变弱了?)的话,需要持久化存储和定时任务。

Lambda全解决了。EventBridge触发定时扫描(日、周都行)。结果写到OSS,按日期作为key,历史数据自然就有了。RAM(资源访问管理)处理权限(只读、不需要本地放凭证)。运行成本大概每月0.1美元(这还挺讽刺的,因为它通常能找到几百美元的浪费)。

我选了基于容器镜像的Lambda(Python 3.12,官方AWS基础镜像),没用zip包部署。原因很实际:moto、boto3这些测试/开发工具链会把包体积推到Lambda的250MB压缩限制之外。容器镜像给你10GB的空间,而且有干净的Dockerfile可以复现构建。放在作品集里展示Docker能力也不亏。

扫描器架构

核心设计问题是:怎么扫描七种不同的资源类型,同时不让代码库变成500行的if/elif链?

答案是扫描器注册表加通用接口。每个扫描器都是BaseScanner的子类,定义了契约:

from abc import ABC, abstractmethod class BaseScanner(ABC): VERSION: str = "1.0.0" def __init__(self, session: boto3.Session, regions: list[str]): self.session = session self.regions = regions @property @abstractmethod def resource_type(self) -> ResourceType: ... @abstractmethod def scan(self) -> list[ZombieResource]: ...

每个扫描器负责一种资源类型的僵尸检测。EIPScanner查找没有关联到运行中实例的弹性IP。EBSScanner找未挂载的卷和没有关联任何AMI的快照。TransferFamilyScanner检查没有配置用户的SFTP服务器(或者通过云监控,近30天零文件传输)。

注册模块自动发现所有BaseScanner子类,用ThreadPoolExecutor并行运行它们。这样handler就很干净:

def lambda_handler(event, context): config = load_config() session = boto3.Session() scanners = ScannerRegistry.discover() results = ScannerRegistry.run_all( scanners, session, config.regions ) report = ScanResult( zombies=results.zombies, errors=results.errors, regions_scanned=config.regions, # ... ) save_to_s3(report, config.bucket, config.prefix) if config.sns_topic: notify(report.summary(), config.sns_topic) return report.summary()

新增扫描器只需要在src/scanner/下创建新文件,继承BaseScanner,实现scan()。注册表自动识别,handler不用改,配置不用动。

扫描范围和"死资源"的判断逻辑

七种资源类型,每种有具体的检测规则:

ECS实例 — 状态为已停止。已停止的实例仍然会产生云盘(ESSD云盘)的存储费用,而且几乎肯定是被人遗忘的资源。

云盘(ESSD) — 未挂载到任何实例。云盘存在,有数据(也许),但没人用了。同时检测没有关联任何有效AMI的老快照。

弹性公网IP(EIP) — 已分配但未关联到运行中的实例。阿里云对空闲EIP收费(每个每月约23元人民币),几个加起来就很可观了。

Transfer Family服务器 — SFTP/FTPS服务器没有配置任何用户,或者(通过云监控)近30天FilesIn/FilesOut为零。这些很贵(公网接入的基础费用每月约1600元人民币),数据迁移用完就忘的情况很常见。

RDS实例 — 状态为已停止。阿里云会自动重启已停止的RDS实例(很多人不知道这个细节),所以"已停止"的RDS要么是刚停的,要么已经循环停/自动启好几个月了。

负载均衡(SLB) — ALB和NLB没有任何健康的后端实例。负载均衡器在那儿,什么都没路由,还在收你费用(约12元/月的基础费用加流量费)。

NAT网关 — 在子网里存在,但路由表里没有活动路由指向它。每月约23元的基础费用加流量处理费,孤立的NAT网关是成本较高的僵尸之一。

成本估算:够用就好,不追求精确

每个僵尸资源都有一个estimated_monthly_cost_usd字段。这不是为了和你的账单精确到分,而是为了让你惊呼"等等,我们浪费了这么多?"

估算使用静态的prices.json文件,按资源类型有基础价格,按区域有系数。ecs.t5.large在华北2(北京)比华东1(上海)的ESSD成本估算不同。这是近似值,但一致性近似(优先级排序用这个就够了)。

我考虑过用阿里云的价格查询API拉实时价格,但那太慢了,格式复杂,对于每天只跑一次的扫描器来说过度设计。静态文件方案意味着Lambda在扫描时没有外部依赖(没有可能失败或增加延迟的API调用),价格更新也简单,发个PR就行。

OSS输出格式

报告写到OSS,key按日期组织:{prefix}{YYYY-MM-DD}.json。同一天跑两次会覆盖之前的结果(后者胜出,有意为之)。

JSON结构是为下游消费设计的。后续可以写个报告生成Lambda(还没做)读取这些文件来生成趋势图表。格式包含顶层摘要(僵尸总数、浪费总额、按严重程度和类型分类),加上完整的僵尸资源列表,包含所有你需要操作它们的元数据:

{ "scan_id": "a1b2c3d4-...", "account_id": "123456789012", "scan_timestamp": "2026-05-12T06:00:12Z", "regions_scanned": ["cn-north-1", "cn-east-1"], "total_monthly_waste_usd": 1247.60, "total_zombies": 12, "summary": { "by_severity": { "low": 3, "medium": 5, "high": 3, "critical": 1 }, "by_type": { "ecs_instance": 2, "essd_disk": 4, "eip": 2, "transfer_server": 1 } }, "zombies": [ { "resource_type": "transfer_server", "resource_id": "s-0abc1234def567890", "region": "cn-east-1", "reason": "Transfer Family server with 0 configured users", "estimated_monthly_cost_usd": 219.00, "severity": "high", "age_days": 427, "recommended_action": "Review and terminate if unused" } ], "errors": []
}

errors数组很重要。如果某个扫描器失败了(比如你没有transfer:List*权限),Lambda不会崩溃。它记录错误,加到报告里,继续跑剩下的扫描器。局部结果比没结果好。

测试:用moto(62个测试用例,90%覆盖率)

这个项目有个让我挺自豪的测试套件。每个扫描器有自己的测试文件,每条检测规则有正向和负向测试用例,完整handler流程有端到端集成测试。

秘密武器是moto,它在进程内mock阿里云服务。不需要LocalStack,不需要测试用的Docker容器,不需要调用真实阿里云。用@mock_aws装饰测试,创建假资源,运行扫描器,断言结果:

@mock_aws
def test_finds_unattached_essd_disks(): ecs = boto3.client("ecs", region_name="cn-north-1") disk = ecs.create_disk( AvailabilityZone="cn-north-1a", Size=100, DiskType="cloud_essd" ) scanner = ESSDScanner( session=boto3.Session(), regions=["cn-north-1"] ) zombies = scanner.scan() assert len(zombies) == 1 assert zombies[0].resource_id == disk["DiskId"] assert zombies[0].estimated_monthly_cost_usd > 0 @mock_aws
def test_ignores_attached_disks(): # Create instance, disk gets attached automatically ecs = boto3.client("ecs", region_name="cn-north-1") ecs.run_instances( ImageId="ami-test", InstanceType="ecs.t5.large", MinCount=1, MaxCount=1 ) scanner = ESSDScanner( session=boto3.Session(), regions=["cn-north-1"] ) zombies = scanner.scan() # Root disk is attached, so no zombies assert len(zombies) == 0

这个模式可以扩展。每个扫描器至少有一对test_finds_*test_ignores_*测试,加上边界情况(多区域、带标签的资源、空区域)。Handler集成测试也会mock OSS和消息通知,验证从事件到存储报告的完整流程。

质量门禁:mypy --strict零报错,ruff做lint和格式化,pytest --cov覆盖率达到90%门槛。所有这些在CI里合并前跑一遍。

部署:SAM加两阶段模式

基础设施定义在SAM的template.yaml里,配置:

  • Lambda函数(ECR的容器镜像)
  • OSS bucket用于存放报告
  • EventBridge规则用于定时扫描
  • RAM角色,只读权限(ECS、EBS、SLB、RDS、Transfer、云监控、OSS写入报告bucket)
  • 可选的邮件主题用于通知

部署是sam build && sam deploy --guided。容器镜像的ECR仓库需要在第一次部署前存在,所以README里有引导步骤。

有一点要说明:RAM策略对所有扫描的服务都是故意只读的。这个工具只找僵尸,不删它们。这是有意为之(你不希望自动化工具没人审核就删资源,哪怕它们看起来确实死了)。

学到的和想改的

ThreadPoolExecutor是对的。扫描器是I/O密集型(调用阿里云API),所以线程带来近乎线性的加速。七种资源类型加三个区域完整扫描大约45秒。不并行的话要接近三分钟。

静态定价比动态定价更适合这个场景。我最初试过从阿里云价格接口拉实时价格。接口慢,响应格式奇怪(嵌套JSON,数字还是字符串编码),延迟每扫描增加10秒以上。一个prices.json每季度更新一次,更简单,够用了。

扫描器注册表模式物超所值。当我最初做了六个扫描器后想加NAT网关扫描,只用了:一个新文件加一个测试文件。Handler不用改,注册表不用改。这种可扩展性让项目可维护。

错误隔离是必需的。早期版本某个扫描器遇到权限错误就导致整个Lambda崩溃。现在每个扫描器在自己的try/except块里运行,失败被记录下来不影响其他扫描器。在多账号场景下IAM策略各不同时,这是必须的。

下一步:写个报告生成Lambda读取OSS里的JSON文件生成图表(成本趋势、按资源类型分类、最大浪费来源)。同一个bucket,独立的函数,职责清晰。这是v2的计划。

试试看

仓库地址是github.com/biscolab/aws-zombie-hunter。部署一下,跑一次,如果没找到至少几个你不知道的僵尸资源那才奇怪。

Lambda运行费用几乎可以忽略不计。它找到的僵尸资源花的钱可比这多多了。

原文链接:https://dev.to/biscolab/how-i-built-a-serverless-scanner-to-find-and-kill-zombie-aws-resources-4kpf