AWS SAM：S3 ホスティング + Lambda + API Gateway + DynamoDB 構成を試してみた

## はじめに

AWS SAM（Serverless Application Model）は、API Gateway + Lambda + DynamoDB + S3 などを簡単に構築できる便利なフレームワークです。

https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/what-is-sam.html

この記事では、S3 ホスティング × Lambda × API Gateway × DynamoDB を連携させた構成を、AWS SAM を使って一括デプロイし、API 経由で DynamoDB のデータを取得・表示するまでの実践手順をまとめています。

※内容に不備などがございましたら、お手数ですが優しくご指摘いただけますと幸いです。

## 書こうと思ったきっかけ

個人的に AWS SAM をキャッチアップしたくて勉強を進めていたのですが、やってみたかった検証が一通りまとまったので、整理してみました。

1本目の記事では、シンプルな Hello World チュートリアルを実施した内容をまとめています。

**興味のある方はぜひご覧ください！**

https://qiita.com/free-honda/items/d7af39a4f8c9d545450a

**1本目はこんな感じの検証内容です**

![Screenshot 2025-06-07 at 7.41.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/955a1252-2d5e-47db-a7e3-c5dfdb513866.png)

引用元：https://docs.aws.amazon.com/ja_jp/serverless-application-model/latest/developerguide/serverless-getting-started-hello-world.html

2本目の記事では、API 経由で DynamoDB に保存されたテストデータを取得し、Lambda 関数からレスポンスとして返す構成を検証しています。

**こちらもぜひご参考ください！**

https://qiita.com/free-honda/items/d1ee8a7507a2e9c9527f

**2本目はこんな感じの検証内容です**

![Screenshot 2025-06-07 at 16.08.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/39aa0b4b-5db5-40cc-8bf1-7213cf8389e4.png)

引用元：https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/http-api-dynamo-db.html

## 今回の検証内容

以下のようなシンプルかつ実用的なサーバーレス構成になります：

```
+-------------------+
|    Web Browser    |
+-------------------+
         │
         ▼
 静的Web（HTML/JS） ← S3でホスティング
         │
         ▼
 JavaScript → API呼び出し（fetchなど）
         │
         ▼
 API Gateway → Lambda → DynamoDB
```

**構成図はこんな感じになります**

![Screenshot 2025-06-08 at 13.13.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/32f56b63-2364-4073-93b9-0b8f6e74a5b9.png)


### 技術構成の詳細

| 番号 | 構成要素                            | 説明                               |
| -- | ------------------------------- | -------------------------------- |
| ①  | S3                              | 静的Webサイト（HTML / CSS / JS）をホスティング |
| ②  | JavaScript                      | API Gateway のエンドポイントを呼び出すコード     |
| ③  | API Gateway + Lambda + DynamoDB | バックエンド処理（データ取得）                  |

### SAM で構築する範囲

* Lambda 関数
* DynamoDB テーブル
* API Gateway エンドポイント
* S3 バケット（静的ホスティング）

## ファイル構成と内容

### ディレクトリ構成

```css
sam-s3-api-example/
├── template.yaml         # SAM テンプレート（インフラ定義）
├── samconfig.toml        # デプロイ設定ファイル（--guided の保存内容）
├── hello_world/
│   ├── app.py            # Lambda 関数（DynamoDB スキャン）
│   └── __init__.py       # 空で OK（Python モジュール認識）
└── frontend/
    └── index.html        # 静的ホスティングされる HTML
```

### template.yaml（インフラ全体定義）

```yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: API Gateway + Lambda + DynamoDB + S3 Static Web Hosting

Globals:
  Function:
    Timeout: 10

Resources:

  WebHostingBucket:
    Type: AWS::S3::Bucket
    Properties:
      WebsiteConfiguration:
        IndexDocument: index.html
      OwnershipControls:
        Rules:
          - ObjectOwnership: ObjectWriter
      PublicAccessBlockConfiguration:
        BlockPublicAcls: false
        BlockPublicPolicy: false
        IgnorePublicAcls: false
        RestrictPublicBuckets: false

  BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref WebHostingBucket
      PolicyDocument:
        Statement:
          - Action: s3:GetObject
            Effect: Allow
            Resource: !Sub "${WebHostingBucket.Arn}/*"
            Principal: "*"

  ItemsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: ItemsTable
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST

  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      Name: MyApi
      StageName: Prod
      Cors:
        AllowMethods: "'GET,OPTIONS'"
        AllowHeaders: "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'"
        AllowOrigin: "'*'"

  GetItemsFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.12
      Environment:
        Variables:
          TABLE_NAME: !Ref ItemsTable
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref ItemsTable
      Events:
        GetItemsApi:
          Type: Api
          Properties:
            RestApiId: !Ref MyApi
            Path: /items
            Method: get

Outputs:
  WebURL:
    Value: !GetAtt WebHostingBucket.WebsiteURL

  ApiURL:
    Value: !Sub "https://${MyApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/items"
```

#### コードの中でやっていること

- **S3 バケットを作成**  
  - 静的 Web サイトホスティングを有効化（`index.html` がトップページ）  
  - パブリックアクセスを許可（**全員がオブジェクトを閲覧可能**）  
  - バケットポリシーを設定し、バケット内のファイル（HTML など）を **誰でも取得できるように設定**  

- **DynamoDB テーブルを作成**  
  - テーブル名：`ItemsTable`  
  - 主キー：`id`（文字列型）  
  - 課金モード：`PAY_PER_REQUEST`（従量課金）  

- **API Gateway を作成**  
  - 名前：`MyApi`  
  - ステージ：`Prod`  
  - **CORS 対応**：全オリジンからの `GET` / `OPTIONS` を許可  

- **Lambda 関数を定義**  
  - コード配置ディレクトリ：`hello_world/`  
  - ハンドラー関数：`app.lambda_handler`  
  - **DynamoDB からデータを読み取る機能** を実装  
  - API Gateway の `/items` `GET` リクエストに応答  

- **出力値（Outputs）を設定**  
  - `WebURL`：S3 静的サイトの URL を出力  
  - `ApiURL`：API Gateway のエンドポイント URL（例：`/items`）を出力  

### hello\_world/app.py（Lambda関数）

```python
import os
import boto3
import json
from decimal import Decimal

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['TABLE_NAME'])

# Decimal を float/int に変換できるようにするヘルパー関数
class DecimalEncoder(json.JSONEncoder):
    def default(self, o):
        if isinstance(o, Decimal):
            # 整数化できる場合は int、それ以外は float
            return int(o) if o % 1 == 0 else float(o)
        return super(DecimalEncoder, self).default(o)

def lambda_handler(event, context):
    print("=== Lambda triggered ===")
    try:
        response = table.scan()
        print("Scan result:", response)
        items = response.get('Items', [])

        return {
            'statusCode': 200,
            'headers': {
                "Access-Control-Allow-Origin": "*",
                "Access-Control-Allow-Headers": "Content-Type",
                "Access-Control-Allow-Methods": "GET,OPTIONS",
                "Content-Type": "application/json"
            },
            'body': json.dumps(items, cls=DecimalEncoder)
        }
    except Exception as e:
        print("Error occurred:", str(e))
        return {
            'statusCode': 500,
            'headers': {
                "Access-Control-Allow-Origin": "*"
            },
            'body': json.dumps({'error': str(e)})
        }
```

#### コードの中でやっていること

- **DynamoDB のデータを全件取得し、JSON 形式でレスポンスとして返す**  
  - Lambda 関数内で `scan()` を使用して **DynamoDB の全データを取得**  
  - クライアントには **JSON 形式で整形されたレスポンス** を返す  

- **API Gateway 経由で呼び出され、CORS に対応**  
  - フロントエンド（S3）からのリクエストを許可するため、**CORS ヘッダーを付与**  
  - 例：`Access-Control-Allow-Origin: *` をレスポンスヘッダーに追加  

- **Decimal 型（DynamoDB 特有）を Python の int や float に変換してから返す**  
  - DynamoDB の `scan()` 結果には `Decimal` 型が含まれるため、  
    **Python の `int` / `float` に変換する処理を追加**（例：カスタム JSON エンコーダーの実装）  


### frontend/index.html（静的Web）

```html
<!-- API Gateway エンドポイントからデータ取得して表示 -->
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Item Viewer</title>
</head>
<body>
  <h1>Items from DynamoDB</h1>
  <ul id="items"></ul>

  <script>
    fetch('https://YOUR_API_ID.ap-northeast-1.amazonaws.com/Prod/items')
      .then(res => res.json())
      .then(jsonString => {
        const data = typeof jsonString === "string" ? JSON.parse(jsonString) : jsonString;

        const ul = document.getElementById('items');
        data.forEach(item => {
          const li = document.createElement('li');
          li.textContent = `${item.name}（${item.id}）: ¥${item.price}`;
          ul.appendChild(li);
        });
      })
      .catch(err => {
        console.error("Fetch error:", err);
      });
  </script>
</body>
</html>
```

:::note alert
※ `YOUR_API_ID` は実際のデプロイ後に表示される API URL に置き換えてください。
:::

#### コードの中でやっていること

- **API Gateway のエンドポイントにアクセス**  
  - `fetch('https://YOUR_API_ID.ap-northeast-1.amazonaws.com/Prod/items')` で、**Lambda 経由で DynamoDB のデータを取得**  

- **取得した JSON データを処理**  
  - レスポンスが **文字列形式かオブジェクト形式かをチェック** し、安全にパース  
  - 各アイテムをループで処理  

- **HTML の `<ul>` 要素にリスト表示**  
  - 取得したデータを元に `<li>` 要素を作成  
  - `<ul id="items">` に追加  
  - **表示形式の例**：`ホンダ（car1）: ¥150`  

- **エラー時の処理**  
  - `fetch()` に失敗した場合は、**コンソールにエラーメッセージを出力**  

## 実際に AWS 環境上にデプロイしてみた

SAM テンプレートをビルド

```bash
sam build
```

**問題なくビルドされていることが確認できました！**

![Screenshot 2025-06-08 at 13.18.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/58f866be-8e72-4bbb-ba4b-dc1e61036c48.png)

対話形式でデプロイ

```bash
sam deploy --guided
```

例）対話での入力内容：

```
Stack Name [sam-app]: sam-s3-api-example
AWS Region [ap-northeast-1]: ap-northeast-1
#Shows you resources changes to be deployed and require a 'Y' to initiate deploy
Confirm changes before deploy [y/N]: n
#SAM needs permission to be able to create roles to connect to the resources in your template
Allow SAM CLI IAM role creation [Y/n]: y
#Preserves the state of previously provisioned resources when an operation fails
Disable rollback [y/N]: n
GetItemsFunction has no authentication. Is this okay? [y/N]: y
Save arguments to configuration file [Y/n]: y
SAM configuration file [samconfig.toml]: 
SAM configuration environment [default]: 
```

表示された出力（Outputs）にある WebURL と ApiURL を控えておく

**ここが大事！！（マネコン上でも確認できますが...！）**

![Screenshot 2025-06-08 at 13.23.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/2f81209f-2182-477a-8a26-5dbb0c8f1d41.png)

`frontend/index.html` の API URL を修正してビルドディレクトリへコピー

HTML ファイルを S3 バケットへアップロード

```bash
aws s3 cp frontend/index.html s3://your-bucket-name/
```

**こんな感じS3バケットに正常にアップロードされていることが確認できました！**

![Screenshot 2025-06-08 at 13.24.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/c1b215e9-823e-4c89-ba47-030ccbacad17.png)

**DynamoDBの動作確認用のテストデータとして、以下のコマンドを実行してください！**

```sh
aws dynamodb batch-write-item --request-items '{
  "ItemsTable": [
    {
      "PutRequest": {
        "Item": {
          "id": { "S": "car1" },
          "name": { "S": "ホンダ" },
          "price": { "N": "150" }
        }
      }
    },
    {
      "PutRequest": {
        "Item": {
          "id": { "S": "car2" },
          "name": { "S": "トヨタ" },
          "price": { "N": "180" }
        }
      }
    },
    {
      "PutRequest": {
        "Item": {
          "id": { "S": "car3" },
          "name": { "S": "スズキ" },
          "price": { "N": "130" }
        }
      }
    }
  ]
}'
```

**問題なくテストデータが投入されたことが、マネジメントコンソール上でも確認できました！**

![Screenshot 2025-06-08 at 13.29.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/ec557f88-9deb-4244-8091-c3484dea9748.png)

**ブラウザで S3のオブジェクト URL にアクセスし、DynamoDB のデータが表示されることを確認**

![Screenshot 2025-06-08 at 13.31.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/14e8c4b9-4f84-47cd-ac2e-04525453e0b0.png)

## まとめ

ここまでお読みいただき、ありがとうございました！

:::note info
**API Gateway + Lambda + DynamoDB + S3 の組み合わせは SAM で簡単に構築でき感動しました...！**
:::

今回は GET 処理 に絞った構成でしたが、POST リクエスト や Cognito 認証 との連携など、さらに応用的な構成にも発展させることが可能なので挑戦してみたいです！

不要になったリソースは以下のコマンドで削除できますので、適宜削除しておくことをおすすめします。

```sh
aws cloudformation delete-stack --stack-name sam-app
```

今後も応用的な構成に対応できるよう、引き続きキャッチアップを進めていきたいと思います...！

## 参考文献

https://dev.classmethod.jp/articles/aws-hands-on-for-beginners-serverless2/

https://zenn.dev/mjxo/articles/21f0dd659a174d

https://qiita.com/i3no29/items/cca2ede577774bbb7274