本記事で紹介するコードはコーディングエージェントを用いて作成している。
読者の方のお使いのコーディングエージェントに要件を伝えれば大体同じように作ってくれると思うので、参考程度に見ていただきたい。
また、実際に運用する際は、記事中で紹介しているBasic認証だけではセキュリティ上充分とは言えず、今回の構成では平文で保存しているため、ほかの認証手段やAWS WAFとの組み合わせも検討いただきたい。
はじめに
AWS Lambda MicroVMsとは
2026年6月にGAされたAWSの新しいサーバーレスコンピューティングのリソースだ。
もともとAWS Lambda Functionの内部でも使われているFirecracker VMを、ユーザーが直接扱えるようにした機能だ。AWS Lambda Functionとの大きな違いは以下の点にある。
- 独立した専有環境: ユーザーやジョブごとに独立したVMが起動する。テナント間でのリソース共有なし
- ステートフル: メモリやディスクの状態を保持したまま一時停止・復帰できる。MicroVMには最大実行時間を設定でき、上限に達すると破棄される
- 柔軟なOS機能の操作: システムパッケージのインストールやファイルシステムのマウントなど、通常のLinux環境と同等の操作ができる
- 専用のHTTPSエンドポイント: 各MicroVMがそれぞれ独自のHTTPSエンドポイントを持つ。ロードバランサー不要で直接アクセスできる
インタラクティブな開発環境、AIコード実行サンドボックス、マルチテナントCI/CDなど、従来のAWS Lambda Functionでは実現が難しかったアプリケーションの実行や環境の構築が可能だ。
今回は、このAWS Lambda MicroVMs上でHonoを使ったWebサーバを、Terraformを使って自動構築する。
Honoとは
HonoはWeb標準APIに基づいて構築された、高速・軽量なTypeScript/JavaScript向けWebフレームワークだ。
もともとはCloudflare Workersなどのエッジコンピューティング環境を想定して開発されたが、現在はNode.js、Deno、Bun、AWS Lambdaなど、さまざまなJavaScriptランタイムに対応している。
今回は、静的ファイルの配信とヘルスチェックエンドポイントを提供するだけのシンプルなWebサーバを実装する。
システム構成
本記事で作成するシステムの構成図は以下の通り。
AWS Lambda MicroVMsのエンドポイントに直接外部からアクセスさせるのではなく、CloudFrontを経由させる構成にする。
Amazon CloudFront Functionsでトークンの有無や有効期限を確認し、有効なMicroVMが存在すればそのエンドポイントへ転送する。MicroVMが起動していない場合や、トークンが失効している場合はAWS Lambda Function(ランチャー)に転送し、AWS Lambda Function(ランチャー)がMicroVMの起動処理やトークンの発行・更新を行う。MicroVMの接続情報はAmazon CloudFront KeyValueStoreに保存し、CloudFront Functionsがそれを読み込んでルーティング先を決定する。
ランチャーはあくまで「起動中」ページを返すだけで、ブラウザ側のJavaScriptが数秒後にリロードする。リロード時点でAmazon CloudFront KeyValueStoreにMicroVMの情報が書き込まれていれば、次のリクエストからCloudFront FunctionsがMicroVMに直接転送するようになる。
コンポーネントを整理すると以下のようになる。
| コンポーネント | 役割 |
|---|---|
| Amazon CloudFront Distribution | エントリーポイント。MicroVMへの転送を担う |
| CloudFront Functions | Basic認証。Amazon CloudFront KeyValueStoreを読み、転送先を決定する |
| Amazon CloudFront KeyValueStore | MicroVMのエンドポイント・トークン・設定を保持 |
| AWS Lambda Function URLs(ランチャー) | MicroVMの状態確認・起動・トークン発行・Amazon CloudFront KeyValueStore更新 |
| AWS Lambda MicroVMs(コントロールプレーン) | AWS Lambda MicroVMの実行管理 |
| MicroVM | 実際に動作するWebサーバ(Hono) |
| Amazon S3 Bucket | MicroVMのアプリケーションコードの置き場 |
Webサーバの構成
まず、MicroVM上で動作させるWebサーバを実装する。
Dockerfile
MicroVM上で実行するアプリケーション環境をコンテナイメージとして構築するため、Dockerfileを用意する。
FROM public.ecr.aws/docker/library/node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY src ./src
COPY public ./public
ENV NODE_ENV=production \
APP_PORT=8080
EXPOSE 8080
CMD ["node", "src/index.js"]
Webサーバの定義
ここでは、Honoのルーティングを定義しておく。
Webサーバはデフォルトで8080番ポートで起動する。
/pingエンドポイントの提供と、public/配下の静的ファイルの配信を行う。
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { Hono } from "hono";
import { logger } from "hono/logger";
const APP_PORT = Number(process.env.APP_PORT ?? "8080");
const app = new Hono();
app.use("*", logger());
app.get("/ping", (c) => c.json({ status: "ok" }));
app.use("/*", serveStatic({ root: "./public" }));
app.notFound((c) => c.text("Not Found", 404));
serve({ fetch: app.fetch, port: APP_PORT }, (info) => {
console.log(`app listening on port ${info.port}`);
});
あわせて、public/配下に、表示するHTMLとCSSも用意する。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AWS Lambda MicroVMs Example</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<main>
<h1>Hello from AWS Lambda MicroVMs</h1>
<p>
この静的ページは AWS Lambda MicroVMs 上で動作しています。
</p>
<ul>
<li><a href="/ping">/ping</a> (JSON の疎通確認)</li>
</ul>
</main>
</body>
</html>
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: #1a1a1a;
background: #f5f7fa;
}
main {
max-width: 40rem;
margin: 4rem auto;
padding: 2rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgb(0 0 0 / 10%);
}
h1 {
margin-top: 0;
color: #232f3e;
}
a {
color: #0b57d0;
}
Amazon CloudFrontの役割と実装
今回の構成でCloudFrontが担うのは主に3つだ。
- Basic認証: 誰でもMicroVMにアクセスできることがないようにするために、CloudFront Functionsでリクエストをフィルタリングする
- ルーティング: Amazon CloudFront KeyValueStoreに有効なMicroVM情報があれば直接MicroVMに転送し、なければAWS Lambda Function(ランチャー)に転送する
- オリジンフェイルオーバー: MicroVMがトークン失効・消失などでエラーを返した場合、自動的にAWS Lambda Function(ランチャー)に振り替える
CloudFront Functions
CloudFront Functionsのコードを見ていこう。
import cf from 'cloudfront';
const kvs = cf.kvs();
const REFRESH_MARGIN_SECONDS = 60;
async function getJson(key) {
try {
const raw = await kvs.get(key);
return JSON.parse(raw);
} catch (e) {
return null;
}
}
function unauthorized() {
return {
statusCode: 401,
statusDescription: 'Unauthorized',
headers: {
'www-authenticate': { value: 'Basic realm="lambda-microvms-example", charset="UTF-8"' },
'cache-control': { value: 'no-store' },
},
};
}
async function handler(event) {
const request = event.request;
// ---- Basic 認証 ------------------------------------------------------------
const config = await getJson('config');
const authorization = request.headers.authorization;
if (!config || !authorization || authorization.value !== 'Basic ' + config.auth) {
return unauthorized();
}
delete request.headers.authorization;
// クライアントからのトークン指定は受け付けない
delete request.headers['x-aws-proxy-auth'];
// ---- AWS Lambda MicroVMs への転送可否 ----------------------------------------
// ポイント1. imageVersionの照合 / ポイント2. 有効期限のマージン
const meta = await getJson('mvm');
const now = Math.floor(Date.now() / 1000);
if (!meta || meta.imageVersion !== config.imageVersion || now >= meta.exp - REFRESH_MARGIN_SECONDS) {
return request;
}
// ポイント3. トークンの分割保持
let token = '';
try {
for (let i = 0; i < meta.n; i++) {
const chunk = await kvs.get('mvm_tok_' + meta.ver + '_' + i);
token = token + chunk;
}
} catch (e) {
return request;
}
request.headers['x-aws-proxy-auth'] = { value: token };
cf.updateRequestOrigin({
domainName: meta.endpoint,
originAccessControlConfig: { enabled: false },
customOriginConfig: {
port: 443,
protocol: 'https',
sslProtocols: ['TLSv1.2'],
},
});
return request;
}
ポイントをいくつか補足しておく。
-
imageVersionの照合
meta.imageVersion !== config.imageVersionのチェックで、Amazon CloudFront KeyValueStoreに古いバージョンのMicroVM情報が残っていた場合はランチャーに流す。Terraformでイメージを更新した際に、古いMicroVMへの転送を防ぐためだ。 -
有効期限のマージン
exp - REFRESH_MARGIN_SECONDSで60秒前にトークンを失効扱いにする。エッジで判定するため、実際の有効期限ギリギリまで使うのは危険だ。 -
トークンの分割保持
MicroVMへの接続に必要なX-aws-proxy-authトークンは、JWEトークンであり、実際に扱うと1000〜1500バイトほどになる。一方、Amazon CloudFront KeyValueStoreの1キーあたりの値のサイズ上限は1KBだ。
トークンをそのまま1つのキーに保存しようとするとUpdateKeys APIがエラーになるため、ランチャーLambdaではTOKEN_CHUNK_SIZE = 900として分割し、mvm_tok_<ver>_0,mvm_tok_<ver>_1...というキーに保存している。CloudFront Functionsはメタ情報(mvmキー)に含まれるチャンク数(n)を読んでループを回し、結合する。
AWS Lambda Function(ランチャー)の実装
ランチャーは、Amazon CloudFrontのフォールバック先として「MicroVMが使えない時」にだけ呼ばれる。処理の流れは以下のようになる。
- Amazon CloudFront KeyValueStoreからMicroVMのメタ情報を取得
- MicroVMの状態を確認し、なければ起動、SUSPENDEDなら再開する
- RUNNING状態になったらトークンを発行してAmazon CloudFront KeyValueStoreに書き込む
- ブラウザに「起動中」ページ(HTTP 503)を返す
- ブラウザが数秒後にリロードすると、Amazon CloudFront KeyValueStoreが更新済みなのでCloudFront FunctionsがMicroVMに転送する
AWS Lambda Function(ランチャー)のreserved_concurrent_executions = 1に注目してほしい。同時実行数を1に制限することで、AWS Lambda Function(ランチャー)によるMicroVMの起動処理が並列実行されることを防いでいる。同時実行数の上限を超えたリクエストに対しては、スロットリングのエラー応答が返される。
package.json(抜粋)
@aws-sdk/signature-v4aが必要なのは、CloudFront KeyValueStoreのAPIがSigV4A署名を要求するためだ。通常のSigV4では署名が通らないので注意が必要だ。
{
"dependencies": {
"@aws-sdk/client-cloudfront-keyvaluestore": "3.1149.0",
"@aws-sdk/client-lambda-microvms": "3.1149.0",
"@aws-sdk/signature-v4a": "3.1138.0"
}
}
AWS Lambda Function(ランチャー)コード
コード全体
import "@aws-sdk/signature-v4a"; // Amazon CloudFront KeyValueStoreのAPIはSigV4A署名が必要
import {
CloudFrontKeyValueStoreClient,
DescribeKeyValueStoreCommand,
GetKeyCommand,
ListKeysCommand,
UpdateKeysCommand,
} from "@aws-sdk/client-cloudfront-keyvaluestore";
import {
CreateMicrovmAuthTokenCommand,
GetMicrovmCommand,
LambdaMicrovmsClient,
ListMicrovmsCommand,
ResourceNotFoundException,
ResumeMicrovmCommand,
RunMicrovmCommand,
TerminateMicrovmCommand,
} from "@aws-sdk/client-lambda-microvms";
const env = (name) => {
const v = process.env[name];
if (!v) throw new Error(`Missing environment variable ${name}`);
return v;
};
const KVS_ARN = env("KVS_ARN");
const IMAGE_ARN = env("MICROVM_IMAGE_ARN");
const IMAGE_VERSION = env("MICROVM_IMAGE_VERSION");
const APP_PORT = Number(env("MICROVM_APP_PORT"));
const MAX_DURATION_SECONDS = Number(env("MICROVM_MAXIMUM_DURATION_IN_SECONDS"));
const IDLE_POLICY = {
autoResumeEnabled: env("MICROVM_IDLE_AUTO_RESUME_ENABLED") === "true",
maxIdleDurationSeconds: Number(env("MICROVM_IDLE_MAX_DURATION_SECONDS")),
suspendedDurationSeconds: Number(env("MICROVM_IDLE_SUSPENDED_DURATION_SECONDS")),
};
// AWS マネージドのネットワークコネクタ (リージョンは AWS Lambda が自動設定する AWS_REGION)
const CONNECTOR_PREFIX = `arn:aws:lambda:${env("AWS_REGION")}:aws:network-connector:aws-network-connector`;
const INGRESS_CONNECTORS = [`${CONNECTOR_PREFIX}:ALL_INGRESS`];
const EGRESS_CONNECTORS = [`${CONNECTOR_PREFIX}:INTERNET_EGRESS`];
const TOKEN_TTL_MINUTES = 60; // API の上限
const TOKEN_CHUNK_SIZE = 900; // Amazon CloudFront KeyValueStoreの値は1KBまで
const REISSUE_GUARD_SECONDS = 30; // 直前に発行済みなら再発行しない (Amazon CloudFront KeyValueStore反映待ち)
const RETRY_AFTER_SECONDS = 5;
const microvms = new LambdaMicrovmsClient({});
const kvs = new CloudFrontKeyValueStoreClient({});
// ------------------------------------------------------------------------------
// Handler
// ------------------------------------------------------------------------------
export const handler = async () => {
try {
const status = await reconcile();
console.log(JSON.stringify({ status }));
return startingPage(status);
} catch (err) {
console.error(err);
return startingPage("error");
}
};
// 現在の MicroVM を「転送可能」な状態に近づける。1回で完了させず、
// ブラウザの再読み込みのたびに呼ばれて少しずつ進む(長時間ブロックしない)。
async function reconcile() {
const meta = await getMeta();
let vm = meta ? await getMicrovm(meta.id) : null;
if (!isAlive(vm)) {
vm = await findAliveMicrovm();
}
// 古いイメージバージョンのMicroVMは作り直す
if (isAlive(vm) && vm.imageVersion !== IMAGE_VERSION) {
await microvms.send(new TerminateMicrovmCommand({ microvmIdentifier: vm.microvmId }));
vm = null;
}
if (!isAlive(vm)) {
vm = await microvms.send(
new RunMicrovmCommand({
imageIdentifier: IMAGE_ARN,
imageVersion: IMAGE_VERSION,
ingressNetworkConnectors: INGRESS_CONNECTORS,
egressNetworkConnectors: EGRESS_CONNECTORS,
idlePolicy: IDLE_POLICY,
maximumDurationInSeconds: MAX_DURATION_SECONDS,
}),
);
return `launched:${vm.microvmId}`;
}
switch (vm.state) {
case "RUNNING":
break;
case "SUSPENDED":
await microvms.send(new ResumeMicrovmCommand({ microvmIdentifier: vm.microvmId }));
return `resuming:${vm.microvmId}`;
default: // PENDING / SUSPENDING
return `waiting:${vm.microvmId}:${vm.state}`;
}
const now = Math.floor(Date.now() / 1000);
if (meta && meta.id === vm.microvmId && now - meta.issuedAt < REISSUE_GUARD_SECONDS) {
return `published:${vm.microvmId}`;
}
await publish(vm, meta, now);
return `published:${vm.microvmId}`;
}
const ALIVE_STATES = new Set(["PENDING", "RUNNING", "SUSPENDING", "SUSPENDED"]);
function isAlive(vm) {
return Boolean(vm && ALIVE_STATES.has(vm.state));
}
async function getMicrovm(microvmId) {
try {
return await microvms.send(new GetMicrovmCommand({ microvmIdentifier: microvmId }));
} catch (err) {
if (err instanceof ResourceNotFoundException) return null;
throw err;
}
}
// 現行バージョンで動いている MicroVM を探す。古いバージョンのものは止める。
async function findAliveMicrovm() {
let found = null;
let nextToken;
do {
const page = await microvms.send(
new ListMicrovmsCommand({ imageIdentifier: IMAGE_ARN, nextToken }),
);
for (const m of page.items ?? []) {
if (!ALIVE_STATES.has(m.state)) continue;
if (m.imageVersion !== IMAGE_VERSION) {
await microvms.send(new TerminateMicrovmCommand({ microvmIdentifier: m.microvmId }));
} else if (!found) {
found = m;
}
}
nextToken = page.nextToken;
} while (nextToken);
return found ? getMicrovm(found.microvmId) : null;
}
// ------------------------------------------------------------------------------
// Amazon CloudFront KeyValueStore
// ------------------------------------------------------------------------------
async function getMeta() {
try {
const res = await kvs.send(new GetKeyCommand({ KvsARN: KVS_ARN, Key: "mvm" }));
return JSON.parse(res.Value);
} catch (err) {
if (err.name === "ResourceNotFoundException") return null;
throw err;
}
}
async function etag() {
const res = await kvs.send(new DescribeKeyValueStoreCommand({ KvsARN: KVS_ARN }));
return res.ETag;
}
async function publish(vm, oldMeta, now) {
const tokenRes = await microvms.send(
new CreateMicrovmAuthTokenCommand({
microvmIdentifier: vm.microvmId,
expirationInMinutes: TOKEN_TTL_MINUTES,
allowedPorts: [{ port: APP_PORT }],
}),
);
const token = tokenRes.authToken?.["X-aws-proxy-auth"];
if (!token) throw new Error("CreateMicrovmAuthToken returned no X-aws-proxy-auth");
const ver = String(now);
const chunks = token.match(new RegExp(`.{1,${TOKEN_CHUNK_SIZE}}`, "g"));
await kvs.send(
new UpdateKeysCommand({
KvsARN: KVS_ARN,
IfMatch: await etag(),
Puts: chunks.map((value, i) => ({ Key: `mvm_tok_${ver}_${i}`, Value: value })),
}),
);
const vmExpiresAt = Math.floor(new Date(vm.startedAt).getTime() / 1000) + MAX_DURATION_SECONDS;
const meta = {
id: vm.microvmId,
endpoint: vm.endpoint.replace(/^https:\/\//, "").replace(/\/$/, ""),
ver,
n: chunks.length,
exp: Math.min(now + TOKEN_TTL_MINUTES * 60, vmExpiresAt),
issuedAt: now,
imageVersion: vm.imageVersion,
};
// 今回と直前の ver 以外のトークンは削除する
const keep = new Set([ver, oldMeta?.ver].filter(Boolean));
const stale = (await listTokenKeys()).filter((k) => !keep.has(k.split("_")[2]));
await kvs.send(
new UpdateKeysCommand({
KvsARN: KVS_ARN,
IfMatch: await etag(),
Puts: [{ Key: "mvm", Value: JSON.stringify(meta) }],
Deletes: stale.slice(0, 49).map((Key) => ({ Key })),
}),
);
}
async function listTokenKeys() {
const keys = [];
let NextToken;
do {
const res = await kvs.send(new ListKeysCommand({ KvsARN: KVS_ARN, NextToken }));
for (const item of res.Items ?? []) {
if (item.Key.startsWith("mvm_tok_")) keys.push(item.Key);
}
NextToken = res.NextToken;
} while (NextToken);
return keys;
}
// ------------------------------------------------------------------------------
// Response
// ------------------------------------------------------------------------------
function startingPage(status) {
const failed = status === "error";
const message = failed
? "MicroVM の準備中にエラーが発生しました。数秒後に自動で再試行します。"
: "MicroVM を起動中です。数秒後に自動で再読み込みします。";
const body = `<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AWS Lambda MicroVMs を起動中です</title>
<noscript><meta http-equiv="refresh" content="${RETRY_AFTER_SECONDS}" /></noscript>
<style>
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: #f5f7fa; color: #1a1a1a; }
main { max-width: 36rem; margin: 4rem auto; padding: 2rem; background: #fff; border-radius: 8px; box-shadow: 0 1px 4px rgb(0 0 0 / 10%); }
a { color: #0b57d0; }
</style>
</head>
<body>
<main>
<h1>AWS Lambda MicroVMs を起動中です</h1>
<p role="status">${message}</p>
<p><a href="" id="retry">今すぐリトライ</a></p>
</main>
<script>
document.getElementById("retry").addEventListener("click", (e) => { e.preventDefault(); location.reload(); });
setTimeout(() => location.reload(), ${RETRY_AFTER_SECONDS * 1000});
</script>
</body>
</html>
`;
return {
statusCode: 503,
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-store",
"retry-after": String(RETRY_AFTER_SECONDS),
},
body,
};
}
IaC (Terraform)
IAM
IAMは2種類作成する。
-
MicroVMs Image Build用のIAMロール
Terraformによるaws_lambdamicrovms_imageリソースを作成する際に、AWS LambdaがAmazon S3からアプリケーションのZIPファイルを取得してイメージをビルドするために使用するロール。 -
AWS Lambda Function(ランチャー)用のIAMロール
MicroVMの操作(RunMicrovm,GetMicrovm,ResumeMicrovm,TerminateMicrovm,CreateMicrovmAuthToken,ListMicrovms)とAmazon CloudFront KeyValueStoreの読み書き権限を付与する。ListMicrovmsはリソースを絞れないので"*"にしている。lambda:PassNetworkConnectorもIngress/EgressのネットワークコネクタをMicroVMに紐付けるために必要だ。
resource "aws_iam_role" "microvms_build" {
name = local.iam_role_microvms_build_name
assume_role_policy = data.aws_iam_policy_document.microvms_build_assume.json
}
data "aws_iam_policy_document" "microvms_build_assume" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
actions = [
"sts:AssumeRole",
"sts:TagSession",
]
}
}
resource "aws_iam_role_policy" "microvms_build" {
name = local.iam_policy_microvms_build_name
role = aws_iam_role.microvms_build.id
policy = data.aws_iam_policy_document.microvms_build_custom.json
}
data "aws_iam_policy_document" "microvms_build_custom" {
statement {
effect = "Allow"
actions = [
"s3:GetObject",
]
resources = [
"${aws_s3_bucket.microvms_app.arn}/*",
]
}
statement {
effect = "Allow"
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
]
resources = [
aws_cloudwatch_log_group.microvms.arn,
"${aws_cloudwatch_log_group.microvms.arn}:*",
]
}
}
resource "aws_iam_role" "lambda_launcher" {
name = local.iam_role_lambda_launcher_name
assume_role_policy = data.aws_iam_policy_document.lambda_launcher_assume.json
}
data "aws_iam_policy_document" "lambda_launcher_assume" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
actions = [
"sts:AssumeRole",
]
}
}
resource "aws_iam_role_policy" "lambda_launcher" {
name = local.iam_policy_lambda_launcher_name
role = aws_iam_role.lambda_launcher.id
policy = data.aws_iam_policy_document.lambda_launcher_custom.json
}
data "aws_iam_policy_document" "lambda_launcher_custom" {
statement {
effect = "Allow"
actions = [
"logs:CreateLogStream",
"logs:PutLogEvents",
]
resources = [
"${aws_cloudwatch_log_group.lambda_launcher.arn}:*",
]
}
statement {
effect = "Allow"
actions = [
"lambda:ListMicrovms",
]
resources = ["*"]
}
statement {
sid = "ManageMicrovms"
effect = "Allow"
actions = [
"lambda:RunMicrovm",
"lambda:GetMicrovm",
"lambda:ResumeMicrovm",
"lambda:TerminateMicrovm",
"lambda:CreateMicrovmAuthToken",
]
resources = [
aws_lambdamicrovms_image.app.arn,
"arn:aws:lambda:${data.aws_region.current.region}:${data.aws_caller_identity.self.account_id}:microvm:*",
]
}
statement {
effect = "Allow"
actions = [
"lambda:PassNetworkConnector",
]
resources = ["*"]
}
statement {
effect = "Allow"
actions = [
"cloudfront-keyvaluestore:DescribeKeyValueStore",
"cloudfront-keyvaluestore:GetKey",
"cloudfront-keyvaluestore:ListKeys",
"cloudfront-keyvaluestore:UpdateKeys",
]
resources = [
aws_cloudfront_key_value_store.main.arn,
]
}
}
Amazon S3 Bucket
アプリケーションをパッケージしたZIPファイルを格納するAmazon S3バケットだ。MicroVMイメージのビルド時にAWS LambdaがここからZIPファイルを取得する。特に難しい設定はなく、セオリー通りのセキュリティ設定(Amazon S3バケットのオブジェクト所有権設定と、パブリックアクセスブロック設定)をしておこう。
resource "aws_s3_bucket" "microvms_app" {
bucket = local.s3_bucket_microvms_app_name
force_destroy = true
}
resource "aws_s3_bucket_ownership_controls" "microvms_app" {
bucket = aws_s3_bucket.microvms_app.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_public_access_block" "microvms_app" {
bucket = aws_s3_bucket.microvms_app.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Amazon CloudFront
Amazon CloudFrontのオリジングループは少し注意が必要な設定がある。プライマリとセカンダリの両方をAWS Lambda Function(ランチャー)のURLに向けておき、CloudFront FunctionsがプライマリのドメインをMicroVMのエンドポイントに動的に差し替える仕組みだ。cf.updateRequestOrigin()でオリジンを上書きできるのはCloudFront Functions(JS 2.0)の機能で、Lambda@Edgeでは使えない。
フェイルオーバーのステータスコードは403/502/504にしている。503は含めないのがポイントだ。ランチャーLambdaの「起動中」ページがHTTP 503を返すため、503をフェイルオーバー対象に含めると、プライマリのランチャーからセカンダリのランチャーへ不要なフェイルオーバーが発生する。
ディストリビューション
resource "aws_cloudfront_distribution" "main" {
enabled = true
price_class = "PriceClass_200"
http_version = "http2and3"
is_ipv6_enabled = true
origin_group {
origin_id = local.cloudfront_origin_group_id
failover_criteria {
status_codes = [403, 502, 504]
}
member {
origin_id = local.cloudfront_origin_id_primary
}
member {
origin_id = local.cloudfront_origin_id_launcher
}
}
dynamic "origin" {
for_each = [
local.cloudfront_origin_id_primary,
local.cloudfront_origin_id_launcher,
]
content {
origin_id = origin.value
domain_name = trimsuffix(trimprefix(aws_lambda_function_url.launcher.function_url, "https://"), "/")
origin_access_control_id = aws_cloudfront_origin_access_control.launcher.id
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
}
default_cache_behavior {
target_origin_id = local.cloudfront_origin_group_id
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = data.aws_cloudfront_cache_policy.caching_disabled.id
origin_request_policy_id = aws_cloudfront_origin_request_policy.main.id
function_association {
event_type = "viewer-request"
function_arn = aws_cloudfront_function.viewer_request.arn
}
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
}
resource "aws_cloudfront_origin_access_control" "launcher" {
name = local.cloudfront_oac_name
origin_access_control_origin_type = "lambda"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_origin_request_policy" "main" {
name = local.cloudfront_origin_request_policy_name
headers_config {
header_behavior = "whitelist"
headers {
items = ["x-aws-proxy-auth"]
}
}
cookies_config {
cookie_behavior = "none"
}
query_strings_config {
query_string_behavior = "all"
}
}
Amazon CloudFront KeyValueStore
Amazon CloudFront KeyValueStoreのconfigキーにBasic認証の情報とイメージバージョンをTerraformで書き込む。
パスワードはrandom_passwordで生成している。通常、設定を変更しなければterraform applyのたびに再生成されることはないが、リソースの置換が発生するとパスワードも変更されるため注意が必要だ。
Amazon CloudFront KeyValueStoreを一度作ったあとに変更する場合は、terraform apply -targetでconfigキーだけ先に当てるか、パスワードのlengthや文字種が変わらない限りrandom_passwordは再生成されないことを確認しておこう。
resource "aws_cloudfront_key_value_store" "main" {
name = local.cloudfront_kvs_name
comment = "AWS Lambda MicroVMs routing state"
}
resource "random_password" "basic_auth" {
length = 24
special = false
}
resource "aws_cloudfrontkeyvaluestore_key" "config" {
key_value_store_arn = aws_cloudfront_key_value_store.main.arn
key = "config"
value = jsonencode({
auth = base64encode("${var.basic_auth_username}:${random_password.basic_auth.result}")
imageVersion = aws_lambdamicrovms_image.app.latest_active_image_version
})
}
resource "aws_cloudfront_function" "viewer_request" {
name = local.cloudfront_function_viewer_request
runtime = "cloudfront-js-2.0"
comment = "Basic auth and dynamic routing to AWS Lambda MicroVMs"
publish = true
code = file("${path.module}/../script/cloudfront/viewer_request.js")
key_value_store_associations = [
aws_cloudfront_key_value_store.main.arn,
]
}
AWS Lambda Function(ランチャー)
ランチャーLambdaのTerraformだ。aws_lambda_permissionが2つ必要になる理由に触れておく。
CloudFrontからAWS Lambda Function URLを呼び出す場合、InvokeFunctionUrlとInvokeFunctionの2つのpermissionが必要だ。前者はFunction URLのエントリーポイントに対するアクセス許可、後者はFunction URLを経由してLambdaの実行エンジンを呼び出す際に必要な許可だ。invoked_via_function_url = trueを指定することでFunction URL経由の呼び出しのみに絞り込める。
data "archive_file" "lambda_launcher" {
type = "zip"
source_dir = "${path.module}/../script/lambda/microvms_launcher"
output_path = "${path.module}/terraform_tmp/script/lambda/microvms_launcher.zip"
}
resource "aws_lambda_function" "launcher" {
depends_on = [
aws_iam_role_policy.lambda_launcher,
terraform_data.microvms_cleanup,
]
function_name = local.lambda_launcher_name
role = aws_iam_role.lambda_launcher.arn
runtime = "nodejs24.x"
architectures = ["arm64"]
handler = "index.handler"
filename = data.archive_file.lambda_launcher.output_path
source_code_hash = data.archive_file.lambda_launcher.output_base64sha256
memory_size = 256
timeout = 20
reserved_concurrent_executions = 1
logging_config {
log_format = "Text"
log_group = aws_cloudwatch_log_group.lambda_launcher.name
}
environment {
variables = {
KVS_ARN = aws_cloudfront_key_value_store.main.arn
MICROVM_IMAGE_ARN = aws_lambdamicrovms_image.app.arn
MICROVM_IMAGE_VERSION = aws_lambdamicrovms_image.app.latest_active_image_version
MICROVM_APP_PORT = "8080"
MICROVM_MAXIMUM_DURATION_IN_SECONDS = "28800"
MICROVM_IDLE_AUTO_RESUME_ENABLED = "true"
MICROVM_IDLE_MAX_DURATION_SECONDS = "900"
MICROVM_IDLE_SUSPENDED_DURATION_SECONDS = "1800"
}
}
}
resource "aws_lambda_function_url" "launcher" {
function_name = aws_lambda_function.launcher.function_name
authorization_type = "AWS_IAM"
}
resource "aws_lambda_permission" "launcher_invoke_function_url" {
statement_id = "AllowCloudFrontInvokeFunctionUrl"
action = "lambda:InvokeFunctionUrl"
function_name = aws_lambda_function.launcher.function_name
principal = "cloudfront.amazonaws.com"
source_arn = aws_cloudfront_distribution.main.arn
function_url_auth_type = "AWS_IAM"
}
resource "aws_lambda_permission" "launcher_invoke_function" {
statement_id = "AllowCloudFrontInvokeFunction"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.launcher.function_name
principal = "cloudfront.amazonaws.com"
source_arn = aws_cloudfront_distribution.main.arn
invoked_via_function_url = true
}
なお、アイドルポリシーの設定値は以下の意味になる。
| 変数 | 設定値 | 意味 |
|---|---|---|
MICROVM_IDLE_AUTO_RESUME_ENABLED |
true |
アイドル後にトラフィックが来たら自動でレジューム |
MICROVM_IDLE_MAX_DURATION_SECONDS |
900 |
900秒(15分)無通信でサスペンド |
MICROVM_IDLE_SUSPENDED_DURATION_SECONDS |
1800 |
サスペンドから1800秒(30分)でターミネート |
MICROVM_MAXIMUM_DURATION_IN_SECONDS |
28800 |
最大8時間で強制ターミネート |
AWS Lambda MicroVMs Image
いよいよMicroVMsのリソース定義だ。aws_lambdamicrovms_imageはTerraform AWS Providerで管理できる。
アプリケーションのZIPファイルをAmazon S3にアップロードし、そのURIをcode_artifact.uriに渡す。base_image_arnはAWS提供のAmazon Linux 2023ベースイメージを使う。
terraform applyを実行すると、バックグラウンドでDockerfileのビルドとスナップショット取得が走る。しばらく時間がかかるため、latest_active_image_versionが出てくるまでproviderが待機する。
data "archive_file" "microvms_app" {
type = "zip"
source_dir = "${path.module}/../script/microvms/app"
output_path = "${path.module}/terraform_tmp/script/microvms/app.zip"
excludes = [
"node_modules/**",
]
}
resource "aws_s3_object" "microvm_app" {
bucket = aws_s3_bucket.microvms_app.id
key = "app/${data.archive_file.microvms_app.output_md5}.zip"
source = data.archive_file.microvms_app.output_path
source_hash = data.archive_file.microvms_app.output_md5
}
resource "aws_lambdamicrovms_image" "app" {
depends_on = [
aws_iam_role_policy.microvms_build,
aws_cloudwatch_log_group.microvms,
]
name = local.microvms_image_name
description = "Web server on AWS Lambda MicroVMs"
base_image_arn = "arn:aws:lambda:${data.aws_region.current.region}:aws:microvm-image:al2023-1"
build_role_arn = aws_iam_role.microvms_build.arn
code_artifact {
uri = "s3://${aws_s3_object.microvm_app.bucket}/${aws_s3_object.microvm_app.key}"
}
cpu_configuration {
architecture = "ARM_64"
}
environment_variables = {
NODE_ENV = "production"
APP_PORT = "8080"
}
}
archive_fileのAmazon S3キーにoutput_md5を含めているのは、アプリのコードを変更した際に自動的に新しいオブジェクトとして上がるようにするためだ。同じキーに上書きするとTerraformがAmazon S3オブジェクトの変更を検知してもaws_lambdamicrovms_imageが再ビルドされないケースがある。
いざ、動かす!
terraform applyが完了すると、Amazon CloudFrontのドメインにアクセスできるようになる。初回アクセス時はMicroVMが起動していないため、ランチャーLambdaが「起動中」ページを返す。ブラウザが5秒ごとにリロードを繰り返し、MicroVMがRUNNING状態になってAmazon CloudFront KeyValueStoreが更新されれば、次のリクエストからHonoのWebサーバに直接転送される。
MicroVMの起動(PENDINGからRUNNINGまで)は、スナップショットからの復帰のため数秒で完了する。Dockerfileを使ったイメージのビルド時にアプリケーションの初期化まで完了し、その状態のスナップショットから起動できることがAWS Lambda MicroVMsのポイントだ。
RUNNINGになると、以下の画面に変わる。
15分間アクセスがない状態が続くとMicroVMが自動で一時停止し、次のアクセス時に自動で復帰する。この復帰も数秒で完了するため、「起動中」ページが一瞬表示されてすぐMicroVMに切り替わる。
これで、AWS Lambda MicroVMsの基本的な使い方を理解して起動できるようになった!


