Skip to content

サーバレスアーキテクチャ

ひとまずDynamoDBを操作するLambdaを作ってみる

python
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の設定をちゃんとしておかないと動かない。

alt text

フロントエンドとなるhtmlはこんな感じ。

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があるディレクトリで以下を実行。

bash
python3 -m http.server 8000

ブラウザでアクセスして動作を確認する。

これからやりたいこと

https://tmokmss.hatenablog.com/entry/serverless-fullstack-webapp-architecture-2025

ここらへんを参考に、これからやりたいことは

  • 認証
  • フロントエンドをリッチに(Nextjsとか?)
  • フロントエンドをCloudFrontとかから配信

フロントエンドをNextjsで作り直す

1. プロジェクト作成

bash
# 好きな作業フォルダで
npx create-next-app@latest chat-frontend
cd chat-frontend
npm run dev

ブラウザで http://localhost:3000 が開けばOK。

2. “静的出力”をONにする(S3で配るため)

next.config.js を作成/編集:

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を書き換える。

typescript
'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) =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[
      m
    ] as string)
  );
}

localhostで開発サーバ起動して挙動確認する。

bash
npm run dev

5. 本番用の静的出力を作る(S3に置くファイル)

bash
npm run build

out/ ディレクトリが出力されます(中に index.html と _next/ ができる)。

6. S3バケット作成

  1. S3 バケットの作成

    1. AWS マネジメントコンソール → S3 → 「バケットを作成」 • バケット名: chat-frontend-XXXX(一意なら何でもOK) • リージョン: Lambdaと同じリージョン(例: ap-northeast-1) • ACL 有効化: 無効のままでOK • パブリックアクセスを全てブロック: チェックを外す • バージョニング: 無効でOK • 暗号化: デフォルト(有効)でOK • 作成
    2. 作成したバケット → 「プロパティ」タブ → 一番下「静的ウェブサイトホスティング」 • 有効化 • インデックスドキュメント: index.html • エラードキュメント: index.html(SPA対応したいならここも index.html) • 保存
    3. 「アップロード」 → Next.js の out/ ディレクトリの中身(index.html, _next/, 画像など)を全部アップロード
  2. バケットポリシーの設定

以下のように設定することで、全世界からS3に直接アクセスできるようになるが、後のCloudFront経由でのアクセスを想定しているので、検証用用途以外には不要。

json
{
  "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になっており、ポリシーステートメントも誰からでもアクセスできる状態。

alt text

json
{
  "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にアクセスすれば見えることが確認できる。

alt text

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) 直叩き防止

  1. Lambda Function URL のリソースポリシーで CloudFront だけ許可

Lambda の対象関数 → [関数URL] → [認証タイプ: NONE] のまま、[リソースポリシーを編集]。以下のように書き換える(値は自分のIDに置換):

json
{
  "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で操作した。

  1. 変数セット
bash
# ==== 入れ替えて使ってください ====
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
  1. いまのポリシーを確認(バックアップ)
bash
aws lambda get-policy \
  --function-name "${FUNCTION_NAME}" \
  --region "${REGION}" \
  --output json || echo "No existing policy"
  1. 既存の“全世界許可”Statementを削除
bash
# 誰でも叩ける許可を削除
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"
  1. CloudFront からだけ許す許可を追加(決定版)
bash
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}"
  1. フロントエンドのnext.jsを修正 .env.local を 相対パスに変更し、CloudFront配下(/api/) を叩く:
bash
# 旧: 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を使って認証する。詰まってるので、別途整理。

まずはLambda@Edgeを試す

Edge用ロールを準備する。すでに作成済みであれ不要。

IAM → ロール作成 • ユースケース: Lambda • ポリシー: AWSLambdaBasicExecutionRole(CloudWatch Logs) • ロール名: lambda-edge-basic-exec • 作成後、信頼ポリシーを編集して edgelambda.amazonaws.com を追加

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": ["lambda.amazonaws.com","edgelambda.amazonaws.com"] },
      "Action": "sts:AssumeRole"
    }
  ]
}

次に、Lambdaを作成する。

python
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とは

mermaid
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