0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS Lambda MicroVMsで動作するWebサーバをTerraformでデプロイする

0
Last updated at Posted at 2026-10-19

本記事で紹介するコードはコーディングエージェントを用いて作成している。
読者の方のお使いのコーディングエージェントに要件を伝えれば大体同じように作ってくれると思うので、参考程度に見ていただきたい。

また、実際に運用する際は、記事中で紹介している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サーバを実装する。

システム構成

本記事で作成するシステムの構成図は以下の通り。

lambda_microvms_example.png

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/配下の静的ファイルの配信を行う。

src/index.js
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も用意する。

public/index.html
<!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>
public/style.css
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つだ。

  1. Basic認証: 誰でもMicroVMにアクセスできることがないようにするために、CloudFront Functionsでリクエストをフィルタリングする
  2. ルーティング: Amazon CloudFront KeyValueStoreに有効なMicroVM情報があれば直接MicroVMに転送し、なければAWS Lambda Function(ランチャー)に転送する
  3. オリジンフェイルオーバー: MicroVMがトークン失効・消失などでエラーを返した場合、自動的にAWS Lambda Function(ランチャー)に振り替える

CloudFront Functions

CloudFront Functionsのコードを見ていこう。

viewer_request.js
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;
}

ポイントをいくつか補足しておく。

  1. imageVersionの照合
    meta.imageVersion !== config.imageVersionのチェックで、Amazon CloudFront KeyValueStoreに古いバージョンのMicroVM情報が残っていた場合はランチャーに流す。Terraformでイメージを更新した際に、古いMicroVMへの転送を防ぐためだ。

  2. 有効期限のマージン
    exp - REFRESH_MARGIN_SECONDSで60秒前にトークンを失効扱いにする。エッジで判定するため、実際の有効期限ギリギリまで使うのは危険だ。

  3. トークンの分割保持
    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が使えない時」にだけ呼ばれる。処理の流れは以下のようになる。

  1. Amazon CloudFront KeyValueStoreからMicroVMのメタ情報を取得
  2. MicroVMの状態を確認し、なければ起動、SUSPENDEDなら再開する
  3. RUNNING状態になったらトークンを発行してAmazon CloudFront KeyValueStoreに書き込む
  4. ブラウザに「起動中」ページ(HTTP 503)を返す
  5. ブラウザが数秒後にリロードすると、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では署名が通らないので注意が必要だ。

package.json
{
  "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(ランチャー)コード

コード全体
index.mjs
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サーバに直接転送される。

image.png

MicroVMの起動(PENDINGからRUNNINGまで)は、スナップショットからの復帰のため数秒で完了する。Dockerfileを使ったイメージのビルド時にアプリケーションの初期化まで完了し、その状態のスナップショットから起動できることがAWS Lambda MicroVMsのポイントだ。

RUNNINGになると、以下の画面に変わる。

image.png

15分間アクセスがない状態が続くとMicroVMが自動で一時停止し、次のアクセス時に自動で復帰する。この復帰も数秒で完了するため、「起動中」ページが一瞬表示されてすぐMicroVMに切り替わる。

これで、AWS Lambda MicroVMsの基本的な使い方を理解して起動できるようになった!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?