AWS S3, Lambda, EFS でお財布に優しい個人開発はじめました

## 開発の動機
個人的に欲しかったサービスを年末年始の時間を利用して開発してみようと考えていたのがきっかけで、なぜこのアーキテクチャにしたかというと、AWSのサービスを使いながら運用コストを抑えた開発をしたいという思いが、まず一番にありました。それと前から気になっていたサーバレスのLambdaを採用したかったのと、Frontの技術を個人開発しながら身につけたいという思いで、比較的コスパの良いアーキテクチャと思ったS3, Lambda, EFSという構成でスモールスタートしてみようと考えました。

## どれぐらいお財布に優しくなったのか
直近の実績ベースで1日の運用コストは約0.5USDでした。1ヶ月30.5日で考えると15.25USD/Mで、仮に1USD=150円で計算すると、1ヶ月2,287.5円程度になります。ちなみに無料利用枠は使っていないので、アカウント今から作る人ならもっとお安くなるのではと思っています。個人的な比較対象として同じAWSのサービスでECSとRDBの構成だとこの倍の請求だったので、個人的には継続課金しても良いと思えるレベルまでコスト抑えられたと思います。

## アーキテクチャ
早速本題に入っていきます。※全てAWSのサービスを使用していますので、AWSやAmazonは省略して表記しています。
- DNS：Route 53
- CDN：CloudFront [※1](#cdnとは)
- Front：S3（React）
- Database：EFS（SQLite）
- Backend：Lambda（Python）

### アーキテクチャ概略図
```mermaid
graph TB
  subgraph AWS["AWS"]
  style AWS fill:none,color:#345,stroke:#345

  subgraph Region["Region"]
  style Region fill:none,color:#59d,stroke:#59d,stroke-dasharray:3

    subgraph VPC["VPC"]
    style VPC fill:none,color:#0a0,stroke:#0a0

      subgraph PrivateSubnet["Private Subnet"]
      style PrivateSubnet fill:#E7F2E8,color:#092,stroke:none
        Lambda["Lambda<br/>(Python)"]
        style Lambda fill:#e83,color:#fff,stroke:none

        MountTarget["EFS Mount Target"]
        style MountTarget fill:#e83,color:#fff,stroke:none
      end

      EFS["EFS<br/>(SQLite)"]
      style EFS fill:#e83,color:#fff,stroke:none
    end
  end

  Route53["Route53"]
  style Route53 fill:#84d,color:#fff,stroke:none

  APIG["API Gateway"]
  style APIG fill:#e83,color:#fff,stroke:none

  S3["S3"]
  style S3 fill:#493,color:#fff,stroke:#fff

  CloudFront["CloudFront<br/>(cache)"]
  style CloudFront fill:#84d,color:#fff,stroke:none
end

ExternalUser["User"]
style ExternalUser fill:#aaa,color:#fff,stroke:#fff

ExternalUser -- "hoge.net / Resolves to IP" --> Route53
ExternalUser -- "Request to / Response from APIG" --> APIG
APIG <--> Lambda
ExternalUser <--> CloudFront
CloudFront <--> S3
Lambda -- "Accesses" --> MountTarget
MountTarget -- "Mounted on" --> EFS
```

### CDNとは
ユーザーにより近いリージョン（例えばユーザーが日本国内なら東京リージョン）にコンテンツをキャッシュして保存し、エンドユーザーのブラウザやアプリケーションにコンテンツをより速く配信するための分散型のサーバーネットワークのことです。
[こちら](https://www.kagoya.jp/howto/it-glossary/web/cdn/)を参考にしていただくと、イラスト付きで理解しやすいと思います。

そんな CDN（Amazon CloudFront） は、セキュリティ面でも心強く、AWSのセキュリティ機能と統合して、データの暗号化、DDoS攻撃の軽減、アクセス制御など、高度なセキュリティ対策を提供してくれているので安心して使えそうなので、採用しました。

## 実装の流れ
- [S3で簡単な静的コンテンツの表示](#s3で簡単な静的コンテンツの表示)
- [独自ドメイン取得してHTTPSでS3へアクセスする](#独自ドメイン取得してhttpsでs3へアクセスする)
- [VPC作成](#vpc作成)
- [EFSの作成](#efsの作成)
- [Lambdaの設定](#lambdaの設定)
- [LambdaとEFSの通信設定](#lambdaとefsの通信設定)
- [Lambda経由でDB作成](#lambda経由でdb作成)
- [バックエンド処理](#バックエンド処理)
- [API Gatewayの設定](#api-gatewayの設定)
- [API Gatewayへのリクエストを追加](#api-gatewayへのリクエストを追加)

最初はこんな感じで全体像を掴むため、上記のMermaidで書いた`アーキテクチャ概略図`を手書きで書いてみて、どこまで実装できたのか確認しながら進めました。

![スクリーンショット 2024-02-12 5.01.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316171/ef624bbc-92b8-bd43-5476-f2ef6caa7f27.png)

## S3で簡単な静的コンテンツの表示
まずはS3へHTMLで実装した簡単なコードをアップロードして、ブラウザで確認できる状態から開始しました。目に見えるものがあると、安心感があるので先に設定しておきました。ちなみに確認の際は、一次的にS3の公開設定をパブリックにして、アップロードしたコードが画面で表示できるか確認しておきました。

```html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width" />
    <title>テストページ</title>
  </head>
  <body>
    <h1>テストページ</h1>
    <ul><li>リストアイテム1</li></ul>
  </body>
</html>
```

## 独自ドメイン取得してHTTPSでS3へアクセスする
HTTPSでS3の静的なページにアクセスするようにAWSのCDNサービスであるCloudFrontを組み合わせることで、より効率的でセキュアな通信を出来るようにします。余談ですが、独自ドメイン取るとなんか愛着湧きますよね。設定作業は、ざっとこんな流れです。

- Amazon Route 53
  - 独自ドメインの取得
  - DNS設定
- S3バケットの設定
- S3バケットで静的ウェブサイトホスティングを有効にする
- AWS Certificate Manager (ACM)でSSL/TLS証明書を取得（地味に時間かかります）
- Amazon CloudFrontの設定
- Route 53 で CloudFrontディストリビューションへのルーティングを設定

設定作業は下記の記事を参考にしたので、ご紹介します。

https://qiita.com/ymzkjpx/items/ae115ed6d0fd2d383cec

https://www.s-toki.net/it/s3-static-site-with-https-p2/

## VPC作成
LambdaやEFSが動作する環境（VPC：Virtual Private Cloud）を東京リージョン `ap-northeast-1` で作成。もし、VPCを設定したことがなければ下記の記事が初学者には分かりやすい内容になっていると思いますので、参考までに

https://qiita.com/c60evaporator/items/b9e645b96afa3a34f41e

## EFSの作成
- EFSサービスから「ファイルシステムの作成」でEFSを作成
- VPCは先ほど作成したVPCを選択
  ![スクリーンショット 2024-02-11 14.43.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316171/7974b9a6-d933-5f6d-f515-a479de940389.png)

## Lambdaの設定
### Lambdaの作成
まず、Lambdaの `関数の作成` から作成
![スクリーンショット 2024-02-11 14.45.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316171/8a14ca60-92de-2e66-baf0-3c24b198bce3.png)
`詳細設定` から、EFSと同じリージョンを選択（後からでも設定できます）
![スクリーンショット 2024-02-11 14.46.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316171/ab8f95c1-4f34-ed6d-2f85-1de32e922d14.png)

### SGの作成
Lambda関数がEFSへアクセスできるようにSG（セキュリティグループ）を作成
![スクリーンショット 2024-02-11 14.50.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316171/aca84f7b-6271-1cb5-c58b-6de8771c788a.png)
- インバウンドルール(`Inbound rules`)：外からLambda(中)へ向かう流れ
  - LambdaからFESへアクセスできるように設定
  - Lambdaが存在するVPCのIPアドレスからのアクセスを許可するによう設定
- アウトバウンドルール(`Outbound rules`)：Lambda(中)から外へ向かう流れ
  - 外部のどこにでも出ていけるように設定しておくので、0.0.0.0/0にする

## LambdaとEFSの通信設定
### アクセスポイントの作成
Lambda関数がEFSにアクセスするためのエントリーポイントの設定で、先ほど作成したEFS内のダッシュボード、アクセスポイントの `アクセスポイントを作成` から作成することができます。
![スクリーンショット 2024-02-11 15.02.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316171/17bbdbca-2a99-0443-2064-7570beefbc19.png)

### マウントターゲットの設定
LambdaがEFSのどのディレクトリをマウントするか設定します。これにより、Lambda関数がEFS上のSQLiteデータベースにアクセスできます。
![スクリーンショット 2024-02-11 15.08.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316171/bfe362d4-306f-b335-e589-096c9768aa75.png)

### EFSのディレクトリの権限変更
`ルートディレクトリ作成のアクセス許可 - オプション` の `アクセスポイントのアクセス許可` から設定できるようです。私は、EFSにアクセスするEC2インスタンスを作成して、EFSのディレクトリの権限をchmodコマンドで変更しました。
![スクリーンショット 2024-02-11 15.10.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316171/e6e4132c-2398-af0a-2e4e-933a28308288.png)

### LambdaとEFSが通信できているか確認
Lambdaのコードソースということころに通信テスト用のコードを記載して、`Test` からコードを実行することで、実際にLambdaとEFSが通信出来ているか確認しました。
![スクリーンショット 2024-02-11 16.18.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316171/23d35e69-519e-d9f9-334d-241b8913bb47.png)
ここでは疎通を確認出来ればいいので、好きなように書いてテストするといいと思います。画像はRubyで書いています。
```python
import json
import os

def lambda_handler(event, context):
  efs_path = '/mnt/efs/testfile.txt'  # EFSにマウントされたパス

  try:
    # ファイルが存在しない場合は作成
    if not os.path.exists(efs_path):
      with open(efs_path, 'w') as file:
        file.write('Hello from Lambda to EFS')

    # ファイルの内容を読み込む
    with open(efs_path, 'r') as file:
      content = file.read()

    return {
      'statusCode': 200,
      'body': json.dumps(f'File Content: {content}')
    }
  except Exception as e:
    print(f"Error: {e}")
    return {
        'statusCode': 500,
        'body': json.dumps(f"Error reading or writing to file: {str(e)}")
    }
```

## Lambda経由でDB作成
### 簡単なテーブルを作成
LambdaとEFSの疎通は確認できたので、次に1つ簡単なテーブルを作成してみます。下記のコードをLambdaで実行して、テーブルを作成します。
```python
# EFSと疎通確認する関数
import sqlite3

def handler(event, context):
  # EFS上のデータベースファイルパス
  db_path = '/mnt/efs/mydatabase.db'

  # データベースに接続
  conn = sqlite3.connect(db_path)

  # テーブルを作成
  conn.execute('''
    CREATE TABLE IF NOT EXISTS Logs (
      id INTEGER PRIMARY KEY,
      title TEXT NOT NULL,
      description TEXT NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    );
  ''')

  # データベースの変更をコミットし、接続を閉じる
  conn.commit()
  conn.close()

  return {
    'statusCode': 200,
    'body': 'Database and tables created successfully.'
  }
```

###  レコード作成
同じようにLambdaから下記のコードを実行して試しにレコードを作成してみます。
```python
# 手動でレコードを作成する関数
import sqlite3
from datetime import date

def handler(event, context):
  db_path = '/mnt/efs/mydatabase.db'
  conn = sqlite3.connect(db_path)
  conn.execute('''
    INSERT INTO Logs (title, description, created_at) VALUES (?, ?, ?)
  ''', ('タイトル', '概要', date.today()))

  # コミットとクローズ
  conn.commit()
  conn.close()

  return {
    'statusCode': 200,
    'body': 'Record added to Logs successfully'
  }
```

### レコードの取得
作成したレコードをLambdaから取得できるか確認しておきます。
```python
# 特定のデータベースの内容を取得する関数
import sqlite3

def handler(event, context):
  db_path = '/mnt/efs/mydatabase.db'
  conn = sqlite3.connect(db_path)
  cursor = conn.cursor()

  # logsテーブルから最新の10件のレコードを取得
  cursor.execute('SELECT * FROM Logs ORDER BY created_at DESC LIMIT 10')

  # 結果を取得
  rows = cursor.fetchall()

  # 結果を表示用に整形
  result = [{
    "id": row[0],
    "title": row[1],
    "description": row[2],
    "created_at": row[4]
  } for row in rows]

  conn.close()

  return {
    'statusCode': 200,
    'body': result
  }
```
ここまでで、Lambdaから、テーブル作成、レコード追加、レコード取得が出来ることを確認しました。更新や削除も同じ要領でLambdaから行うことが出来ます。

## バックエンド処理
動的コンテンツのリクエストに対してレコードを取得して、レスポンスとして返してあげるバックエンドの処理をLambdaに書いていきます。完成系ですが、この後出てくるHTTPリクエストの取得など、デバッグしながら書いています。

```python
import sqlite3
import json
import logging
from datetime import date

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

def handler(event, context):
  db_path = '/mnt/efs/mydatabase.db'
  conn = sqlite3.connect(db_path)

  logger.info('Connected to the database successfully')
  http_method = event.get('method')  # HTTPメソッドによる処理の分岐

  if http_method == 'GET':  # GETリクエストの処理
    try:
      cursor = conn.cursor()
      cursor.execute("SELECT title, description, implementation_date FROM Logs")
      logs = cursor.fetchall()
      logger.info('Fetched records from the database successfully')
      conn.close()

      # HTMLコンテンツの生成
      html_content = ""
      for log in logs:
        html_content += f"<li><strong>{log[0]}</strong> - {log[2]}<p>{log[1]}</p></li>"

    except Exception as e:
      logger.error('Error fetching records from the database: %s', e)
      return {
        'statusCode': 500,
        'body': json.dumps({'error': str(e)})
      }

    return {
      'statusCode': 200,
      'headers': {'Content-Type': 'text/html'},
      'body': html_content
    }
```

## API Gatewayの設定
API Gatewayは、クライアントから動的コンテンツを取得するためのリクエストを受け取って、クライアントが欲しい情報を返してあげるGatewayの設定が必要です。こちらの記事が参考になります。

https://dev.classmethod.jp/articles/api-gateway-mapping-template/

注意点として、今回はAPIがクライアントからGETメソッドを受け取ることを想定しているので、GETの `結合リクエスト` で `マッピングテンプレート` を設定する必要があります。ここで設定しておかないとLambdaが何のリクエストが来たのか分からなくて困ります。

![スクリーンショット 2024-02-12 3.44.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316171/08fa60ac-b67f-f349-cac9-6adb8b6d684f.png)
![スクリーンショット 2024-02-12 3.46.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2316171/fa7da75d-3e2a-47f6-bfd6-80f799f3cfc2.png)

## API Gatewayへのリクエストを追加
最後にS3へアップロードしていたファイルに少し手を加えて動的コンテンツを取得するようにします。
```html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width" />
    <title>テストページ</title>
  </head>
  <body>
    <h1>テストページ</h1>
    <ul id="hoge-list"></ul>

    <script>
    fetch('https://XXXXXX.execute-api.ap-northeast-1.amazonaws.com/hoge')
        .then(response => response.text())
        .then(data => {
            document.getElementById('hoge-list').innerHTML = data;
        })
        .catch(error => console.error('Error fetching data:', error));
    </script>
  </body>
</htm
```
この設定で、ユーザーがアクセスすると下記のような流れで欲しい情報を取得してブラウザに表示してくれます。
- S3から静的コンテンツを取得、表示させる
- JavaScriptのfetch関数で、API Gatewayに設定されたエンドポイントに対してHTTPリクエストを非同期で送信
- API Gatewayの設定に基づいてリクエストがLambda関数にルーティングされる
- Lambda経由でEFSからレコードを取得
- 取得したレコードを `<ul id="hoge-list"></ul>` のliタグ内に表示

## まとめ
年末年始にまとまった時間を確保出来たので、新しいアーキテクチャで新しいサービスを開発できたのは学びも多く、開発者として幸せになってます。途中省略したところもあったので、気づいたら記事も更新していこうと思いますが、なにはともあれ運用コストも抑えることが出来たので、当初の目的は概ね達成出来たと思います。次は開発環境の整備とFrontの開発を進めてヌルヌルした画面を作って行こうと思います。

最後までお読みいただきありがとうございました。