Lambda MicroVMs で作る、ブラウザだけで開ける使い捨て VS Code 環境
2026-11-04 | Author : 三宅 翔
はじめに
ハンズオンや社内研修では、開始 30 分が参加者の環境構築で溶けがちです。バラバラの持ち込み PC、ツールを入れられない社用端末、「私の環境だけ動かない」の声。かといって Amazon EC2 を人数分立てれば起動も後片付けも一仕事、コンテナで root 権限ごと使い捨て環境を人数分用意するにも、分離やライフサイクル管理の作り込みが要ります。
この記事では AWS Lambda MicroVMs で「参加者が URL を開くと、その場で専用のブラウザ Visual Studio Code (VS Code / code-server) が払い出される」仕組みを作ります。参加者に必要なのはブラウザだけ。インストールも AWS アカウントも不要で、root で何をしても他の参加者に影響せず、イベントが終われば環境は自動的に消えます。
ご注意
本記事で紹介する AWS サービスを起動する際には、料金がかかります。builders.flash メールメンバー特典の、クラウドレシピ向けクレジットコードプレゼントの入手をお勧めします。
builders.flash メールメンバー登録
builders.flash メールメンバー登録で、毎月の最新アップデート情報とともに、AWS を無料でお試しいただけるクレジットコードを受け取ることができます。
利用するサービス
AWS Lambda MicroVMs は、Firecracker による仮想マシンレベルの分離環境を、スナップショットからの高速起動とともに提供するコンピューティング基盤です。今回の用途で注目した特徴は次の 4 点です。
- VM レベルの分離 — 参加者ごとに独立したカーネル。影響を及ぼせるのは自分の環境だけ
- スナップショット起動 — ツール導入済みの状態から人数分を複製起動。実測で 1 台あたり数秒
- アイドルポリシー — 無操作の VM は自動サスペンドし、その間のコンピューティング課金はゼロ。次のアクセスで自動復帰
- 多くの標準プロトコル対応 — イングレスは HTTP/2・gRPC・WebSocket をサポートし、VS Code の通信 (WebSocket 含む) がそのまま通ります
ブラウザ IDE には OSS の code-server (ブラウザで動く VS Code) を、払い出しとルーティングには Amazon CloudFront + Lambda@Edge + Amazon DynamoDB を使います。なお Lambda MicroVMs は Arm64 (Graviton) で動作します。
鍵は「セッション管理層を挟む」こと
この構成の要点は、入口と VM の間に「セッション管理層」を一枚挟むことにあります。MicroVM のエンドポイントは VM ごとに固有 で、追加で払い出すたびに別の URL になります。しかもリクエストには認証トークンを X-aws-proxy-auth ヘッダーで送る必要があり、ブラウザは URL を開くだけではカスタムヘッダーを付けられません。
そこで「1 つの入口 URL」と「VM ごとにバラバラな宛先」の間に、どの参加者をどの VM に割り当て、どのトークンを注入するかを管理する層を挟みます。受付とルーティングを Lambda@Edge (origin-request) に担わせ、割り当て表を DynamoDB に持たせます。構成要素は CloudFront (+ Lambda@Edge)、DynamoDB、MicroVM 群の 3 つだけです。
URL を開くだけで専用のブラウザに VS Code が払い出される仕組み
払い出しの流れ
- ① 共通 URL を開く: 参加者が入口 URL ( / ) を開く
- ② 空き VM を確保 : 受付 Lambda (Lambda@Edge) が DynamoDB のプールから空きセッションを 1 つ確保 (state = free → assigned)
- ③ 302 で払い出し : そのセッションの URL (/s/<ID>/。<ID> は払い出しごとに発行される一意なセッション ID) へ 302 リダイレクトを返す
- ④ セッション URL でアクセス : ブラウザが /s/<ID>/* を取得しにいく (以降のアクセスはこの経路)
- ⑤ endpoint + トークン取得 : Lambda@Edge がセッション ID から VM のエンドポイントとトークンを DynamoDB から取得 (エッジ内 60 秒キャッシュ)
- ⑥ 転送 : オリジンをその VM に書き換え、X-aws-proxy-auth を注入して転送
- ⑦ 接続の維持 : code-server の WebSocket も同じ経路 (④→⑥) で確立され、以降はエディタ操作・ファイル同期・ターミナルなど VS Code の通信が双方向に流れる
利点は 3 つ。トークンがブラウザに一切渡らないこと、CloudFront の設定が固定で VM の追加・交換が DynamoDB の書き換えだけで済むこと、構成が最小なことです。
定員はプールの数で決まる
(用意) 事前に人数分の VM を起動し、DynamoDB に「空き (state = free)」として登録しておきます。登録した数がそのまま定員になります。
(割り当て) 参加者がアクセスすると、DynamoDB の条件付き更新 (state = free のときだけ assigned に変更) で空き 1 台を割り当てます。この条件付き更新のおかげで、同時アクセスでも同じ VM が二重に割り当てられることはありません。空きがなければ「満席」ページを返し、運営が VM を追加すれば即座に割り当てが再開されます。
なお、同時に起動できる MicroVM の規模は、アカウント・リージョンごとに割り当てられるメモリ総量 (Lambda クォータ) で決まります。例えば東京リージョンの既定は 1,024 GB (1 台 2 GB なら約 512 台) なので、ハンズオン規模なら十分ですが、大人数で開く場合はプールの台数がこの枠に収まるか確認し、必要なら引き上げを申請してください。
また今回は、ハンズオンや研修で開始直後のアクセス集中に備え、latency 削減のために VM を事前起動してプール化しています。起動済みでも無操作ならアイドルポリシーで自動サスペンドし、その間のコンピューティング課金はかかりません。イメージからの起動は数秒なので、プールを持たず受付時に動的に VM を起動して払い出す設計でも構いません (セッション数が増えると補充やエラー処理が煩雑になるため、本記事では事前プール方式にしています)。
前提
Lambda MicroVMs を初めて触る場合は、イメージ作成から MicroVM 起動までを一通り体験できる公式チュートリアル 最初の Lambda MicroVM を作成する を先に通しておくと、本記事がスムーズに読めます。
以下は作成済みの前提で進めます (AWS CLI v2 と jq を使用、リージョンは us-east-1)。IAM ロールは要点のみ示し、詳細なポリシー JSON は割愛します。実際には各注記の観点で必要最小限の権限を付与してください。
- ビルドロール — create-microvm-image の --build-role-arn に指定。MicroVM のイメージビルドは Lambda がサーバー側で Dockerfile を実行する仕組みで、その際にこのロールを引き受けます。必要な権限: アーティファクト用 Amazon S3 バケットの読み取りと Amazon CloudWatch Logs 書き込み (信頼ポリシーは lambda.amazonaws.com)。
- 実行ロール — run-microvm の --execution-role-arn に指定。起動後の VM 内アプリが AWS API を呼ぶ際の権限です。今回のブラウザ IDE は AWS API を呼ばないため、必要な権限は CloudWatch Logs 書き込み程度で十分です。
- Lambda@Edge 用ロール — lambda.amazonaws.com と edgelambda.amazonaws.com の両方が引き受け可能なロール。必要な権限: DynamoDB への GetItem / Scan / UpdateItem と CloudWatch Logs 書き込み。
なお、これらロールの信頼ポリシー・権限ポリシーの具体的な JSON と作成コマンドは、同じ Lambda MicroVMs を扱う AWS Lambda MicroVMs で軽量なコード検証環境を作ってみた のビルドロール作成手順が参考になります。
S3 バケットと DynamoDB テーブル (handson-sessions 、パーティションキー sessionId) は、次の「作ってみる」の冒頭で作成します。
作ってみる
作業用ディレクトリの作成
まず作業用のディレクトリを作り、以降のコマンドはこの中で実行します。
mkdir -p ~/handson-microvm && cd ~/handson-microvm
環境変数の事前設定
続いて、以降のコマンドで使う値を環境変数にまとめて設定します。ご自身の環境に合わせて最初の 4 つを書き換えてください。本記事ではすべて us-east-1 で進めますが、Lambda MicroVMs は東京リージョン (ap-northeast-1) にも対応しています (対応リージョンは AWS Lambda MicroVMs のドキュメントを参照)。
export BUCKET="<アーティファクト用 S3 バケット名>"
export BUILD_ROLE="<ビルドロール名>"
export EXEC_ROLE="<実行ロール名>"
export EDGE_ROLE_ARN="<Lambda@Edge 用ロールの ARN>"
# ▼ 以下はそのままでよい
export REGION="us-east-1"
export ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
export IMAGE_NAME="handson-codeserver"
export TABLE="handson-sessions"
export AWS_PAGER="" # get-* の結果がページャ(less)で開くのを防ぐ
S3 バケットと DynamoDB テーブルを作成
アーティファクト用の S3 バケットと、セッション管理用の DynamoDB テーブルを作成します (ビルドロールには、この S3 バケットの読み取り権限を付与しておいてください)。
# アーティファクト用 S3 バケット
aws s3 mb "s3://${BUCKET}" --region "${REGION}"
# セッション管理用 DynamoDB テーブル (パーティションキー sessionId、オンデマンド)
aws dynamodb create-table --table-name "${TABLE}" \
--attribute-definitions AttributeName=sessionId,AttributeType=S \
--key-schema AttributeName=sessionId,KeyType=HASH \
--billing-mode PAY_PER_REQUEST --region "${REGION}"
aws dynamodb wait table-exists --table-name "${TABLE}" --region "${REGION}"
ステップ 1: 配る VS Code をイメージに焼き込む
Dockerfile に code-server と研修用ツールを入れます。一番大事なコツは、起動スクリプトで 8080 番ポートを即座に開くことです。イメージビルドは「アプリが起動してポートが開いた状態」をスナップショットしますが、起動時に時間のかかる処理を同期的に挟むとポートが開く前にタイムアウトして失敗します (筆者はこれで CREATE_FAILED を踏みました)。重い処理はバックグラウンドに回し、まず 8080 を上げましょう。
Dockerfile を作成
次の内容で、作業ディレクトリ直下に Dockerfile を作成します。
cat > Dockerfile <<'EOF'
FROM public.ecr.aws/lambda/microvms:al2023-minimal
ARG CODE_VERSION=4.139.1
# 研修で使うツールと code-server の展開に必要なもの
RUN dnf install -y tar gzip git which shadow-utils procps-ng && dnf clean all
# code-server(Arm64)を導入
RUN curl -fsSL "https://github.com/coder/code-server/releases/download/v${CODE_VERSION}/code-server-${CODE_VERSION}-linux-arm64.tar.gz" \
-o /tmp/cs.tar.gz \
&& mkdir -p /usr/lib/code-server \
&& tar -C /usr/lib/code-server --strip-components=1 -xzf /tmp/cs.tar.gz \
&& ln -sf /usr/lib/code-server/bin/code-server /usr/bin/code-server \
&& rm -f /tmp/cs.tar.gz
# 作業ユーザー(root では動かさない)
RUN useradd -m -s /bin/bash builder
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
EXPOSE 8080
CMD ["/app/start.sh"]
EOF
起動スクリプト
起動スクリプト (start.sh) は code-server を 8080 で即座に立ち上げるだけです。--auth none にしているのは、認証は前段のセッション管理層 (トークン注入) で担うため、code-server 自身のパスワード認証は不要だからです。同じく下記をそのまま実行して作成します。
cat > start.sh <<'EOF'
#!/bin/bash
export HOME=/home/builder
# 8080 を即座に開く(ビルド時のスナップショット取得をブロックしない)
exec runuser -u builder -- /usr/bin/code-server \
--auth none --bind-addr 0.0.0.0:8080 \
--disable-telemetry --disable-update-check \
/home/builder
EOF
イメージを作成
2 ファイルを zip にして S3 へ置き、イメージを作成します (ビルドは実測で 3 分ほど)。
zip -r app.zip Dockerfile start.sh
aws s3 cp app.zip "s3://${BUCKET}/handson/app.zip" --region "${REGION}"
aws lambda-microvms create-microvm-image \
--name "${IMAGE_NAME}" \
--code-artifact "{\"uri\":\"s3://${BUCKET}/handson/app.zip\"}" \
--base-image-arn "arn:aws:lambda:${REGION}:aws:microvm-image:al2023-1" \
--base-image-version 0 \
--build-role-arn "arn:aws:iam::${ACCOUNT_ID}:role/${BUILD_ROLE}" \
--additional-os-capabilities '["ALL"]' \
--region "${REGION}"
# state が CREATED になるまで get-microvm-image でポーリング
aws lambda-microvms get-microvm-image \
--image-identifier "arn:aws:lambda:${REGION}:${ACCOUNT_ID}:microvm-image:${IMAGE_NAME}" \
--region "${REGION}" --query state --output text
ステップ 2: 人数分の VM をプールに投入する
イメージから定員分の VM を起動し、トークンとともに DynamoDB (テーブル handson-sessions) へ free 状態で登録します。
PARTICIPANTS=10
IMAGE_ARN="arn:aws:lambda:${REGION}:${ACCOUNT_ID}:microvm-image:${IMAGE_NAME}"
EXECUTION_ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/${EXEC_ROLE}"
for i in $(seq 1 $PARTICIPANTS); do
# スナップショットから複製起動(実測 数秒 / 台)
RESP=$(aws lambda-microvms run-microvm \
--image-identifier "$IMAGE_ARN" --image-version "1.0" \
--execution-role-arn "$EXECUTION_ROLE_ARN" \
--idle-policy '{"maxIdleDurationSeconds":1800,"suspendedDurationSeconds":3600,"autoResumeEnabled":true}' \
--region "${REGION}")
MICROVM_ID=$(echo "$RESP" | jq -r '.microvmId')
ENDPOINT=$(echo "$RESP" | jq -r '.endpoint') # VM ごとに固有のエンドポイント
# VM ごとの認証トークン(最大 60 分。後述のコラムも参照)
TOKEN=$(aws lambda-microvms create-microvm-auth-token \
--microvm-identifier "$MICROVM_ID" --expiration-in-minutes 60 \
--allowed-ports '[{"port":8080}]' \
--query 'authToken."X-aws-proxy-auth"' --output text \
--region "${REGION}")
# プールへ登録(state=free が「空き」の印)
aws dynamodb put-item --table-name "${TABLE}" --region "${REGION}" --item "{
\"sessionId\": {\"S\": \"p$(openssl rand -hex 4)\"},
\"microvmId\": {\"S\": \"$MICROVM_ID\"},
\"endpoint\": {\"S\": \"$ENDPOINT\"},
\"token\": {\"S\": \"$TOKEN\"},
\"state\": {\"S\": \"free\"}}"
done
アイドルポリシーについて
アイドルポリシーは、 maxIdleDurationSeconds=1800 で 30 分無操作なら自動サスペンド (以降コンピューティング課金ゼロ)、 autoResumeEnabled=true で次アクセス時に自動復帰、 suspendedDurationSeconds=3600 でサスペンドのまま 1 時間経てば自動終了、という意味です。休憩中のコスト削減と消し忘れ対策がこの 1 行に収まります。
ステップ 3: Lambda@Edge — 受付とルーティングを 1 関数で
本構成の心臓部です。us-east-1 に作成し、バージョン発行のうえ CloudFront に関連付けます。
ここで実機で踏んだ落とし穴を 1 つ。code-server は WebSocket 接続時に Origin と Host ヘッダーの一致を検証します。ルーティングのために Host を VM のエンドポイントへ書き換えると、ブラウザが送る Origin (CloudFront のドメイン) と食い違い、WebSocket が確立直後に 1006 で切断されます。対策は、エッジ関数で Origin も VM のエンドポイントに合わせて書き換えることです (下記コードの該当箇所にコメント)。
Lambda@Edge によるセッション振り分けとオリジンルーティング
cat > index.js <<'EOF'
// Lambda@Edge (origin-request):
// / : プールから空きを確保して /s/<ID>/ へ 302(空きゼロなら満席ページ)
// /s/<ID>/*: セッションを引いてオリジン差し替え + トークン注入
const { DynamoDBClient, GetItemCommand, ScanCommand, UpdateItemCommand }
= require("@aws-sdk/client-dynamodb");
const ddb = new DynamoDBClient({ region: "us-east-1" });
const TABLE = "handson-sessions";
const cache = new Map(); // sessionId -> { value, expires } の 60 秒キャッシュ
async function lookup(sessionId) {
const hit = cache.get(sessionId);
if (hit && hit.expires > Date.now()) return hit.value;
const out = await ddb.send(new GetItemCommand({
TableName: TABLE, Key: { sessionId: { S: sessionId } } }));
if (!out.Item) return null;
const value = { endpoint: out.Item.endpoint.S, token: out.Item.token.S };
cache.set(sessionId, { value, expires: Date.now() + 60000 });
return value;
}
// 空きセッションを 1 つ確保。条件付き更新なので同時アクセスでも二重配布されない
async function claimFreeSession() {
const scan = await ddb.send(new ScanCommand({
TableName: TABLE,
FilterExpression: "#st = :free",
ExpressionAttributeNames: { "#st": "state" },
ExpressionAttributeValues: { ":free": { S: "free" } },
ProjectionExpression: "sessionId" }));
for (const item of scan.Items || []) {
try {
await ddb.send(new UpdateItemCommand({
TableName: TABLE, Key: { sessionId: item.sessionId },
UpdateExpression: "SET #st = :assigned",
ConditionExpression: "#st = :free", // free のときだけ成功 = 定員管理
ExpressionAttributeNames: { "#st": "state" },
ExpressionAttributeValues: {
":assigned": { S: "assigned" }, ":free": { S: "free" } } }));
return item.sessionId.S;
} catch (e) {
if (e.name === "ConditionalCheckFailedException") continue; // 競り負け→次へ
throw e;
}
}
return null; // 満席
}
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
// 受付: 共通 URL
if (request.uri === "/" || request.uri === "") {
const sessionId = await claimFreeSession();
if (!sessionId) {
return { status: "503", statusDescription: "Service Unavailable",
headers: { "content-type": [{ key: "Content-Type",
value: "text/html; charset=utf-8" }] },
body: "<h1>ただいま満席です</h1>" };
}
return { status: "302", statusDescription: "Found",
headers: { location:
[{ key: "Location", value: `/s/${sessionId}/` }] } };
}
// ルーティング: /s/<ID>/*
const m = request.uri.match(/^\/s\/([^/]+)(\/.*)?$/);
const session = m && await lookup(m[1]);
if (!session) return { status: "404", statusDescription: "Not Found" };
request.origin = { custom: {
domainName: session.endpoint, port: 443, protocol: "https", path: "",
sslProtocols: ["TLSv1.2"], readTimeout: 60, keepaliveTimeout: 60,
customHeaders: {} } };
request.headers["host"] = [{ key: "Host", value: session.endpoint }];
request.headers["x-aws-proxy-auth"] =
[{ key: "X-aws-proxy-auth", value: session.token }]; // トークン注入
request.headers["x-aws-proxy-port"] =
[{ key: "X-aws-proxy-port", value: "8080" }];
// ★ code-server は WebSocket の Origin==Host を検証する。
// Host を VM に書き換えるので、Origin も VM に合わせないと 1006 で切れる。
if (request.headers["origin"]) {
request.headers["origin"] =
[{ key: "Origin", value: `https://${session.endpoint}` }];
}
request.uri = m[2] || "/"; // /s/<ID> プレフィックスを除去
return request;
};
EOF
関数の作成とバージョン発行
上記を index.js として保存し、関数の作成とバージョン発行を行います (Lambda@Edge は必ず us-east-1 に作成し、$LATEST ではなく発行済みバージョンを CloudFront に関連付けます)。
zip edge.zip index.js
# Lambda@Edge は us-east-1 に作成する必要があります(REGION とは独立)
aws lambda create-function \
--function-name handson-edge-router \
--runtime nodejs24.x --handler index.handler \
--role "${EDGE_ROLE_ARN}" \
--zip-file fileb://edge.zip \
--timeout 10 --memory-size 128 --region us-east-1
aws lambda wait function-active --function-name handson-edge-router --region us-east-1
# CloudFront に関連付けるためのバージョンを発行し、環境変数に保持
export EDGE_ARN=$(aws lambda publish-version \
--function-name handson-edge-router --region us-east-1 \
--query 'FunctionArn' --output text)
echo $EDGE_ARN # 例: arn:aws:lambda:us-east-1:...:function:handson-edge-router:1
ステップ 4 : CloudFront ディストリビューション
ビヘイビアは 1 つだけで、以後イベントごとに触ることはありません。設定の要点は次のとおりです。
|
設定
|
値
|
理由
|
|---|---|---|
|
オリジン
|
ダミー (例: example.com) |
毎リクエスト Lambda@Edge が差し替えるため |
|
キャッシュポリシー
|
CachingDisabled (マネージド) |
IDE は全て動的 |
|
オリジンリクエストポリシー
|
AllViewer (マネージド) |
WebSocket・Origin を含め全ヘッダーを転送 |
|
許可メソッド
|
全 HTTP メソッド |
|
|
Lambda 関数の関連付け
|
origin-request に上記関数のバージョン ARN |
|
オリジンリクエストポリシーは、エッジ関数が Host と Origin を明示的に書き換える設計のため AllViewer (全ヘッダー転送) で動作します。下記をそのまま実行すると、先ほど発行した EDGE_ARN を埋め込んだ dist-config.json が作成されます。
dist-config.json の作成
cat > dist-config.json <<EOF
{
"CallerReference": "handson-codeserver-$(date +%s)",
"Comment": "browser handson code-server environment",
"Enabled": true,
"Origins": { "Quantity": 1, "Items": [{
"Id": "placeholder", "DomainName": "example.com",
"CustomOriginConfig": {
"HTTPPort": 80, "HTTPSPort": 443,
"OriginProtocolPolicy": "https-only",
"OriginSslProtocols": { "Quantity": 1, "Items": ["TLSv1.2"] },
"OriginReadTimeout": 60, "OriginKeepaliveTimeout": 60 } }] },
"DefaultCacheBehavior": {
"TargetOriginId": "placeholder",
"ViewerProtocolPolicy": "redirect-to-https",
"AllowedMethods": {
"Quantity": 7,
"Items": ["GET","HEAD","OPTIONS","PUT","POST","PATCH","DELETE"],
"CachedMethods": { "Quantity": 2, "Items": ["GET","HEAD"] } },
"Compress": false,
"CachePolicyId": "4135ea2d-6df8-44a3-9df3-4b5a84be39ad",
"OriginRequestPolicyId": "216adef6-5c7f-47e4-b989-5492eafa07d3",
"LambdaFunctionAssociations": { "Quantity": 1, "Items": [{
"LambdaFunctionARN": "${EDGE_ARN}",
"EventType": "origin-request",
"IncludeBody": false }] } }
}
EOF
デプロイ
デプロイ完了後、参加者に配るのは https://<出力されたドメイン>/ の 1 行だけです。
aws cloudfront create-distribution --distribution-config file://dist-config.json \
--query 'Distribution.DomainName' --output text
# デプロイ完了まで数分待つ
使ってみる
参加者に伝えるのは共通 URL 1 つだけ。開くと専用環境に転送され、ブラウザにフル機能の VS Code が表示されます。ターミナルでコマンドを叩き、ファイルを編集し、拡張機能を入れるなど、ローカルの VS Code と同じ操作感で使用できます。
運営上のメリット
運営ならではの嬉しさが 3 つあります。
- 壊しても他の参加者に影響しない。 sudo rm -rf /usr のような破壊も被害はその参加者の VM だけ。名前空間ではなくカーネルごと別だからです。壊れたら VM を捨てて新しい 1 台を補充するだけで、スナップショットから数秒で初期状態が戻ります。
- 休憩中は課金が止まる。 30 分無操作で自動サスペンド。午後の部でブラウザを再読み込みすれば数秒で復帰します。
- 消し忘れの保険が組み込み。 MicroVM のライフタイムは最長 8 時間。終了処理を忘れても、翌週まで課金され続ける事故は構造的に起きません。
セキュリティの注意 : 本番運用では認証認可を足す
本記事の構成は「URL を知っていれば誰でも開ける」ハンズオン向けの簡易版です。社内の限られたイベントなら URL の共有範囲を絞るだけでも回りますが、社外公開や恒常運用では、入口に認証認可を一枚足すことを推奨します。例えば Amazon Cognito でログイン認証を挟み、その結果に応じて Amazon CloudFront の署名付き URL / 署名付き Cookie を発行してアクセスを限定する、といった認証認可の層を足せます (本記事では扱いません)。
コラム : トークンと WebSocket の寿命
本記事のエッジ関数は DynamoDB のトークンをそのまま注入するだけの実装で、2 つの「寿命」が関わります。
- トークンの寿命 : 認証トークンは最大 60 分。トークンは接続確立時に検証されるため、一度確立した WebSocket は切れても維持されますが、ブラウザをリロードしたり VM がサスペンドから復帰して VS Code が再接続したりすると、期限切れトークンでは 403 Token authentication failed になります。
- VM の寿命 : アイドルポリシーで無操作が続くと VM は終了し、古いエンドポイント/トークンへのアクセスは同じくエラーになります。
したがって 短時間 (~ 60 分) で完結するハンズオンなら、直前にプールを用意すれば十分です。数時間や恒常運用では「プール保守レイヤー」を足します (本記事では扱いません)。(例: エッジ関数でトークンを都度発行して短時間キャッシュする/Amazon EventBridge の定期実行 Lambda で終了 VM の補充とトークン更新を行う)。
なお「トークン管理は VM の外側 (払い出し/ルーティング層) の責務」という点は重要です。VM 内のライフサイクルフックは自分のトークンを再発行できず、そもそもトークンが切れると外から VM に入れないため、フックでは解決できません。
これを開発環境にするなら (発展)
ここまでは「終われば消える使い捨て」が前提でした。同じ仕組みを日常の開発環境にしたくなったら、永続化が論点になります。「昨日の続きから」をやりたいなら、状態を VM の外へ逃がします。選択肢は 3 つです。
- Git でこまめに push — 最も手軽。永続ストレージを持たず、成果物はリモートリポジトリに預けます。ハンズオンや軽い検証はこれで十分です。VM の終了 (terminate) 前に呼ばれるライフサイクルフックで wip/YYYY-MM-DD ブランチへ自動コミット/push するようにしておけば、VM が消える前に成果物を退避でき、保存し忘れも防げます。
- Amazon S3 をマウント — Mountpoint for Amazon S3 で作業ディレクトリにマウント。ユーザーごとにプレフィックスやバケットを分ければ保存先も分離できます。より POSIX に近いファイルアクセスが必要なら、2026 年に GA した Amazon S3 Files (S3 バケットを NFS v4.2 でマウントする機能。Lambda からも利用可) も選べます。
- Amazon EFS をマウント — 共有ファイルシステムを永続ホームに。VPC egress のネットワークコネクタを構成し、VM 内で mount -t nfs4 します (run-microvm にファイルシステムを直接指定する引数は無いため起動スクリプトでマウント)。VPC egress を使う場合、実行ロールに ENI 作成権限が必要な点に注意してください (無いと VM が起動直後に終了します)。
ここで「VM を分離したのにストレージは全員共有でいいのか」が気になります。共有ストレージをそのまま使うと他人のファイルに影響しかねません。Amazon EFS ならアクセスポイントを利用者ごとに作り、各自のサブディレクトリだけを見せれば「隔離された VM + 隔離されたストレージ」を両立できます。S3 ならプレフィックス/バケットの分離が同じ役割を果たします。使い捨てなら Git、恒常運用ならアクセスポイントで分離した EFS、と使い分けるとよいでしょう。
クリーンアップ
検証やイベントが終わったら、作成したリソースを削除します。放置すると MicroVM や CloudFront などで課金が続くため、必ず後片付けしてください。以降は冒頭で設定した環境変数が有効な前提です。
MicroVM / DynamoDB / S3 バケットの終了・削除
まず MicroVM をすべて終了し、プールと DynamoDB テーブル、S3 バケットを削除します。
# 稼働中・サスペンド中の MicroVM をすべて terminate
for MID in $(aws lambda-microvms list-microvms --region "${REGION}" \
--query "items[?state!='TERMINATED'].microvmId" --output text); do
aws lambda-microvms terminate-microvm --microvm-identifier "$MID" --region "${REGION}"
done
# MicroVM イメージ・DynamoDB テーブル・S3 バケットを削除
aws lambda-microvms delete-microvm-image \
--image-identifier "arn:aws:lambda:${REGION}:${ACCOUNT_ID}:microvm-image:${IMAGE_NAME}" \
--region "${REGION}"
aws dynamodb delete-table --table-name "${TABLE}" --region "${REGION}"
aws s3 rb "s3://${BUCKET}" --force
CloudFront / Lambda@Edge の削除
次に CloudFront と Lambda@Edge を削除します。この 2 つは削除に順序と待ち時間がある点に注意してください。CloudFront ディストリビューションはまず無効化し、Deployed に戻ってから削除します。Lambda@Edge 関数は、CloudFront から切り離された後、各エッジロケーションのレプリカが削除されるまで数時間かかることがあり、それまで delete-function は失敗します (時間を置いて再実行してください)。
DIST_ID=<作成した CloudFront ディストリビューション ID>
# 1) 無効化: 現在の config を取得し Enabled=false にして更新
ETAG=$(aws cloudfront get-distribution-config --id "$DIST_ID" --query ETag --output text)
aws cloudfront get-distribution-config --id "$DIST_ID" \
--query DistributionConfig --output json > dist-current.json
# dist-current.json の "Enabled" を false に変更して保存してから:
aws cloudfront update-distribution --id "$DIST_ID" \
--distribution-config file://dist-current.json --if-match "$ETAG"
# 2) Status が Deployed に戻るまで待ってから削除
aws cloudfront wait distribution-deployed --id "$DIST_ID"
ETAG=$(aws cloudfront get-distribution --id "$DIST_ID" --query ETag --output text)
aws cloudfront delete-distribution --id "$DIST_ID" --if-match "$ETAG"
# 3) Lambda@Edge 関数の削除(レプリカが消えるまで時間がかかる。失敗したら時間を置いて再実行)
aws lambda delete-function --function-name handson-edge-router --region us-east-1
まとめ
Lambda MicroVMs の「スナップショットからの高速複製」「VM 分離」「サスペンドで課金ゼロ」という特性を、CloudFront + Lambda@Edge + DynamoDB によるセッション管理層 (払い出しとルーティング) と組み合わせることで、URL を開くだけで使える使い捨てのブラウザ VS Code 環境ができました。VM ごとに固有のエンドポイントとトークンを、参加者からは 1 つの入口 URL に見せる——この「間に一枚挟む」発想が鍵です。定員はプールの数で決まり、満席・補充・交換はすべて DynamoDB の書き換えだけで完結します。
面接のコーディング環境、新メンバーのオンボーディング、不具合再現用サンドボックスなど、「隔離された開発環境を必要なときだけ配りたい」場面にそのまま応用できます。code-server を Lambda MicroVMs 上で動かす構成は Serverless Land の VS Code Server (code-server) on AWS Lambda MicroVMs パターン も参考になります (こちらはローカルプロキシでトークンを注入する構成で、本記事のプール払い出しとは狙いが異なります)。
同じ Lambda MicroVMs を使った別アプローチとして、AWS Lambda MicroVMs で軽量なコード検証環境を作ってみた (環境をイメージに固定し、検証対象コードを起動後に同期して反復を高速化する構成) も参考になります。
なお、各 API のクォータや制限、料金は執筆時点のものです。最新情報は AWS Lambda MicroVMs のドキュメント および AWS Lambda の料金 をご確認ください。
筆者プロフィール
三宅 翔 (Sho Miyake / LinkedIn)
アマゾン ウェブ サービス ジャパン合同会社 / クラウドサポートエンジニア
テレコムキャリア向けシステム開発でインフラとアプリ両面の経験を積む。クラウドに興味を持ち、現在 AWS Japan でクラウドサポートエンジニアとして主にサーバーレス関連の技術サポートを提供。Lambda と API Gateway を中心に、AI やモバイル関連サービスがマイトレンド。