Lambda Rust × DynamoDB — 90倍差は実ワークロードでどこまで縮まるか
目次
はじめに
Lambda Rust は Python 比90倍速い — ただし SDK を使わない Hello World での話だ。
前回の記事では、CPU 負荷とメモリ負荷の合成ベンチマークで Rust が Python の90倍の実行速度を記録した。しかし実際のアプリケーションは DynamoDB や S3 と通信する。ネットワーク I/O が入ったとき、この90倍差はどうなるのか?
本記事では AWS SDK for Rust(aws-sdk-dynamodb)を使って DynamoDB の各種操作を実行し、同等の Python(boto3)関数とレイテンシを比較する。操作パターンを「DynamoDB レイテンシ支配型」と「クライアント処理支配型」に分類し、どのワークロードで Rust の優位性が残るかを定量的に示す。
検証環境
| 項目 | 値 |
|---|---|
| リージョン | ap-northeast-1(東京) |
| アーキテクチャ | arm64(Graviton) |
| メモリ | 128 MB |
| Rust バージョン | 1.94.1 |
| cargo-lambda | 1.9.1 |
| aws-sdk-dynamodb | 1.110.0 |
| Python ランタイム | python3.13 |
| DynamoDB | オンデマンドキャパシティ |
DynamoDB テーブルはパーティションキー pk(String)とソートキー sk(String)の複合キー構成。各アイテムに data(1KB 文字列)と timestamp を持たせた。
Rust のデプロイパッケージは11MB。第1回の SDK なし構成(1.2MB)から約9倍に増加している。AWS SDK の HTTP クライアント(hyper + rustls)と DynamoDB クライアントが大部分を占める。
前提条件:
- Rust ツールチェーン + cargo-lambda
- AWS CLI セットアップ済み(
lambda:*、iam:*、dynamodb:*の操作権限)
実装
Rust・Python ともに、クエリパラメータ op で操作を切り替える設計にした。SDK クライアントは Lambda のベストプラクティスに従い、Init フェーズ(ハンドラの外)で初期化する。これにより2回目以降の呼び出しで HTTP 接続プールが再利用される。
各操作の所要時間はアプリケーション内で sdk_call_ms(SDK 呼び出し全体)として計測し、Lambda の Duration(ランタイムオーバーヘッド含む)と合わせて記録した。
Rust 関数コード(Cargo.toml + src/main.rs)
[package]
name = "rust-dynamodb-bench"
version = "0.1.0"
edition = "2021"
[dependencies]
aws-config = { version = "1", features = ["behavior-version-latest"] }
aws-sdk-dynamodb = "1"
lambda_http = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros"] }use aws_sdk_dynamodb::types::AttributeValue;
use aws_sdk_dynamodb::Client;
use lambda_http::{Body, Error, Request, RequestExt, Response, run, service_fn};
use serde::Serialize;
use std::time::Instant;
#[derive(Serialize)]
struct BenchResult {
operation: String,
runtime: String,
duration_ms: f64,
sdk_call_ms: f64,
items_count: usize,
}
async fn function_handler(
client: &Client,
event: Request,
) -> Result<Response<Body>, Error> {
let params = event.query_string_parameters();
let op = params.first("op").unwrap_or("get");
let total_start = Instant::now();
let mut items_count: usize = 0;
let sdk_start = Instant::now();
match op {
"put" => {
client.put_item()
.table_name("lambda-rust-bench")
.item("pk", AttributeValue::S("bench".into()))
.item("sk", AttributeValue::S("item-0".into()))
.item("data", AttributeValue::S("x".repeat(1000)))
.item("timestamp", AttributeValue::N(chrono_now().to_string()))
.send().await?;
items_count = 1;
}
"get" => {
let resp = client.get_item()
.table_name("lambda-rust-bench")
.key("pk", AttributeValue::S("bench".into()))
.key("sk", AttributeValue::S("item-0".into()))
.send().await?;
items_count = if resp.item().is_some() { 1 } else { 0 };
}
"query" => {
let resp = client.query()
.table_name("lambda-rust-bench")
.key_condition_expression("pk = :pk")
.expression_attribute_values(":pk", AttributeValue::S("bench".into()))
.send().await?;
items_count = resp.count() as usize;
}
"batch_write" => {
use aws_sdk_dynamodb::types::{WriteRequest, PutRequest};
let requests: Vec<WriteRequest> = (0..25).map(|i| {
let mut item = std::collections::HashMap::new();
item.insert("pk".into(), AttributeValue::S("bench".into()));
item.insert("sk".into(), AttributeValue::S(format!("item-{}", i)));
item.insert("data".into(), AttributeValue::S("x".repeat(1000)));
item.insert("timestamp".into(), AttributeValue::N(chrono_now().to_string()));
WriteRequest::builder()
.put_request(PutRequest::builder().set_item(Some(item)).build().unwrap())
.build()
}).collect();
client.batch_write_item()
.request_items("lambda-rust-bench", requests)
.send().await?;
items_count = 25;
}
_ => {}
}
let sdk_call_ms = sdk_start.elapsed().as_secs_f64() * 1000.0;
let duration_ms = total_start.elapsed().as_secs_f64() * 1000.0;
let result = BenchResult {
operation: op.to_string(),
runtime: "rust".to_string(),
duration_ms, sdk_call_ms, items_count,
};
let body = serde_json::to_string(&result)?;
let resp = Response::builder()
.status(200)
.header("content-type", "application/json")
.body(body.into())
.map_err(Box::new)?;
Ok(resp)
}
fn chrono_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
#[tokio::main]
async fn main() -> Result<(), Error> {
let config = aws_config::load_defaults(
aws_config::BehaviorVersion::latest(),
).await;
let client = Client::new(&config);
run(service_fn(|event: Request| {
let client = client.clone();
async move { function_handler(&client, event).await }
})).await
}Python 関数コード(lambda_function.py)
import json
import time
import boto3
import os
client = boto3.client("dynamodb", region_name="ap-northeast-1")
TABLE_NAME = os.environ.get("TABLE_NAME", "lambda-rust-bench")
def lambda_handler(event, context):
params = event.get("queryStringParameters") or {}
op = params.get("op", "get")
total_start = time.perf_counter()
items_count = 0
sdk_start = time.perf_counter()
if op == "put":
client.put_item(
TableName=TABLE_NAME,
Item={
"pk": {"S": "bench"},
"sk": {"S": "item-0"},
"data": {"S": "x" * 1000},
"timestamp": {"N": str(int(time.time()))},
},
)
items_count = 1
elif op == "get":
resp = client.get_item(
TableName=TABLE_NAME,
Key={"pk": {"S": "bench"}, "sk": {"S": "item-0"}},
)
items_count = 1 if "Item" in resp else 0
elif op == "query":
resp = client.query(
TableName=TABLE_NAME,
KeyConditionExpression="pk = :pk",
ExpressionAttributeValues={":pk": {"S": "bench"}},
)
items_count = resp.get("Count", 0)
elif op == "batch_write":
requests = []
for i in range(25):
requests.append({
"PutRequest": {
"Item": {
"pk": {"S": "bench"},
"sk": {"S": f"item-{i}"},
"data": {"S": "x" * 1000},
"timestamp": {"N": str(int(time.time()))},
}
}
})
client.batch_write_item(RequestItems={TABLE_NAME: requests})
items_count = 25
sdk_call_ms = (time.perf_counter() - sdk_start) * 1000
duration_ms = (time.perf_counter() - total_start) * 1000
return {
"statusCode": 200,
"headers": {"content-type": "application/json"},
"body": json.dumps({
"operation": op,
"runtime": "python",
"duration_ms": round(duration_ms, 4),
"sdk_call_ms": round(sdk_call_ms, 4),
"items_count": items_count,
}),
}デプロイ手順(IAM + DynamoDB + Lambda)
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION="ap-northeast-1"
# DynamoDB テーブル作成
aws dynamodb create-table \
--table-name lambda-rust-bench \
--attribute-definitions \
AttributeName=pk,AttributeType=S \
AttributeName=sk,AttributeType=S \
--key-schema \
AttributeName=pk,KeyType=HASH \
AttributeName=sk,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--region $REGION
# IAM ロール作成
aws iam create-role --role-name lambda-rust-dynamodb-role \
--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
aws iam attach-role-policy --role-name lambda-rust-dynamodb-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam put-role-policy --role-name lambda-rust-dynamodb-role \
--policy-name dynamodb-bench-access \
--policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"dynamodb:PutItem\",\"dynamodb:GetItem\",\"dynamodb:Query\",\"dynamodb:BatchWriteItem\",\"dynamodb:DeleteItem\"],\"Resource\":\"arn:aws:dynamodb:${REGION}:${ACCOUNT_ID}:table/lambda-rust-bench\"}]}"
# Rust: ビルド → デプロイ
cargo lambda build --release --arm64
cargo lambda deploy rust-dynamodb-bench \
--iam-role arn:aws:iam::${ACCOUNT_ID}:role/lambda-rust-dynamodb-role \
--region $REGION --memory 128 --timeout 30 \
--env-vars TABLE_NAME=lambda-rust-bench
# Python: zip → デプロイ
zip -j function.zip lambda_function.py
aws lambda create-function \
--function-name python-dynamodb-bench \
--runtime python3.13 \
--handler lambda_function.lambda_handler \
--role arn:aws:iam::${ACCOUNT_ID}:role/lambda-rust-dynamodb-role \
--zip-file fileb://function.zip \
--memory-size 128 --architectures arm64 --timeout 30 \
--environment "Variables={TABLE_NAME=lambda-rust-bench}" \
--region $REGION
# テストデータ投入(BatchWriteItem で25件)
aws lambda invoke --function-name rust-dynamodb-bench \
--cli-binary-format raw-in-base64-out \
--payload '{"requestContext":{"http":{"method":"GET"}},"queryStringParameters":{"op":"batch_write"},"rawPath":"/","headers":{}}' \
--region $REGION /tmp/seed.jsonコールドスタート: SDK 追加の影響
まず SDK 追加がコールドスタートに与える影響を確認する。環境変数を更新してコールドスタートを強制し、GetItem 操作で計測した。
ベンチマーク実行手順(コールドスタート計測)
REGION="ap-northeast-1"
# コールドスタート強制(環境変数更新で実行環境を再作成)
aws lambda update-function-configuration \
--function-name rust-dynamodb-bench \
--environment "Variables={TABLE_NAME=lambda-rust-bench,BENCH_RUN=$(date +%s)}" \
--region $REGION --output text --query 'FunctionName'
aws lambda wait function-updated \
--function-name rust-dynamodb-bench --region $REGION
# 1回目(コールドスタート)— REPORT 行から Init Duration / Duration を取得
aws lambda invoke \
--function-name rust-dynamodb-bench \
--cli-binary-format raw-in-base64-out \
--payload '{"requestContext":{"http":{"method":"GET"}},"queryStringParameters":{"op":"get"},"rawPath":"/","headers":{}}' \
--region $REGION --log-type Tail \
--query 'LogResult' --output text \
/tmp/cold_1.json | base64 -d | grep REPORT
cat /tmp/cold_1.json
# 2回目(接続再利用の確認)
aws lambda invoke \
--function-name rust-dynamodb-bench \
--cli-binary-format raw-in-base64-out \
--payload '{"requestContext":{"http":{"method":"GET"}},"queryStringParameters":{"op":"get"},"rawPath":"/","headers":{}}' \
--region $REGION --log-type Tail \
--query 'LogResult' --output text \
/tmp/cold_2.json | base64 -d | grep REPORTPython 側も同じ手順で --function-name python-dynamodb-bench に変えて実行する。ペイロードは '{"queryStringParameters":{"op":"get"}}' に変更する。
| 指標 | Rust | Python |
|---|---|---|
| Init Duration | 112 ms | 357 ms |
| 初回 Duration | 912 ms | 291 ms |
| Billed Duration(合計) | 1,025 ms | 648 ms |
| Max Memory Used | 30 MB | 90 MB |
意外な結果が出た。 Init Duration は Rust(112ms)が Python(357ms)の3.2倍高速だが、初回の DynamoDB 呼び出し(Duration)は Rust(912ms)が Python(291ms)の3.1倍遅い。合計の Billed Duration では Python の方が速い。
この原因は AWS SDK for Rust の初回リクエスト時の初期化コストにある。Rust の SDK は Init フェーズでクライアントオブジェクトを生成するが、内部で使用する hyper の HTTP クライアントはリクエスト時にオンデマンドで TCP 接続を作成する設計だ。初回リクエストでは DNS 解決、TCP 接続確立、TLS ハンドシェイクが一括で発生するため、約900ms のオーバーヘッドが加わったと考えられる。
一方 Python の boto3 は Init フェーズで API 定義の動的構築(JSON パース → Python クラス生成)に時間を費やすため、Init Duration が357ms と長い。初回 Duration の291ms にも HTTP 接続確立コストが含まれているが、Rust ほどの差は出ていない。
この挙動は追加で3回コールドスタートを再現し、計4回の計測で再現性を確認した。
| 回 | Init Duration | 初回 Duration | 2回目 Duration |
|---|---|---|---|
| #1 | 112 ms | 912 ms | — |
| #2 | 112 ms | 920 ms | 3.3 ms |
| #3 | 115 ms | 905 ms | 3.1 ms |
| #4 | 110 ms | 916 ms | 2.9 ms |
注目すべきは 2回目の呼び出しが即座に2〜3ms に落ちる点だ。HTTP 接続プールが確立されれば、以降は接続を再利用する。つまり Rust のコールドスタートペナルティは「1回限り」であり、定常状態の性能とは分けて評価すべきだ。
第1回の SDK なし構成では Init Duration 29ms だった。SDK 追加で約80ms 増加しているが、これは aws-sdk-dynamodb + aws-config + hyper + rustls の初期化コストだ。実用上、API Gateway のタイムアウト(29秒)に対して1秒程度のコールドスタートは問題にならない水準だろう。
操作別レイテンシ: 90倍差はどこまで縮まるか
ここからが本題だ。ウォームスタート状態で4つの DynamoDB 操作を実行し、Rust と Python のレイテンシを比較する。
フレームワーク: 2つの操作分類
DynamoDB 操作のレイテンシは2つの要素で構成される。
- DynamoDB 側の処理時間 — ネットワーク往復 + DynamoDB エンジンの処理。言語に依存しない
- クライアント側の処理時間 — リクエストのシリアライズ、レスポンスのデシリアライズ、HTTP 処理。言語の性能差が出る
操作パターンによってどちらが支配的かが変わる。
| 分類 | 操作 | 仮説 |
|---|---|---|
| DynamoDB レイテンシ支配型 | GetItem、PutItem | 1件の読み書き。クライアント処理は軽く、DynamoDB 往復が大部分を占める。言語差は小さいはず |
| クライアント処理支配型 | Query(25件)、BatchWriteItem(25件) | 複数件のシリアライズ/デシリアライズが発生。クライアント側の処理比率が上がり、言語差が出るはず |
Query の25件はテストデータとして事前投入した件数だ。BatchWriteItem の25件は API の上限値であり、シリアライズ処理量を最大化して言語差が最も顕著に出る条件で計測する。実務では件数に応じて差は縮まると想定される。
この仮説を検証する。
計測結果
各操作5回のウォームスタート呼び出しで計測した sdk_call_ms(アプリケーション内計測)の結果。
ベンチマーク実行手順(ウォームスタート計測)
REGION="ap-northeast-1"
# ウォームアップ(1回捨てる)
aws lambda invoke \
--function-name rust-dynamodb-bench \
--cli-binary-format raw-in-base64-out \
--payload '{"requestContext":{"http":{"method":"GET"}},"queryStringParameters":{"op":"get"},"rawPath":"/","headers":{}}' \
--region $REGION /tmp/warmup.json > /dev/null 2>&1
# 5回計測(op を get / put / query / batch_write に変えて繰り返す)
for i in $(seq 1 5); do
aws lambda invoke \
--function-name rust-dynamodb-bench \
--cli-binary-format raw-in-base64-out \
--payload '{"requestContext":{"http":{"method":"GET"}},"queryStringParameters":{"op":"get"},"rawPath":"/","headers":{}}' \
--region $REGION --log-type Tail \
--query 'LogResult' --output text \
/tmp/warm_${i}.json | base64 -d | grep REPORT
cat /tmp/warm_${i}.json
donePython 側も同じ手順で --function-name python-dynamodb-bench に変更し、ペイロードを '{"queryStringParameters":{"op":"get"}}' に変えて実行する。
| 操作 | Rust sdk_call_ms | Python sdk_call_ms | 倍率 |
|---|---|---|---|
| GetItem | 2.9 ms | 13.8 ms | 4.7倍 |
| PutItem | 3.6 ms | 13.3 ms | 3.6倍 |
| Query(25件) | 2.6 ms | 16.2 ms | 6.3倍 |
| BatchWriteItem(25件) | 6.0 ms | 20.3 ms | 3.4倍 |
sdk_call_ms はアプリケーションコード内で計測した純粋な SDK 呼び出し時間だ。一方 Lambda の Duration はランタイムのイベントループやレスポンスのシリアライズなどのオーバーヘッドを含む。言語間の SDK 性能差を見るなら sdk_call_ms、実際の課金に影響する値を見るなら Duration を参照するとよい。
| 操作 | Rust Duration | Python Duration | 倍率 |
|---|---|---|---|
| GetItem | 6.7 ms | 34.5 ms | 5.1倍 |
| PutItem | 7.0 ms | 26.4 ms | 3.8倍 |
| Query(25件) | 9.7 ms | 35.9 ms | 3.7倍 |
| BatchWriteItem(25件) | 13.0 ms | 40.1 ms | 3.1倍 |
メモリ使用量は全操作で一貫して Rust 30MB / Python 90MB だった。
内訳分析
仮説と照らし合わせる。
DynamoDB レイテンシ支配型(GetItem / PutItem)の結果:
仮説では「言語差は小さい(1.5〜2倍)」と予想したが、実測は3.6〜4.7倍だった。仮説より大きな差が残った。
理由として、Python の boto3 のオーバーヘッドが想定より大きかったことが挙げられる。boto3 は DynamoDB のレスポンスを Python の dict に変換する過程で、型チェックやバリデーションなどの処理を行う。Rust の serde はコンパイル時に型情報を解決するため、デシリアライズ処理が高速だ。sdk_call_ms の差(GetItem で約11ms)の大部分は、このクライアント側の処理効率の違いに起因すると考えられる。
クライアント処理支配型(Query / BatchWriteItem)の結果:
Query 25件で6.3倍、BatchWriteItem 25件で3.4倍。Query の方が差が大きいのは、レスポンスの25件分のデシリアライズでクライアント側の処理量が増え、言語間の効率差が拡大したためと考えられる。
BatchWriteItem は25件のシリアライズ(リクエスト構築)が主な処理だが、DynamoDB 側の書き込み処理も含まれるため、全体に占めるクライアント側処理の比率が Query より低く、結果として倍率も小さくなったと推測される。
90倍 → 3〜6倍: 差はどこに消えたか
第1回の90倍差と今回の3〜6倍差の違いを整理する。
第1回のベンチマークは CPU 負荷(フィボナッチ計算)とメモリ負荷(10万要素のベクタ割り当て)で構成されていた。これらは純粋なクライアント側処理であり、Rust のゼロコスト抽象化とネイティブコード実行の優位性が最大限に発揮される条件だった。
DynamoDB 操作では、処理時間の多くがネットワーク往復(Lambda → DynamoDB エンドポイント)に費やされると考えられる。この部分は言語に依存しない。Rust が速くできるのはシリアライズ/デシリアライズと HTTP 処理であり、全体に占める比率が小さくなるため、倍率は縮まる方向に働く。
ただし「縮まった」とはいえ3〜6倍の差は無視できない。月100万回呼び出しの場合、Billed Duration の差はそのままコスト差に反映される。
考察: Rust Lambda + DynamoDB を選ぶ判断基準
検証データから言えること
- すべての操作パターンで Rust が3〜6倍高速。「DynamoDB レイテンシ支配型でも差は消えない」が本記事の結論だ
- Query(複数件取得)で最も差が大きい(6.3倍)。デシリアライズ処理量に比例して差が開く
- コールドスタートは Rust の方が遅い(合計1,025ms vs 648ms)。ただし2回目以降は即座に2〜3ms に安定する
- メモリ使用量は一貫して Rust が3倍効率的(30MB vs 90MB)
検証外の考慮事項
以下は検証データからは導けないが、採用判断に影響する要素だ。
呼び出し頻度とコスト: 128MB / arm64 の東京リージョン料金で月100万回呼び出しを想定した場合、Billed Duration の差(Rust 平均10ms vs Python 平均35ms)はそのままコンピュート料金に反映される。高頻度になるほど Rust の経済的メリットが大きい。
チームの Rust 習熟度: Lambda 関数は比較的小さいコードベースのため Rust 入門としては適切なスコープだが、所有権やライフタイムの学習コストは無視できない。チームに Rust 経験者がいない場合、最初の関数の開発速度は Python の数倍かかるだろう。
まとめ
- 90倍差は3〜6倍に縮まるが、消えない — DynamoDB のネットワーク往復が支配的になっても、SDK のシリアライズ/デシリアライズ処理で Rust の優位性は残る
- 操作パターンで差の大きさが変わる — Query(複数件取得)で6.3倍、単一操作で3.6〜4.7倍。デシリアライズ処理量に比例して差が開く
- コールドスタートは「1回限りのペナルティ」として評価すべき — Rust の初回リクエストは TLS 接続確立で912ms かかるが、2回目以降は即座に2〜3ms に安定する
- メモリ効率は一貫して Rust が3倍優位 — 30MB vs 90MB。128MB の最小メモリ設定でも Rust は余裕がある
クリーンアップ
リソース削除コマンド
REGION="ap-northeast-1"
# Lambda 関数削除
aws lambda delete-function --function-name rust-dynamodb-bench --region $REGION
aws lambda delete-function --function-name python-dynamodb-bench --region $REGION
# DynamoDB テーブル削除
aws dynamodb delete-table --table-name lambda-rust-bench --region $REGION
# IAM ロール削除
aws iam delete-role-policy --role-name lambda-rust-dynamodb-role \
--policy-name dynamodb-bench-access
aws iam detach-role-policy --role-name lambda-rust-dynamodb-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name lambda-rust-dynamodb-role