🔥

AWSでサーバレスToDoアプリを作成してみた🌱

に公開

AWSでサーバレスToDoアプリの作成

実務でAWSに触れているが、自分で手を動かさないと知識が身につかないと感じ、最小構成のサーバレスToDoアプリを作ることにした。

なおアプリ作成にあたってGPT-5を教師役とした。

  • 教師役として講義→問題の形式で会話をする
  • 考える余地を残すために全ての手順を貼らない

とプロンプトで指定。

アプリケーション概要

以下のアーキテクチャ構成とする。

  • S3 : 静的ウェブサイトホスティング(ビルド済み React を配置)
  • API Gateway + Lambda : API 実装
  • DynamoDB : データ格納

(なおS3以外は資格の勉強でしか触れたことがなく、どれも具体的な利用方法を把握していなかった。。)

アプリの機能として、

  • タスクの一覧表示
  • タスクの登録
  • タスクの削除
  • タスクの完了状況の更新

を実装する。

DynamoDB

複数のユーザが利用することを想定して、パーティションキーをユーザIDとする。一つ一つのToDoに付与されるIDを指定して、タスクの削除等の操作を実行するため、ソートキーにタスクのID(content_idと今回は定義した)を設定した。
GSI、LSIは今回は特に必要でないため作成しない。

  • テーブル名
    • todo_contents
  • パーティションキー
    • user_id
  • ソートキー
    • content_id

他は基本的にデフォルトの設定で作成したが、無料枠で収めたいので、キャパシティーモードをプロビジョンドに設定。WCU:1,RCU:1とした。

備忘

パーティションキー:このキーに指定した項目によってテーブルのItemが複数のパーティションに分散される。
ソートキー:このキーを用いてクエリやソートが実施される。パーティションキー/ソートキー以外の項目で検索すると基本的にはフルスキャンになってしまう。(これを防ぐためにLSIやGSIを作る。)1つしか作成できない。
LSI:テーブルと同じパーティションキーを持ち、異なるソートキーを設定できるインデックス。テーブル作成時のみ追加可能。
GSI:テーブルとは異なるパーティションキーやソートキーを設定できるインデックス。テーブル作成後でも追加可能。

API Gateway + Lambda

Lambdaの実装

まず適切なアクセス権限を与える。
ログ出力用のAWSLambdaBasicExecutionRoleと、別途作成したDynamoDBへの操作を許可するポリシーを添付したロールを作成してアタッチする。

{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Sid": "TodoTableCrudLeastPrivilege",
			"Effect": "Allow",
			"Action": [
				"dynamodb:PutItem",
				"dynamodb:DeleteItem",
				"dynamodb:Query",
				"dynamodb:UpdateItem"
			],
			"Resource": "arn:aws:dynamodb:ap-northeast-1:<ACCOUNT-ID>:table/todo_contents"
		}
	]
}

実際の実装部分について、Node.jsは全く書いたことがなかったので、GPT-5に1つメソッドを書いてもらってそれを見つつ質問しつつで完成させた。複数ユーザ想定と言いつつ、自分しか使わないのでユーザは固定。

工夫した点としてcontent_idを1ずつ増やすように採番したかったため、content_id = 0, next_id = [最新のID]という採番用のItemをユーザごとに持ち、新しいToDoタスクを登録する際にそのItemを参照・更新するという仕組みにした。

コードの中身
// index.js  (Node.js 20 / AWS SDK v3)
const { DynamoDBClient, QueryCommand, UpdateItemCommand, PutItemCommand, DeleteItemCommand } = require("@aws-sdk/client-dynamodb");
const { marshall, unmarshall } = require("@aws-sdk/util-dynamodb");

const ddb = new DynamoDBClient({ region: "ap-northeast-1" });
const TABLE = process.env.TABLE_NAME || "todo_contents";
const FIXED_USER = "00000000-0000-0000-0000-000000000000";

function json(status, data) {
  return { statusCode: status, headers: { "content-type": "application/json" }, body: JSON.stringify(data) };
}
function getMethod(event){ return event?.requestContext?.http?.method || event?.httpMethod || "GET"; }
function safeParse(s){ try { return typeof s === "string" ? JSON.parse(s) : s; } catch { return null; } }

console.log("TABLE:", TABLE);

exports.handler = async (event) => {
  const method = getMethod(event);
  try {
    if (method === "GET") {
      const out = await ddb.send(new QueryCommand({
        TableName: TABLE,
        KeyConditionExpression: "user_id = :u AND content_id > :zero",
        ExpressionAttributeValues: marshall({ ":u": FIXED_USER, ":zero": 0 }),
        ScanIndexForward: false, // 降順で返す
      }));
      const items = (out.Items || []).map(unmarshall);
      return json(200, items);
    }

    if (method === "POST") {
      const body = safeParse(event.body);
      if (!body?.content || typeof body.content !== "string") {
        return json(400, { message: "content(string) is required" });
      }

      // ① カウンタ(+1)して新IDを得る(SK=0 のメタ行)
      const upd = await ddb.send(new UpdateItemCommand({
        TableName: TABLE,
        Key: marshall({ user_id: FIXED_USER, content_id: 0 }),
        UpdateExpression: "SET next_id = if_not_exists(next_id, :zero) + :one",
        ExpressionAttributeValues: marshall({ ":zero": 0, ":one": 1 }),
        ReturnValues: "UPDATED_NEW",
      }));
      const newId = Number(upd.Attributes.next_id.N ?? upd.Attributes.next_id); // 念のため

      // ② 本体Put(同一キーが無いことを条件に)
      const now = new Date().toISOString();
      const item = {
        user_id: FIXED_USER,
        content_id: newId,
        status: "open",
        content: body.content,
        created_at: now,
        due_date: body.due_date || null,
      };
      await ddb.send(new PutItemCommand({
        TableName: TABLE,
        Item: marshall(item, { removeUndefinedValues: true }),
        ConditionExpression: "attribute_not_exists(user_id) AND attribute_not_exists(content_id)",
      }));
      return json(201, item);
    }
    //一部を更新
    if (method === "PATCH"){
      const id = Number(event.pathParameters?.id);
      if (!Number.isFinite(id)){
        return json(400, {message:"id must be a number"});
      }
      const body = safeParse(event.body) || {};

      const ALLOW = ["content","status","due_date"];
      const specified = Object.fromEntries(
        Object.entries(body).filter(([k,v])=>ALLOW.includes(k) && v !== undefined)
      )
      if(Object.keys(specified).length === 0){
        return json(400, {message:"no updateable fields"});
      }

      const sets = [];
      const removes = [];
      const Names = {};
      const Values= {};
      
      for (const [k,v] of Object.entries(specified)){
        const nk = `#${k[0]}`;
        const vk = `:${k[0]}`;
        Names[nk] = k;

        if(v===null){
          removes.push(nk);
        }else{
          if (k === "status" && !["open","closed"].includes(v)){
            return json(400, {message:"status must be 'open' or 'closed'"});
          }
          if (k === "due_date" && !/^\d{4}-\d{2}-\d{2}$/.test(String(v))){
            return json(400, {message:"due_date must be YYYY-MM-DD"});
          }
          Values[vk] = k === "content" ? String(v) : v;
          sets.push(`${nk} = ${vk}`);
        }
      }
      let UpdateExpression = "";
      if(sets.length > 0){
        UpdateExpression += "SET " + sets.join(", ");
      }
      if(removes.length > 0){
        UpdateExpression += " REMOVE " + removes.join(", ");
      }

      try {
        const out = await ddb.send(new UpdateItemCommand({
          TableName: TABLE,
          Key: marshall({ user_id: FIXED_USER, content_id: id }),
          UpdateExpression: UpdateExpression,
          ExpressionAttributeNames: Names,
          ExpressionAttributeValues: Object.keys(Values).length
            ? marshall(Values, {removeUndefinedValues:true})
            : undefined,
          ConditionExpression: "attribute_exists(user_id) AND attribute_exists(content_id)",
          ReturnValues: "ALL_NEW",
        }));
        const updated = unmarshall(out.Attributes);
        return json(200, updated);
      }catch(e){
        if(e && (e.name === "ConditionalCheckFailedException" || /ConditionalCheckFailed/.test(e.message))){
          return json(404, {message:"not found"});
        }
        if(e && e.name === "ValidationException"){
          return json(400, {message:"validation error"});
        }
        console.error(e);
        return json(500, {message:"internal server error"});
      }
    }
    if(method === "DELETE") {
      const id = Number(event.pathParameters?.id);
      if (!Number.isFinite(id)){
        return json(400, {message:"id must be a number"});
      }
      try {
        const out = await ddb.send(new DeleteItemCommand({
          TableName:TABLE,
          Key:marshall({user_id: FIXED_USER, content_id:id}),
          ConditionExpression: "attribute_exists(user_id) AND attribute_exists(content_id)"
        }));
        return {statusCode:204, headers: {},body:""};
      }
      catch (e){
        if(e && (e.name === "ConditionalCheckFailedException" || /ConditionalCheckFailed/.test(e.message))){
          return json(404, {message:"not found"});
        }
        if(e && e.name === "ValidationException"){
          return json(400, {message:"validation error"});
        }
        console.error(e);
        return json(500, {message:"internal server error"});
      }

    }
  
    return json(405, { message: "Method Not Allowed" });
  } catch (e) {
    console.error(e);
    return json(500, { message: "Internal Server Error" });
  }
};

そして、この作成したLambdaを以下の手順でAPI化する。

  • API GatewayのHTTP APIを作成→「統合を追加」から作成したLambdaを選択。
  • 「ルートを追加」でメソッドとリソースパスを適切に選択。
    alt text
  • デフォルトステージを設定。
  • 一応ステージのスロットルをレート制限50、バースト制限20に設定。

これでcurlでAPIが叩けるようになる。

備忘

  • ARNとか接続先URLみたいな値はLambdaの環境変数で設定できる(機密情報はダメ)
  • PATCHは部分更新のHTTPメソッド。
  • API GatewayはHTTP APIが一番シンプルな設定でルーティング可能。より高度な機能が必要な場合、REST API。
  • ルート設定で、どのリクエストをどの統合に送るか設定する。今回は1つのLambdaだったが、このルートの時はALBに飛ばすみたいな設定も可能。
  • ステージは環境を分けたり、段階的なデプロイ戦略をとったりするために利用できる。
  • ブラウザからクロスオリジンで呼び出す場合は、CORSを許可するレスポンスヘッダーをAPI Gateway 側で設定しないとブラウザがリクエストをブロックする。(次章で設定する)

S3

ここは何でもよかったので完全にCodex CLIに実装してもらった。
一旦今まで作ったAPIを利用できるようなTodoアプリケーションを作成してと指示してシンプルな画面を作成してもらい、デザイン面で少し指示をして以下のような画面を作成。

alt text

(Reactはなんとなく読めるけど書けはしないというレベルの低さなので、特に何も参照せずにちょっとは書けるようになりたい...)

その後以下の手順でS3にデプロイした。

  • npm run buildでコンパイルし、S3にアップロード。(バケットのルートにindex.html等をアップロードする必要がある)
  • S3のブロックパブリックアクセスをオフにする。
  • S3のリソースベースのバケットポリシーでs3:GetObjectのみ許可する。
  • CORSの設定をする
    alt text

これで、初めてのサーバレスアプリケーションを作成し、デプロイすることに成功した!

結論

全部ほとんど業務では触れてこなかったサービスや機能のため、習熟コストが少し高かったが、これだけ簡単な操作で動くWebアプリケーションを作成できることに驚いた。
SAAを勉強した時になんとなくそれぞれのサービスについて理解していたつもりであったが、ハンズオンでいろいろやってみることで更に理解が深まったと感じる。(特にDynamoDBのGSI、LSI周り)

初めて自分の勉強記録を公開するのですが、間違っているところや、実務ではこのように利用してます等、指摘・アドバイスがあればコメントしてくださるとありがたいです。(サボらず続けていきたい...。)

Discussion