はじめに
2026年4月に、AWS Lambda から Amazon S3 Files をマウントできるようになりました。
S3 Filesを使うと、Amazon S3バケットのオブジェクトをLambda関数内のローカルマウントパスからファイルとして扱えます。これまでもSDKを使ってS3オブジェクトを取得したり、別の選択肢としてAmazon EFSをLambdaにマウントしてファイルシステムを使ったりすることはできました。
そこでこの記事では、Lambdaからファイルを読み書きする場合に、S3 Files / S3 SDKでどのくらい速度面の差が出るのか を検証してみます。
この記事は検証設計と結果をまとめる記事です。測定結果は環境やリージョン、Lambdaのメモリ、ファイルサイズ、同時実行数によって変わる可能性があります。
S3 Filesとは
Amazon S3 Files は、Amazon S3バケットのデータをファイルシステムとして扱えるサービスです。S3と言えばオブジェクトストレージですが、S3 Filesはファイルシステムとして扱うところがポイントです。
ドキュメントの画像がわかりやすいですが、ファイルの内容はS3バケットに自動で同期され、S3 Filesを経由していい感じにS3バケットにリクエストすることでデータにアクセスできます。
「いい感じ」とは、ドキュメントに従うと「インテリジェントに変換」するということです。
S3 Files は、ファイルシステムのオペレーションを、ユーザーに代わって効率的な S3 リクエストにインテリジェントに変換します。
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/s3-files.html
また、一定のファイルサイズ未満(デフォルトでは128KiB未満)であればファイルシステムの高性能ストレージにインポートされ、読み取り速度が速くなります。一方で、高性能ストレージに保存されていない場合と1MiB以上のファイル読み取りを行う場合はS3バケットから直接ファイルを読み取ります。
検証内容
今回は A:従来方式でもある 非VPC Lambda + S3 SDK、B:Lambda + S3 Files の2構成で検証し、以下の3つの観点で比較します。
- 小さいファイルの読み込み:S3 Filesの低レイテンシーアクセスが効くか
- 大きいファイルの読み込み:1MiB以上の読み込みでS3 SDKとの差がどう出るか
- 書き込み:ファイル書き込みとS3 PUTでどのくらい差が出るか
| 構成 | 目的 | |
|---|---|---|
| A | 非VPC Lambda + S3 SDK | 最もシンプルなS3アクセスの基準 |
| B | Lambda + S3 Files | S3をファイルシステムとしてマウントする新しい構成 |
なお、S3 Files構成はVPCとマウントターゲットを含むため、この記事の比較にはS3 APIとファイルI/Oの違いだけでなく、構成差も含まれます。
検証では、64KiBの小さいファイルと16MiBの大きめのファイルを使い、単発読み込み、単発書き込みを測定します。結果は平均値だけでなく、min / p50 / p90 / p99 / max も見ます。
ちなみにコールドスタートの観点での検証は他の方の記事もありますのでこちらもご覧ください。
比較する構成
A. 非VPC Lambda + S3 SDK
VPCに接続しない通常のLambda関数から、AWS SDKでS3へアクセスします。
これは最もシンプルな構成です。単純にS3オブジェクトを読み書きするだけであれば、この構成が比較の基準になります。
B. Lambda + S3 Files
S3 FilesをLambdaにマウントし、Lambda関数内の /mnt/... 配下からファイルとして読み書きします。
S3 Filesは、S3バケットまたはプレフィックスにリンクされたファイルシステムを作成し、Lambdaなどのコンピューティングリソースからマウントして使います。Lambdaから使う場合は、Lambda関数がマウントターゲットと同じVPCに配置されており、関数を配置する各サブネットにマウントターゲットが必要です。
また、LambdaのS3 Files設定では、S3からの直接読み込みはメモリ512MB以上の関数でサポートされると説明されています。今回は両構成でメモリを揃え、512MBにします。
Amazon S3 Files optimizes throughput by reading directly from Amazon S3. Direct reads from Amazon S3 are supported only for functions configured with 512 MB or more of memory.
https://docs.aws.amazon.com/lambda/latest/dg/configuration-filesystem-s3files.html
後から気づきましたが、VPC Lambda + S3 SDKのパターンも検証すべきでした
検証環境
検証環境は以下の条件で揃えます。
| 項目 | 値 |
|---|---|
| リージョン | ap-northeast-1 |
| Lambda Runtime | Python 3.14 |
| Lambda Architecture | arm64 |
| Lambda Memory | 512MB |
| S3バケット | S3 Standard |
| 小ファイルサイズ | 64KiB |
| 大ファイルサイズ | 16MiB |
| 集計値 | min / avg / p50 / p90 / p99 / max |
測定方法
Lambda関数の中で、ファイル操作またはS3 API呼び出しの直前と直後に時刻を取り、処理時間を計測します。
Lambda全体のDurationには初期化処理やログ出力の時間も含まれるため、この記事ではまずファイル読み書き処理そのものの時間を主な比較対象にします。
測定回数は次のようにします。
| 検証 | 実行回数 |
|---|---|
| 単発読み込み | 100回 |
| 単発書き込み | 100回 |
p99を見るには本来もっと試行回数が欲しいところですが、今回は検証コストとのバランスを見てこの回数にしています。まずは傾向を見ることを目的にします。
各パターンは iterations=105 / warmup=5 で実行し、先頭5回分は集計対象から除外しています。
検証パターン
読み込み
| ID | 内容 | サイズ | 個数 | 実行回数 |
|---|---|---|---|---|
| R1 | 小ファイル単発読み込み | 64KiB | 1 | 100回 |
| R2 | 大ファイル単発読み込み | 16MiB | 1 | 100回 |
S3 Filesは、初回アクセス時と2回目以降で挙動が変わる可能性があります。
書き込み
| ID | 内容 | サイズ | 個数 | 実行回数 |
|---|---|---|---|---|
| W1 | 小ファイル単発書き込み | 64KiB | 1 | 100回 |
| W2 | 大ファイル単発書き込み | 16MiB | 1 | 100回 |
書き込みについては、S3 Filesがファイルシステムへの書き込みとS3バケットへのバックグラウンド同期を分けて持つ点を踏まえて、処理時間を見ます。
事前準備(Lambda関数)
コードについては以下の通りです。
A. 非VPC Lambda + S3 SDK
"""A. 非VPC Lambda + S3 SDK: 内部でループ計測し、JSON 1行でログ出力する。
呼び出し方(aws lambda invoke の payload):
{"op": "setup"} read対象を予め用意(最初に1回)
{"op": "read", "size": "small"} 64KiB 読み込みを計測
{"op": "read", "size": "large", "read_mode": "unique"} iterationごとに別ファイルを読み込み
{"op": "write", "size": "large"} 16MiB 書き込みを計測
iterations(既定105) / warmup(既定5) も上書き可。
計測値は warmup を除いた回だけ JSON 1行でログに出す(=Logs Insightsで集計)。
"""
import json
import os
import re
import time
import boto3
from botocore.config import Config
BUCKET = os.environ["BUCKET"]
PREFIX = os.environ.get("KEY_PREFIX", "bench").strip("/")
SIZES = {"small": 64 * 1024, "large": 16 * 1024 * 1024} # 64KiB / 16MiB
# read用(予め用意して壊さない)と write用(毎回上書き)でキーを分ける
READ_KEY = PREFIX + "/read_{size}.bin"
READ_UNIQUE_KEY = PREFIX + "/read_{size}_{index:03d}.bin"
READ_UNIQUE_DATASET_KEY = PREFIX + "/read_{size}_{dataset}_{index:03d}.bin"
WRITE_KEY = PREFIX + "/write_{size}_{request_id}_{iteration}.bin"
s3 = boto3.client(
"s3",
config=Config(
retries={"total_max_attempts": 3, "mode": "standard"},
connect_timeout=3,
read_timeout=60,
),
)
# handler の外(initフェーズ)はコールドスタート時だけ走る → そのコンテナ初回だけ True
is_cold_start = True
def _unique_key(size: str, iteration: int, read_dataset: str) -> str:
if read_dataset:
return READ_UNIQUE_DATASET_KEY.format(size=size, dataset=read_dataset, index=iteration)
return READ_UNIQUE_KEY.format(size=size, index=iteration)
def _read(size: str, read_mode: str, read_dataset: str, iteration: int) -> tuple[int, float]:
if read_mode == "unique":
key = _unique_key(size, iteration, read_dataset)
else:
key = READ_KEY.format(size=size)
start = time.perf_counter()
response = s3.get_object(Bucket=BUCKET, Key=key)
with response["Body"] as body:
data = body.read()
return len(data), (time.perf_counter() - start) * 1000
def _write(size: str, request_id: str, iteration: int, data: bytes) -> tuple[int, float]:
key = WRITE_KEY.format(size=size, request_id=request_id, iteration=iteration)
start = time.perf_counter()
s3.put_object(Bucket=BUCKET, Key=key, Body=data)
return len(data), (time.perf_counter() - start) * 1000
def _setup(event: dict) -> list[dict]:
"""read検証で使う 64KiB / 16MiB のオブジェクトを予め用意する。"""
created = []
for size, byte_count in SIZES.items():
key = READ_KEY.format(size=size)
s3.put_object(Bucket=BUCKET, Key=key, Body=os.urandom(byte_count))
created.append({"bucket": BUCKET, "key": key, "bytes": byte_count})
if event.get("create_unique", False):
unique_count = _positive_int(event, "unique_count", 105)
read_dataset = _read_dataset(event)
sizes = [event["size"]] if event.get("size") in SIZES else SIZES.keys()
for size in sizes:
byte_count = SIZES[size]
for i in range(unique_count):
key = _unique_key(size, i, read_dataset)
s3.put_object(Bucket=BUCKET, Key=key, Body=os.urandom(byte_count))
created.append({"bucket": BUCKET, "key": key, "bytes": byte_count})
return created
def _positive_int(event: dict, name: str, default: int) -> int:
value = int(event.get(name, default))
if value < 0:
raise ValueError(f"{name} must be greater than or equal to 0")
return value
def _read_dataset(event: dict) -> str:
value = str(event.get("read_dataset", "")).strip()
if value and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", value):
raise ValueError("read_dataset must be 1-64 characters: A-Z, a-z, 0-9, '_' or '-'")
return value
def _validate_event(event: dict) -> tuple[str, str, int, int, str, str]:
op = event.get("op", "read")
size = event.get("size", "small")
read_mode = event.get("read_mode", "single")
read_dataset = _read_dataset(event)
if op not in {"read", "write"}:
raise ValueError("op must be 'read' or 'write'")
if size not in SIZES:
raise ValueError("size must be 'small' or 'large'")
if read_mode not in {"single", "unique"}:
raise ValueError("read_mode must be 'single' or 'unique'")
iterations = _positive_int(event, "iterations", 105)
warmup = _positive_int(event, "warmup", 5)
if warmup > iterations:
raise ValueError("warmup must be less than or equal to iterations")
return op, size, iterations, warmup, read_mode, read_dataset
def handler(event, context):
global is_cold_start
cold = is_cold_start
is_cold_start = False
event = event or {}
op = event.get("op", "read")
if op == "setup":
return {"ok": True, "config": "A_s3_sdk", "created": _setup(event)}
op, size, iterations, warmup, read_mode, read_dataset = _validate_event(event)
request_id = getattr(context, "aws_request_id", "local")
write_data = os.urandom(SIZES[size]) if op == "write" else b""
for i in range(iterations):
if op == "read":
byte_count, elapsed_ms = _read(size, read_mode, read_dataset, i)
else:
byte_count, elapsed_ms = _write(size, request_id, i, write_data)
if i < warmup:
continue # warmup区間はログに出さない(=集計対象から除外)
print(json.dumps({
"schema": "s3_access_benchmark_v1",
"config": "A_s3_sdk",
"op": op,
"size": size,
"read_mode": read_mode if op == "read" else "",
"read_dataset": read_dataset if op == "read" else "",
"bytes": byte_count,
"sample_index": i - warmup,
"sample_count": iterations - warmup,
"warmup": warmup,
"elapsed_ms": round(elapsed_ms, 3),
"cold_invocation": cold,
"request_id": request_id,
"runtime": os.environ.get("AWS_EXECUTION_ENV", "unknown"),
}))
return {
"ok": True,
"config": "A_s3_sdk",
"op": op,
"size": size,
"read_mode": read_mode if op == "read" else "",
"read_dataset": read_dataset if op == "read" else "",
"cold_invocation": cold,
"samples_logged": iterations - warmup,
}
B. Lambda + S3 Files
"""B. Lambda + S3 Files: マウントパス配下を内部ループ計測し、JSON 1行でログ出力する。
S3 Files をマウントしたパス(/mnt/s3files)に対して通常のファイルI/Oで読み書きする。
この関数はマウントターゲットと同じ VPC / サブネットに配置する必要がある。
呼び出し方は構成Aと同じ({"op":"setup"} → {"op":"read"/"write", "size":...})。
{"op":"read","size":"large","read_mode":"unique"} で iteration ごとに別ファイルを読む。
"""
import json
import os
import re
import time
MOUNT = os.environ.get("MOUNT_PATH", "/mnt/s3files").rstrip("/")
PREFIX = os.environ.get("KEY_PREFIX", "bench").strip("/")
SIZES = {"small": 64 * 1024, "large": 16 * 1024 * 1024} # 64KiB / 16MiB
BENCH_DIR = os.path.join(MOUNT, PREFIX)
READ_PATH = os.path.join(BENCH_DIR, "read_{size}.bin")
READ_UNIQUE_PATH = os.path.join(BENCH_DIR, "read_{size}_{index:03d}.bin")
READ_UNIQUE_DATASET_PATH = os.path.join(BENCH_DIR, "read_{size}_{dataset}_{index:03d}.bin")
WRITE_PATH = os.path.join(BENCH_DIR, "write_{size}_{request_id}_{iteration}.bin")
is_cold_start = True # コンテナ初回(コールドスタート)だけ True
def _unique_path(size: str, iteration: int, read_dataset: str) -> str:
if read_dataset:
return READ_UNIQUE_DATASET_PATH.format(size=size, dataset=read_dataset, index=iteration)
return READ_UNIQUE_PATH.format(size=size, index=iteration)
def _read(size: str, read_mode: str, read_dataset: str, iteration: int) -> tuple[int, float]:
if read_mode == "unique":
path = _unique_path(size, iteration, read_dataset)
else:
path = READ_PATH.format(size=size)
start = time.perf_counter()
with open(path, "rb") as f:
data = f.read()
return len(data), (time.perf_counter() - start) * 1000
def _write(size: str, request_id: str, iteration: int, data: bytes) -> tuple[int, float]:
path = WRITE_PATH.format(size=size, request_id=request_id, iteration=iteration)
start = time.perf_counter()
with open(path, "wb") as f:
f.write(data)
f.flush()
os.fsync(f.fileno()) # Lambdaから見たファイルシステムへの書き込み完了を待つ。S3へのエクスポート完了とは別
return len(data), (time.perf_counter() - start) * 1000
def _setup(event: dict) -> list[dict]:
"""read検証で使う 64KiB / 16MiB のファイルを予め用意する。"""
created = []
for size, byte_count in SIZES.items():
path = READ_PATH.format(size=size)
with open(path, "wb") as f:
f.write(os.urandom(byte_count))
f.flush()
os.fsync(f.fileno())
created.append({"path": path, "bytes": byte_count})
if event.get("create_unique", False):
unique_count = _positive_int(event, "unique_count", 105)
read_dataset = _read_dataset(event)
sizes = [event["size"]] if event.get("size") in SIZES else SIZES.keys()
for size in sizes:
byte_count = SIZES[size]
for i in range(unique_count):
path = _unique_path(size, i, read_dataset)
with open(path, "wb") as f:
f.write(os.urandom(byte_count))
f.flush()
os.fsync(f.fileno())
created.append({"path": path, "bytes": byte_count})
return created
def _positive_int(event: dict, name: str, default: int) -> int:
value = int(event.get(name, default))
if value < 0:
raise ValueError(f"{name} must be greater than or equal to 0")
return value
def _read_dataset(event: dict) -> str:
value = str(event.get("read_dataset", "")).strip()
if value and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", value):
raise ValueError("read_dataset must be 1-64 characters: A-Z, a-z, 0-9, '_' or '-'")
return value
def _validate_event(event: dict) -> tuple[str, str, int, int, str, str]:
op = event.get("op", "read")
size = event.get("size", "small")
read_mode = event.get("read_mode", "single")
read_dataset = _read_dataset(event)
if op not in {"read", "write"}:
raise ValueError("op must be 'read' or 'write'")
if size not in SIZES:
raise ValueError("size must be 'small' or 'large'")
if read_mode not in {"single", "unique"}:
raise ValueError("read_mode must be 'single' or 'unique'")
iterations = _positive_int(event, "iterations", 105)
warmup = _positive_int(event, "warmup", 5)
if warmup > iterations:
raise ValueError("warmup must be less than or equal to iterations")
return op, size, iterations, warmup, read_mode, read_dataset
def handler(event, context):
global is_cold_start
cold = is_cold_start
is_cold_start = False
event = event or {}
os.makedirs(BENCH_DIR, exist_ok=True) # 書き込み先ディレクトリを確保
op = event.get("op", "read")
if op == "setup":
return {"ok": True, "config": "B_s3_files", "created": _setup(event)}
op, size, iterations, warmup, read_mode, read_dataset = _validate_event(event)
request_id = getattr(context, "aws_request_id", "local")
write_data = os.urandom(SIZES[size]) if op == "write" else b""
for i in range(iterations):
if op == "read":
byte_count, elapsed_ms = _read(size, read_mode, read_dataset, i)
else:
byte_count, elapsed_ms = _write(size, request_id, i, write_data)
if i < warmup:
continue # warmup区間はログに出さない(=集計対象から除外)
print(json.dumps({
"schema": "s3_access_benchmark_v1",
"config": "B_s3_files",
"op": op,
"size": size,
"read_mode": read_mode if op == "read" else "",
"read_dataset": read_dataset if op == "read" else "",
"bytes": byte_count,
"sample_index": i - warmup,
"sample_count": iterations - warmup,
"warmup": warmup,
"elapsed_ms": round(elapsed_ms, 3),
"cold_invocation": cold,
"request_id": request_id,
"runtime": os.environ.get("AWS_EXECUTION_ENV", "unknown"),
}))
return {
"ok": True,
"config": "B_s3_files",
"op": op,
"size": size,
"read_mode": read_mode if op == "read" else "",
"read_dataset": read_dataset if op == "read" else "",
"cold_invocation": cold,
"samples_logged": iterations - warmup,
}
今回の iterations=105 は、1回のLambda呼び出しの中で同じ処理を105回繰り返す内部ループです。そのため、ここでの warmup=5 はLambdaのコールドスタートを除外するためではなく、読み書き処理そのものの初回ゆらぎを集計から外すために入れています。
集計する読み書き処理は100回分となります。
事前準備(S3 Filesの設定)
下記のページにLambdaでマウントするときの前提条件について記載があります。これに従って各種リソースを作成します。
VPC作成
下記の通り、プライベートサブネットが1つだけあるVPCを作成します。

S3バケット作成
ファイルシステムを作成する前に、S3バケットを作成しておきます。

S3ファイルシステム作成
ファイルシステム作成時、バケットを選択するとバージョニングが必要と言われてしまったので大人しくバージョニングを有効にします。
その後、作成したVPCを選択してファイルシステムを作成します。

マウントターゲットの作成(自動作成)
次にマウントターゲットを作成しようと思ったところ、すでに作成されていました。

アクセスポイントの作成(自動作成)
アクセスポイントについても自動で作成されていました。ルートディレクトリパスが「/lambda」として作成されています。

セキュリティグループ
マウントターゲットおよびVPC内Lambdaのセキュリティグループを設定します。
マウントターゲットのセキュリティグループではインバウンドルールに2049ポート(NFS)を許可するルール、VPC内Lambdaのセキュリティグループにはアウトバウンドルールに2049ポート(NFS)を許可するルールを追加します。
結果
計測は各パターン100回分のログをCloudWatch Logs Insightsで集計しました。
R1. 64KiB単発読み込み
| 構成 | min | avg | p50 | p90 | p99 | max | 備考 |
|---|---|---|---|---|---|---|---|
| 非VPC Lambda + S3 SDK | 21.2 ms | 25.4 ms | 24.8 ms | 27.7 ms | 40.0 ms | 57.0 ms | S3 GetObject
|
| Lambda + S3 Files | 1.8 ms | 2.0 ms | 2.0 ms | 2.2 ms | 2.4 ms | 2.5 ms |
/mnt/s3files から open().read()
|
64KiBの読み込みでは、S3 Filesがかなり速い結果になりました。p50では約12倍、p99では約16倍の差が出ています。
S3 Filesは小さいファイルを高性能ストレージ側に取り込むため、同じファイルを繰り返し読む今回の条件では低レイテンシーアクセスが強く効いているように見えます。
R1補足. 64KiBを毎回別ファイルで読み込み
同じファイルを繰り返し読むことでキャッシュが強く効いている可能性があります。そこで、iterationごとに別ファイルを読む条件でも追加検証しました。
| 構成 | min | avg | p50 | p90 | p99 | max | 備考 |
|---|---|---|---|---|---|---|---|
| 非VPC Lambda + S3 SDK | 34.9 ms | 55.4 ms | 53.8 ms | 68.2 ms | 104.6 ms | 112.3 ms | 毎回別のS3オブジェクトを GetObject
|
| Lambda + S3 Files | 2.2 ms | 2.4 ms | 2.4 ms | 2.5 ms | 2.7 ms | 2.7 ms | 毎回別のファイルを open().read()
|
64KiBでは、毎回別ファイルを読む条件でもS3 Filesがかなり速い結果になりました。p50では約23倍、p99では約39倍の差です。
R2. 16MiB単発読み込み
| 構成 | min | avg | p50 | p90 | p99 | max | 備考 |
|---|---|---|---|---|---|---|---|
| 非VPC Lambda + S3 SDK | 171.2 ms | 204.9 ms | 172.5 ms | 281.5 ms | 300.4 ms | 303.0 ms | S3 GetObject
|
| Lambda + S3 Files | 4.3 ms | 10.5 ms | 14.8 ms | 15.8 ms | 19.9 ms | 19.9 ms |
/mnt/s3files から open().read()
|
16MiBの読み込みでもS3 Filesが大きく速い結果になりました。p50では約12倍、p99では約15倍の差です。
ただし、この結果は「同じファイルを同じLambda呼び出し内で連続して読む」条件です。S3 Filesや実行環境側のキャッシュが効いている可能性が高いため、毎回別ファイルを読むケースでも追加検証しました。
R2補足. 16MiBを毎回別ファイルで読み込み
追加検証ではiterationごとに別ファイルを読むようにしました。条件は同じく iterations=105 / warmup=5 で、先頭5回を除外して100件を集計しています。
| 構成 | min | avg | p50 | p90 | p99 | max | 備考 |
|---|---|---|---|---|---|---|---|
| 非VPC Lambda + S3 SDK | 171.3 ms | 206.5 ms | 179.7 ms | 270.2 ms | 284.3 ms | 292.1 ms | 毎回別のS3オブジェクトを GetObject
|
| Lambda + S3 Files | 1,751.5 ms | 2,331.2 ms | 1,776.2 ms | 5,269.6 ms | 5,290.7 ms | 5,299.1 ms | 毎回別のファイルを open().read()
|
この条件では、S3 SDKのほうがかなり速い結果になりました。
この結果から、前述の「同一ファイルを連続で読むとS3 Filesが非常に速い」という結果は、キャッシュが強く効いている状態として見るのが自然です。大きいファイルを毎回別々に読む処理では、S3 SDKのほうが安定して扱いやすい可能性があります。
W1. 64KiB単発書き込み
| 構成 | min | avg | p50 | p90 | p99 | max | 備考 |
|---|---|---|---|---|---|---|---|
| 非VPC Lambda + S3 SDK | 31.4 ms | 38.2 ms | 37.5 ms | 41.9 ms | 52.9 ms | 76.9 ms | S3 PutObject
|
| Lambda + S3 Files | 12.4 ms | 13.4 ms | 13.4 ms | 14.1 ms | 15.3 ms | 17.1 ms |
write() + flush() + fsync()
|
64KiBの書き込みではS3 Filesが速く、ばらつきも小さい結果になりました。
ただし、この差は「S3 FilesのほうがS3への永続化まで常に速い」という意味ではありません。S3 SDKのPutObjectは、S3 APIにリクエストを送り、S3側の応答が返るまでを測っています。一方、S3 Files側はwrite() + flush() + fsync()で、Lambdaから見たファイルシステムへの書き込み完了までを測っています。
つまり、S3 Filesではファイルシステム側への書き込みと、背後のS3バケットへの同期処理が分かれているため、S3のPutObjectと完全に同じ意味の完了タイミングではありません。S3 Filesはファイル変更後、書き込みが止まってから60秒待ってS3バケットへエクスポートします。64KiBのような小さい書き込みでは、データ転送時間よりもAPI呼び出しやHTTP通信などの固定オーバーヘッドの差が見えやすく、そのぶんS3 Filesが速く見えた可能性があります。
W2. 16MiB単発書き込み
| 構成 | min | avg | p50 | p90 | p99 | max | 備考 |
|---|---|---|---|---|---|---|---|
| 非VPC Lambda + S3 SDK | 168.8 ms | 196.8 ms | 187.0 ms | 246.9 ms | 274.4 ms | 302.5 ms | S3 PutObject
|
| Lambda + S3 Files | 147.5 ms | 198.1 ms | 190.1 ms | 240.6 ms | 286.3 ms | 298.4 ms |
write() + flush() + fsync()
|
16MiBの書き込みは、S3 SDKとS3 Filesでかなり近い結果になりました。大きいファイルの書き込みでは、小さいファイルほどS3 Filesの優位性は出ませんでした。
結果サマリ
詳細な数値とは別に、検証ごとの傾向をまとめます。読み込みについては、同じファイルを連続して読むケースと、iterationごとに別ファイルを読むケースで傾向が変わるため、分けて整理します。
| 検証 | 条件 | 速かった構成 | p99が安定していた構成 | コメント |
|---|---|---|---|---|
| 64KiB読み込み | 同じファイルを連続読み込み | Lambda + S3 Files | Lambda + S3 Files | S3 Filesの低レイテンシーアクセスが強く出た |
| 64KiB読み込み | 毎回別ファイルを読み込み | Lambda + S3 Files | Lambda + S3 Files | 別ファイルでもS3 Filesが速かった。小さいファイルを多数読む用途では相性が良さそう |
| 16MiB読み込み | 同じファイルを連続読み込み | Lambda + S3 Files | Lambda + S3 Files | S3 Filesや実行環境側のキャッシュが効いている状態として見るのが自然 |
| 16MiB読み込み | 毎回別ファイルを読み込み | 非VPC Lambda + S3 SDK | 非VPC Lambda + S3 SDK | 大きいファイルを都度読む場合は、S3 SDKのほうが安定して扱いやすそう |
| 64KiB書き込み | 単発書き込み | Lambda + S3 Files | Lambda + S3 Files | 小さい書き込みではS3 Filesが速く、ばらつきも小さかった |
| 16MiB書き込み | 単発書き込み | ほぼ同等 | 非VPC Lambda + S3 SDK | p50/avgはほぼ同じ。p99はS3 SDKが少し安定していた |
今回の結果を見る限り、S3 Filesは小さいファイルの読み込みや、同じファイルを繰り返し読む処理ではかなり強く見えます。一方で、大きいファイルを毎回別々に読む処理ではS3 SDKのほうが速く、用途によって向き不向きがはっきり出ました。
どう選ぶとよさそうか
最終的には、速度だけで選ぶよりも用途で選ぶのが良さそうです。
| 用途 | 候補 |
|---|---|
| 単純にS3オブジェクトを読み書きするだけ | S3 SDK |
| ファイルパス前提のライブラリを使いたい | S3 Files |
| S3を正本にしつつファイルシステムとして扱いたい | S3 Files |
| 複数Lambdaで共有ワークスペースを使いたい | S3 Files、(EFS) |
単純な読み書きだけなら、S3 SDKはやはり強いはずです。コードも構成もシンプルで、VPCも必要ありません。
一方で、S3 Filesは「S3のデータをファイルとして扱える」こと自体に価値があります。既存のファイルベースの処理をLambdaへ持っていきたい場合や、AIエージェントの作業ディレクトリのように複数ステップでファイルを共有したい場合には、S3 Filesがかなり便利に使えるかもしれません。
まとめ
今回の検証では、小さいファイルと大きいファイルそれぞれの単発読み込み・単発書き込みという観点で、S3 SDKとS3 Filesを比較しました。
S3 FilesがLambdaから使えるようになったことで、S3上のデータをファイルシステムとして扱う選択肢が増えました。
ただ、S3 FilesはS3 SDKの単純な置き換えというより、S3を正本にしながらファイルシステムの使い勝手を得るための選択肢と考えるのがよさそうです。
参考

