0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【AWS】S3+Lambda+RDS+API GatewayでWebアプリを構築してみた

Posted at

はじめに

今回は、AWSの主要なサーバーレスサービスを組み合わせ、「ひとこと投稿Webアプリケーション」をゼロから構築するまでの手順を、備忘録も兼ねて紹介します。

構成としては、S3とCloudFrontでフロントエンドを配信し、API Gatewayを窓口として、LambdaからRDS(MySQL)にデータを読み書きする、というモダンなWebアプリケーションの基本形です。

すべての手順を書いてしまうとかなりの長さになってしますので、この記事では、各サービスの基本的な作成手順については、過去に私がまとめた以下の記事を参考として連携して進める形式を取っています。流れはこの記事で確認し、各ステップの詳細な設定方法は連携する他の記事を見ていただくとできるようになっています。

完成するアプリケーション

この記事を最後まで進めると、以下のような「ひとこと投稿Webアプリケーション」が完成します。

キャプチャ.PNG

【このアプリでできること】
・名前とメッセージを入力して、投稿ボタンを押すとデータが保存される。
・ページを読み込むと、これまでに投稿されたメッセージの一覧が表示される。

【参考にする記事】

今回の手順で連携する記事は以下3つです。
①:【AWS】Lambda から RDS に接続してデータベースを作成してみた
②:【AWS】S3+CloudFrontのOACでセキュアに静的ウェブサイトを公開する方法
③:【AWS×サーバーレス】S3 + API Gateway + Lambda で簡単なアプリ作ってみた

今回構築するアプリの構成

まずは、今回作成するアプリの全体構成図です。
ユーザーのブラウザから各AWSサービスがどのように連携して動作するのか、全体の流れをイメージしてみてください。

Untitled diagram _ Mermaid Chart-2025-07-26-075730.png

ユーザーは CloudFront のURLにアクセスし、Webページ(フロントエンド)を受け取ります。
CloudFrontは、S3に保管されたHTMLファイルを安全に配信します。
Webページ上で投稿や表示を行うと、ブラウザから API Gateway にリクエストが送信されます。
API Gateway は、リクエストの種類に応じて適切な Lambda 関数を呼び出します。
Lambda 関数は、VPC内で RDS データベースに接続し、データの書き込みや読み取りを行います。

作業のステップ

作業は大きく分けて以下の4つのステップで進めていきます。
Step 1: バックエンドの基盤準備(VPC, RDS, レイヤー)
Step 2: ビジネスロジックの実装(Lambda関数)
Step 3: APIの作成(API Gateway)
Step 4: フロントエンドの公開(S3, CloudFront)

それでは、早速始めていきましょう!

Step 1: バックエンドの基盤準備

最初に、アプリケーションの心臓部となるデータベースと、それが安全に動作するネットワーク環境を構築します。
詳細な手順は、以下の記事を参考に進めてください。
➡️ 【AWS】Lambda から RDS に接続してデータベースを作成してみた

VPCの作成

上記記事中の「VPC 作成」の手順に従い、プライベートサブネットを2つ持つVPCを作成します。

RDSの作成

上記記事中の「RDSの作成」の手順に従い、作成したVPC内にMySQLデータベースを作成します。
このとき、後で使う「エンドポイント」「マスターユーザー名」「マスターパスワード」を必ず控えておきましょう。

Lambdaレイヤーの作成

上記記事中の「Lambdaレイヤーの作成」の手順に従い、PythonからMySQLに接続するためのライブラリpymysqlを含むレイヤーを作成します。

このステップが完了すると、Lambda関数からデータベースにアクセスするための準備が整います。

Step 2: ビジネスロジックの実装(Lambda関数)

次に、API Gatewayから呼び出される「データ追加用」と「データ取得用」の2つのLambda関数を作成します。

1. データ追加用Lambdaの作成 (post-message-function)

投稿されたメッセージをデータベースに保存するための関数です。
この関数は、初回実行時に自動でデータベースとテーブルを作成する便利な機能も持っています。

関数の作成と設定

まずは基本的なLambda関数を作成します。その後、Step 1で準備したVPC接続設定、Lambdaレイヤー、そしてRDS接続情報(環境変数)を設定してください。

コードの記述

以下のPythonコードを貼り付け、「Deploy」します。

データ追加用Lambda (post-message-function) のコード

# データ追加用Lambda (post-message-function) の完全なコード
import os
import pymysql
import json
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    # API GatewayからのPOSTリクエストのbodyを取得
    try:
        body = json.loads(event['body'])
        user_name = body['name']
        message = body['message']
    except Exception as e:
        logger.error(f"入力データが不正です: {e}")
        return {'statusCode': 400, 'headers': {'Access-Control-Allow-Origin': '*'}, 'body': json.dumps({'error': 'Invalid input: ' + str(e)})}

    # RDS接続情報は環境変数から取得
    host = os.environ['DB_HOST']
    user = os.environ['DB_USER']
    password = os.environ['DB_PASS']
    db_name = "test_db" # 使用するデータベース名

    # 初回接続(DB作成のため)はDB名を指定せずに行う
    try:
        connection = pymysql.connect(host=host, user=user, password=password, charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor)
        logger.info("初期接続に成功しました。")
    except pymysql.MySQLError as e:
        logger.error(f"初期データベース接続エラー: {e}")
        return {'statusCode': 500, 'headers': {'Access-Control-Allow-Origin': '*'}, 'body': json.dumps({'error': 'Database connection failed: ' + str(e)})}

    try:
        with connection.cursor() as cursor:
            # 1. データベースが存在しない場合のみ作成する
            cursor.execute(f"CREATE DATABASE IF NOT EXISTS {db_name}")
            cursor.execute(f"USE {db_name}")
            logger.info(f"データベース '{db_name}' の準備が完了しました。")
            
            # 2. messages テーブルが存在しない場合のみ作成する
            create_table_sql = """
            CREATE TABLE IF NOT EXISTS messages (
                id INT AUTO_INCREMENT PRIMARY KEY,
                name VARCHAR(50),
                message VARCHAR(255),
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
            """
            cursor.execute(create_table_sql)
            logger.info("テーブル 'messages' の準備が完了しました。")

            # 3. 受け取ったデータをテーブルに挿入する
            insert_sql = "INSERT INTO messages (name, message) VALUES (%s, %s)"
            cursor.execute(insert_sql, (user_name, message))
        
        # 変更を確定
        connection.commit()
        
        logger.info(f"メッセージの追加に成功しました: Name='{user_name}', Message='{message}'")
        return {
            'statusCode': 200,
            'headers': {'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json'},
            'body': json.dumps({'message': 'Message added successfully!'})
        }
    except Exception as e:
        logger.error(f"クエリ実行エラー: {e}")
        return {'statusCode': 500, 'headers': {'Access-Control-Allow-Origin': '*'}, 'body': json.dumps({'error': str(e)})}
    finally:
        # 接続を閉じる
        if 'connection' in locals() and connection.open:
            connection.close()

2. データ取得用Lambdaの作成 (get-messages-function)

データベースに保存されているメッセージの一覧を取得するための関数です。

関数の作成と設定

データ追加用と同様に、基本的な関数を作成し、VPC接続、レイヤー、環境変数を設定します。

コードの記述

以下のコードを貼り付け、「Deploy」します。
データ取得用Lambda (get-messages-function) のコード

import os
import pymysql
import json
import logging

# ロガーをセットアップして、CloudWatch Logsでデバッグしやすくします
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# 環境変数からRDS接続情報を取得
# Lambdaの環境変数に DB_HOST, DB_USER, DB_PASS を設定してください
try:
    db_host = os.environ['DB_HOST']
    db_user = os.environ['DB_USER']
    db_pass = os.environ['DB_PASS']
    db_name = "test_db"  # 記事に合わせて固定
except KeyError as e:
    logger.error(f"必要な環境変数が設定されていません: {e}")
    # Lambda実行環境の設定ミスなので、早期に失敗させます
    raise e

def lambda_handler(event, context):
    """
    RDSから投稿メッセージ一覧を取得する。
    API GatewayからのGETリクエストでトリガーされることを想定。
    """
    logger.info("データ取得リクエストを受信しました。")
    
    # Webページ(JavaScript)からのアクセスを許可するためのCORSヘッダー
    headers = {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, OPTIONS', # OPTIONSはCORSのPreflightリクエスト用
        'Access-Control-Allow-Headers': 'Content-Type'
    }

    # データベースへの接続
    try:
        connection = pymysql.connect(
            host=db_host,
            user=db_user,
            password=db_pass,
            database=db_name,
            charset='utf8mb4',
            cursorclass=pymysql.cursors.DictCursor, # 結果を辞書形式で取得
            connect_timeout=5
        )
        logger.info("データベースへの接続に成功しました。")
    except pymysql.MySQLError as e:
        logger.error(f"データベース接続エラー: {e}")
        return {
            'statusCode': 500,
            'headers': headers,
            'body': json.dumps({'error': 'Database connection failed.'})
        }

    try:
        with connection.cursor() as cursor:
            # 新しい投稿が上に表示されるよう、作成日時(created_at)の降順でデータを取得
            sql = "SELECT `id`, `name`, `message`, `created_at` FROM `messages` ORDER BY `created_at` DESC"
            cursor.execute(sql)
            results = cursor.fetchall()
            logger.info(f"{len(results)}件のデータを取得しました。")
        
        # 成功レスポンスを返す
        # default=str は、datetimeオブジェクトをJSONシリアライズ可能な文字列に変換するために必要
        return {
            'statusCode': 200,
            'headers': headers,
            'body': json.dumps({
                'message': 'Successfully retrieved messages.',
                'data': results
            }, ensure_ascii=False, default=str)
        }
    
    except pymysql.MySQLError as e:
        logger.error(f"クエリ実行エラー: {e}")
        # 例: テーブルが存在しない場合など
        if e.args[0] == 1146: # Error Code 1146: Table '...' doesn't exist
            error_message = f"テーブル 'messages' が存在しません。先にデータを投稿してください。"
        else:
            error_message = "Failed to execute query."
        return {
            'statusCode': 500,
            'headers': headers,
            'body': json.dumps({'error': error_message})
        }

    finally:
        # 接続が確立されていれば必ずクローズする
        if 'connection' in locals() and connection.open:
            connection.close()
            logger.info("データベース接続をクローズしました。")

ハマりどころ注意:Lambdaのテスト
Lambdaコンソールから関数をテストする際、API Gatewayからのリクエストをシミュレートした正しいテストイベントを使用する必要があります。
event['body'] が見つからないというエラーが出た場合、テストイベントのJSONがAPI Gatewayプロキシ統合の形式になっていないことが原因です。空のテンプレートではなく、API Gateway AWS Proxy テンプレートを使いましょう。

Step 3: APIの作成(API Gateway)

フロントエンド(ブラウザ)とバックエンド(Lambda)を繋ぐためのAPIを作成します。
詳細な手順は、以下の記事が参考になります。
➡️ 【AWS×サーバーレス】S3 + API Gateway + Lambda で簡単なアプリ作ってみた

REST APIの作成

API Gatewayのコンソールから、新しいREST APIを作成します。

リソースとメソッドの作成

  1. /messages というリソースを作成します。
  2. このリソースに、POSTメソッドGETメソッドを追加します。
  3. 各メソッドとLambda関数を接続する際、「Lambdaプロキシ統合」のチェックボックスを必ずオンにしてください。
    • POST → post-message-function に接続
    • GET → get-messages-function に接続
  4. 最後に、/messages リソースを選択した状態で「アクション」から「CORSを有効にする」を実行します。

デプロイ

最後に「APIをデプロイ」を実行し、ステージを作成します。発行される**「呼び出しURL」**は次のステップで使うので、必ずコピーしておいてください。

ハマりどころ注意:一覧が表示されない!
データ投稿は成功するのに、一覧が表示されない場合、GETメソッドの「Lambdaプロキシ統合」が有効になっていないことが原因のほとんどです。API Gatewayはメソッドごとに設定が必要なので、POSTだけでなくGETの設定も忘れずに確認しましょう。また、変更後は必ず再デプロイが必要です。

Step 4: フロントエンドの公開

最後に、ユーザーが操作するWebページを作成し、インターネットに公開します。
詳細な手順は、こちらの記事を参考にしてください。
➡️ 【AWS】S3+CloudFrontのOACでセキュアに静的ウェブサイトを公開する方法

HTMLファイルの作成

以下のHTMLコードを、index.htmlなどの名前で保存します。
コード内の const apiUrl = '...' の部分を、Step 3でコピーしたAPIの呼び出しURLに忘れずに書き換えてください。

<!-- ひとこと投稿アプリのHTMLコード -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <title>ひとこと投稿アプリ</title>
    <script>
        // Step3で取得したAPIのURLに書き換える
        const apiUrl = 'API URL';

        // ページ読み込み時に投稿一覧を取得
        window.onload = function() {
            fetchMessages();
        };

        // 投稿一覧を取得して表示する関数
        function fetchMessages() {
            fetch(apiUrl)
                .then(response => response.json())
                .then(result => {
                    const messagesDiv = document.getElementById('messages');
                    messagesDiv.innerHTML = ''; // 一旦クリア
                    if (result.data) {
                        result.data.forEach(item => {
                            const p = document.createElement('p');
                            p.textContent = `[${item.name}]: ${item.message}`;
                            messagesDiv.appendChild(p);
                        });
                    }
                })
                .catch(error => console.error('Error:', error));
        }

        // メッセージを投稿する関数
        function postMessage() {
            const name = document.getElementById('name').value;
            const message = document.getElementById('message').value;
            
            fetch(apiUrl, {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({ name: name, message: message })
            })
            .then(response => response.json())
            .then(result => {
                console.log(result.message);
                // 投稿後、入力欄をクリアして一覧を再読み込み
                document.getElementById('name').value = '';
                document.getElementById('message').value = '';
                fetchMessages(); 
            })
            .catch(error => console.error('Error:', error));
        }
    </script>
</head>
<body>
    <h1>ひとこと投稿アプリ</h1>
    <div>
        <input type="text" id="name" placeholder="名前">
        <input type="text" id="message" placeholder="メッセージ">
        <button onclick="postMessage()">投稿</button>
    </div>
    <hr>
    <h2>投稿一覧</h2>
    <div id="messages"></div>
</body>
</html>

S3とCloudFrontでの公開

参考記事の手順に従い、S3バケットを作成し、作成したHTMLファイルをアップロードします。
CloudFrontディストリビューションを作成し、S3バケットをOAC設定でオリジンにします。

ハマりどころ注意:文字化け
CloudFront経由でアクセスしたら日本語が文字化けした場合、S3オブジェクトのメタデータが原因です。
S3コンソールで、アップロードしたHTMLファイルのメタデータを編集し、「Content-Type」の値を text/html から text/html;charset=utf-8 に変更してください。変更後は、CloudFrontのキャッシュを削除(Invalidationを作成)するのを忘れずに!

動作確認

CloudFrontのディストリビューションドメイン名にアクセスし、アプリが正常に動作すれば完成です!

さいごに

お疲れ様でした!
これで、完全にサーバーレスなWebアプリケーションが完成しました。各サービスが専門家のように役割を分担し、連携して一つのサービスを作り上げる面白さを感じていただけたなら幸いです。
今回の構成は、多くのWebアプリケーションの基礎となるものです。ここから認証機能(Cognito)を追加したり、データベースをDynamoDBに変えてみたりと、様々な応用が考えられます。ぜひ、皆さんも自分だけのアプリ開発に挑戦してみてください。

この記事が参考になりましたらぜひ「いいね」「フォロー」など励みになるのでよろしくお願いします!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?