亚马逊AWS官方博客

Amazon EFS 目录级配额监控:多租户 SaaS方案

摘要:Amazon EFS 不提供原生目录级配额能力。本文基于 AWS Lambda 双层 fan-out 架构与 Amazon EventBridge,给出按客户/项目两级目录粒度的存储用量监控方案,涵盖架构设计、完整代码、部署命令及成本分析。


一、引言

Amazon EFS 不提供原生的目录级配额能力,本文给出一种基于 AWS Lambda 双层 fan-out 架构与 Amazon EventBridge 的轻量监控方案,按“客户/项目”两级目录粒度持续追踪用量,并在接近或超出阈值时将状态交给下游系统处理。

二、背景与需求

许多 SaaS 平台采用一套 Amazon EFS 文件系统承载全部租户数据,目录按 /{customer}/{project}/ 两级组织。

Amazon EFS 本身不提供目录级别的配额能力:

  • Amazon EFS 不支持对单个目录设定存储配额,其 Access Point 仅做路径隔离与 POSIX 权限映射,无法限制容量
  • Amazon CloudWatch 提供的 StorageBytes 指标是整个文件系统的总容量,无法细分到某个客户或项目目录

因此需要在 Amazon EFS 之外自建一套轻量的监控方案。具体要求:

  • 以”客户 / 项目”两级目录为最小监控单元,分别统计真实磁盘占用
  • 配额阈值可灵活调整,新增客户或项目时无需改动监控逻辑

三、总体架构

方案采用双层 AWS Lambda fan-out 架构,如架构图 1 所示:

  • Dispatcher Lambda(调度层):Amazon EventBridge 按设定频率(默认每小时)触发。该函数通过 Amazon EFS Access Point 只读挂载文件系统根目录,扫描第一层目录得到客户列表,然后为每个客户异步调用一个 Worker Lambda 实例。
  • Worker Lambda(扫描层):每个实例负责一个客户目录,遍历该客户下所有项目目录并用 du -s 统计大小,完成后上报 Amazon CloudWatch 指标并将配额状态推送给下游处置系统。

[图1]

架构图说明:① Amazon EventBridge 按设定频率触发 Dispatcher Lambda;② Dispatcher 通过 EFS Access Point 只读挂载根目录,扫描得到客户列表;③ Dispatcher 为每个客户异步 invoke 一个 Worker Lambda;④ 各 Worker 并行挂载 EFS,用 du -s 统计该客户下各项目目录大小;⑤ Worker 将结果写入 CloudWatch 自定义指标,并可选推送 Webhook 通知下游系统。

相比单层架构,双层 fan-out 设计有两个优势:

1. 并行扫描:各客户目录同时扫描,总耗时取决于最慢的单个客户而非所有客户之和

2. 天然可扩展:客户数增长时 Dispatcher 多发几个 invoke 即可,单个 Worker 永远只处理一个客户,不存在超时风险

网络链路上,两层 Lambda 函数均部署在同一 Amazon VPC 的私有子网内,通过 NFS 端口 2049 在内网挂载 Amazon EFS;推送状态走 HTTPS 443,是否需要 NAT 网关取决于下游系统的位置,部署章节会展开说明。

四、实现与部署

本节先讲两层函数的实现逻辑,再给出从零部署到 AWS 的完整命令。

4.1 Dispatcher Lambda:调度层

Dispatcher 的职责很简单:扫描 EFS 根目录得到客户列表,为每个客户异步调用 Worker。它本身不做 du 统计,耗时通常在秒级。

import os
import json
import boto3

lambda_client = boto3.client('lambda')

EFS_MOUNT_PATH = os.environ.get('EFS_MOUNT_PATH', '/mnt/efs')
WORKER_FUNCTION_NAME = os.environ.get('WORKER_FUNCTION_NAME', '')
FILE_SYSTEM_ID = os.environ.get('FILE_SYSTEM_ID', '')
QUOTA_GB = os.environ.get('QUOTA_GB', '50')
SOFT_LIMIT_PERCENT = os.environ.get('SOFT_LIMIT_PERCENT', '80')
WEBHOOK_URL = os.environ.get('WEBHOOK_URL', '')
WEBHOOK_TOKEN = os.environ.get('WEBHOOK_TOKEN', '')
CW_NAMESPACE = os.environ.get('CW_NAMESPACE', 'Custom/EFS/ProjectQuota')


def lambda_handler(event, context):
    print(f"=== EFS Quota Monitor Dispatcher ===")
    print(f"Mount: {EFS_MOUNT_PATH}, Worker: {WORKER_FUNCTION_NAME}")

    if not os.path.exists(EFS_MOUNT_PATH):
        return {'statusCode': 500, 'body': 'EFS mount path not found'}

    customers = [
        d for d in sorted(os.listdir(EFS_MOUNT_PATH))
        if os.path.isdir(os.path.join(EFS_MOUNT_PATH, d)) and not d.startswith('.')
    ]
    print(f"Found {len(customers)} customers: {customers}")

    invoked = 0
    for customer in customers:
        payload = {
            'customer': customer,
            'efs_mount_path': EFS_MOUNT_PATH,
            'file_system_id': FILE_SYSTEM_ID,
            'quota_gb': QUOTA_GB,
            'soft_limit_percent': SOFT_LIMIT_PERCENT,
            'webhook_url': WEBHOOK_URL,
            'webhook_token': WEBHOOK_TOKEN,
            'cw_namespace': CW_NAMESPACE,
        }
        lambda_client.invoke(
            FunctionName=WORKER_FUNCTION_NAME,
            InvocationType='Event',  # 异步调用,不等待返回
            Payload=json.dumps(payload).encode()
        )
        invoked += 1
        print(f"  Dispatched: {customer}")

    return {
        'statusCode': 200,
        'body': json.dumps({'customers_dispatched': invoked})
    }

InvocationType=’Event’ 是关键:Dispatcher 不等待 Worker 返回,发完即结束。AWS Lambda 服务会为每个客户创建独立的 Worker 实例并行执行。

4.2 Worker Lambda:扫描层

Worker 接收 Dispatcher 传入的客户名,挂载 EFS 后扫描该客户下所有项目目录。核心逻辑是对每个项目调用 du -s 统计字节数,扫描完成后将结果写入 Amazon CloudWatch 自定义指标。下游对接(Webhook、告警等)可根据实际需求在此基础上扩展。

import os
import subprocess
import json
import boto3
from datetime import datetime, timezone

cloudwatch = boto3.client('cloudwatch')


def scan_customer_projects(base_path, customer):
    """扫描单个客户下的所有项目目录,返回各项目字节数"""
    customer_path = os.path.join(base_path, customer)
    results = []

    if not os.path.isdir(customer_path):
        return results

    for project in sorted(os.listdir(customer_path)):
        project_path = os.path.join(customer_path, project)
        if not os.path.isdir(project_path) or project.startswith('.'):
            continue
        try:
            r = subprocess.run(['du', '-s', project_path],
                               capture_output=True, text=True, timeout=300)
            if r.returncode == 0:
                size_bytes = int(r.stdout.split()[0])
                results.append({
                    'customer': customer,
                    'project': project,
                    'size_gb': round(size_bytes / (1024 ** 3), 4),
                })
                print(f"  {customer}/{project}: {size_bytes / (1024**3):.2f} GB")
        except (subprocess.TimeoutExpired, Exception) as e:
            print(f"  ERROR {project}: {e}")

    return results


def lambda_handler(event, context):
    customer = event['customer']
    efs_mount_path = event['efs_mount_path']
    file_system_id = event['file_system_id']
    quota_gb = int(event.get('quota_gb', 50))
    cw_namespace = event.get('cw_namespace', 'Custom/EFS/ProjectQuota')

    print(f"=== Worker: {customer} | quota={quota_gb}GB ===")
    results = scan_customer_projects(efs_mount_path, customer)

    if not results:
        return {'statusCode': 200, 'body': json.dumps({'customer': customer, 'projects': 0})}

    # 上报 CloudWatch 自定义指标
    metric_data = []
    for item in results:
        dims = [{'Name': 'FileSystemId', 'Value': file_system_id},
                {'Name': 'Customer', 'Value': item['customer']},
                {'Name': 'Project', 'Value': item['project']}]
        ts = datetime.now(timezone.utc)
        metric_data.append({'MetricName': 'ProjectSizeGB', 'Dimensions': dims,
                            'Value': item['size_gb'], 'Unit': 'Gigabytes', 'Timestamp': ts})
        metric_data.append({'MetricName': 'ProjectUsagePercent', 'Dimensions': dims,
                            'Value': round((item['size_gb'] / quota_gb) * 100, 2),
                            'Unit': 'Percent', 'Timestamp': ts})
    for i in range(0, len(metric_data), 20):
        cloudwatch.put_metric_data(Namespace=cw_namespace, MetricData=metric_data[i:i+20])

    over = [r for r in results if r['size_gb'] > quota_gb]
    print(f"  Done: {len(results)} projects, {len(over)} exceeded")
    return {'statusCode': 200,
            'body': json.dumps({'customer': customer, 'scanned': len(results), 'exceeded': len(over)})}

Worker 的设计要点:

  • 每次调用只处理一个客户目录,天然避免了 Lambda 超时问题
  • 配置参数由 Dispatcher 通过 event payload 传入,Worker 本身无需预设环境变量
  • 扫描结果写入 CloudWatch 指标后即完成;如需通知业务系统(Webhook、SNS、SQS 等),可在 lambda_handler 末尾按需扩展

4.3 部署到 AWS

理清实现后,通过 AWS 命令行逐步部署。整个过程依次创建监控专用的 Amazon EFS Access Point、最小权限的 IAM 角色、Amazon VPC 网络与安全组、两个 AWS Lambda 函数,以及 Amazon EventBridge 定时规则。下文命令中的文件系统 ID、Amazon VPC、子网、安全组等均为示例值,使用时请替换为实际环境取值。部署前需具备以下条件:

  • 已有一套 Amazon EFS 文件系统,且 Amazon VPC 私有子网内已就绪对应的 Amazon EFS 挂载目标
  • 业务系统提供一个可接收 HTTP POST JSON 的 API 端点(若采用 Webhook 接入方式)
  • 已配置具备相应权限的 AWS 命令行环境

4.3.1 第一步:创建监控专用 Access Point

为监控函数单独创建一个挂载到根目录的 Amazon EFS Access Point,使其能遍历所有客户与项目目录。Dispatcher 和 Worker 共用同一个 Access Point:

EFS_ID="fs-0123456789abcdef0"   # 示例值,请替换

MONITOR_AP=$(aws efs create-access-point \
  --file-system-id $EFS_ID \
  --posix-user "Uid=0,Gid=0" \
  --root-directory "Path=/,CreationInfo={OwnerUid=0,OwnerGid=0,Permissions=755}" \
  --tags "Key=Name,Value=ap-quota-monitor" \
  --query 'AccessPointId' --output text)

4.3.2 第二步:创建最小权限 IAM 角色

双层架构需要两个 IAM 角色。Dispatcher 除基本权限外还需要 lambda:InvokeFunction 来调用 Worker;Worker 需要 Amazon EFS 只读挂载和 Amazon CloudWatch 写入权限。

4.3.3 第三步:配置网络与安全组

两层 Lambda 函数均部署在 Amazon VPC 私有子网内,需要打通两条网络链路:经 NFS 端口 2049 在同 Amazon VPC 内网访问 Amazon EFS 挂载目标;经 HTTPS 443 访问下游处置系统端点和 AWS Lambda 服务 API(用于 Dispatcher 调用 Worker)。

VPC_ID="vpc-xxxxxxxxx"           # 示例值,请替换

# 获取 EFS 挂载目标的安全组
MOUNT_TARGET_ID=$(aws efs describe-mount-targets --file-system-id $EFS_ID \
  --query 'MountTargets[0].MountTargetId' --output text)
EFS_SG=$(aws efs describe-mount-target-security-groups \
  --mount-target-id $MOUNT_TARGET_ID --query 'SecurityGroups[0]' --output text)

# 创建 Lambda 安全组并放行 Lambda → EFS 的 NFS 流量
LAMBDA_SG=$(aws ec2 create-security-group \
  --group-name efs-quota-monitor-lambda-sg \
  --description "Lambda SG for EFS quota monitor" \
  --vpc-id $VPC_ID --query 'GroupId' --output text)

aws ec2 authorize-security-group-ingress \
  --group-id $EFS_SG --protocol tcp --port 2049 --source-group $LAMBDA_SG

Dispatcher 调用 Worker 时走 AWS Lambda 服务 API(HTTPS 443),该请求通过 NAT 网关或 VPC Endpoint 出网。如果下游业务系统也位于公网,Lambda 所在子网需要配置 NAT 网关。

4.3.4 第四步:部署 Worker Lambda

先部署 Worker,因为 Dispatcher 需要引用 Worker 的函数名:

REGION="ap-southeast-1"          # 示例值,请替换
SUBNET_1="subnet-xxxxxxxx"
SUBNET_2="subnet-yyyyyyyy"
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

zip efs_quota_worker.zip worker_lambda.py

aws lambda create-function \
  --function-name efs-quota-monitor-worker \
  --runtime python3.12 \
  --handler worker_lambda.lambda_handler \
  --role $ROLE_ARN \
  --zip-file fileb://efs_quota_worker.zip \
  --timeout 900 --memory-size 512 \
  --vpc-config "SubnetIds=$SUBNET_1,$SUBNET_2,SecurityGroupIds=$LAMBDA_SG" \
  --file-system-configs "Arn=arn:aws:elasticfilesystem:${REGION}:${ACCOUNT_ID}:access-point/${MONITOR_AP},LocalMountPath=/mnt/efs"

Worker 不需要预设环境变量——所有配置由 Dispatcher 通过 event payload 传入。

4.3.5 第五步:部署 Dispatcher Lambda

WEBHOOK_URL="https://your-biz-system.com/api/efs-quota-event"
WEBHOOK_TOKEN="your-shared-secret-token"

zip efs_quota_dispatcher.zip dispatcher_lambda.py

aws lambda create-function \
  --function-name efs-quota-monitor-dispatcher \
  --runtime python3.12 \
  --handler dispatcher_lambda.lambda_handler \
  --role $ROLE_ARN \
  --zip-file fileb://efs_quota_dispatcher.zip \
  --timeout 60 --memory-size 256 \
  --vpc-config "SubnetIds=$SUBNET_1,$SUBNET_2,SecurityGroupIds=$LAMBDA_SG" \
  --file-system-configs "Arn=arn:aws:elasticfilesystem:${REGION}:${ACCOUNT_ID}:access-point/${MONITOR_AP},LocalMountPath=/mnt/efs" \
  --environment "Variables={EFS_MOUNT_PATH=/mnt/efs,FILE_SYSTEM_ID=$EFS_ID,WORKER_FUNCTION_NAME=efs-quota-monitor-worker,QUOTA_GB=50,SOFT_LIMIT_PERCENT=80,WEBHOOK_URL=$WEBHOOK_URL,WEBHOOK_TOKEN=$WEBHOOK_TOKEN,CW_NAMESPACE=Custom/EFS/ProjectQuota}"

Dispatcher 超时设为 60 秒即可——它只做目录列举和异步 invoke,不做实际扫描。各环境变量含义:

  • WORKER_FUNCTION_NAME:Worker Lambda 的函数名
  • FILE_SYSTEM_ID:被监控的 Amazon EFS 文件系统 ID
  • QUOTA_GB:单项目配额(GB),默认 50
  • SOFT_LIMIT_PERCENT:软限制百分比,默认 80
  • WEBHOOK_URL / WEBHOOK_TOKEN:业务系统接收端点及认证 Token
  • CW_NAMESPACE:Amazon CloudWatch 自定义指标命名空间

4.3.6 第六步:配置 Amazon EventBridge 定时触发

创建定时规则触发 Dispatcher:

aws events put-rule \
  --name efs-quota-monitor-schedule \
  --schedule-expression "rate(1 hour)" --state ENABLED

DISPATCHER_ARN=$(aws lambda get-function --function-name efs-quota-monitor-dispatcher \
  --query 'Configuration.FunctionArn' --output text)
RULE_ARN=$(aws events describe-rule --name efs-quota-monitor-schedule \
  --query 'Arn' --output text)

aws events put-targets --rule efs-quota-monitor-schedule \
  --targets "Id=dispatcher,Arn=$DISPATCHER_ARN"

aws lambda add-permission \
  --function-name efs-quota-monitor-dispatcher \
  --statement-id eventbridge-invoke \
  --action lambda:InvokeFunction \
  --principal events.amazonaws.com \
  --source-arn $RULE_ARN

五、效果与成本

5.1 运行效果

部署完成后,手动触发一次扫描验证:

aws lambda invoke \
  --function-name efs-quota-monitor-dispatcher \
  --payload '{}' \
  --cli-read-timeout 60 \
  response.json && cat response.json

Dispatcher 在数秒内完成调度,返回类似 {“customers_dispatched”: 3}。随后各 Worker 并行执行,可在各自的 Amazon CloudWatch Logs 中查看扫描结果。下图展示了一次扫描在 Amazon CloudWatch Logs 中的输出。

[图2]

如启用了 Amazon CloudWatch 指标上报,还可基于自定义命名空间建立 Dashboard 观察各项目用量历史趋势,如下图所示。

[图3]

5.2 性能表现

扫描耗时主要取决于 du 遍历目录的速度,而 du 的速度与目录下的文件数量(元数据量)相关,与总字节数关系不大。以下为基于实际部署的实测数据(4 个客户、7 个项目目录,其中 customerX/bigproject 含约 13 万文件),Worker Lambda 配置为 512 MB 内存,6 轮测试取平均值:

5.2.1 Dispatcher 耗时

指标 实测值
调度 4 个客户 455 ms

5.2.2 Worker 耗时(按客户)

客户 项目数 文件数 平均耗时 范围
customerX 1 ~130,000 27 秒 8.7~41 秒
customerA 3 ~500 2.1 秒 1.0~3.4 秒
customerB 2 ~230 2.6 秒 1.0~5.2 秒
customerC 1 ~300 1.7 秒 0.9~3.3 秒

端到端完成时间(从 EventBridge 触发到全部 Worker 完成):取决于最慢的 Worker,实测约 8~41 秒。

扫描过程只消耗 Amazon EFS 的元数据读取 IOPS,不影响业务对文件系统的正常读写吞吐。

六、总结

本方案通过 EventBridge 定时触发 Dispatcher Lambda 发现客户目录,再异步 fan-out 多个 Worker Lambda 并行扫描,实现了按“客户/项目”两级目录粒度的存储用量监控。方案无额外存储组件依赖,新增客户或项目时零配置,配额阈值通过环境变量灵活调整,适用于多租户 SaaS 平台的共享 EFS 存储场景。

➡️ 下一步行动:

相关产品:

相关文章:

*前述特定亚马逊云科技生成式人工智能相关的服务目前在亚马逊云科技海外区域可用。亚马逊云科技中国区域相关云服务由西云数据和光环新网运营,具体信息以中国区域官网为准。

本篇作者

朱文倩

亚马逊云科技解决方案架构师,负责亚马逊云科技云计算方案咨询和设计。同时致力于生成式 AI 应用方面的研究和推广,并通过可实施的解决方案,帮助客户取得业务价值。

戴逸洋

AWS数据存储资深架构师,致力于推广存储技术在云上的各种最佳实践。


AWS 架构师中心:云端创新的引领者

探索 AWS 架构师中心,获取经实战验证的最佳实践与架构指南,助您高效构建安全、可靠的云上应用