S3をファイルシステムとして使用できるS3 FilesとS3 FilesをAWS Lambdaにマウントできる機能が追加されました。
これにより、S3バケットをEFSのようにLambdaからファイルシステムとして使用できます。
この記事では、マウントしたS3上のSQLiteファイルをLambdaから実際に読み書きし、どう動くか確認した記録をまとめます。
- 基本的な読み取り
- Lambdaからの書き込み
- 書き込み直後に読み取る
- 同時書き込み(5並列)
- 同時読み込み(5並列)
- S3バージョニングの挙動
- 想定される構成と用途
検証に使ったCDKコードとスクリプトはこちらのリポジトリで公開しています。
https://github.com/k8shiro/lambda-s3-sqlite
S3 Filesとは
S3バケットをNFSでマウントする機能です。Lambda関数をVPC内に配置し、同じVPCに作成したマウントターゲットとNFSで接続します。マウントするとS3上のファイルをaws s3 cpでアップロードすれば、Lambdaから/mnt/のパスでそのまま読めます。
設定や動作の制約として以下があります。
- LambdaをVPC内に配置する必要がある
- バケットのバージョニングを有効にする必要がある
-
memory_sizeは512 MB以上推奨 - 書き込みは約60秒のバッファリング後にS3へ反映される
環境構成
S3 バケット(バージョニング有効)
↕ S3 Files(NFS)
マウントターゲット(VPC内サブネット)
↕ NFS
Lambda関数(VPC内、/mnt/s3dataにマウント)
↑
ローカルからaws lambda invokeで直接呼び出し
今回はAPI Gateway等は使わず、aws lambda invokeコマンドで直接実行しています。
リポジトリの構成とデプロイ
検証で使用したリポジトリの構成は以下です。
lambda-s3-sqlite/
├── cdk/ # CDKスタック(VPC, S3, S3 Files, Lambda)
├── lambda/ # Lambda関数コード
├── scripts/ # テスト・検証スクリプト
├── Dockerfile # 実行環境(AWS CLI + CDK)
├── docker-compose.yml
└── .env.example # 環境変数テンプレート
CDK(Python)で構築しています。CDKの詳細については今回は割愛しますが、リポジトリのcdk/ディレクトリで以下のように実行すれば同環境を構築できます。
cd cdk
pip install -r requirements.txt
cdk bootstrap # 初回のみ
cdk deploy
ちなみに執筆時点ではTerraformが対応してなさそうだったのでCDKを使っています。
テスト用SQLite DBを用意する
users テーブルとサンプルデータ5件を含むSQLiteファイルを作成してS3にアップロードします。
バケット名とLambda関数名は cdk deploy の出力からも確認できますが、
以降のコマンドで使うので以下のコマンドで変数に入れておきます。
# S3のバケット名とLambda関数名を取得して環境変数にセット
BUCKET_NAME=$(aws cloudformation describe-stacks \
--stack-name LambdaS3SqliteStack \
--query "Stacks[0].Outputs[?OutputKey=='S3BucketName'].OutputValue" \
--output text)
FUNCTION_NAME=$(aws cloudformation describe-stacks \
--stack-name LambdaS3SqliteStack \
--query "Stacks[0].Outputs[?OutputKey=='LambdaFunctionName'].OutputValue" \
--output text)
以下のコマンドでSQLiteファイルを作成してS3にアップロードします。
python3 scripts/create_db.py
aws s3 cp /tmp/database.db s3://$BUCKET_NAME/database.db
S3 FilesはS3にアップロードされたオブジェクトをマウントパスへ同期します。Lambda側では /mnt/s3data/database.db として見えます。
Lambda関数のコード
Lambda関数のコードは以下のようになっています。/mnt/s3data/database.db をSQLiteファイルとして読み書きし、テスト用にクエリを実行するアクションを複数用意しています。このLambda関数をデプロイしてSQLiteの操作を検証していきます。
import json
import logging
import os
import sqlite3
import time
logger = logging.getLogger()
logger.setLevel(logging.INFO)
DB_PATH = os.environ.get("DB_PATH", "/mnt/s3data/database.db")
def lambda_handler(event, context):
action = event.get("action", "query_users")
logger.info(f"action={action}, db_path={DB_PATH}")
if not os.path.exists(DB_PATH):
logger.error(f"データベースが見つかりません: {DB_PATH}")
return {
"statusCode": 500,
"body": f"データベースが見つかりません: {DB_PATH}",
}
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
if action == "query_users":
# ユーザー一覧を取得する(読み取りテスト)
rows = conn.execute("SELECT * FROM users ORDER BY id").fetchall()
users = [dict(row) for row in rows]
logger.info(f"{len(users)} 件のユーザーを取得しました")
return {
"statusCode": 200,
"body": {
"users": users,
"count": len(users),
"db_path": DB_PATH,
},
}
elif action == "db_info":
# DB のメタ情報を返す
tables = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
return {
"statusCode": 200,
"body": {
"tables": [t["name"] for t in tables],
"db_path": DB_PATH,
"db_size_bytes": os.path.getsize(DB_PATH),
},
}
elif action == "insert_user":
# ユーザーを1件 INSERT する(書き込みテスト)
name = event.get("name", "TestUser")
email = event.get("email", "test@example.com")
created_at = event.get("created_at", time.strftime("%Y-%m-%d"))
conn.execute(
"INSERT INTO users (name, email, created_at) VALUES (?, ?, ?)",
(name, email, created_at),
)
conn.commit()
new_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
logger.info(f"INSERT 完了: id={new_id}, 合計={count}件")
return {
"statusCode": 200,
"body": {
"inserted_id": new_id,
"total_count": count,
},
}
elif action == "concurrent_write":
# 同時書き込みテスト用: 少し待ってから INSERT する
# 複数の Lambda を同時に起動したときの競合を観察する
delay = event.get("delay_ms", 0)
label = event.get("label", "unknown")
if delay > 0:
time.sleep(delay / 1000)
start = time.time()
conn.execute(
"INSERT INTO users (name, email, created_at) VALUES (?, ?, ?)",
(f"concurrent_{label}", f"{label}@example.com", time.strftime("%Y-%m-%d")),
)
conn.commit()
elapsed_ms = int((time.time() - start) * 1000)
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
logger.info(f"concurrent_write: label={label}, commit={elapsed_ms}ms, total={count}")
return {
"statusCode": 200,
"body": {
"label": label,
"commit_ms": elapsed_ms,
"total_count": count,
},
}
elif action == "write_then_read":
# 書き込み直後に同じ接続で読み返すテスト
# NFS マウント越しでも即座に読み取れるか確認する
name = event.get("name", "ImmediateReadUser")
email = event.get("email", "immediate@example.com")
created_at = time.strftime("%Y-%m-%d")
conn.execute(
"INSERT INTO users (name, email, created_at) VALUES (?, ?, ?)",
(name, email, created_at),
)
conn.commit()
new_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
# 同じ接続でそのまま SELECT
row = conn.execute(
"SELECT * FROM users WHERE id = ?", (new_id,)
).fetchone()
all_rows = conn.execute("SELECT * FROM users ORDER BY id").fetchall()
logger.info(f"write_then_read: inserted id={new_id}, immediately readable={row is not None}")
return {
"statusCode": 200,
"body": {
"inserted_id": new_id,
"immediately_readable": row is not None,
"inserted_row": dict(row) if row else None,
"total_count": len(all_rows),
},
}
else:
return {"statusCode": 400, "body": f"不明なアクション: {action}"}
finally:
conn.close()
テスト1: 基本的な読み取り
マウントしたSQLiteをLambdaからシンプルにSELECTしてみます。
aws lambda invoke \
--function-name $FUNCTION_NAME \
--payload '{"action": "query_users"}' \
--cli-binary-format raw-in-base64-out \
/tmp/response.json && cat /tmp/response.json
結果は以下のようになりました。
{
"statusCode": 200,
"body": {
"users": [
{"id": 1, "name": "Alice", "email": "alice@example.com", "created_at": "2024-01-01"},
{"id": 2, "name": "Bob", "email": "bob@example.com", "created_at": "2024-02-15"},
{"id": 3, "name": "Carol", "email": "carol@example.com", "created_at": "2024-03-20"},
{"id": 4, "name": "Dave", "email": "dave@example.com", "created_at": "2024-04-10"},
{"id": 5, "name": "Eve", "email": "eve@example.com", "created_at": "2024-05-05"}
],
"count": 5,
"db_path": "/mnt/s3data/database.db"
}
}
ファイルパスを指定するだけで、S3上のファイルをローカルファイルと同じように扱えています。
テスト2: Lambdaからの書き込み
INSERTして、その内容がS3に反映されるか確認します。
aws lambda invoke \
--function-name $FUNCTION_NAME \
--payload '{"action": "insert_user", "name": "Frank", "email": "frank@example.com"}' \
--cli-binary-format raw-in-base64-out \
/tmp/response.json && cat /tmp/response.json
結果は以下のようになりました。
{
"statusCode": 200,
"body": {
"inserted_id": 6,
"total_count": 6
}
}
INSERT自体はすぐ成功します。ただしS3への反映は約60秒後です。試しにINSERT直後に aws s3 cp s3://バケット/database.db /tmp/check.db でダウンロードして確認すると、まだ古いファイルが返ってきます。60秒ほど待ってからダウンロードすると、新しいレコードが入ったファイルが取得できました。
テスト3: 書き込み直後に読み取る
テスト2でS3への反映に60秒かかることがわかりました。では、同じLambda実行内でINSERTした直後にSELECTすると、書いたレコードは見えるのか確認します。
aws lambda invoke \
--function-name $FUNCTION_NAME \
--payload '{"action": "write_then_read", "name": "ImmediateUser", "email": "immediate@example.com"}' \
--cli-binary-format raw-in-base64-out \
/tmp/response.json && cat /tmp/response.json
結果は以下です。
{
"statusCode": 200,
"body": {
"inserted_id": 7,
"immediately_readable": true,
"inserted_row": {
"id": 7,
"name": "ImmediateUser",
"email": "immediate@example.com",
"created_at": "2026-04-22"
},
"total_count": 7
}
}
INSERT直後のSELECTで新しいレコードがちゃんと取得できています。S3への反映は遅れていますが、NFSマウント越しの読み取りには影響しません。Lambdaが書き込むとNFSストレージ層にはすぐ反映され、S3への同期は約60秒後に非同期で行われます。Lambda側の読み取りはS3を経由せずNFS層を直接見るため、書き込み直後でも一貫して読めます。
aws s3 cp でダウンロードすると古いファイルが返ってくるのは、S3から直接読んでいるためです。Lambda間でデータをやりとりする用途であれば気にしなくてよいみたいです。
テスト4: 同時書き込み(5並列)
5つのLambda呼び出しを同時に実行して、同時書き込み時の挙動を見ます。
for i in $(seq 1 5); do
aws lambda invoke \
--function-name $FUNCTION_NAME \
--payload "{\"action\": \"concurrent_write\", \"label\": \"worker${i}\"}" \
--cli-binary-format raw-in-base64-out \
/tmp/write_${i}.json > /dev/null &
done
wait
結果は以下です。
worker1: commit=109ms, total=10件
worker2: commit=111ms, total=11件
worker3: commit=115ms, total=8件
worker4: commit=108ms, total=9件
worker5: commit=125ms, total=12件
書き込み自体は全件成功しています。total_countが8→9→10→11→12と順番に積み上がっており、データが消えたり重複したりはしていません。commitにかかる時間は108〜125msとほぼ横並びでSQLiteのロック機構がそのまま動いている形で、競合したときは少し待ってリトライしているようです。
ただし5並列くらいでは問題が出なかっただけで、SQLiteなので大量並列書き込みを想定した用途には向かないと思われます。
テスト5: 同時読み込み(5並列)
for i in $(seq 1 5); do
aws lambda invoke \
--function-name $FUNCTION_NAME \
--payload '{"action": "query_users"}' \
--cli-binary-format raw-in-base64-out \
/tmp/read_${i}.json > /dev/null &
done
wait
reader1: count=5件
reader2: count=5件
reader3: count=5件
reader4: count=5件
reader5: count=5件
読み込みは全並列で問題なし。全リーダーで同じレコード数が返ってきています。
テスト6: S3バージョニングの挙動
S3 Filesはバージョニングが必須です。Lambdaが書き込んでS3に同期されるたびに新バージョンが作られるのか、古いバージョンに戻せるのかを確認してみます。
# 書き込み前のバージョン数を確認
aws s3api list-object-versions \
--bucket $BUCKET_NAME \
--prefix "database.db" \
--query "Versions[].{VersionId:VersionId, LastModified:LastModified, IsLatest:IsLatest}" \
--output table
Lambdaで数件INSERTしてから70秒待ち、バージョン一覧を再確認します。
bash scripts/04_versioning_test.sh
書き込み前は3バージョン、3件INSERTして70秒後に確認すると4バージョンに増えていました。
書き込み前のバージョン数: 3
書き込み後のバージョン数: 4(増加: 1)
3件のINSERTに対してバージョンの増加は1つだけでした。S3 Filesは約60秒の同期サイクルで複数の変更をまとめて1回のS3 PUTにするため、書き込み1件ごとにバージョンが増えるわけではないようです。書き込み頻度が高くてもバージョン数の増加は抑えられそうです。
古いバージョンをダウンロードしてSQLiteを開くと、INSERT前のレコード数が確認できました。ロールバックできることも確認できました。
想定される構成と用途
S3とLambdaでの構成なのでなるべくコストをかけないで動く構成と用途を考えたいです。
S3 FilesをマウントするにはLambdaをVPC内に配置する必要があります。外部からのリクエストはAPI Gatewayで受けて、VPC内のLambdaに転送する構成が基本になるかと思います。
インターネット
↓
API Gateway(VPC外)
↓
Lambda(プライベートサブネット)
↕ NFS
S3 Filesマウントターゲット(同じサブネット)
↕ S3 Files同期
S3バケット
S3 Filesのマウント通信はVPC内のみで完結するため、Lambdaはプライベートサブネットに配置できます。ただし、Lambdaから外部API等を呼ぶ必要がある場合はNATゲートウェイが別途必要になります。しかしNATゲートウェイは約\$30/月〜とコストが高いので、コストを考えると使用を避けたいです。
S3・NFS I/O・Lambdaの実行コスト自体は小さく、S3のストレージ料金(東京リージョン: \$0.025/GB/月)とLambda料金(月100万リクエストで\$0.20前後)が主なコストです。
注意点として、S3 Filesはバージョニングが必須のため、Lambdaが書き込むたびに古いバージョンがS3に積み上がります。ライフサイクルポリシーで自動削除しておかないと、長期運用でストレージコストが膨らむことがありそうです。
参考
- AWS Lambda関数にS3ファイルシステムをマウントする
- Amazon S3 Filesへのアクセス設定
- S3ファイルの前提条件
まとめ
S3マウント機能を使ってSQLiteをLambdaから読み書きできることを確認できました。
コードは sqlite3 標準ライブラリだけで完結し、S3にファイルを置くだけでLambdaから参照できるので何かしら用途はありそうです。構成としても複雑にならないで済みました。ただし、大量並列書き込みには向かない(今回はとりあえず動作したがあまり信頼できない)ので「S3上に置いた参照データをLambdaで検索して読みたい」「Lambdaからバッチ的に軽い書き込みもしたい」といった用途には使える場合があるかなと思いました。なのでプロダクトに組み込むというよりは個人用途の自動化のDBとして使うような方向だと有効かもしれません。
検証に使ったコード(CDK・Lambda関数・テストスクリプト等)はこちらで公開しています。