【CDK】Honoで簡単なCRUD処理のバックエンドAPIを作成してみた(Lambda + API Gateway + RDS(Aurora))

【CDK】Honoで簡単なCRUD処理のバックエンドAPIを作成してみた(Lambda + API Gateway + RDS(Aurora))

Clock Icon2025.02.03

はじめに

コンサルティング部の神野です。

以前にHonoで簡単なCRUDシステムを作って下記記事で紹介させていただきました。
その際にDynamoDBを採用したのですが、RDS(Aurora)を使いたい場合も考慮して実装してみました!

前回の記事

https://dev.classmethod.jp/articles/cdk-hono-crud-api-lambda-api-gateway-dynamodb/

構成するシステム

システム構成図

下記構成をCDKで構築し、Honoの実装含めて全てTypeScriptで実装します。

  • Honoの実行環境はLambdaを選択
  • LambdaはAPI Gateway経由で実行
  • データソースとしてはAurora MySQL Serverlessを選択

CleanShot 2025-01-28 at 20.32.41@2x

補足

今回はRDS Proxyを使用しておりません。理由としてはあくまで接続検証が目的で瞬間的にしか使用しないためです。
RDS Proxyを使用するかどうか検討する記事がございますので、ワークロードに応じてご検討ください。

https://dev.classmethod.jp/articles/do-you-really-need-rds-proxy/

機能

Todoアプリケーションを作成します。データソースをRDS(Aurora)とする簡単なCRUD処理を行うバックエンドのAPIサーバーを作成していきます。

Todosテーブル

Todosテーブルとしては下記レイアウトは下記想定で実装を進めていきます。シンプルなテーブルを題材としていきます。

テーブル定義
論理名 物理名 備考
ID id VARCHAR プライマリーキー
タイトル title VARCHAR NOT NULL
完了フラグ completed BOOLEAN デフォルト: false
期日 dueDate TIMESTAMP NULL許容
ユーザーID userId VARCHAR
作成日 createdAt TIMESTAMP デフォルト: 現在日時
更新日 updatedAt TIMESTAMP 更新時に自動更新

構築

全てのコードはgithubにアップロードしているので、必要に応じてご参照ください。

https://github.com/yuu551/hono-sample-application-rds

事前準備

TypeScriptの実行環境としてBun、環境構築にCDKを使って実装を進めていくので事前にライブラリをインストールしておきます。
またMySQLをローカルで起動するためにDockerも併せてインストールしておきます。使用したバージョンは下記となります。

  • CDK・・・2.176.0
  • Docker・・・27.1.1-rd
  • Bun・・・1.1.26
  • Node・・・v20.16.0

任意のフォルダでCDKプロジェクトを作成します。

CDKプロジェクト作成
cdk init --language=typescript

作成後はプロジェクトで必要なライブラリをインストールします。

ライブラリインストール
# PrismaとAWS SDKの関連ライブラリ
bun install prisma@6.3.0 @prisma/client@6.3.0 @types/aws-lambda@8.10.147

# Honoとその関連ライブラリ
bun install hono@4.6.2 @hono/zod-validator@0.2.2 zod@3.23.8

# その他の必要なライブラリ
bun install uuid@10.0.0

# 開発用ライブラリ
bun install --save-dev @types/uuid@10.0.0 dotenv-cli@8.0.0
  • prisma@6.3.0
  • @prisma/client@6.3.0
  • @types/aws-lambda@8.10.147
  • hono: 4.6.2
  • @hono/zod-validator: 0.2.2
  • dotenv-cli@8.0.0
  • uuid: 10.0.0
  • zod: 3.23.8

補足:Bunについて

今回はTypeScriptの実行環境として、Bunを使用しました。使用感は下記が素晴らしく採用しました

  • TypeScriptのままコードを実行できる(トランスパイルが不要!!)

    実行例
    # example
    bun run --hot server.ts
    
  • Honoの実行環境としてサポートされている

  • パッケージマネージャーとしても使用可能で、ライブラリインストールも早い

ディレクトリ構成

下記ディレクトリ構成で実装を進めていきます。

ディレクトリ構成
.
├── README.md                     # プロジェクトの説明文書
├── bin
│   └── blog-sample-hono-rds.ts   # CDKアプリケーションのエントリーポイント
├── cdk.json                      # CDKの設定ファイル
├── compose.yaml                  # ローカル開発用のDocker設定
├── .env                          # 環境変数設定ファイル(Githubにはコミットしない)
├── lambda                        # Lambda関数のソースコード
│   ├── index.ts                  # Lambda関数のエントリーポイント
│   └── src
│       ├── api
│       │   └── todos
│       │       └── todos.ts      # Todoに関するAPI実装
│       ├── app.ts                # Honoアプリケーションの主要設定
│       ├── .env.local            # ローカル開発環境用の環境変数(Githubにはコミットしない)
│       └── prisma                # Prisma関連のファイル
│           ├── schema.prisma     # Prismaスキーマ定義
│           └── migrations/       # データベースマイグレーションファイル
├── lib
│   └── blog-sample-hono-rds-stack.ts  # CDKスタックの定義
├── package.json                  # プロジェクトの依存関係と設定
└── tsconfig.json                 # TypeScriptの設定ファイル

Hono部分

ローカルDB

今回はAurora MySQLを使用するので、ローカルでもMySQLを使用します。
compose.ymlファイルを作成して、環境を作成します。

compose.yml
version: '3.8'

services:
  db:
    image: mysql:8.0
    container_name: mysql_container
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: todo_db
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql

volumes:
  mysql_data:

作成したらdocker composeコマンドでMySQLを立ち上げます。

実行コマンド
docker compose up -d
[+] Running 1/1
 ✔ Container mysql_container  Started                                                                                 0.6s 

Prisma

今回はORMとしてPrismaを使用します。
使用するに際して、まずinitコマンドを実行して初期化します。
また、初期化が完了したら元のディレクトリに戻ります。

実行コマンド
# ディレクトリ移動
cd lambda/src
bunx prisma init --datasource-provider mysql

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.

warn You already have a .gitignore file. Don't forget to add `.env` in it to not commit any private information.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Run prisma db pull to turn your database schema into a Prisma schema.
3. Run prisma generate to generate the Prisma Client. You can then start querying your database.
4. Tip: Explore how you can extend the ORM with scalable connection pooling, global caching, and real-time database events. Read: https://pris.ly/cli/beyond-orm

More information in our documentation:
https://pris.ly/d/getting-started

# 初期化が完了したら元のディレクトリへ
cd ../..

実行後はprismaフォルダおよびフォルダ内にschema.prismaファイルが作成されています。
schemaファイル内にテーブルの定義を追記します。

lambda/src/prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
  binaryTargets = ["linux-arm64-openssl-3.0.x", "darwin-arm64"]
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model Todo {
  id        String    @id @default(uuid())
  userId    String
  title     String
  completed Boolean   @default(false)
  dueDate   DateTime?
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
}

作成したタイミングで、ローカル開発用の環境変数である.env.localを作成し、ローカル開発用の環境変数DATABASE_URLを定義します。compose.ymlで定義したユーザーやパスワードなどの情報を元にURLを記載します。

lambda/src/.env.local
DATABASE_URL=mysql://root:rootpassword@localhost:3306/todo_db

環境変数を作成したタイミングで、開発環境のDBにマイグレーションを実施します。
.env.localを読みこむために、dotenvを使用します。
毎回マイグレーションコマンドを実行するためにディレクトリ移動するのは少し手間なので、package.jsonにマイグレーションコマンド(migrate:dev)を追加し実行します。

package.json
{
  ///省略
  ...
  "scripts": {
		// 追加
    "migrate:dev": "cd lambda/src && bunx dotenv -e .env.local bunx prisma migrate dev"
  },
  ...
}

追加後はコマンドを実行します。
マイグレーションの名前を求められるので、任意の名前でOKです。
今回はinitとしました。

実行コマンド
bunx run migrate:dev

✔ Enter a name for the new migration: … init
Applying migration `20250203080509_init`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20250203080509_init/
    └─ migration.sql

コマンドが無事完了したら、該当のフォルダにSQLが存在しているか確認します。

lambda/src/prisma/migrations/20250203080509_init/migration.sql
-- CreateTable
CREATE TABLE `Todo` (
    `id` VARCHAR(191) NOT NULL,
    `userId` VARCHAR(191) NOT NULL,
    `title` VARCHAR(191) NOT NULL,
    `completed` BOOLEAN NOT NULL DEFAULT false,
    `dueDate` DATETIME(3) NULL,
    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    `updatedAt` DATETIME(3) NOT NULL,

    PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

問題なくマイグレーションに成功してSQL作成できていますね!
具体的な処理実装に移ります。

Todo処理部分

TodoアプリケーションのCRUD操作を実装しています。

  • Zodを使用してリクエストのバリデーションを行っています。
  • Prismaのドキュメントクライアントを使用してデータの操作を行っています。
  • 各エンドポイント(POST, GET, PUT, DELETE)でTodoの作成、取得、更新、削除の処理を実装しています。
CRUD処理一覧
操作 HTTPメソッド エンドポイント 説明 リクエストボディ レスポンス
Create POST /api/todos 新しいTodoを作成 userId, title, completed, dueDate(optional) 201 Created, 作成されたTodo
Read (All) GET /api/todos/user/:userId 特定ユーザーの全Todoを取得 - 200 OK, Todoの配列
Read (Single) GET /api/todos/:id 特定のTodoを取得 - 200 OK, Todoオブジェクト or 404 Not Found
Update PUT /api/todos/:id Todoを更新 title(optional), completed(optional), dueDate(optional) 200 OK, 更新されたTodo
Delete DELETE /api/todos/:id Todoを削除 - 200 OK, 削除成功メッセージ
src/lambda/api/todos/todos.ts
// lambda/index.ts

import { Hono } from "hono";
import { z } from "zod";
import { zValidator } from "@hono/zod-validator";
import { PrismaClient } from "@prisma/client"

const prisma = new PrismaClient();

// カスタムZodスキーマ for YYYY-MM-DD形式の日付
const dateSchema = z.string().refine(
  (val) => {
    return /^\d{4}-\d{2}-\d{2}$/.test(val) && !isNaN(Date.parse(val));
  },
  {
    message: "Invalid date format. Use YYYY-MM-DD",
  }
);

// Zodスキーマの定義
const TodoSchema = z.object({
  userId: z.string().min(1),
  title: z.string().min(1).max(100),
  completed: z.boolean(),
  dueDate: dateSchema.optional(),
});

const TodoUpdateSchema = TodoSchema.partial().omit({ userId: true });

const todos = new Hono()
  .post("/", zValidator("json", TodoSchema), async (c) => {
    const validatedData = c.req.valid("json");

    try {
      const todo = await prisma.todos.create({
        data: {
          ...validatedData,
          dueDate: validatedData.dueDate ? new Date(validatedData.dueDate) : null,
        },
      });

      return c.json({ message: "Todo created successfully", todo }, 201);
    } catch (error) {
      console.error(error);
      return c.json({ error: "Failed to create todo" }, 500);
    }
  })
  .get("/user/:userId", async (c) => {
    const userId = c.req.param("userId");

    try {
      const todos = await prisma.todos.findMany({
        where: { userId },
        orderBy: { createdAt: "desc" },
      });

      return c.json(todos);
    } catch (error) {
      console.error(error);
      return c.json({ error: "Failed to retrieve todos" }, 500);
    }
  })
  .get("/:id", async (c) => {
    const id = c.req.param("id");

    try {
      const todo = await prisma.todos.findUnique({
        where: { id },
      });

      if (!todo) {
        return c.json({ error: "Todo not found" }, 404);
      }

      return c.json(todo);
    } catch (error) {
      console.error(error);
      return c.json({ error: "Failed to retrieve todo" }, 500);
    }
  })
  .put("/:id", zValidator("json", TodoUpdateSchema), async (c) => {
    const id = c.req.param("id");
    const validatedData = c.req.valid("json");

    try {
      const todo = await prisma.todos.update({
        where: { id },
        data: {
          ...validatedData,
          dueDate: validatedData.dueDate ? new Date(validatedData.dueDate) : undefined,
        },
      });

      return c.json(todo);
    } catch (error) {
      console.error(error);
      return c.json({ error: "Failed to update todo" }, 500);
    }
  })
  .delete("/:id", async (c) => {
    const id = c.req.param("id");

    try {
      await prisma.todos.delete({
        where: { id },
      });

      return c.json({ message: "Todo deleted successfully" });
    } catch (error) {
      console.error(error);
      return c.json({ error: "Failed to delete todo" }, 500);
    }
  });

export { todos };

app.ts

サーバー起動処理部分です。Middlewareを使用して、ログとBasic認証を実装します。Honoは標準で提供されているので簡単で素晴らしいですね!!

lambda/src/app.ts
import { Hono } from "hono";
import { logger } from "hono/logger";
import { basicAuth } from "hono/basic-auth";
import { todos } from "./api/todos/todos";

const app = new Hono();

// ログの設定
app.use("*", logger());
//Basic認証の設定
app.use(
  "*",
  basicAuth({
    username: process.env.BASIC_USERNAME ? process.env.BASIC_USERNAME : "a",
    password: process.env.BASIC_PASSWORD ? process.env.BASIC_PASSWORD : "a",
  })
);

app.route("/api/todos", todos);

export default app;

index.ts

Lambda関数のエントリーポイントとなるファイルです。

  • HonoアプリケーションをAWS Lambda用のハンドラーでラップしています。
  • これにより、HonoアプリケーションをLambda関数として実行できるようになります。
lambda/index.ts
import { handle } from 'hono/aws-lambda'
import app from './src/app'

// index.ts で定義された純粋なHTTPサーバをAWS Lambda用のアダプタでラップしてハンドラとしてエクスポート
export const handler = handle(app)

migration.ts

CDKのカスタムリソースとしてPrismaのマイグレーションを実施するLambda関数用のファイルとなります。
マイグレーションの実装は下記を参考にさせていただいております。(実装はほぼ同一のものです)

https://zenn.dev/winteryukky/articles/d766b9ab98eb23

lambda/src/migration.ts
import { execSync } from "child_process";
import {
  CdkCustomResourceHandler,
  CdkCustomResourceResponse,
} from "aws-lambda";
import { v4 as uuidv4 } from 'uuid';

export const handler: CdkCustomResourceHandler = async (event) => {
  const physicalResourceId = event.ResourceProperties.physicalResourceId ?? uuidv4();
  if (event.RequestType === "Delete") {
    return {
      PhysicalResourceId: physicalResourceId,
    };
  }
  if (!process.env.DATABASE_URL) {
    throw new Error("DB_CONNECTION is not set");
  }

  return new Promise<CdkCustomResourceResponse>((resolve) => {
    setInterval(() => {
      try {

        const stdout = execSync(`prisma migrate deploy`, {
          env: {
            ...process.env,
            DATABASE_URL: process.env.DATABASE_URL,
          }
        });
        console.log(stdout.toString());
        resolve({
          PhysicalResourceId: physicalResourceId,
        });
      } catch (error) {
        console.error("Migration is failed %s, will be retry...", error);
      }
    }, 10 * 1000);
  });
};

このLambda関数はコンテナで実行するために、Dockerfileを作成します。

lambda/src/Dockerfile
FROM node:20 AS builder

WORKDIR /app
RUN npm init -y
RUN npm install -g typescript
RUN npm install --save-dev @types/node @types/aws-lambda
COPY migration.ts ./
RUN tsc migration.ts

FROM public.ecr.aws/lambda/nodejs:20

COPY --from=builder /app/migration.js ${LAMBDA_TASK_ROOT}/
COPY src/prisma ${LAMBDA_TASK_ROOT}/
RUN npm i -g prisma

CMD [ "migration.handler" ]

package.json

サーバー起動用のコマンドを追加しておきます。設定することで簡単にbun run devでサーバーを起動できます。

package.json
{
  ///省略
  ...
  "scripts": {
		// 追加
    "dev": "bunx dotenv -e ./lambda/src/.env.local bun run lambda/src/app.ts"
  },
  ...
}

また、BASIC認証用にIDとパスワードも.env.localに追記します。

lambda/src/.env.local
BASIC_USERNAME=test
BASIC_PASSWORD=test

動作確認

APIサーバを起動します。

APIサーバーを起動
bun run dev

サーバーが立ち上がったので各種リクエストを送ってみます。
Authorizationヘッダーには.env.localで設定したユーザー情報を設定します。

正常系のリクエスト
# Create: 新しいTodoを作成
curl -i -X POST "http://localhost:3000/api/todos" \
  -H "Content-Type: application/json" \
  -H "Authorization: Basic <your-information>" \
  -d '{"userId": "user123", "title": "新しいタスク", "completed": false, "dueDate": "2023-12-31"}'
HTTP/1.1 201 Created
Content-Type: application/json
Date: Mon, 03 Feb 2025 08:29:18 GMT
Content-Length: 282

{"message":"Todo created successfully","todo":{"id":"01JK5EV8SYA8MSE2THNMS2HWXJ","userId":"user123","title":"新しいタスク","completed":false,"dueDate":"2023-12-31T00:00:00.000Z","description":null,"createdAt":"2025-02-03T08:29:19.038Z","updatedAt":"2025-02-03T08:29:19.038Z"}}

# Read
curl -i -X GET "http://localhost:3000/api/todos/user/user123" \
  -H "Authorization: Basic <your-information>"
HTTP/1.1 200 OK
Content-Type: application/json
Date: Mon, 03 Feb 2025 08:29:49 GMT
Content-Length: 237

[{"id":"01JK5EV8SYA8MSE2THNMS2HWXJ","userId":"user123","title":"新しいタスク","completed":false,"dueDate":"2023-12-31T00:00:00.000Z","description":null,"createdAt":"2025-02-03T08:29:19.038Z","updatedAt":"2025-02-03T08:29:19.038Z"}]   

# Update
curl -i -X PUT "http://localhost:3000/api/todos/01JK5EV8SYA8MSE2THNMS2HWXJ" \
  -H "Content-Type: application/json" \
  -H "Authorization: Basic <your-information>" \
  -d '{"title": "更新されたタスク", "completed": true}'
HTTP/1.1 200 OK
Content-Type: application/json
Date: Mon, 03 Feb 2025 08:30:35 GMT
Content-Length: 240

{"id":"01JK5EV8SYA8MSE2THNMS2HWXJ","userId":"user123","title":"更新されたタスク","completed":true,"dueDate":"2023-12-31T00:00:00.000Z","description":null,"createdAt":"2025-02-03T08:29:19.038Z","updatedAt":"2025-02-03T08:30:36.144Z"}

# Delete
curl -i -X DELETE "http://localhost:3000/api/todos/01JK5EV8SYA8MSE2THNMS2HWXJ" \
  -H "Authorization: Basic <your-information>"
HTTP/1.1 200 OK
Content-Type: application/json
Date: Mon, 03 Feb 2025 08:31:29 GMT
Content-Length: 39

{"message":"Todo deleted successfully"}

CRUD処理が適切に動いていて、いい感じですね!
後はバリデーションチェックが機能しているか、認証の失敗パターンもチェックしてみます!

エラー系のリクエスト
# 認証エラー
curl -i -X GET "http://localhost:3000/api/todos/user/user123" \
  -H "Authorization: Basic test"
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Secure Area"
content-type: application/octet-stream
Date: Sat, 01 Feb 2025 16:13:22 GMT
Content-Length: 12

Unauthorized

# 必須フィールドの欠落
curl -i -X POST "http://localhost:3000/api/todos" \
  -H "Content-Type: application/json" \
  -H "Authorization: Basic <your-information>" \
  -d '{"title": "新しいタスク", "completed": false}'
HTTP/1.1 400 Bad Request
Content-Type: application/json
Date: Sat, 01 Feb 2025 16:13:54 GMT
Content-Length: 162

{"success":false,"error":{"issues":[{"code":"invalid_type","expected":"string","received":"undefined","path":["userId"],"message":"Required"}],"name":"ZodError"}}

こちらも問題なく実装できていますね!エラー時や認証失敗時のレスポンスを作り込まずともフレームワーク側で自動で返却してもらえるのは嬉しいですね!!!

一通りローカルで実装が完了したので、AWS上にデプロイしていきます。

環境構築部分

環境構築部分はCDKで実装していきます。環境変数は.envファイルをプロジェクト直下に作成し、下記を定義しておきます。

環境変数

  • Basic認証のユーザー名とパスワード
  • RDSの情報
    • データベース名:todo_db
    • ユーザー名:任意のユーザー名
    • パスワード:任意のパスワード
.envの例
BASIC_USERNAME=XXX
BASIC_PASSWORD=YYY
RDS_DATABASE_NAME=todo_db
RDS_USERNAME=ZZZ
RDS_PASSWORD=DDD

CDKコード

下記リソースを作成します。

  • VPC
    • RDSとLambdaを配置するため
  • セキュリティグループ
    • Aurora用セキュリティグループ
      • LambdaからのTCP 3306アクセスを許可
    • Lambda用セキュリティグループ
  • Aurora MySQL
    • Serverless v2で動作するAurora MySQLクラスター
      • ACU0.5 ~ 1
  • Lambda関数
    • Hono実行用Lambda
      • Prismaクライアントを利用
        • バンドル時にNative Query Engineやスキーマ定義をコピー
      • 環境変数からBasic認証情報とDATABASE_URLを設定
    • マイグレーション用Lambda(Dockerイメージ使用)
      • デプロイ時にPrismaのマイグレーションを自動実行
  • API Gateway
    • Honoアプリを公開するエンドポイント
      • Lambda関数をラップ
  • カスタムリソース
    • マイグレーション用Lambdaをトリガー
      • 最新のマイグレーションを実行する仕組み

コードの全体は長いので省略します。

コード全体
/lib/blog-sample-hono-rds-stack.ts
import * as cdk from "aws-cdk-lib";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as apigw from "aws-cdk-lib/aws-apigateway";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as rds from "aws-cdk-lib/aws-rds";
import * as dotenv from "dotenv";
import path = require("path");
import * as fs from "fs";
import * as cr from "aws-cdk-lib/custom-resources";

// 環境変数の読み込み
dotenv.config();

// 必須の環境変数をチェックする関数
function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Environment variable ${name} is required`);
  }
  return value;
}

export class BlogSampleHonoRdsStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 環境変数から値を取得
    const databaseName = requireEnv("RDS_DATABASE_NAME");
    const username = requireEnv("RDS_USERNAME");
    const password = requireEnv("RDS_PASSWORD");

    // VPCの作成
    const vpc = new ec2.Vpc(this, "TodoAppVpc", {
      maxAzs: 2,
      natGateways: 0,
    });

    // RDSのセキュリティグループ
    const dbSecurityGroup = new ec2.SecurityGroup(this, "DbSecurityGroup", {
      vpc,
      description: "Security group for Aurora database",
      allowAllOutbound: true,
    });

    // Aurora MySQL クラスターの作成
    const cluster = new rds.DatabaseCluster(this, "TodoDatabase", {
      engine: rds.DatabaseClusterEngine.auroraMysql({
        version: rds.AuroraMysqlEngineVersion.VER_3_07_1,
      }),
      serverlessV2MinCapacity: 0.5,
      serverlessV2MaxCapacity: 1,
      credentials: {
        username: username,
        password: cdk.SecretValue.unsafePlainText(password),
      },
      writer: rds.ClusterInstance.serverlessV2("writer", {
        autoMinorVersionUpgrade: false,
        instanceIdentifier: "hono-db-writer",
        publiclyAccessible: false,
      }),
      vpc,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      },
      securityGroups: [dbSecurityGroup],
      defaultDatabaseName: databaseName,
      // バックアップ保持期間(日数)
      backup: {
        retention: cdk.Duration.days(7),
      },
      // 削除保護(本番環境では true にすることを推奨)
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      enableDataApi: true,
    });

    // Lambda関数のセキュリティグループ
    const lambdaSecurityGroup = new ec2.SecurityGroup(
      this,
      "LambdaSecurityGroup",
      {
        vpc,
        description: "Security group for Lambda function",
        allowAllOutbound: true,
      }
    );

    // セキュリティグループ間の通信許可
    dbSecurityGroup.addIngressRule(
      lambdaSecurityGroup,
      ec2.Port.tcp(3306),
      "Allow Lambda to access Aurora MySQL"
    );

    // Lambda関数の作成
    const honoLambda = new NodejsFunction(this, "lambda", {
      entry: "lambda/index.ts",
      handler: "handler",
      runtime: lambda.Runtime.NODEJS_20_X,
      vpc,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      },
      securityGroups: [lambdaSecurityGroup],
      environment: {
        BASIC_USERNAME: requireEnv("BASIC_USERNAME"),
        BASIC_PASSWORD: requireEnv("BASIC_PASSWORD"),
        DATABASE_URL: `mysql://${username}:${password}@${cluster.clusterEndpoint.hostname}:${cluster.clusterEndpoint.port}/${databaseName}`,
      },
      timeout: cdk.Duration.seconds(30),
      memorySize: 1024,
      architecture: lambda.Architecture.ARM_64,
      bundling: {
        // commandHooksでインストール前、バンドル前、後にコマンドを組み込める
        commandHooks: {
          beforeInstall(inputDir: string, outputDir: string): string[] {
            return [``];
          },
          beforeBundling(inputDir: string, outputDir: string): string[] {
            return [``];
          },
          afterBundling(inputDir: string, outputDir: string): string[] {
            return [
              // クエリエンジンを追加
              `cp ${inputDir}/node_modules/.prisma/client/libquery_engine-linux-arm64-* ${outputDir}`,
              // スキーマ定義を追加
              `cp ${inputDir}/lambda/src/prisma/schema.prisma ${outputDir}`,
            ];
          },
        },
      },
    });

    // API Gatewayの作成
    const apiGw = new apigw.LambdaRestApi(this, "honoApi", {
      handler: honoLambda,
      proxy: true,
    });

    // マイグレーション用のLambda関数
    const migrationLambda = new lambda.DockerImageFunction(
      this,
      "MigrationLambda",
      {
        code: lambda.DockerImageCode.fromImageAsset("lambda", {
          file: "Dockerfile",
          cmd: ["migration.handler"],
        }),
        vpc,
        vpcSubnets: {
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
        securityGroups: [lambdaSecurityGroup],
        environment: {
          DATABASE_URL: `mysql://${username}:${password}@${cluster.clusterEndpoint.hostname}:${cluster.clusterEndpoint.port}/${databaseName}`,
        },
        timeout: cdk.Duration.minutes(15),
        memorySize: 1024,
        architecture: lambda.Architecture.ARM_64,
      }
    );

    // 最新のマイグレーションファイル名を取得
    const migrationsDir = path.join(
      __dirname,
      "../lambda/src/prisma/migrations"
    );
    const latestMigration = fs
      .readdirSync(migrationsDir)
      .filter((file) => file !== "migration_lock.toml")
      .sort((a, b) => b.localeCompare(a))[0];

    console.log(latestMigration);

    // カスタムリソースプロバイダーの作成
    const provider = new cr.Provider(this, "MigrationProvider", {
      onEventHandler: migrationLambda,
    });

    // カスタムリソースの作成
    const migrationCustomResource = new cdk.CustomResource(
      this,
      "MigrationCustomResource",
      {
        serviceToken: provider.serviceToken,
        properties: {
          latestMigration: latestMigration,
        },
      }
    );

    // 明示的な依存関係の追加
    migrationCustomResource.node.addDependency(cluster);

    // 出力の設定
    new cdk.CfnOutput(this, "ApiEndpoint", {
      value: apiGw.url,
      description: "API Gateway endpoint URL",
    });
  }
}

デプロイ

コードも書けたので、デプロイします。
Auroraのデプロイに時間がかかるため、15分ほどかかります。

デプロイ実施
cdk deploy

 ✅  BlogSampleHonoRdsStack

✨  Deployment time: 903.1s

Outputs:
BlogSampleHonoRdsStack.ApiEndpoint = https://<your-endpoint>
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:<your-account>:stack/BlogSampleHonoRdsStack/xxx

✨  Total time: 925.12s

問題なくデプロイできたので、出力されたエンドポイントにリクエストを送ってみます。

動作確認

Authorizationヘッダーには.envに記載した認証情報を設定します。

動作確認
# Create: 新しいTodoを作成
curl -i -X POST "https://your-endopoint/api/todos" \
  -H "Content-Type: application/json" \
  -H "Authorization: Basic <your-information>" \
  -d '{"userId": "user123", "title": "新しいタスク", "completed": false, "dueDate": "2023-12-31"}'
HTTP/2 201 
content-type: application/json
content-length: 282
date: Mon, 03 Feb 2025 07:38:33 GMT
x-amzn-trace-id: Root=1-67a07278-034fea4b36f4d3f100add63f;Parent=35912d6a5ea32718;Sampled=0;Lineage=1:d9c01db3:0
x-amzn-requestid: 3caa63ab-8868-40f5-94ff-87c02707f0d8
x-amz-apigw-id: FZbS6Gc4tjMEZjQ=
x-cache: Miss from cloudfront
via: 1.1 77c20654dd474081d033f27ad1b56e1e.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT51-C4
x-amz-cf-id: T6rcuZ3hzxTGK7qXqsi5H7TrC-Y7Wf-vO_qFWBW7cW2lbveiqc3qxQ==

{"message":"Todo created successfully","todo":{"id":"01JK5BYAQXSM6B74XX338WFBQH","userId":"user123","title":"新しいタスク","completed":false,"dueDate":"2023-12-31T00:00:00.000Z","description":null,"createdAt":"2025-02-03T07:38:33.596Z","updatedAt":"2025-02-03T07:38:33.596Z"}}

curl -i -X GET "https://your-endopoint/api/todos/user/user123" \
  -H "Authorization: Basic <your-information>"
HTTP/2 200 
content-type: application/json
content-length: 237
date: Mon, 03 Feb 2025 07:40:15 GMT
x-amzn-trace-id: Root=1-67a072de-7287eadb3ca4eeb039b54059;Parent=6e7ab73aca882002;Sampled=0;Lineage=1:d9c01db3:0
x-amzn-requestid: 619b11fc-89d2-44a4-95f7-13e6e23ff51e
x-amz-apigw-id: FZbi5EyztjMESmw=
x-cache: Miss from cloudfront
via: 1.1 70679ce15d5e20423e4b28a0e958e480.cloudfront.net (CloudFront)
x-amz-cf-pop: NRT57-P3
x-amz-cf-id: HfwXF8o9KYi5fEluITrUrNKSlOybB-pMTpOBF067emXTfAIt7N5U8Q==

[{"id":"01JK5BYAQXSM6B74XX338WFBQH","userId":"user123","title":"新しいタスク","completed":false,"dueDate":"2023-12-31T00:00:00.000Z","description":null,"createdAt":"2025-02-03T07:38:33.596Z","updatedAt":"2025-02-03T07:38:33.596Z"}]% 

AWS上にデプロイしても問題なく実行できていますね!!

念の為、コンソール上からもレコードが登録されているか確認してみます。

レコード確認
  1. コンソール上からクエリエディタを選択し下記情報を入力しデータベースに接続しますボタンを押下

    • データーベースインスタンスまたはクラスター:今回作成したクラスター
    • データベースユーザー名:.envに記載したユーザー名
    • データベースパスワード:.envに記載したパスワード
    • データベースまたはスキーマの名前:todo_db

    CleanShot 2025-02-03 at 16.50.59@2x-8570175

  2. select * from todo_db.todosをエディタに入力し、実行ボタンを押下
    CleanShot 2025-02-03 at 16.56.05@2x

しっかりと先程登録したレコードが返却されていますね!!

おわりに

RDSを使用したHonoで実装するバックエンド処理はいかがでしたでしょうか?
DynamoDB版よりは少し手間な箇所もありますが、お手軽にバックエンドサーバーを作れるかと思います!
最後までご覧いただきありがとうございました!

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.