サーバレスアーキテクチャ
ひとまずDynamoDBを操作するLambdaを作ってみる
import json
import boto3
import time
from boto3.dynamodb.conditions import Key
from decimal import Decimal
def _resp(status, body=None):
return {
"statusCode": status,
"headers": {
"Content-Type": "application/json",
# "Access-Control-Allow-Origin": "*",
# "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
# "Access-Control-Allow-Headers": "Content-Type",
},
"body": json.dumps(body or {})
}
def _get_method(event):
return (event.get("requestContext", {}).get("http", {}).get("method")
or event.get("httpMethod") or "GET")
def _parse_body(event):
body_raw = event.get("body") or ""
return json.loads(body_raw)
def _to_jsonable(x):
if isinstance(x, list):
return [_to_jsonable(i) for i in x]
if isinstance(x, dict):
return {k: _to_jsonable(v) for k, v in x.items()}
if isinstance(x, Decimal):
# 1758384246206 のような整数なら int に、そうでなければ float に
return int(x) if x == int(x) else float(x)
return x
dynamodb = boto3.resource("dynamodb")
TABLE_NAME = "Messages"
table = dynamodb.Table(TABLE_NAME)
def lambda_handler(event, context):
method = _get_method(event)
# if method == "OPTIONS":
# return _resp(200, {}) # CORSプリフライト
if method == "GET":
qs = event.get("queryStringParameters") or {}
roomId = (qs.get("roomId") or "general").strip()
try:
limit = int(qs.get("limit") or "50")
except (ValueError, TypeError):
limit = 50
res = table.query(
KeyConditionExpression=Key("roomId").eq(roomId),
ScanIndexForward=True, # 古→新
Limit=limit
)
items = res.get("Items", [])
items = _to_jsonable(items)
return _resp(200, {"ok": True, "items": items})
if method == "POST":
try:
body = _parse_body(event)
except json.JSONDecodeError:
return _resp(400, {"error": "Invalid JSON"})
roomId = (body.get("roomId") or "default").strip()
user = (body.get("user") or "annonymous").strip()
text = (body.get("text") or "").strip()
if not text:
return _resp(400, {"error": "Text is empty"})
ts = int(time.time() * 1000)
item = {
"roomId": roomId,
"ts": ts,
"user": user,
"text": text,
}
table.put_item(Item=item)
return _resp(200, {"ok": True, "item": item})
return _resp(405, {"error": "Method not allowd"})
Lambda関数URLも有効化しておく。CORSの設定をちゃんとしておかないと動かない。
フロントエンドとなるhtmlはこんな感じ。
<!doctype html>
<meta charset="utf-8" />
<title>Serverless Chat (mini)</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 40px auto; }
#messages { border: 1px solid #ddd; padding: 8px; min-height: 200px; }
.msg { margin: 4px 0; padding: 6px; border-radius: 6px; background: #f7f7f7; }
</style>
<h1>Serverless Chat</h1>
<div id="messages"></div>
<form id="form">
<input id="user" placeholder="name" value="noim">
<input id="text" placeholder="message">
<button>Send</button>
</form>
<script>
const URL = "https://2smlcb36562yoidtepzyyskcve0zqnyb.lambda-url.ap-northeast-1.on.aws";
const messagesDiv = document.getElementById("messages");
const form = document.getElementById("form");
const userInput = document.getElementById("user");
const textInput = document.getElementById("text");
async function load() {
const res = await fetch(`${URL}?roomId=general&limit=20`);
const data = await res.json();
messagesDiv.innerHTML = data.items.map(m =>
`<div class="msg"><b>${m.user}</b>: ${m.text}</div>`
).join("");
}
form.addEventListener("submit", async e => {
e.preventDefault();
const body = { roomId: "general", user: userInput.value, text: textInput.value };
await fetch(URL, { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify(body) });
textInput.value = "";
await load();
});
load();
setInterval(load, 5000); // 5秒ごとに更新
</script>
index.htmlを作成したら、ローカルで簡単なwebサーバを立てて上記のhtmlを表示させる。 index.htmlがあるディレクトリで以下を実行。
python3 -m http.server 8000
ブラウザでアクセスして動作を確認する。
これからやりたいこと
https://tmokmss.hatenablog.com/entry/serverless-fullstack-webapp-architecture-2025
ここらへんを参考に、これからやりたいことは
- 認証
- フロントエンドをリッチに(Nextjsとか?)
- フロントエンドをCloudFrontとかから配信
フロントエンドをNextjsで作り直す
1. プロジェクト作成
# 好きな作業フォルダで
npx create-next-app@latest chat-frontend
cd chat-frontend
npm run dev
ブラウザで http://localhost:3000 が開けばOK。
2. “静的出力”をONにする(S3で配るため)
next.config.js を作成/編集:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export', // ← これで "next export" が有効になる(静的サイト出力)
};
module.exports = nextConfig;
3. APIのURLを環境変数に置く
ルートに .env.local を作って、あなたの Lambda Function URL を入れる:
NEXT_PUBLIC_API_URL=https://2smlcb36562yoidtepzyyskcve0zqnyb.lambda-url.ap-northeast-1.on.aws
NEXT_PUBLIC_ から始めると、ブラウザ側で参照できます。
4. 画面を作る(index.html → Next.js へ移植)
src/app/page.tsx
を書き換える。
'use client';
import { useEffect, useRef, useState } from 'react';
type Message = {
roomId: string;
ts: number;
user: string;
text: string;
};
const API = process.env.NEXT_PUBLIC_API_URL as string;
export default function Page() {
const [items, setItems] = useState<Message[]>([]);
const [user, setUser] = useState<string>('noim');
const [text, setText] = useState<string>('');
const roomIdRef = useRef<string>('general');
async function load() {
const url = `${API}?roomId=${encodeURIComponent(roomIdRef.current)}&limit=50`;
const r = await fetch(url);
const data = await r.json();
setItems(data.items ?? []);
}
async function send(e: React.FormEvent) {
e.preventDefault();
const payload = { roomId: roomIdRef.current, user, text };
await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
setText('');
await load();
}
useEffect(() => {
load();
const id = setInterval(load, 5000); // 5秒ごとに更新
return () => clearInterval(id);
}, []);
return (
<div
style={{
fontFamily: 'system-ui, sans-serif',
maxWidth: 680,
margin: '40px auto',
padding: '0 12px',
}}
>
<h1>Serverless Chat (Next.js)</h1>
<label>
Room:{' '}
<input
defaultValue="general"
onChange={(e) => (roomIdRef.current = e.target.value)}
/>
</label>
<div
id="messages"
style={{
maxHeight: '60vh',
overflow: 'auto',
border: '1px solid #eee',
padding: 8,
borderRadius: 8,
marginTop: 8,
}}
>
{items.map((m) => (
<div
key={m.ts}
style={{
padding: '8px 10px',
border: '1px solid #ddd',
borderRadius: 8,
margin: '6px 0',
background: m.user === user ? '#f7faff' : '#f7f7f7',
}}
>
<div>
<strong>{m.user}</strong>{' '}
<small>{new Date(m.ts).toLocaleString()}</small>
</div>
<div>{escapeHtml(m.text)}</div>
</div>
))}
</div>
<form
onSubmit={send}
style={{ display: 'flex', gap: 8, marginTop: 12 }}
>
<input
value={user}
onChange={(e) => setUser(e.target.value)}
placeholder="name"
/>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="message"
style={{ flex: 1 }}
/>
<button type="submit">Send</button>
</form>
</div>
);
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (m) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[
m
] as string)
);
}
localhostで開発サーバ起動して挙動確認する。
npm run dev
5. 本番用の静的出力を作る(S3に置くファイル)
npm run build
out/ ディレクトリが出力されます(中に index.html と _next/ ができる)。
6. S3バケット作成
S3 バケットの作成
- AWS マネジメントコンソール → S3 → 「バケットを作成」 • バケット名: chat-frontend-XXXX(一意なら何でもOK) • リージョン: Lambdaと同じリージョン(例: ap-northeast-1) • ACL 有効化: 無効のままでOK • パブリックアクセスを全てブロック: チェックを外す • バージョニング: 無効でOK • 暗号化: デフォルト(有効)でOK • 作成
- 作成したバケット → 「プロパティ」タブ → 一番下「静的ウェブサイトホスティング」 • 有効化 • インデックスドキュメント: index.html • エラードキュメント: index.html(SPA対応したいならここも index.html) • 保存
- 「アップロード」 → Next.js の out/ ディレクトリの中身(index.html, _next/, 画像など)を全部アップロード
バケットポリシーの設定
以下のように設定することで、全世界からS3に直接アクセスできるようになるが、後のCloudFront経由でのアクセスを想定しているので、検証用用途以外には不要。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::chat-frontend-noim/*"
}
]
}
7. CloudFront ディストリビューションの作成
1. CloudFront → 「ディストリビューション作成」
• オリジンドメイン: さっき作ったS3バケットを選択(chat-frontend-noim.s3-website-ap-northeast-1.amazonaws.com) Webサイトエンドポイントを指定した。
• オリジンアクセス: 「オリジンアクセスコントロール(推奨)」で作成
(とりあえずは「パブリックバケット」にしてもOK) • ビヘイビア(デフォルト) • ビューワプロトコルポリシー: Redirect HTTP to HTTPS • キャッシュポリシー: CachingOptimized(デフォルトでOK) • デフォルトルートオブジェクト: index.html 2. 作成 → 数分で CloudFront ドメインが発行される 例: https://d123456abcdef8.cloudfront.net
6. S3 + CloudFront に置く(配信)
1. S3 バケットを作成(静的ウェブサイトON、index: index.html)
2. out/ の中身をまるごとアップロード
3. CloudFront でそのS3をオリジンにディストリビューションを作成
4. Lambda Function URL の CORS に CloudFrontドメイン(例 https://dxxxxx.cloudfront.net)を追加
• Allowed methods: GET, POST(OPTIONSは自動)
• Allowed headers: Content-Type
これで CloudFront のURLからアクセス → Next.jsのUIが見えて、LambdaチャットAPIと連携します。
API(Lambda Funciton)へのアクセスもCloudFront経由にする
現状の確認
Lambda側の設定を見てみると関数URLが設定されており、認証タイプもNONEになっており、ポリシーステートメントも誰からでもアクセスできる状態。
{
"Version": "2012-10-17",
"Statement": [
{
"StatementId": "FunctionURLAllowPublicAccess",
"Effect": "Allow",
"Principal": "*",
"Action": "lambda:InvokeFunctionUrl",
"Resource": "arn:aws:lambda:ap-northeast-1:89aaaaaaaaaa:function:myFirstChatApp",
"Condition": {
"StringEquals": {
"lambda:FunctionUrlAuthType": "NONE"
}
}
},
{
"StatementId": "FunctionURLAllowInvokeAction",
"Effect": "Allow",
"Principal": "*",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:ap-northeast-1:89aaaaaaaaaa:function:myFirstChatApp",
"Condition": {
"Bool": {
"lambda:InvokedViaFunctionUrl": "true"
}
}
}
]
}
実際にLambda関数URLにアクセスすれば見えることが確認できる。
CloudFront経由アクセスに限定する
では、実際にLambda直アクセスを禁止し、CloudFront経由に限定してみる。
1) CloudFront で「S3(静的)」&「Lambda URL(API)」の二つのオリジンを登録
1. CloudFront → 「ディストリビューションを作成」【ここは作成済みのため、スキップ】
2. オリジン1(静的) 【ここは作成済みのため、スキップ】
• オリジンドメイン:S3のWebサイトエンドポイントを選択
例)xxx.s3-website-ap-northeast-1.amazonaws.com • オリジン名:origin-s3-site • オリジンアクセス:最初はパブリックでOK(後でOAC/OAIに切替でも可) 3. オリジン2(API) を追加 • 「オリジンを追加」 • オリジンドメイン:Lambda Function URL のドメイン部 例)xxxxx.lambda-url.ap-northeast-1.on.aws • オリジン名:origin-lambda-url • (TLS/HTTP設定はデフォルトでOK)
補足:APIは“カスタムオリジン”扱いです。S3と見た目が違うだけで普通に登録できます。
Lambda関数URLのオリジンドメインはhttps://2smlcb36562yoidtepzyyskcve0zqnyb.lambda-url.ap-northeast-1.on.aws/
2) ビヘイビア(パスルール)の設定
1. 既定のビヘイビア(Default (*))
• オリジン:origin-s3-site
• ビューワプロトコルポリシー:Redirect HTTP to HTTPS
• キャッシュポリシー:CachingOptimized(とりあえずでOK)
• 圧縮:ON
2. 追加ビヘイビア
• パスパターン:/api/*
• オリジン:origin-lambda-url
• キャッシュポリシー:CachingDisabled(APIはキャッシュ無効推奨)
• オリジンリクエストポリシー:AllViewer(必要に応じてヘッダ/クエリを全部渡す)
• ビューワプロトコル:HTTPS強制
これで「静的はS3」「/api/*はLambda URL」へ振り分けられます。
3) 直叩き防止
- Lambda Function URL のリソースポリシーで CloudFront だけ許可
Lambda の対象関数 → [関数URL] → [認証タイプ: NONE] のまま、[リソースポリシーを編集]。以下のように書き換える(値は自分のIDに置換):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "lambda:InvokeFunctionUrl",
"Resource": "arn:aws:lambda:ap-northeast-1:891393919774:function:myFirstChatApp",
"Condition": {
"ArnLike": {
"AWS:SourceArn": "arn:aws:cloudfront::891393919774:distribution/E33CY889YGF5K"
}
}
}
]
}
GUIだとリソースポリシーが編集できなかったので、CloudShellでcliで操作した。
- 変数セット
# ==== 入れ替えて使ってください ====
export REGION=ap-northeast-1
export ACCOUNT_ID=891393919774
export FUNCTION_NAME=myFirstChatApp
export DISTRIBUTION_ID=E1ABCDEF2GHIJK
# ===================================
export FUNCTION_ARN="arn:aws:lambda:${REGION}:${ACCOUNT_ID}:function:${FUNCTION_NAME}"
echo $FUNCTION_ARN
- いまのポリシーを確認(バックアップ)
aws lambda get-policy \
--function-name "${FUNCTION_NAME}" \
--region "${REGION}" \
--output json || echo "No existing policy"
- 既存の“全世界許可”Statementを削除
# 誰でも叩ける許可を削除
aws lambda remove-permission \
--function-name "${FUNCTION_NAME}" \
--statement-id "FunctionURLAllowPublicAccess" \
--region "${REGION}" 2>/dev/null || echo "Already removed or missing: FunctionURLAllowPublicAccess"
# FunctionUrl 経由の Invoke の冗長許可を削除(CloudFront限定にするため整理)
aws lambda remove-permission \
--function-name "${FUNCTION_NAME}" \
--statement-id "FunctionURLAllowInvokeAction" \
--region "${REGION}" 2>/dev/null || echo "Already removed or missing: FunctionURLAllowInvokeAction"
- CloudFront からだけ許す許可を追加(決定版)
aws lambda add-permission \
--statement-id "AllowCloudFrontServicePrincipal" \
--action "lambda:InvokeFunctionUrl" \
--principal "cloudfront.amazonaws.com" \
--source-arn "arn:aws:cloudfront::${ACCOUNT_ID}:distribution/${DISTRIBUTION_ID}" \
--function-name "${FUNCTION_NAME}" \
--region "${REGION}"
- フロントエンドのnext.jsを修正 .env.local を 相対パスに変更し、CloudFront配下(/api/) を叩く:
# 旧: NEXT_PUBLIC_API_URL=https://xxxxx.lambda-url.ap-northeast-1.on.aws
NEXT_PUBLIC_API_URL=/api/
既存の fetch(${API}?roomId=…) はそのまま動作し、CloudFront → Lambda URL へ流れます。
----- ↓をやらないとダメだった。CloudFrontからLamnda関数にアクセス制限するにはLambda側をIAM認証に設定して、CloudFrontにはOACを作成する必要があった-----------
Lambdaの認証タイプをNONE
からAWS IAM
に変更する
CloudFrontで新しくOACを作成する。 コマンドが出てくるので、aws cliで実行する。
S3のパブリックアクセスを不可として、CloudFront経由のみとする
https://zenn.dev/port_inc/articles/next-s3-cloudfront
認証認可
CoudFrontでLambda@Edgeを使って認証する。詰まってるので、別途整理。
- https://zenn.dev/yh1224/articles/f473a9325a48404bd
- https://blog.serverworks.co.jp/cloudfront-lambda-edge-basic-authentication
まずはLambda@Edgeを試す
Edge用ロールを準備する。すでに作成済みであれ不要。
IAM → ロール作成 • ユースケース: Lambda • ポリシー: AWSLambdaBasicExecutionRole(CloudWatch Logs) • ロール名: lambda-edge-basic-exec • 作成後、信頼ポリシーを編集して edgelambda.amazonaws.com を追加
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": ["lambda.amazonaws.com","edgelambda.amazonaws.com"] },
"Action": "sts:AssumeRole"
}
]
}
次に、Lambdaを作成する。
import json
# -*- coding: utf-8 -*-
"""
Lambda@Edge (Viewer Request) — 超最小のフォーム表示
- https://<CloudFrontドメイン>/edge-form でアクセスすると:
- クエリが無い → HTMLフォームを返す
- ?name=xxx がある → その名前で挨拶を返す
- オリジン(S3やAPI)へは一切行かず、この関数が直接レスポンスを返す。
ポイント:
- Edgeは "Viewer Request" でもレスポンスを返せる(オリジンに行かない)
- POSTは扱いが難しいので、GETのクエリだけで完結させる
"""
import html
import urllib.parse
def _resp(status: int, headers: dict, body: str):
"""CloudFrontが受け付けるレスポンス形式に整形"""
# ヘッダは { "Header-Name": "value" } を
# { "header-name": [{"key":"Header-Name","value":"value"}] } に変換
cf_headers = {}
for k, v in headers.items():
cf_headers[k.lower()] = [{"key": k, "value": v}]
return {
"status": str(status),
"statusDescription": "OK" if status == 200 else "Edge Response",
"headers": cf_headers,
"body": body,
}
def _page(content: str) -> str:
"""最低限のHTMLテンプレート"""
return f"""<!doctype html>
<meta charset="utf-8">
<title>Edge Form</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 40px auto; max-width: 640px; }}
form {{ display: flex; gap: 8px; }}
input[type=text] {{ flex: 1; padding: 8px; }}
.card {{ border: 1px solid #ddd; border-radius: 8px; padding: 16px; background: #fafafa; }}
</style>
<div class="card">
{content}
</div>
"""
def lambda_handler(event, context):
# CloudFrontからのリクエスト情報
req = event["Records"][0]["cf"]["request"]
uri = req.get("uri", "/")
method = req.get("method", "GET")
query = req.get("querystring", "") # 例: "name=noim"
# 1) /edge-form 以外は何もしないでオリジンへ通す
if not uri.startswith("/edge-form"):
return req # ← これが「素通し」
# 2) GET以外は405
if method != "GET":
return _resp(405,
{"Content-Type":"text/plain; charset=utf-8",
"Cache-Control":"no-store"},
"Method Not Allowed")
# 3) クエリを解析(name=...)
params = dict(urllib.parse.parse_qsl(query)) if query else {}
name = params.get("name", "").strip()
# 4) name が無ければ フォーム表示
if not name:
html_body = _page("""
<h1>Edge Form (GET)</h1>
<p>名前を入れて送信すると、Edgeが直接レスポンスを返します(オリジンへは行きません)。</p>
<form method="GET" action="/edge-form">
<input type="text" name="name" placeholder="Your name" />
<button type="submit">Send</button>
</form>
<p style="margin-top:8px;color:#555">例: /edge-form?name=noim</p>
""")
return _resp(200,
{"Content-Type":"text/html; charset=utf-8",
"Cache-Control":"no-store"},
html_body)
# 5) name があれば 挨拶して返す
safe = html.escape(name)
html_body = _page(f"""
<h1>Hello, {safe}!</h1>
<p>これは Lambda@Edge(Viewer Request) が生成したHTMLです。</p>
<p><a href="/edge-form">戻る</a></p>
""")
return _resp(200,
{"Content-Type":"text/html; charset=utf-8",
"Cache-Control":"no-store"},
html_body)
Lambdaの新しいバージョンを発行して、からそのARNをCloudFrontに関連付ける
CloudFront → ディストリビューション → (デフォルトの)ビヘイビア → 編集 一番下の Lambda 関数の関連付けで: • イベントタイプ:Viewer request • ARN:arn:aws:lambda:us-east-1:...:function:edge-form-viewer-request:1(:1 のような番号付き) • 保存 → 数分待つ
動作確認してみると、Lambda@Edgeでで作成したフォームが表示されるはず。
• ブラウザで https://<あなたのCloudFrontドメイン>/edge-form
• フォームが出れば成功
• ?name=xxx で挨拶HTMLが返ればOK
• オリジン設定が壊れていてもこのパスはEdgeが直接返すので表示されるはず(S3/Function URLに到達しない)
Lambda@Edgeとは
sequenceDiagram
participant U as 👤 ユーザー(ブラウザ)
participant CF as ☁️ CloudFront
participant E1 as 🧩 Lambda@Edge<br/>Viewer Request
participant E2 as 🧩 Lambda@Edge<br/>Origin Request
participant O as 🗄 オリジン(S3やLambda URL)
participant E3 as 🧩 Lambda@Edge<br/>Origin Response
participant E4 as 🧩 Lambda@Edge<br/>Viewer Response
U->>CF: ① HTTPリクエスト
CF->>E1: (Viewer Request)
alt Edgeがレスポンスを返す場合
E1-->>U: HTMLを直接返す
else オリジンへ転送
E1-->>CF: リクエスト
CF->>E2: (Origin Request)
E2-->>CF: リクエスト
CF->>O: オリジンへ転送
O-->>CF: レスポンス
CF->>E3: (Origin Response)
E3-->>CF: レスポンス
CF->>E4: (Viewer Response)
E4-->>CF: レスポンス
CF-->>U: ユーザーへ返却
end