Lambda Web Adapter + CDK で組むエージェントWebアプリ

SkillCloud & infra

Overall architecture for building an AI agent web app with "AWS CDK (deploys via CDKD) + Lambda Web Adapter + CloudFront + AgentCore Runtime": how to split stacks, the Dockerfile and serving server for the Lambda container that serves the UI, settings to protect the Function URL with CloudFront OAC

Use Lambda Web Adapter + CDK で組むエージェントWebアプリ in Claude, ChatGPT or Ahel Desktop

Free. Sign in, add Lambda Web Adapter + CDK で組むエージェントWebアプリ and connect your AI. About a minute.

Also: Claude Code · Cursor · Codex

Then ask your AI: use the Lambda Web Adapter + CDK で組むエージェントWebアプリ skill

Details

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

Add Ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Lambda Web Adapter + CDK で組むエージェントWebアプリStart free

What this skill tells your AI

The instructions your AI receives, as published by minorun365/agent-builder-skills in skills/kb-lambda-web-adapter-cdk/SKILL.md and read by Ahel’s review.

画面の配信と、エージェントの実行を、どちらもコンテナにして CDK で一括管理する構成。 動く実物は minorun365/marp-agent(infra/ 配下)。迷ったら実物を読む。

関連スキル:Runtime の細部は kb-agentcore-cdk、エージェント本体は kb-strands-agentcore、 認証は kb-demo-app-auth、ストリーミングの受け方は kb-frontend-sse、デプロイコマンドは cdkd-deploy。

1. 全体像

ブラウザ
  │  ① 画面(HTML/JS)と runtime-config.json
  ▼
CloudFront ──OAC(SigV4)──▶ Lambda Function URL(AWS_IAM)
                              └ Lambdaコンテナ:Lambda Web Adapter + Node の配信サーバー
  │
  │  ② Cognito でサインイン(ID/アクセストークンを取得)
  │
  │  ③ エージェント呼び出し(Authorization: Bearer <JWT>、SSE で受信)
  ▼
AgentCore Runtime(コンテナ。JWT オーソライザーが Cognito のトークンを検証)
  └ Strands Agents + Bedrock のモデル
  • 画面の配信とエージェントの実行は別のコンテナに分ける。 画面は Lambda、エージェントは AgentCore Runtime
  • エージェントの呼び出しは CloudFront も Lambda も通さない。 ブラウザから AgentCore のエンドポイントへ直接つなぐ。 Lambda を挟むと、応答サイズと実行時間の上限がエージェントの長い応答にそのまま効いてしまう
  • Lambda Web Adapter(以下 LWA)は Lambda の拡張機能。ふつうの HTTP サーバーを、コードを変えずに Lambda で動かせる。 「Lambda」と略さない——ハンドラ関数を書く普通の Lambda とは作りが別物なので、構成図でも LWA と明記する

この構成を選ぶ理由

  • インフラが CDK の1系統にまとまる。認証・エージェント・配信のあいだの値の受け渡しが、スタック間参照だけで済む
  • 画面の配信に API の処理を足したくなったとき、同じ HTTP サーバーへルートを足すだけで済む
  • Git への push と本番反映が切り離される。反映は明示的にコマンドを打ったときだけ起きる

2. スタックの分け方

役割ごとに分け、依存の向きを一方向に保つ。

スタック持つもの作り直しの頻度
Foundationドメイン(Route 53・ACM 証明書)、永続データ、シークレットほぼ変えない
Access(実行ロール)Lambda・AgentCore の実行ロール、ロググループまれ
AuthCognito の User Pool・アプリクライアント・ドメインまれ
AgentAgentCore Runtime、エージェントのコンテナイメージ頻繁
Web画面の Lambda コンテナ、Function URL、CloudFront頻繁
auth.addStackDependency(foundation);
agent.addStackDependency(auth);      // JWT オーソライザーが User Pool を参照する
web.addStackDependency(agent);       // runtime-config.json が Runtime の ARN を参照する
web.addStackDependency(auth);
  • 消えると困るもの(データ・ドメイン・利用者)を、頻繁に作り直すもの(Agent・Web)から離す。 removalPolicy: RETAIN も Foundation 側へ寄せる
  • cdk.Tags.of(app).add('Project', '<名前>') をアプリ全体へ付ける。コストの切り分けに効く

3. 画面を配信する Lambda コンテナ

Dockerfile

FROM public.ecr.aws/docker/library/node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM public.ecr.aws/docker/library/node:22-slim
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.0.1 /lambda-adapter /opt/extensions/lambda-adapter
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY infra/web/server.mjs ./infra/web/server.mjs
ENV PORT=8080 AWS_LWA_PORT=8080 AWS_LWA_READINESS_CHECK_PATH=/health
CMD ["node", "infra/web/server.mjs"]
  • LWA は /opt/extensions/ へ1ファイル置くだけで有効になる。アプリ側のコードは Lambda を意識しない
  • AWS_LWA_PORT は HTTP サーバーが待ち受けるポート、AWS_LWA_READINESS_CHECK_PATH は起動確認に使うパス。 このパスが 200 を返すまで LWA はリクエストを流さないので、配信サーバーに必ず実装する
  • ベースイメージは Docker Hub ではなく ECR Public(public.ecr.aws/docker/library/…)から取る。 ビルド環境での取得回数制限を踏まない
  • LWA のバージョンは変わるので、使うときに awslabs/aws-lambda-web-adapter で確かめる

配信サーバーに持たせる5つの役目

Node 標準の node:http だけで80行ほど。フレームワークは要らない。

  1. /health … 200 を返すだけ。LWA の起動確認用
  2. /runtime-config.json … 環境変数 RUNTIME_CONFIG_JSON の中身をそのまま返す(次節)
  3. 静的ファイル … dist/ から返す。パスは normalize して .. を落とす
  4. SPA フォールバック … ファイルが無ければ index.html を返す
  5. 圧縮とキャッシュヘッダー … 下の2点
response.setHeader('Cache-Control',
  filePath.endsWith('index.html')
    ? 'no-cache, no-store, must-revalidate'
    : 'public, max-age=31536000, immutable');   // Vite のハッシュつきファイル名が前提

圧縮は配信サーバー側でやる。 応答をストリームで返すと Content-Length が付かず、 CloudFront は自動圧縮を諦める。数MBの JS が無圧縮で流れて初期表示が秒単位で遅くなる。 Accept-Encoding を見て Brotli か gzip で圧縮し、Content-Encoding と Vary: Accept-Encoding を付ければ CloudFront はそのまま通す。品質は Brotli 4・gzip 5 程度で足りる(既定の最高品質は CPU 時間が伸びるだけ)。

4. runtime-config.json:環境の値をビルドへ焼き込まない

Cognito の ID や Runtime の ARN を VITE_* でビルド時に埋めると、環境ごとにイメージを作り分けることになる。 CDK が JSON を組み立てて Lambda の環境変数へ入れ、配信サーバーがそれを返し、画面は起動時に取りに行く。

const runtimeConfig = cdk.Fn.toJsonString({
  auth: {
    region: this.region,
    userPoolId: props.auth.userPool.userPoolId,
    userPoolClientId: props.auth.userPoolClient.userPoolClientId,
  },
  agent: { runtimeArn: props.agent.runtime.attrAgentRuntimeArn, protocol: 'HTTP' },
  environment: 'production',
});
// → DockerImageFunction の environment: { AWS_LWA_PORT: '8080', RUNTIME_CONFIG_JSON: runtimeConfig }
// 画面側(main.tsx)。Amplify.configure より前に読む
const response = await fetch('/runtime-config.json', { cache: 'no-store' });
if (!response.ok) throw new Error(`runtime-config.json: ${response.status}`);
  • 秘密の値は入れない。 このJSONは誰でも取得できる。入れてよいのは ID・ARN・URL のような公開前提の値だけ
  • CloudFront 側は、このパスだけキャッシュを無効にする(次節)
  • ローカル開発では同じ形の runtime-config.local.json を置き、environment だけ local に変えて取り違えを防ぐ

5. CDK:Function URL を CloudFront の OAC で守る

const webFunction = new lambda.DockerImageFunction(this, 'WebFunction', {
  code: lambda.DockerImageCode.fromImageAsset(path.join(currentDir, '../..'), {
    file: 'infra/web/Dockerfile',
    platform: cdk.aws_ecr_assets.Platform.LINUX_ARM64,
    exclude: ['.git', 'node_modules', 'cdk.out', 'dist', 'docs', 'tests', 'agent', '**/__pycache__'],
    ignoreMode: cdk.IgnoreMode.GLOB,
  }),
  architecture: lambda.Architecture.ARM_64,
  memorySize: 1024,
  timeout: cdk.Duration.seconds(30),
  environment: { AWS_LWA_PORT: '8080', RUNTIME_CONFIG_JSON: runtimeConfig },
});

const functionUrl = webFunction.addFunctionUrl({
  authType: lambda.FunctionUrlAuthType.AWS_IAM,      // URL を直接叩かれても通さない
  invokeMode: lambda.InvokeMode.BUFFERED,
});
const webOrigin = origins.FunctionUrlOrigin.withOriginAccessControl(functionUrl);

OAC だけでは 403 になる。権限がもう1つ要る。 AWS_IAM で保護した Function URL は、 lambda:InvokeFunctionUrl に加えて lambda:InvokeFunction も要求する。 FunctionUrlOrigin.withOriginAccessControl が作るのは前者だけなので、後者をディストリビューション単位で足す。

webFunction.addPermission('CloudFrontInvokeFunction', {
  principal: new iam.ServicePrincipal('cloudfront.amazonaws.com'),
  action: 'lambda:InvokeFunction',
  sourceArn: cdk.Stack.of(this).formatArn({
    service: 'cloudfront', region: '', resource: 'distribution',
    resourceName: distribution.distributionId,
  }),
  invokedViaFunctionUrl: true,
});

キャッシュは「オリジンの Cache-Control に従う」ポリシーを1つ作り、runtime-config.json だけ無効にする。

const originAwareCache = new cloudfront.CachePolicy(this, 'OriginAwareCachePolicy', {
  minTtl: cdk.Duration.seconds(0),
  defaultTtl: cdk.Duration.seconds(0),
  maxTtl: cdk.Duration.days(365),
  enableAcceptEncodingBrotli: true,
  enableAcceptEncodingGzip: true,       // Accept-Encoding をキャッシュキーへ入れる
});

new cloudfront.Distribution(this, 'Distribution', {
  defaultBehavior: { origin: webOrigin, cachePolicy: originAwareCache, compress: true,
    viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS },
  additionalBehaviors: {
    'runtime-config.json': { origin: webOrigin,
      cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
      viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS },
  },
});
  • exclude を書かないと、node_modules や .git がビルドコンテキストへ入ってイメージ作成が極端に遅くなる。 エージェント側のディレクトリ(agent/)も画面のイメージには要らないので外す
  • BUFFERED は応答が 6MB までという Lambda の上限を受ける。圧縮後に超えるファイルを作らない (超えそうなら、その大きな資産だけ S3 オリジンへ逃がす)
  • 利用者が上げるファイルや共有用の成果物は、S3 バケットを別オリジンにしてパスで振り分ける

6. フロントから AgentCore Runtime を呼ぶ配線

Runtime は Cognito の JWT を検証するオーソライザーで守り、画面からはトークンを付けて直接呼ぶ。

new agentcore.CfnRuntime(this, 'Runtime', {
  agentRuntimeName: 'my_agent',
  agentRuntimeArtifact: { containerConfiguration: { containerUri: runtimeImage.imageUri } },
  authorizerConfiguration: {
    customJwtAuthorizer: {
      discoveryUrl: `https://cognito-idp.${this.region}.amazonaws.com/${userPoolId}/.well-known/openid-configuration`,
      allowedClients: [userPoolClientId],
    },
  },
  networkConfiguration: { networkMode: 'PUBLIC' },
  protocolConfiguration: 'HTTP',
  requestHeaderConfiguration: { requestHeaderAllowlist: ['Authorization'] },
  roleArn: runtimeRole.roleArn,
});
// 呼び出し先。ARN は URL エンコードする
const url = `https://bedrock-agentcore.${region}.amazonaws.com/runtimes/${encodeURIComponent(runtimeArn)}/invocations?qualifier=DEFAULT`;
  • AgentCore は既定でリクエストヘッダーをコンテナへ渡さない。 利用者ごとの処理(利用統計、利用者別のデータ)が要るなら requestHeaderAllowlist に Authorization を入れる。入れないと、コンテナ側では誰が呼んだのか分からない。 署名の検証はオーソライザーが済ませているので、コンテナ側は sub を読むだけでよい
  • Runtime のロググループは CDK で先に作る。 AgentCore が自動で作るロググループは保持期間が短い。 /aws/bedrock-agentcore/runtimes/<RuntimeId>-DEFAULT を保持期間つきで定義しておく。 Runtime を作り直すと ID が変わるので、同じスタックに置いて一緒に作り直されるようにする
  • エージェントのコンテナも Platform.LINUX_ARM64 でビルドする(AgentCore Runtime は ARM64)

7. ローカル開発

CDKD のローカル実行で、エージェントのコンテナを手元で動かしながら Vite で画面を開発する。

// infra/scripts/start-dev.mjs の要点:2つのプロセスを同時に起動する
start('./node_modules/.bin/cdkd', ['local', 'start-agentcore', '<AgentStack>/Runtime',
  '--watch', '--port', '8081', '--no-verify-auth']);
start('./node_modules/.bin/vite', [], { VITE_AGENT_ENDPOINT: '/local-agent' });
  • 画面は VITE_AGENT_ENDPOINT があるときだけ、呼び出し先をローカルのエージェントへ切り替える(Vite のプロキシで /local-agent → 127.0.0.1:8081)
  • 認証は本番と同じ Cognito を使う。アプリクライアントのコールバック URL に http://localhost:5173/ を入れておく
  • CloudFront 相当の配信経路まで手元で再現したいときは、CDKD のローカル実行に CloudFront 用のコマンドがある。 フラグは変わりやすいので、cdkd-deploy スキルの手順で最新のヘルプを確かめてから使う

8. npm scripts

デプロイの入口を npm scripts へ固定して、人もエージェントも同じ手順を踏むようにする。

{
  "dev": "node infra/scripts/start-dev.mjs",
  "infra:synth": "cdkd synth",
  "infra:diff": "cdkd diff",
  "infra:dry-run": "cdkd deploy --all --dry-run",
  "infra:deploy": "cdkd deploy --full-wait",
  "infra:drift": "cdkd drift --all"
}

反映の順序は synth → diff → dry-run → deploy。CDKD はコミュニティ製で、CloudFormation を介さず AWS の API を直接呼ぶ。 本番で使うかどうかの線引きと、既存の CloudFormation スタックへ当ててはいけない理由は cdkd-deploy スキルにある。 素の cdk deploy でも、この構成はそのまま動く。

9. デプロイ後の検証

「デプロイが通った」は動作確認にならない。次を外から確かめる。

# 画面と設定が返る
curl -sS -o /dev/null -w '%{http_code}\n' https://<配信ドメイン>/
curl -sS https://<配信ドメイン>/runtime-config.json

# 圧縮が効いている(content-encoding: br か gzip)
curl -sS -I -H 'Accept-Encoding: br' https://<配信ドメイン>/assets/<ハッシュつきJS> | grep -i content-encoding

# Function URL を直接叩くと拒否される(403 なら正しい)
curl -sS -o /dev/null -w '%{http_code}\n' https://<id>.lambda-url.<region>.on.aws/

# トークンなしのエージェント呼び出しは拒否される(401/403 なら正しい)
curl -sS -o /dev/null -w '%{http_code}\n' -X POST '<Runtime の invocations URL>'

そのうえで、サインインからエージェントの応答が返るまでを、PC とスマホの両方で実際に操作して通す。

10. よくある詰まりどころ

症状原因と対処
CloudFront 経由で 403lambda:InvokeFunction の権限が無い(5節)。invokedViaFunctionUrl: true を付けて足す
初回アクセスだけ 502・タイムアウトAWS_LWA_READINESS_CHECK_PATH のパスが 200 を返していない、または AWS_LWA_PORT とサーバーの待ち受けポートが違う
画面が古いままindex.html に no-cache が付いていない。ハッシュつき資産だけを長期キャッシュにする
初期表示が遅い圧縮が効いていない(3節)。content-encoding を実際に確かめる
イメージのビルドが遅いexclude が足りない。node_modules・.git・cdk.out を外す
エージェント側で利用者を識別できないrequestHeaderAllowlist に Authorization が無い(6節)
リロードすると 404SPA フォールバックが無い。ファイルが無いパスには index.html を返す

Signals

GitHub stars
41
Last commit
Sep 2026
Advanced
Item type
skill
Key
kb-lambda-web-adapter-cdk
Source
github.com/minorun365/agent-builder-skills