「コンテナはもう要らない」は本当か? AWS Lambda の provided.al2023 で WebAssembly を動かしてみた
クラウド事業統括本部の石川です。「コンテナはもう要らない、これからは WebAssembly(Wasm)の時代だ」という論調を目にして実際のところどうなのかが気になりましたので、AWS Lambda の OS-only ランタイム(provided.al2023)の上で Wasm モジュールを動かし、「軽量・小さい・高速起動・移植性が高い」という Wasm の特徴がどこまで本当なのかを実機で確かめてみました。
WebAssembly とコンテナ不要論
いわゆる「コンテナ不要論」のきっかけのひとつが、Docker の創業者である Solomon Hykes 氏が 2019 年に投稿した「もし 2008 年に Wasm と WASI が存在していたら、Docker を作る必要はなかっただろう」という趣旨のツイートです。この前半部分だけが独り歩きしましたが、同氏は後に「Wasm がコンテナを置き換えるとは言っていないし、そう思ってもいない」と補足しています。
つまり本来の論点は「置き換え」ではなく、「起動頻度・稼働時間・状態の有無・スレッド・ネットワーク・言語・可観測性といったシステムの制約から、適切な実行環境を選べるか」という話です。本記事では、この論点を机上で語るのではなく、AWS Lambda 上で Wasm を実際に動かして、特徴を一つずつ実測で確認していきます。
検証に使うのは、AWS Lambda のカスタムランタイム(OS-only ランタイムである provided ファミリー)です。任意の言語・実行形態を bootstrap という実行可能ファイル経由で動かせます。
WebAssembly と WASI とは
WebAssembly(Wasm)は、元々はブラウザ上でプログラムを高速に動かすために作られたバイナリ命令形式です。C/C++ や Rust などで書いたコードを Wasm という共通のバイトコードにコンパイルし、ランタイム上で実行します。本質は「ブラウザ専用」という点ではなく、OS を丸ごと持ち込まずに小さなランタイム上で動く「軽く、安全で、移植しやすい実行形式」である点にあります。
その Wasm をブラウザの外(サーバーやエッジ)で動かすための標準インターフェースが WASI(WebAssembly System Interface)です。Wasm 単体ではファイルやネットワーク、環境変数などのシステム機能に勝手に触れることはできず、ホストが明示的に渡した能力(capability)のみを利用できる、強力なサンドボックスを備えています。
今回はランタイムとして、Bytecode Alliance が開発する wasmtime を使用します。
やってみた
検証構成
今回の検証の全体像は以下のとおりです。ローカル(macOS arm64)でビルドした 1 つの .wasm を、ローカルと Lambda の両方で動かし、結果が一致するか(=移植性)を確認します。
前提条件
- AWS アカウント(検証リージョン: ap-northeast-1)
- ローカル環境: macOS arm64
- rustup 1.29.0 / rustc 1.96.0、ターゲット wasm32-wasip1
- wasmtime 45.0.0(ローカル実行用、Homebrew 版)
ローカルのツールは以下で導入しています。
% curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path
% export PATH="$HOME/.cargo/bin:$PATH"
% rustup target add wasm32-wasip1
% brew install wasmtime
Rust で WASI アプリを作成する
標準入力から JSON を受け取り、加工して標準出力へ JSON を返す、エッジでのリクエスト加工を模した小さなアプリを作成します。あわせて、(1) CPU を使う計算(フィボナッチ)、(2) 環境変数の読み取りを入れて、WASI のサンドボックス挙動を観察できるようにします。
Cargo.toml ではサイズを小さくする最適化を有効にします。
[package]
name = "wasm-greet"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
opt-level = "z" # サイズ優先の最適化
lto = true # リンク時最適化
strip = true # シンボル除去
panic = "abort" # パニック巻き戻しコードを削除
codegen-units = 1
wasm-lambda/wasm-greet/src/main.rs
use std::io::{self, Read, Write};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Input {
name: Option<String>,
n: Option<u64>,
}
#[derive(Serialize)]
struct Output {
message: String,
n: u64,
fib: u64,
runtime: String,
// 環境変数が渡されているか/いないかを出力に含めてサンドボックス挙動を可視化する
greeting_prefix_from_env: String,
}
fn fib(n: u64) -> u64 {
let (mut a, mut b) = (0u64, 1u64);
for _ in 0..n {
let t = a.wrapping_add(b);
a = b;
b = t;
}
a
}
fn main() {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf).ok();
let input: Input = serde_json::from_str(buf.trim()).unwrap_or(Input { name: None, n: None });
let name = input.name.unwrap_or_else(|| "world".to_string());
let n = input.n.unwrap_or(30).min(90);
// WASI ではホストが明示的に渡した環境変数のみ参照できる
let prefix = std::env::var("GREETING_PREFIX").unwrap_or_else(|_| "(env not provided)".to_string());
let out = Output {
message: format!("{} Hello, {}! This runs as WebAssembly (WASI).", prefix, name),
n,
fib: fib(n),
runtime: "wasm32-wasip1".to_string(),
greeting_prefix_from_env: prefix,
};
let s = serde_json::to_string(&out).expect("serialize");
io::stdout().write_all(s.as_bytes()).ok();
io::stdout().write_all(b"\n").ok();
}
wasm32-wasip1 にビルドする
WASI 向けターゲット wasm32-wasip1 でリリースビルドします。
% cargo build --release --target wasm32-wasip1
Compiling proc-macro2 v1.0.106
Compiling unicode-ident v1.0.24
Compiling quote v1.0.45
Compiling serde_core v1.0.228
Compiling zmij v1.0.21
Compiling serde_json v1.0.150
Compiling serde v1.0.228
Compiling itoa v1.0.18
Compiling memchr v2.8.1
Compiling syn v2.0.117
Compiling serde_derive v1.0.228
Compiling wasm-greet v0.1.0 (/Users/ishikawa.satoru/workspaces/cc/blog/20260604-wasm-is-nani/wasm-lambda/wasm-greet)
Finished `release` profile [optimized] target(s) in 9.05s
生成された .wasm のサイズを確認します。
$ ll target/wasm32-wasip1/release/wasm-greet.wasm
-rwxr-xr-x@ 1 ishikawa.satoru staff 98070 Jul 31 22:34 target/wasm32-wasip1/release/wasm-greet.wasm
JSON の処理ライブラリ(serde / serde_json)を含めても、生成物は 98,070 バイト(約 95KB) に収まりました。OS のユーザーランドや言語ランタイムを丸ごと含むコンテナイメージが数10MB〜100MB を超えることを考えると、ロジックだけをコンパイルした Wasm モジュールの小ささが分かります。
ローカルの wasmtime で実行する
まず手元で動かします。後で Lambda 上の結果と突き合わせるため、SHA-256 も記録しておきます。
% shasum -a 256 target/wasm32-wasip1/release/wasm-greet.wasm
6d536e679eac87f8962dad7bd04de8c8be590d764ff548ae6930af353e406c49 target/wasm32-wasip1/release/wasm-greet.wasm
環境変数を渡さずに実行すると、greeting_prefix_from_env は (env not provided) になります。
% echo '{"name":"classmethod","n":35}' | wasmtime run target/wasm32-wasip1/release/wasm-greet.wasm
{"message":"(env not provided) Hello, classmethod! This runs as WebAssembly (WASI).","n":35,"fib":9227465,"runtime":"wasm32-wasip1","greeting_prefix_from_env":"(env not provided)"}
次に --env で環境変数を明示的に渡すと、その値が反映されます。
% echo '{"name":"classmethod","n":35}' | wasmtime run --env GREETING_PREFIX="[from-host-env]" target/wasm32-wasip1/release/wasm-greet.wasm
{"message":"[from-host-env] Hello, classmethod! This runs as WebAssembly (WASI).","n":35,"fib":9227465,"runtime":"wasm32-wasip1","greeting_prefix_from_env":"[from-host-env]"}
環境変数という基本的な情報ですら、ホストが明示的に渡さない限り Wasm 側からは見えません。これが WASI のサンドボックスの挙動です。
Lambda 用にパッケージングする
ここで一点、注意すべき点があります。bootstrap から Lambda Runtime API(次のイベント取得やレスポンス返却)を HTTP で叩くために curl を使いたいのですが、provided.al2023 は AL2023-minimal をベースにしており、curl は同梱されていません。これは provided.al2 との差分のひとつです。
そこで、静的リンクされた curl を同梱します。stunnel/static-curl が配布する aarch64 向けの完全静的バイナリを利用しました。
ランタイム本体である wasmtime も、Lambda(Linux arm64)向けのバイナリを取得します。
bootstrap は、Runtime API のループを回し、受け取ったイベント(JSON)を標準入力で wasm に渡し、その標準出力をレスポンスとして返します。ヘッダ解析は bash の組み込み機能だけで行い、grep / sed / tr に依存しないようにしています(これらも最小ランタイムには含まれない可能性があるため)。
wasm-lambda/bootstrap
#!/bin/bash
set -uo pipefail
export HOME=/tmp
API="http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime"
ROOT="${LAMBDA_TASK_ROOT}"
CURL="${ROOT}/curl"
WASMTIME="${ROOT}/wasmtime"
WASM="${ROOT}/wasm-greet.wasm"
HDR="/tmp/headers.txt"
while true; do
EVENT="$("$CURL" -sS -D "$HDR" "${API}/invocation/next")"
REQUEST_ID=""
while IFS= read -r line; do
line="${line%$'\r'}"
if [[ "$line" =~ ^[Ll]ambda-[Rr]untime-[Aa]ws-[Rr]equest-[Ii]d:[[:space:]]*(.*)$ ]]; then
REQUEST_ID="${BASH_REMATCH[1]}"
fi
done < "$HDR"
# Lambda の環境変数 GREETING_PREFIX を wasm へ明示的に渡す
if RESPONSE="$(printf '%s' "$EVENT" | "$WASMTIME" run --env GREETING_PREFIX="${GREETING_PREFIX:-(env not provided)}" "$WASM" 2>/tmp/wasmerr)"; then
"$CURL" -sS "${API}/invocation/${REQUEST_ID}/response" -d "$RESPONSE" >/dev/null
else
"$CURL" -sS "${API}/invocation/${REQUEST_ID}/error" \
-d '{"errorMessage":"wasm execution failed","errorType":"WasmError"}' >/dev/null
fi
done
bootstrap、wasmtime、curl、app.wasm を 1 つの zip にまとめます。
% zip -j function.zip bootstrap wasmtime curl wasm-greet.wasm
updating: bootstrap (deflated 36%)
% unzip -l function.zip
Archive: function.zip
Length Date Time Name
--------- ---------- ----- ----
1591 06-04-2026 17:19 bootstrap
66184112 06-04-2026 17:21 wasmtime
9214360 06-04-2026 17:21 curl
97487 06-04-2026 17:21 wasm-greet.wasm
--------- -------
75497550 4 files
zip 全体は圧縮後 約 21MB(21,995,387 バイト)でした。50MB 以内のため、S3 を経由せず直接アップロードできます。なお内訳を見ると、ロジック本体である app.wasm は 95KB に過ぎず、サイズの大半はランタイムである wasmtime(約 63MB)と curl(約 8.8MB)です。この点は後ほど考察します。
IAM ロールと Lambda 関数を作成する
Lambda 実行ロールを作成します。
% aws iam create-role --role-name wasm-greet-role \
--assume-role-policy-document file:///tmp/trust.json \
--query 'Role.Arn' --output text
arn:aws:iam::123456789012:role/wasm-greet-role
% aws iam attach-role-policy --role-name wasm-greet-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
/tmp/trust.json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
続いて関数を作成します。ランタイムに provided.al2023、アーキテクチャに arm64 を指定し、環境変数 GREETING_PREFIX を渡します。
% ROLE_ARN=$(aws iam get-role --role-name wasm-greet-role --query Role.Arn --output text) && echo "$ROLE_ARN"
arn:aws:iam::123456789012:role/wasm-greet-role
% aws lambda create-function --function-name wasm-greet \
--runtime provided.al2023 --architectures arm64 \
--role "$ROLE_ARN" --handler bootstrap \
--zip-file fileb://function.zip \
--timeout 30 --memory-size 256 \
--environment '{"Variables":{"GREETING_PREFIX":"[from-lambda-env]"}}' \
--region ap-northeast-1{
"FunctionName": "wasm-greet",
"FunctionArn": "arn:aws:lambda:ap-northeast-1:123456789012:function:wasm-greet",
"Runtime": "provided.al2023",
"Role": "arn:aws:iam::123456789012:role/wasm-greet-role",
"Handler": "bootstrap",
"CodeSize": 21995387,
"Description": "",
"Timeout": 30,
"MemorySize": 256,
"LastModified": "2026-07-31T13:55:28.576+0000",
"CodeSha256": "lEZKGlJkOVOU+f0sC1aGifzPtmGklg7xdxhHE1qMKGM=",
"Version": "$LATEST",
"Environment": {
"Variables": {
"GREETING_PREFIX": "[from-lambda-env]"
}
},
"TracingConfig": {
"Mode": "PassThrough"
},
"RevisionId": "0c6a963e-29ba-4708-a70a-156a110ca4fb",
"State": "Pending",
"StateReason": "The function is being created.",
"StateReasonCode": "Creating",
"PackageType": "Zip",
"Architectures": [
"arm64"
],
"EphemeralStorage": {
"Size": 512
},
"SnapStart": {
"ApplyOn": "None",
"OptimizationStatus": "Off"
},
"RuntimeVersionConfig": {
"RuntimeVersionArn": "arn:aws:lambda:ap-northeast-1::runtime:5ff811a78397efb5e8385d3640b2a92edc47b47cb6b5e8abba974de9d995ed59"
},
"LoggingConfig": {
"LogFormat": "Text",
"LogGroup": "/aws/lambda/wasm-greet"
}
}
なお、環境変数の指定で Variables={GREETING_PREFIX=[from-lambda-env]} というショートハンド記法を使うと、値の角括弧がリストとして解釈され Invalid type for parameter というエラーになりました。上記のように JSON 形式で指定することで回避できます。
invoke して移植性を確認する
ローカルと同じ入力で呼び出します。
% aws lambda invoke --function-name wasm-greet \
--cli-binary-format raw-in-base64-out \
--payload '{"name":"classmethod","n":35}' \
--region ap-northeast-1 /tmp/lambda_out.json
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
cat /tmp/lambda_out.json
{"message":"[from-lambda-env] Hello, classmethod! This runs as WebAssembly (WASI).","n":35,"fib":9227465,"runtime":"wasm32-wasip1","greeting_prefix_from_env":"[from-lambda-env]"}
ローカルでの実行結果と並べてみます。
| 項目 | ローカル(macOS arm64) | Lambda(AL2023 arm64) |
|---|---|---|
fib(フィボナッチ数列の計算結果) |
9227465 | 9227465 |
n(入力パラメータ) |
35 | 35 |
runtime |
wasm32-wasip1 | wasm32-wasip1 |
greeting_prefix_from_env |
[from-host-env] |
[from-lambda-env] |
greeting_prefix_from_env は、ローカルと Lambda でそれぞれ渡した環境変数の値がそのまま反映されているだけで、ロジックの出力(fib や runtime)は完全に一致しています。macOS でビルドした 1 つの .wasm(SHA-256 6d536…)を、再ビルドすることなく Linux の Lambda 上でもそのまま動かせました。OS も実行場所も異なるのに同じ結果が得られる、というのが Wasm の移植性です。
サイズと起動時間を測る
Invokeを --log-type Tail を付けて呼び出すと、レスポンスに実行ログ(REPORT 行)が含まれます。初回呼び出し(cold start)の結果は以下のとおりです。
% aws lambda invoke --function-name wasm-greet \
--cli-binary-format raw-in-base64-out \
--payload '{"name":"classmethod","n":35}' \
--log-type Tail \
--query LogResult --output text \
--region ap-northeast-1 /tmp/lambda_out.json | base64 -d
START RequestId: 6d067737-6ec9-4323-9684-722250d82869 Version: $LATEST
END RequestId: 6d067737-6ec9-4323-9684-722250d82869
REPORT RequestId: 6d067737-6ec9-4323-9684-722250d82869 Duration: 2243.71 ms Billed Duration: 2318 ms Memory Size: 256 MB Max Memory Used: 62 MB Init Duration: 73.94 ms
Init Duration の項目が出ているかどうかが cold start の判別材料になります。warm な呼び出しではこの項目自体が REPORT 行に現れません。
続けて 6 回呼び出した結果(warm)です。
% for i in $(seq 1 6); do
aws lambda invoke --function-name wasm-greet \
--cli-binary-format raw-in-base64-out \
--payload '{"name":"classmethod","n":35}' \
--log-type Tail --query LogResult --output text \
--region ap-northeast-1 /tmp/lambda_out.json \
| base64 -d | awk -v i=$i '/^REPORT/ {print "[invoke " i "] Duration: " $5 " " $6 " Max Memory Used: " $18 " " $19}'
done
[invoke 1] Duration: 154.68 ms Max Memory Used: 62 MB
[invoke 2] Duration: 135.61 ms Max Memory Used: 62 MB
[invoke 3] Duration: 112.89 ms Max Memory Used: 62 MB
[invoke 4] Duration: 122.21 ms Max Memory Used: 62 MB
[invoke 5] Duration: 121.04 ms Max Memory Used: 62 MB
[invoke 6] Duration: 134.11 ms Max Memory Used: 62 MB
計測結果をまとめます。
| 指標 | 値 |
|---|---|
| app.wasm サイズ | 約 95KB(98,070 バイト) |
| デプロイ zip サイズ | 約 21MB(21,995,387 バイト) |
| Init Duration(cold) | 73.94 ms |
| Duration(cold、初回) | 2243.71 ms |
| Duration(warm、平均) | 約 130.1 ms(112.9〜154.7 ms) |
| Max Memory Used | 62 MB |
考察
冒頭で挙げた Wasm の特徴を、今回の実測と照らし合わせて整理します。
小さい・移植できる・隔離されている、はそのとおりだった
- サイズが小さい: ロジック本体の app.wasm は serde を含めても 95KB でした。Wasm モジュール単体は確かに小さくまとまります。
- 移植性が高い: macOS arm64 でビルドした同一の
.wasmを、再ビルドなしで Linux の Lambda 上でも動かせ、ロジックの出力が一致しました。「1 度ビルドすればさまざまな場所で動く」という抽象化は実機でも確認できました。 - サンドボックスが強い: 環境変数すら、ホストが明示的に渡さない限り Wasm 側からは見えませんでした。デフォルトで外の世界に触れない、という性質を体感できました。
「起動が早い」は実行モデル次第だった
ここが今回いちばん示唆的でした。Lambda の初期化フェーズである Init Duration は 73.94 ms と短い一方で、初回の Duration は 2244 ms と長く、warm でも 1 回あたり約 130 ms かかりました。
理由は、今回の bootstrap が呼び出しごとに wasmtime run で新しいプロセスを起動し、その都度 Wasm モジュールを機械語に JIT コンパイルしているためと考えられます。つまり「Wasm だから速い」のではなく、「どう実行するか」で起動コストは大きく変わります。本番運用では、事前コンパイル(wasmtime compile による .cwasm の同梱)でコンパイルを省く、ランタイムを常駐させて Wasm インスタンスを再利用する、といった工夫で改善できる余地があります。
サイズの大半はロジックではなくランタイムだった
zip 全体は約 21MB でしたが、その大半は wasmtime(約 63MB、展開時)と curl(約 8.8MB)であり、ロジックである app.wasm は 95KB に過ぎませんでした。「Wasm は小さい」はモジュール単体の話であって、それをどこで動かすか(ランタイムをどう用意するか)まで含めると話は変わります。Wasm に対応したプラットフォーム側がランタイムを内蔵していれば、持ち運ぶのはこの 95KB だけで済む、という見方もできます。
苦手な領域はそのまま苦手
今回検証したのは、入力を受け取って結果を返す短命で軽量な処理でした。これは Wasm(やサーバーレス関数)と相性の良い領域です。一方で、マルチスレッドで CPU を使い切る処理や、長時間動き続けるステートフルな処理は、今回の構成(Lambda + 単発の wasmtime 実行)の延長線上にはありません。データベースや常時通信するワークロードは、引き続きコンテナや専用基盤が向いています。
「置き換え」ではなく「収束」
そもそも今回は、Lambda という既存のサーバーレス基盤の上で Wasm を動かしました。これは「コンテナ対 Wasm」という対立ではなく、既存基盤が Wasm を実行手段の 1 つとして取り込む動き、すなわち「収束」の一例といえます。実際、コンテナ基盤の側でも Wasm を実行する仕組みが整いつつあります。
最後に
AWS Lambda 上で Wasm を動かしてみた結果、「小さい・移植できる・隔離されている」は実機でもそのとおりでしたが、「起動が早い」は実行モデル次第であり、無条件に速いわけではないことが分かりました。
「コンテナか Wasm か」という二択ではなく、起動頻度・稼働時間・状態の有無・スレッド・言語・可観測性といったシステムの制約から、短命で軽量・隔離したい処理には Wasm、ステートフルで汎用的な処理にはコンテナ、と使い分けるのが現実的です。流行の見出しに踊らされず、自分のワークロードの制約に照らして実行環境を選ぶための判断材料として、実際に手を動かして測ってみることをおすすめします。
この記事はきっと誰の役にもたたない記事だろうな。







