"Do We Really No Longer Need Containers?" - Running WebAssembly on AWS Lambda with provided.al2023
This page has been translated by machine translation. View original
This is Ishikawa from the Cloud Business Division. After seeing arguments along the lines of "containers are no longer needed, the era of WebAssembly (Wasm) is here," I became curious about the actual state of things, so I ran a Wasm module on AWS Lambda's OS-only runtime (provided.al2023) to verify firsthand how true the characteristics of Wasm—"lightweight, small, fast startup, high portability"—really are.
WebAssembly and the "No More Containers" Argument
One of the catalysts for the so-called "no more containers" argument was a tweet posted in 2019 by Docker co-founder Solomon Hykes, to the effect that "if Wasm and WASI had existed in 2008, there would have been no need to create Docker." Only this first half took on a life of its own, but he later clarified that "I never said Wasm would replace containers, nor do I think so."
In other words, the original point was not about "replacement," but rather about whether you can choose the appropriate execution environment based on system constraints such as startup frequency, uptime, statefulness, threads, networking, language, and observability. Rather than discussing this point theoretically, this article will actually run Wasm on AWS Lambda and verify each characteristic through real measurements.
For verification, we will use AWS Lambda's custom runtime (the provided family, which is an OS-only runtime). It allows you to run any language or execution form via an executable file called bootstrap.
What Are WebAssembly and WASI?
WebAssembly (Wasm) is a binary instruction format originally created to run programs quickly in browsers. Code written in C/C++, Rust, and other languages is compiled into common bytecode called Wasm and executed on a runtime. The essence is not that it is "browser-only," but rather that it is a "lightweight, safe, and portable execution format" that runs on a small runtime without bringing in an entire OS.
WASI (WebAssembly System Interface) is the standard interface for running Wasm outside the browser (on servers and at the edge). Wasm alone cannot freely access system capabilities such as files, networks, or environment variables; it has a powerful sandbox that allows it to use only the capabilities explicitly passed by the host.
For this verification, we will use wasmtime, developed by the Bytecode Alliance, as the runtime.
Trying It Out
Verification Setup
Here is an overview of this verification. We will run a single .wasm built locally (macOS arm64) both locally and on Lambda, and confirm whether the results match (= portability).
Prerequisites
- AWS account (verification region: ap-northeast-1)
- Local environment: macOS arm64
- rustup 1.29.0 / rustc 1.96.0, target wasm32-wasip1
- wasmtime 45.0.0 (for local execution, Homebrew version)
Local tools are installed with the following:
% 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
Creating a WASI App in Rust
We will create a small app that receives JSON from standard input, processes it, and returns JSON to standard output—modeling edge request processing. We will also include (1) a CPU-intensive calculation (Fibonacci) and (2) reading environment variables, to observe WASI's sandbox behavior.
In Cargo.toml, we enable optimizations to minimize size.
[package]
name = "wasm-greet"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
opt-level = "z" # Size-priority optimization
lto = true # Link-time optimization
strip = true # Strip symbols
panic = "abort" # Remove panic unwinding code
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,
// Include whether or not environment variables were passed in the output to visualize sandbox behavior
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);
// In WASI, only environment variables explicitly passed by the host can be referenced
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();
}
Building for wasm32-wasip1
We perform a release build targeting WASI with 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
Let's check the size of the generated .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
Even including the JSON processing library (serde / serde_json), the output fits within 98,070 bytes (approximately 95KB). Considering that container images including an OS userland and language runtime can exceed tens of MB to over 100MB, you can see how small a Wasm module is when only the logic is compiled.
Running with Local wasmtime
First, let's run it locally. We also record the SHA-256 so we can compare it with the Lambda results later.
% shasum -a 256 target/wasm32-wasip1/release/wasm-greet.wasm
6d536e679eac87f8962dad7bd04de8c8be590d764ff548ae6930af353e406c49 target/wasm32-wasip1/release/wasm-greet.wasm
Running without passing environment variables, greeting_prefix_from_env becomes (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)"}
Next, when we explicitly pass an environment variable with --env, its value is reflected.
% 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]"}
Even basic information like environment variables is invisible to the Wasm side unless the host explicitly passes it. This is the sandbox behavior of WASI.
Packaging for Lambda
There is one important point to note here. To call the Lambda Runtime API (fetching the next event and returning a response) via HTTP from bootstrap, we want to use curl, but provided.al2023 is based on AL2023-minimal, and curl is not included. This is one of the differences from provided.al2.
Therefore, we bundle a statically linked curl. We used the fully static binary for aarch64 distributed by stunnel/static-curl.
We also obtain the wasmtime binary for Lambda (Linux arm64), which is the runtime itself.
The bootstrap loops through the Runtime API, passes the received event (JSON) to the wasm via standard input, and returns its standard output as the response. Header parsing is done using only bash built-in features, without relying on grep / sed / tr (as these may not be included in the minimal runtime).
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"
# Explicitly pass Lambda's environment variable GREETING_PREFIX to 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
Bundle bootstrap, wasmtime, curl, and app.wasm into a single 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
The total zip was approximately 21MB (21,995,387 bytes) after compression. This is within 50MB, so it can be uploaded directly without going through S3. Looking at the breakdown, the logic itself, app.wasm, is only 95KB, and the majority of the size comes from the runtime wasmtime (approximately 63MB) and curl (approximately 8.8MB). We will discuss this point later.
Creating an IAM Role and Lambda Function
Create a Lambda execution role.
% 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"
}
]
}
Next, create the function. Specify provided.al2023 as the runtime, arm64 as the architecture, and pass the environment variable 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"
}
}
Note that using the shorthand notation Variables={GREETING_PREFIX=[from-lambda-env]} for the environment variable specification will cause the square brackets in the value to be interpreted as a list, resulting in an Invalid type for parameter error. This can be avoided by specifying it in JSON format as shown above.
Invoking to Verify Portability
Invoke with the same input as locally.
% 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]"}
Let's compare the local and Lambda execution results side by side.
| Item | Local (macOS arm64) | Lambda (AL2023 arm64) |
|---|---|---|
fib (Fibonacci sequence calculation result) |
9227465 | 9227465 |
n (input parameter) |
35 | 35 |
runtime |
wasm32-wasip1 | wasm32-wasip1 |
greeting_prefix_from_env |
[from-host-env] |
[from-lambda-env] |
greeting_prefix_from_env simply reflects the environment variable values passed locally and to Lambda respectively, while the logic outputs (fib and runtime) match completely. We were able to run a single .wasm (SHA-256 6d536…) built on macOS directly on Linux Lambda without rebuilding. Getting the same result despite different OS and execution location—that is the portability of Wasm.
Measuring Size and Startup Time
Invoking with --log-type Tail includes the execution log (REPORT line) in the response. The result of the first invocation (cold start) is as follows.
% 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
Whether or not the Init Duration item appears is a criterion for identifying a cold start. For warm invocations, this item itself does not appear in the REPORT line.
Here are the results of 6 subsequent invocations (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
Here is a summary of the measurement results.
| Metric | Value |
|---|---|
| app.wasm size | Approx. 95KB (98,070 bytes) |
| Deployment zip size | Approx. 21MB (21,995,387 bytes) |
| Init Duration (cold) | 73.94 ms |
| Duration (cold, first invocation) | 2243.71 ms |
| Duration (warm, average) | Approx. 130.1 ms (112.9–154.7 ms) |
| Max Memory Used | 62 MB |
Discussion
Let's organize the Wasm characteristics mentioned at the beginning and compare them against the actual measurements from this verification.
Small, Portable, and Isolated Were All True
- Small size: The logic body app.wasm was 95KB even including serde. A Wasm module on its own is indeed compact.
- High portability: We ran the same
.wasmbuilt on macOS arm64 on Linux Lambda without rebuilding, and the logic outputs matched. The abstraction of "build once, run anywhere" was confirmed on real hardware. - Strong sandbox: Even environment variables were invisible to the Wasm side unless the host explicitly passed them. We were able to experience firsthand the nature of not touching the outside world by default.
"Fast Startup" Depended on the Execution Model
This was the most suggestive finding this time. While the Init Duration, which is Lambda's initialization phase, was a short 73.94 ms, the first Duration was 2244 ms, and warm invocations also took about 130 ms each.
The reason is thought to be that the bootstrap in this verification launches a new process with wasmtime run for each invocation, JIT-compiling the Wasm module to machine code each time. In other words, it's not that "Wasm is fast"—the startup cost varies significantly depending on "how it is executed." In production use, there is room for improvement through techniques such as pre-compilation (bundling .cwasm generated by wasmtime compile to skip compilation) and keeping the runtime resident to reuse Wasm instances.
The Majority of the Size Was the Runtime, Not the Logic
While the entire zip was approximately 21MB, the majority came from wasmtime (approximately 63MB when expanded) and curl (approximately 8.8MB), with the logic app.wasm being only 95KB. "Wasm is small" refers to the module alone; the picture changes when you include how you run it (how you prepare the runtime). You could also look at it this way: if the platform on which you run Wasm has the runtime built in, you only need to carry this 95KB.
Weaknesses Remain as Weaknesses
What we verified this time was a short-lived, lightweight process that receives input and returns results. This is an area where Wasm (and serverless functions) are well-suited. On the other hand, multi-threaded CPU-intensive processing and long-running stateful processing are not an extension of this configuration (Lambda + single wasmtime execution). Workloads involving databases or persistent connections are still better suited to containers or dedicated infrastructure.
"Convergence," Not "Replacement"
In the first place, we ran Wasm on top of an existing serverless infrastructure called Lambda. This is not a "containers vs. Wasm" opposition, but rather an example of existing infrastructure incorporating Wasm as one of its execution methods—in other words, "convergence." In fact, mechanisms for executing Wasm are also being established on the container infrastructure side.
In Closing
After running Wasm on AWS Lambda, we found that "small, portable, and isolated" held true on real hardware, but "fast startup" depended on the execution model and was not unconditionally fast.
Rather than a binary choice of "containers or Wasm," the practical approach is to differentiate based on system constraints such as startup frequency, uptime, statefulness, threads, language, and observability: use Wasm for short-lived, lightweight, isolated processes, and containers for stateful, general-purpose processes. Rather than being swept away by trending headlines, I recommend actually getting hands-on and measuring things yourself to build the judgment needed to choose an execution environment based on your own workload's constraints.
I'm sure this article won't be useful to anyone.
