サイトアイコン 協栄情報ブログ

AWS SAM入門 Lambdaでマイクロサービス型ToDoアプリハンズオン

1.はじめに

1.1.背景

AWS SAMとLambdaを使用して、マイクロサービスパターンの考え方を取り入れたサーバーレスToDoアプリを構築し、その理解を深めることを目的とします。

今回作成したGitHubへのリンク

1.2.マイクロサービスとは?

マイクロサービスとは、一つの大きなアプリケーションを小さなサービスの集合として構成するアーキテクチャパターンです。
このハンズオンでは、ToDoアプリの各機能(一覧取得、作成、更新など)をそれぞれ独立したLambda関数として実装し、マイクロサービスの考え方を適用していきます。

1.3.AWS SAM(Serverless Application Model)とは?

AWS上でサーバーレスアプリケーションを構築・デプロイするためのオープンソースフレームワークです。
CloudFormationを拡張したもので、YAML形式のテンプレートを使って、Lambda関数、API Gateway、DynamoDBテーブルといったリソースを簡潔に定義することが可能でサーバーレス開発を容易にします。

1.4.本ハンズオンの構成

2.ハンズオン

2.1.前提

2.1.1.実行環境

項目 設定
環境 AWS CloudShell
リージョン バージニア北部(us-east-1)
AWS CLI 2.27.8
Python 3.9.21

2.1.2.ToDoアプリのファイル構成

/
├── template.yaml                       # SAM定義ファイル
├── dispatch/                           # ディスパッチLambda
│   └── app.py
├── get-todos/                          # 一覧取得Lambda
│   └── app.py
├── create-todo/                        # 作成Lambda
│   └── app.py
├── get-todo-by-id/                     # ID指定取得Lambda
│   └── app.py
├── update-todo/                        # 更新Lambda
│   └── app.py
├── delete-todo/                        # 削除Lambda
│   └── app.py
└── layer/                              # 共通レイヤー
    ├── requirements.txt                # Pythonパッケージ定義
    └── todo_common/
      └── __init__.py             # 共通ユーティリティ

2.2.プロジェクト構造のセットアップ

# メインフォルダ作成
mkdir todo-app && cd todo-app

# 各Lambda関数用ディレクトリ作成
mkdir -p dispatch
mkdir -p get-todos
mkdir -p create-todo
mkdir -p get-todo-by-id
mkdir -p update-todo
mkdir -p delete-todo
mkdir -p layer/todo_common

# テンプレートファイル作成
touch template.yaml
touch dispatch/app.py
touch get-todos/app.py
touch create-todo/app.py
touch get-todo-by-id/app.py
touch update-todo/app.py
touch delete-todo/app.py
touch layer/todo_common/__init__.py
touch layer/requirements.txt

# ディレクトリ構造確認
find . -type f | sort

2.3.SAMテンプレート作成

cat > template.yaml << EOF
# CloudFormationのテンプレート形式のバージョンを指定
AWSTemplateFormatVersion: '2010-09-09'

# SAM(Serverless Application Model)変換を有効化
# これによりSAM特有の簡略化された構文が使えるようになる
Transform: AWS::Serverless-2016-10-31

# テンプレートの説明
Description: Python ToDo API

# グローバル設定 - すべてのLambda関数に適用される共通設定
Globals:
  # すべてのLambda関数に適用される設定
  Function:
    # 関数のタイムアウト時間(秒単位)- 10秒でタイムアウトする
    Timeout: 10
    # 使用するランタイム - Python 3.9を使用
    Runtime: python3.9
    # CPU/メモリアーキテクチャ - x86_64を使用
    Architectures:
      - x86_64
    # すべての関数に共通の環境変数
    Environment:
      Variables:
        # DynamoDBテーブル名を環境変数として設定
        # !Refを使用して別のリソース(TodosTable)を参照
        TABLE_NAME: !Ref TodosTable

# テンプレート内で定義されるAWSリソース
Resources:
  # 共通レイヤー - 複数のLambda関数で共有されるコード
  CommonLayer:
    # AWS::Serverless::LayerVersionはSAMの拡張リソースタイプ
    Type: AWS::Serverless::LayerVersion
    Properties:
      # レイヤーの名前
      LayerName: todo-common-layer
      # レイヤーの説明
      Description: Common dependencies for Todo functions
      # レイヤーのコードの場所(ローカルパス)
      ContentUri: layer/
      # このレイヤーと互換性のあるランタイム
      CompatibleRuntimes:
        - python3.9
    # レイヤーのビルド方法に関するメタデータ
    Metadata:
      BuildMethod: python3.9

  # API Gateway - すべてのHTTPリクエストのエントリポイント
  TodoApi:
    # AWS::Serverless::ApiはSAMの拡張リソースタイプ
    Type: AWS::Serverless::Api
    Properties:
      # APIのデプロイステージ名
      StageName: Prod
      # CORS(クロスオリジンリソース共有)の設定
      Cors:
        # 許可するオリジン - *は全てのドメインからのアクセスを許可
        AllowOrigin: "'*'"
        # 許可するHTTPヘッダー
        AllowHeaders: "'Content-Type,Authorization'"
        # 許可するHTTPメソッド
        AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'"

  # ディスパッチ(管理)用Lambda関数 - APIのルートパスを処理
  DispatchFunction:
    # AWS::Serverless::FunctionはSAMの拡張リソースタイプ
    Type: AWS::Serverless::Function
    Properties:
      # 関数のコードの場所(ローカルパス)
      CodeUri: dispatch/
      # 関数のハンドラー(ファイル名.関数名)
      Handler: app.lambda_handler
      # 関数が使用するレイヤー
      Layers:
        # CommonLayerを参照
        - !Ref CommonLayer
      # 関数のトリガーとなるイベント
      Events:
        # APIルートパスのイベント
        ApiRoot:
          # APIイベントタイプ
          Type: Api
          Properties:
            # 使用するAPI Gateway
            RestApiId: !Ref TodoApi
            # APIのパス
            Path: /
            # 対象のHTTPメソッド(ANYは全メソッド対応)
            Method: ANY

  # ToDo一覧取得用Lambda関数
  GetTodosFunction:
    Type: AWS::Serverless::Function
    Properties:
      # 関数のコードの場所
      CodeUri: get-todos/
      # 関数のハンドラー
      Handler: app.lambda_handler
      # 関数が使用するレイヤー
      Layers:
        - !Ref CommonLayer
      # 関数のトリガーとなるイベント
      Events:
        # API /todos GETイベント
        ApiEvent:
          Type: Api
          Properties:
            RestApiId: !Ref TodoApi
            # /todosパスを処理
            Path: /todos
            # GETメソッドのみを処理
            Method: GET
      # 関数に付与するIAMポリシー
      Policies:
        # DynamoDBの読み取り専用ポリシー
        - DynamoDBReadPolicy:
            # 対象のテーブル名
            TableName: !Ref TodosTable

  # ToDo作成用Lambda関数
  CreateTodoFunction:
    Type: AWS::Serverless::Function
    Properties:
      # 関数のコードの場所
      CodeUri: create-todo/
      # 関数のハンドラー
      Handler: app.lambda_handler
      # 関数が使用するレイヤー
      Layers:
        - !Ref CommonLayer
      # 関数のトリガーとなるイベント
      Events:
        # API /todos POSTイベント
        ApiEvent:
          Type: Api
          Properties:
            RestApiId: !Ref TodoApi
            # /todosパスを処理
            Path: /todos
            # POSTメソッドのみを処理
            Method: POST
      # 関数に付与するIAMポリシー
      Policies:
        # DynamoDBの書き込み専用ポリシー
        - DynamoDBWritePolicy:
            # 対象のテーブル名
            TableName: !Ref TodosTable

  # 個別ToDo取得用Lambda関数
  GetTodoByIdFunction:
    Type: AWS::Serverless::Function
    Properties:
      # 関数のコードの場所
      CodeUri: get-todo-by-id/
      # 関数のハンドラー
      Handler: app.lambda_handler
      # 関数が使用するレイヤー
      Layers:
        - !Ref CommonLayer
      # 関数のトリガーとなるイベント
      Events:
        # API /todos/{id} GETイベント
        ApiEvent:
          Type: Api
          Properties:
            RestApiId: !Ref TodoApi
            # /todos/{id}パスを処理 - {id}は動的パラメータ
            Path: /todos/{id}
            # GETメソッドのみを処理
            Method: GET
      # 関数に付与するIAMポリシー
      Policies:
        # DynamoDBの読み取り専用ポリシー
        - DynamoDBReadPolicy:
            # 対象のテーブル名
            TableName: !Ref TodosTable

  # ToDo更新用Lambda関数
  UpdateTodoFunction:
    Type: AWS::Serverless::Function
    Properties:
      # 関数のコードの場所
      CodeUri: update-todo/
      # 関数のハンドラー
      Handler: app.lambda_handler
      # 関数が使用するレイヤー
      Layers:
        - !Ref CommonLayer
      # 関数のトリガーとなるイベント
      Events:
        # API /todos/{id} PUTイベント
        ApiEvent:
          Type: Api
          Properties:
            RestApiId: !Ref TodoApi
            # /todos/{id}パスを処理
            Path: /todos/{id}
            # PUTメソッドのみを処理
            Method: PUT
      # 関数に付与するIAMポリシー
      Policies:
        # DynamoDBのCRUD(作成・読取・更新・削除)ポリシー
        - DynamoDBCrudPolicy:
            # 対象のテーブル名
            TableName: !Ref TodosTable

  # ToDo削除用Lambda関数
  DeleteTodoFunction:
    Type: AWS::Serverless::Function
    Properties:
      # 関数のコードの場所
      CodeUri: delete-todo/
      # 関数のハンドラー
      Handler: app.lambda_handler
      # 関数が使用するレイヤー
      Layers:
        - !Ref CommonLayer
      # 関数のトリガーとなるイベント
      Events:
        # API /todos/{id} DELETEイベント
        ApiEvent:
          Type: Api
          Properties:
            RestApiId: !Ref TodoApi
            # /todos/{id}パスを処理
            Path: /todos/{id}
            # DELETEメソッドのみを処理
            Method: DELETE
      # 関数に付与するIAMポリシー
      Policies:
        # DynamoDBのCRUDポリシー
        - DynamoDBCrudPolicy:
            # 対象のテーブル名
            TableName: !Ref TodosTable

  # DynamoDBテーブル - ToDoデータの永続化に使用
  TodosTable:
    # AWS::DynamoDB::TableはCloudFormationの標準リソースタイプ
    Type: AWS::DynamoDB::Table
    Properties:
      # テーブルの名前
      TableName: PythonTodos
      # 課金モード - PAY_PER_REQUESTはオンデマンド課金
      BillingMode: PAY_PER_REQUEST
      # テーブルの属性(カラム)定義
      AttributeDefinitions:
        # id属性を定義
        - AttributeName: id
          # S = 文字列型
          AttributeType: S
      # テーブルのキースキーマ
      KeySchema:
        # idをハッシュキー(プライマリキー)として使用
        - AttributeName: id
          KeyType: HASH

# テンプレートの出力値
Outputs:
  # API URLを出力値として定義
  ApiUrl:
    # 出力値の説明
    Description: URL of the API endpoint
    # APIのURL - !Subを使用して文字列を組み立て
    # ${TodoApi}はAPI IDに、${AWS::Region}は現在のリージョンに置き換えられる
    Value: !Sub "https://${TodoApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
EOF

2.4.共通レイヤの作成

2.4.1.共通レイヤとは?

■ 今回は以下の要素を共通レイヤとする

項番 項目名 詳細
1 共通のユーティリティ関数 複数の場所で使われる汎用的な機能
2 データベース接続のセットアップ DynamoDBなどへの接続初期化
3 レスポンス形式の標準化 API応答の一貫したフォーマット
4 ビジネスロジックの共通部分 アプリケーション固有の共有機能

2.4.2.ファイルの作成

# 共通ユーティリティの作成
cat > layer/todo_common/__init__.py << EOF
import os
import json
import boto3
import decimal
import uuid
from datetime import datetime

# デシマル型をJSONに変換するためのエンコーダ
class DecimalEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, decimal.Decimal):
            return float(obj)
        return super(DecimalEncoder, self).default(obj)

# DynamoDB接続
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ.get('TABLE_NAME', 'PythonTodos'))

# レスポンス作成ヘルパー
def create_response(status_code, body=None):
    response = {
        'statusCode': status_code,
        'headers': {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
            'Access-Control-Allow-Headers': 'Content-Type,Authorization'
        }
    }

    if body is not None:
        response['body'] = json.dumps(body, cls=DecimalEncoder)

    return response

# 新しいToDo ID生成
def generate_todo_id():
    return str(uuid.uuid4())

# 現在のタイムスタンプ取得
def get_timestamp():
    return datetime.now().isoformat()
EOF

2.5.各Lambda関数の実装

2.5.1.ディスパッチ(管理用)関数

cat > dispatch/app.py << EOF
import json

def lambda_handler(event, context):
    return {
        'statusCode': 200,
        'headers': {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'
        },
        'body': json.dumps({
            'message': 'Welcome to ToDo API',
            'endpoints': {
                'GET /todos': 'List all todos',
                'POST /todos': 'Create a new todo',
                'GET /todos/{id}': 'Get a specific todo',
                'PUT /todos/{id}': 'Update a todo',
                'DELETE /todos/{id}': 'Delete a todo'
            }
        })
    }
EOF

2.5.2.ToDo一覧取得関数

cat > get-todos/app.py << EOF
from todo_common import table, create_response

def lambda_handler(event, context):
    try:
        # DynamoDBからすべてのToDoを取得
        response = table.scan()
        items = response.get('Items', [])

        return create_response(200, {'todos': items})
    except Exception as e:
        print(f"Error: {str(e)}")
        return create_response(500, {'error': 'Error fetching todos'})
EOF

2.5.3.ToDo作成関数

cat > create-todo/app.py << EOF
import json
from todo_common import table, create_response, generate_todo_id, get_timestamp

def lambda_handler(event, context):
    try:
        # リクエストボディを取得
        body = json.loads(event['body']) if event.get('body') else {}

        # タイトルがなければエラー
        if 'title' not in body:
            return create_response(400, {'error': 'Title is required'})

        # 新しいToDoアイテムを作成
        todo_id = generate_todo_id()
        new_todo = {
            'id': todo_id,
            'title': body['title'],
            'completed': False,
            'createdAt': get_timestamp()
        }

        # DynamoDBに保存
        table.put_item(Item=new_todo)

        return create_response(201, new_todo)
    except Exception as e:
        print(f"Error: {str(e)}")
        return create_response(500, {'error': 'Error creating todo'})
EOF

2.5.4.ToDo個別取得関数

cat > get-todo-by-id/app.py << EOF
from todo_common import table, create_response

def lambda_handler(event, context):
    try:
        # パスパラメータからIDを取得
        todo_id = event['pathParameters']['id']

        # DynamoDBから指定されたIDのToDoを取得
        response = table.get_item(Key={'id': todo_id})

        # アイテムが見つからない場合は404エラー
        if 'Item' not in response:
            return create_response(404, {'error': 'Todo not found'})

        return create_response(200, response['Item'])
    except Exception as e:
        print(f"Error: {str(e)}")
        return create_response(500, {'error': 'Error fetching todo'})
EOF

2.5.5.ToDo更新関数

-ToDoの更新をおこなう関数

cat > update-todo/app.py << EOF
import json
from todo_common import table, create_response, get_timestamp

def lambda_handler(event, context):
    try:
        # パスパラメータからIDを取得
        todo_id = event['pathParameters']['id']

        # リクエストボディを取得
        body = json.loads(event['body']) if event.get('body') else {}

        # 既存のToDoを確認
        response = table.get_item(Key={'id': todo_id})
        if 'Item' not in response:
            return create_response(404, {'error': 'Todo not found'})

        # 更新式の構築
        update_expression = "SET "
        expression_values = {}

        if 'title' in body:
            update_expression += "title = :title, "
            expression_values[':title'] = body['title']

        if 'completed' in body:
            update_expression += "completed = :completed, "
            expression_values[':completed'] = body['completed']

        update_expression += "updatedAt = :updatedAt"
        expression_values[':updatedAt'] = get_timestamp()

        # DynamoDBを更新
        response = table.update_item(
            Key={'id': todo_id},
            UpdateExpression=update_expression,
            ExpressionAttributeValues=expression_values,
            ReturnValues="ALL_NEW"
        )

        return create_response(200, response['Attributes'])
    except Exception as e:
        print(f"Error: {str(e)}")
        return create_response(500, {'error': 'Error updating todo'})
EOF

2.5.6.ToDo削除関数

cat > delete-todo/app.py << EOF
from todo_common import table, create_response

def lambda_handler(event, context):
    try:
        # パスパラメータからIDを取得
        todo_id = event['pathParameters']['id']

        # DynamoDBから削除
        table.delete_item(Key={'id': todo_id})

        return create_response(204)
    except Exception as e:
        print(f"Error: {str(e)}")
        return create_response(500, {'error': 'Error deleting todo'})
EOF

2.6.ビルドとデプロイ

2.6.1.SAMによるビルド

# samコマンドによるビルド
sam build

2.6.2.SAMによるデプロイ

■ 質問への回答例

項番 項目 項目(日本語) 回答内容
1 Stack Name [sam-app] スタック名 todo-app
2 AWS Region [us-east-1] リージョン us-east-1
3 Confirm changes before deploy [y/N] デプロイを実行する前に、変更内容を確認しますか? y
4 Allow SAM CLI IAM role creation [Y/n] SAMがIAMロール作成を許可するか? y
5 Disable rollback [y/N] ロールバックを無効にするか? n
6 ${関数名} Function has no authentication. Is this okay? [y/N] (※各関数で聞かれる)認証が設定されていませんが、これでよいですか? y
7 Save arguments to configuration file [Y/n] 設定ファイルに引数を保存しますか? y
8 SAM configuration file [samconfig.toml] SAM設定ファイルの名前 ENTER(デフォルトの「samconfig.toml」)
9 SAM configuration environment [default] ファイル内で異なる環境(開発環境、テスト環境、本番環境など)の設定 ENTER(デフォルトの「default」)
# samコマンドによるデプロイ
sam deploy --guided
2.6.2.1.デプロイして発生したエラー
Managed S3 bucket: aws-sam-cli-managed-default-samclisourcebucket-XXXXXXXXXXXX
マネージドS3バケット: aws-sam-cli-managed-default-samclisourcebucket-XXXXXXXXXXXX

A different default S3 bucket can be set in samconfig.toml and auto resolution of buckets turned off by setting resolve_s3=False
samconfig.tomlで別のデフォルトS3バケットを設定できます。また、resolve_s3=Falseと設定することで、バケットの自動解決をオフにできます。
(中略)
S3 Bucket does not exist.
S3バケットが存在しません。

参考: Facing some issues with AWS EventBridge Application

2.6.2.2.解決に向けた実行コマンド
# マネージドS3バケット作成コマンド実施
aws s3 mb s3://aws-sam-cli-managed-default-samclisourcebucket-XXXXXXXXXXXX --region us-east-1

# 再度samコマンドによるデプロイ
sam deploy --guided

2.7.挙動の確認

2.7.1.URL取得

# デプロイされたURLを設定 (実際のURLに置き換えてください)
export API_URL=https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/Prod

# または以下のコマンドでURLを取得
export API_URL="https://$(aws cloudformation list-stack-resources --stack-name todo-app --query "StackResourceSummaries[?ResourceType=='AWS::ApiGateway::RestApi'].PhysicalResourceId" --output text).execute-api.us-east-1.amazonaws.com/Prod/"

echo $API_URL

2.7.2.一連の挙動確認

2.7.2.1.ToDoの作成
# ディスパッチ関数(ルート)をテスト
curl $API_URL

# 新しいToDoを作成し、そのIDを変数に保存
TODO_ID=$(curl -X POST $API_URL/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "AWS SAMを使いこなす"}' | jq -r '.id')

# 参考レスポンス
{
  "completed": false,
  "createdAt": "2025-05-13T13:34:35.480359",
  "id": "00653957-229a-49e8-99e0-6dc8baf11cac",
  "title": "AWS SAMを使いこなす"
}
2.7.2.2.ToDoの更新
# ToDoを完了済みに更新
curl -X PUT $API_URL/todos/$TODO_ID \
  -H "Content-Type: application/json" \
  -d '{"completed": true}' | jq

# 参考レスポンス
{
  "completed": true,
  "createdAt": "2025-05-13T13:34:35.480359",
  "id": "00653957-229a-49e8-99e0-6dc8baf11cac",
  "updatedAt": "2025-05-13T13:36:55.682951",
  "title": "AWS SAMを使いこなす"
}
2.7.2.3.ToDo一覧取得
# ToDo一覧取得
curl $API_URL/todos | jq

# 参考レスポンス
{
  "todos": [
    {
      "completed": true,
      "createdAt": "2025-05-13T13:34:35.480359",
      "id": "00653957-229a-49e8-99e0-6dc8baf11cac",
      "updatedAt": "2025-05-13T13:36:55.682951",
      "title": "AWS SAMを使いこなす"
    }
  ]
}
2.7.2.4.ToDo削除
# ToDo削除
curl -X DELETE $API_URL/todos/$TODO_ID

# 参考レスポンス
なし

# ToDo一覧取得
curl $API_URL/todos | jq

# 参考レスポンス
{
  "todos": []
}

2.8.クリーンアップ

項番 項目 項目(日本語) 回答内容
1 Are you sure you want to delete the stack todo-app in the region us-east-1 ? [y/N] us-east-1リージョンのtodo-appスタックを削除してもよいか? y
2 Are you sure you want to delete the folder todo-app in S3 which contains the artifacts? [y/N] S3のtodo-appフォルダを削除してもよいか? y
# 削除コマンド
sam delete

3.おわりに

3.1.得られた知見

3.2.今後の課題

モバイルバージョンを終了