以前、弊社のお問い合わせ機能は、AWS Lightsail 上に Docker Compose で構築した環境で運用していました。Python + Django、MySQL、Nginx を組み合わせた、一般的な Web アプリケーション構成です。
当時は Django や Docker、サーバー運用への理解を深める目的もあり、この構成には学習上の意味がありました。ただ、現在のお問い合わせ機能に求められているのは、フォームの送信内容を受け取って保存し、確認メールと社内通知メールを送るという、比較的ミニマムな処理です。
あらためて振り返ると、「この規模の機能のためだけに Django アプリケーションを用意し、サーバーを立て続ける必要があるのか」という疑問が出てきました。学習目的としては十分に意味があった一方、現時点では運用・保守の負担が機能の規模に対して相対的に大きくなっていました。
そこで今回、お問い合わせ機能を Hono・Cloudflare Workers・Cloudflare D1 を使ったサーバーレス構成に置き換えることにしました。メール送信については、従来通り AWS SES を利用しています。
以前の構成
以前のお問い合わせ機能は、AWS Lightsail 上で Docker Compose を使って構築していました。
AWS Lightsail
├── Nginx
├── Django
└── MySQLDjango 側でフォームの POST を受け取り、MySQL に保存し、メール送信処理を行う構成です。Web アプリケーションとしては自然な形ですが、現在のお問い合わせ機能に必要な処理と照らし合わせると、やや大がかりに感じていました。
具体的には、次のような点が気になっていました。
- お問い合わせ機能だけのために Django アプリケーションを維持している
- Lightsail インスタンス、Nginx、MySQL、Docker Compose の運用が必要
- OS やミドルウェアの更新、プロセス監視などを継続的に意識する必要がある
- 機能の規模に対して、構成が重くなっている
Django は非常に優れたフレームワークで、管理画面や認証、複雑な業務ロジックを持つアプリケーションでは今でも有力な選択肢です。ただ、「フォームを受け付けて保存し、メールを送る」だけの用途に絞れば、もう少し軽い構成でも十分に対応できると考えました。
新しい構成
今回採用した構成は次のとおりです。
Frontend
↓
Cloudflare Workers + Hono
↓
Cloudflare D1
Cloudflare Workers
↓
AWS SESフロントエンドから送信されたお問い合わせ内容を Cloudflare Workers で受け取り、Hono でルーティングと入力値の検証を行い、Cloudflare D1 に保存します。その後、AWS SES を使ってお問い合わせ者への確認メールと社内向けの通知メールを送信します。
大きな Web アプリケーションサーバーを立てるのではなく、必要な処理だけを Worker 上で完結させる構成です。
Hono / Cloudflare Workers / D1 を採用した理由
ミニマムに実装できる
お問い合わせ機能に必要な処理は、主に次の 3 つです。
- フォームの入力値を受け取る
- 内容を検証して DB に保存する
- 確認メール・通知メールを送る
この程度の処理であれば、フルスタックな Web フレームワークや常時起動するサーバーは必要ありません。Cloudflare Workers はリクエストが来たときだけ処理を実行するため、インフラ管理の負担を大きく減らせます。
TypeScript で書ける
弊社の自社サイトや周辺の Web アプリケーションでは、Next.js や React を利用しています。フロントエンドと同じ TypeScript でバックエンドの処理を書けることは、技術スタックの統一という観点で素直に利点でした。
Django / Python での実装と比べると、フロントエンドとバックエンドで言語や型の扱いを揃えやすくなります。フォームの入力項目とバックエンドのスキーマが密接に関係する箇所では、この一貫性が実装の見通しをよくしてくれます。
Hono が軽量で扱いやすい
Hono は、Cloudflare Workers などの Edge Runtime で動作する軽量な Web フレームワークです。Express に近い感覚でルーティングを書けるうえに Workers 環境との相性がよく、今回のような小さな API には適切な選択肢でした。
ルーティングや CORS の設定、バリデーションとの組み合わせもシンプルに記述できます。必要なエンドポイントだけを定義する、という小さな構成にちょうどよくフィットしました。
const app = new Hono();
app.use("/contact", cors(/* 許可するオリジンを設定 */));
app.post("/contact", validateRequest, async (c) => {
// 入力値を検証する
// D1 に保存する
// メール送信処理を予約する
// レスポンスを返す
});実際の実装では、許可するオリジンを環境変数から読み込み、お問い合わせフォームを設置しているドメインからのリクエストだけを受け付けるようにしています。
Cloudflare D1 で十分だった
お問い合わせデータの保存先には Cloudflare D1 を採用しました。D1 は Cloudflare が提供する SQLite ベースのサーバーレスデータベースです。
今回の要件はシンプルで、送信された氏名・メールアドレス・会社名・住所・件名・本文などを保存できれば十分です。
CREATE TABLE inquiries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
organization TEXT,
subject TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);実際には氏名の姓・名、ふりがな、住所の各項目など、フォームに合わせてもう少しカラムを細かく分けています。ただデータ構造としてはシンプルであり、常時稼働する MySQL サーバーを管理するほどの複雑さはありません。D1 を使うことで、データベースプロセスの管理を意識せずに済むようになりました。
ローカル開発環境について
Cloudflare Workers の開発には、公式 CLI ツールの Wrangler を使っています。
wrangler dev コマンドを実行すると、Workers のランタイムをローカルでエミュレートした
開発サーバーが起動します。
D1 についても、Wrangler はローカル上に SQLite のモック環境を自動的に用意してくれます。
wrangler.toml に D1 バインディングを定義しておけば、
ローカル実行時にはその設定に従ってローカルの SQLite ファイルが使われ、
実際の D1 データベースには影響しません。
[[d1_databases]]
binding = "DB"
database_name = "inquiries-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
マイグレーションも Wrangler から実行できます。
--local オプションを付けることで、ローカルのモック環境に対してのみ適用され、
本番の D1 は変更されません。
# ローカル環境にマイグレーションを適用
wrangler d1 migrations apply inquiries-db --local
# 本番環境に適用する場合は --local なし
wrangler d1 migrations apply inquiries-db
ローカル開発時の環境変数は .dev.vars ファイルに記述します。
このファイルは .gitignore に追加しておき、リポジトリには含めないようにします。
本番の secret との対応関係が明確になるため、どの値をローカルで差し替えているかも把握しやすくなります。
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
SENDER_EMAIL=dev@example.com
NOTIFY_EMAIL=dev-internal@example.com
ALLOWED_ORIGIN=http://localhost:3000Turnstile については、Cloudflare がテスト用のシークレットキーとサイトキーを公式に提供しています。 ローカル開発時はこのテスト用キーを使うと、実際のチャレンジを経由せずに検証が常に成功するため、 フォームの動作確認がしやすくなります。
実装の流れ
入力値を検証する
フォームから送信される値の検証には Zod を利用しています。Hono と Zod を組み合わせることで、リクエストの JSON を受け取った段階で、必須項目やメールアドレス形式、本文の最大文字数などをまとめて確認できます。
const InquirySchema = z.object({
name: z.string().min(1),
email: z.email(),
organization: z.string().optional(),
subject: z.string().min(1),
content: z.string().min(1).max(2000),
});実際のフォームでは氏名・ふりがな・住所・電話番号など、もう少し多くの項目を扱っています。スキーマとして一箇所に定義しておくと、どの項目が必須でどれが任意かが一目でわかるため、フォームとの整合性も確認しやすくなります。
Turnstile でボット送信を防ぐ
フォームのボット送信対策として、Cloudflare Turnstile を導入しています。 Turnstile は reCAPTCHA の代替として Cloudflare が提供するチャレンジ機能で、 フロントエンド側でトークンを取得し、バックエンドでその有効性を検証する形をとります。
Workers 側では、フォームのデータを受け取った後、まず Turnstile のシークレットキーを使って トークンの検証リクエストを Cloudflare のエンドポイントに送ります。 検証が通らなかった場合はそこで処理を打ち切り、D1 への保存もメール送信も行いません。
const verifyRes = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: c.env.TURNSTILE_SECRET_KEY,
response: body.turnstileToken,
}),
}
);
const { success } = await verifyRes.json<{ success: boolean }>();
if (!success) {
return c.json({ error: "ボット判定されました。時間をおいて再度お試しください。" }, 400);
}Turnstile のシークレットキーは他のクレデンシャルと同様に Cloudflare Workers の secret として設定し、アプリケーションコードには直接含めていません。
D1 に保存する
検証を通過したデータは Cloudflare D1 に保存します。Workers では D1 を binding として扱えるため、環境変数経由でデータベースにアクセスできます。
await db
.prepare("INSERT INTO inquiries (...) VALUES (...)")
.bind(/* フォームから受け取った値 */)
.run();SQL を直接実行する形ですが、今回のような小さな用途では十分に見通しよく書けます。保存に失敗した場合はエラーレスポンスを返し、成功した場合のみメール送信処理に進む流れにしています。
メール送信を予約する
お問い合わせを保存した後は、AWS SES を使ってメールを送信します。送信するのは、お問い合わせ者への受付確認メールと、社内向けの通知メールの 2 通です。
メール送信は API のレスポンスを遅らせないよう、Workers のバックグラウンド処理として実行しています。
c.executionCtx.waitUntil(
Promise.all([
sendConfirmationMail(/* お問い合わせ者宛 */),
sendInternalNotification(/* 社内通知宛 */),
]),
);
waitUntil を使うことで、レスポンスを返した後も Worker 側で処理を継続できます。ユーザーへはできるだけ早く送信完了を返しつつ、メール送信も確実に行いたい場面では、この仕組みがよく合います。
AWS SES を使った理由
メール送信については、移行後も AWS SES を引き続き利用しています。もともと使っていた送信基盤をそのまま活かせること、送信元ドメインやメールアドレスの管理をそのまま継続できることが主な理由です。
Cloudflare Workers から AWS SES を呼び出す際には、AWS の署名付きリクエストを作成する必要があります。今回はその部分を扱いやすくするためのライブラリを利用し、Worker から SES の API を呼び出す形にしました。
const client = createAwsClient({
region: "ap-northeast-1",
service: "ses",
});
await client.fetch(sesEndpoint, {
method: "POST",
body: buildSesRequestBody(/* 宛先・件名・本文 */),
});アクセスキーや送信元メールアドレスなどは環境変数として管理し、アプリケーションコードには直接書かずに Cloudflare 側の secret として設定しています。
環境変数で管理するもの
今回の構成では、次の値を主に環境変数として管理しています。
- CORS で許可するオリジン
- AWS のアクセスキー
- 送信元メールアドレス
- 社内通知用のメールアドレス
- Turnstileのシークレットキー
ローカル開発時は開発用の環境変数ファイルを使い、本番環境では Cloudflare Workers の secret として設定します。機密情報をソースコードに含めずに運用できる点は、チームで扱う際にも安心です。
移行してよかった点
Django + MySQL + Nginx + Docker Compose + Lightsail の構成から、Hono + Cloudflare Workers + D1 へ移行したことで、全体の構成はかなりすっきりしました。
- サーバーを常時起動しなくてよくなった
- OS やミドルウェアの保守を意識する場面が減った
- TypeScript で小さく実装できた
- フロントエンド側の技術スタックと統一感が出た
- D1 により、シンプルなデータ保存をサーバーレスで実現できた
- メール送信をレスポンス後の非同期処理として自然に扱えた
特に、お問い合わせフォームのような小規模なバックエンド機能では、サーバーを持たない構成にするだけで、日常的な保守への心理的負荷もかなり下がりました。
あえて Django を使わない判断
今回の移行は、Django が不要だという話ではありません。管理画面・ORM・認証・権限管理が一通り揃った Django は、ある程度規模のある業務アプリケーションや、管理画面を含むシステムでは今でも有力な選択肢です。
ただ今回のお問い合わせ機能に必要だったのは、入力を受け取り、保存し、メールを送るという限定的な処理でした。それだけのためにアプリケーションサーバーを維持し続けるのは、現在の要件に対して少し大きすぎると判断しました。
技術選定において重要なのは、技術そのものの優劣ではなく、対象となる機能の規模や運用体制に合っているかどうかです。今回の用途では、Hono・Cloudflare Workers・D1 の組み合わせがちょうどよいサイズ感でした。
まとめ
今回は、弊社のお問い合わせ機能を Lightsail + Docker Compose + Django + MySQL + Nginx の構成から、Hono + Cloudflare Workers + D1 のサーバーレス構成に置き換えた経緯と実装方針を紹介しました。
当初の Django 構成は、学習目的も含めて意味のある選択でした。ただ、現在のお問い合わせ機能の規模を考えると、サーバーを立てて運用し続けるよりも、Cloudflare Workers 上でミニマムに実装する方が実態に合っています。
Hono は軽量で扱いやすく、TypeScript で書けるため Next.js などを使っているプロジェクトとの相性もよいと感じています。Cloudflare D1 を組み合わせれば、簡単なデータ保存もサーバーレスで完結します。
お問い合わせフォームのような小さなバックエンド機能であれば、Hono・Cloudflare Workers・D1・AWS SES の組み合わせは、運用コストと実装のシンプルさのバランスがよく取れた構成だと思います。