"Do We Really No Longer Need Containers?" - Running WebAssembly on AWS Lambda with provided.al2023

"Do We Really No Longer Need Containers?" - Running WebAssembly on AWS Lambda with provided.al2023

I actually ran WebAssembly (Wasm) on AWS Lambda to verify whether it truly lives up to claims of being "lightweight, small, fast-starting, and highly portable." I put the theoretical arguments to the test with real measurements.
2026.07.31

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.

https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html

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.

https://docs.aws.amazon.com/lambda/latest/dg/runtimes-provided.html

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.

https://webassembly.org/

https://wasi.dev/

For this verification, we will use wasmtime, developed by the Bytecode Alliance, as the runtime.

https://wasmtime.dev/

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.

https://aws.amazon.com/blogs/compute/introducing-the-amazon-linux-2023-runtime-for-aws-lambda/

Therefore, we bundle a statically linked curl. We used the fully static binary for aarch64 distributed by stunnel/static-curl.

https://github.com/stunnel/static-curl

We also obtain the wasmtime binary for Lambda (Linux arm64), which is the runtime itself.

https://github.com/bytecodealliance/wasmtime

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 .wasm built 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.


クラスメソッドのエンジニアと1on1で話してみませんか?

選考に関係のないカジュアルな面談です。
「技術スタックや開発環境について詳しく知りたい」「実際のプロジェクト事例を聞きたい」「リモートワークや評価制度について確認したい」など、気になることを直接エンジニアに質問できます。
カジュアル面談に申し込む

Share this article

AWSのお困り事はクラスメソッドへ