記事一覧へ戻る
TRANSMISSION / CYAN読了 8 分

エッジでステートフルなアプリケーションを構築する

Cloudflare Workers は高速で D1 は近くにあります。しかし本当に難しいのは、状態、一貫性、障害の境界を明確に設計することです。

#Cloudflare#D1#エンジニアリング
中央のデータベースを囲むエッジノードがステートフルなアプリケーショントポロジーを構成している
状態、エッジノード、一貫性の境界

エッジコンピューティングの最も魅力的な約束は、コードをユーザーの近くへ届けることです。しかし、アプリケーションがアカウント、セッション、コメントを保存し始めると、問題は単に「関数をより多くの拠点へデプロイする」ことではなくなります。状態は実際にどこにあり、誰が責任を持ち、障害時にシステムはどう縮退するのかを考えなければなりません。

この記事では、ログインとコメント機能を備えたブログを最小の例として、Cloudflare Workers と D1 でステートフルなアプリケーションを構築するときに重要な設計境界を整理します。

まず状態の地図を描く

小さく見えるブログにも、少なくとも三種類のまったく異なる状態があります。

状態保存元ライフサイクル
記事本文Git 内の Markdownデプロイとともに更新
ユーザーとコメントD1長期的に永続化
ログインセッションD1 + Cookie明確な有効期限

記事コンテンツはコードのバージョンと一緒に管理するのが適しています。レビュー、ロールバック、ビルド時検証は必要ですが、読者が実行時に変更する必要はありません。ユーザーデータはその逆で、デプロイのライフサイクルから独立して存在し続けなければなりません。

データベースを選ぶ前に、そのデータがコードのバージョンに従うべきか、ユーザーの行動に従うべきかを判断します。

この区別から、自然にシンプルな構成が導かれます。Nuxt Content が Markdown を、D1 が実行時の業務データを担当し、Worker が一つのリクエストの中で両者を組み合わせます。

ネットワークリクエストより binding が重要

Worker が D1 にアクセスするために API Token を保存する必要はなく、実行時に Cloudflare REST API を呼び出すべきでもありません。D1 は binding を通じてランタイムへ直接渡されます。

{
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "signal-log-db",
      "database_id": "<YOUR_DATABASE_ID>"
    }
  ]
}

サーバールートでは、データベースオブジェクトを現在のリクエストコンテキストから取得します。すべての SQL は prepared statement を使います。

const comment = await db
  .prepare(`
    SELECT id, body, created_at
    FROM comments
    WHERE post_slug = ?1 AND status = 'published'
    ORDER BY created_at DESC
    LIMIT 100
  `)
  .bind(postSlug)
  .all()

パラメータ binding は単なるコードスタイルではなく、入力境界の一部です。記事パス、メールアドレス、表示名、コメント本文はいずれもユーザー由来です。どの値も SQL 文字列へ連結してはいけません。

セッションには不可逆なダイジェストだけを保存する

ログインに成功すると、ブラウザはランダムなセッショントークンを受け取ります。一方、データベースに保存するのは SHA-256 ダイジェストだけです。セッションテーブルが誤って公開されても、元のトークンをそのままログインに利用することはできません。

const token = createRandomToken()
const tokenHash = await sha256(token)

await db
  .prepare('INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?1, ?2, ?3)')
  .bind(tokenHash, userId, expiresAt)
  .run()

Cookie には HttpOnly を設定してフロントエンド JavaScript から読めないようにし、SameSite=Lax でクロスサイトリクエストのリスクを下げ、本番環境ではさらに Secure を付けます。

これでシステムが「絶対に安全」になるわけではありません。より正確には、各レイヤーが仕事に必要な最小限の能力だけを持つようにしています。

一貫性はユーザー操作を中心に設計する

D1 はリレーショナルデータベースですが、エッジアプリケーションでも、どのフローが強い一貫性を必要とするかを明確にする必要があります。

ブログの重要な操作は多くありません。

  1. 登録直後にログインできること。
  2. コメント送信の成功直後に現在のページへ表示されること。
  3. ログアウト直後に古いセッションが無効になること。

これらのフローは、同じプライマリデータベースに書き込み、その直後の読み取りもそこから行うべきです。記事一覧や公開コメント一覧などの読み取り専用の場面にこそ、より大きなキャッシュ余地があります。

「どこをキャッシュできるか」ではなく、「ユーザーが今完了した約束は何か」から考えます。

障害にもプロダクト設計が必要

エッジランタイムは失敗することがあり、データベースも一時的に利用できなくなることがあります。違いは、その失敗をユーザーが理解できる状態に変えられるかどうかです。

  • ログイン失敗時は「メールアドレスが存在しない」と「パスワードが違う」を区別せず、アカウント列挙を防ぎます。
  • コメント書き込みに失敗したら入力内容を保持し、再試行できるようにします。
  • コンテンツ取得に失敗したら、空白ページではなく正しいエラー状態を返します。
  • サーバーエラーは構造化ログに記録しますが、内部 SQL やスタックトレースをブラウザへ返しません。

信頼性とは「絶対に失敗しない」ことではなく、失敗が起きても境界が壊れないことです。

最後に、退屈なアーキテクチャを保つ

この構成にはメッセージキューも、分散ロックも、追加の認証サービスもありません。それらに価値がないからではなく、現在の問題がまだ必要としていないからです。

長期的に保守しやすい個人サイトの基礎は、たいてい次のようにシンプルです。

  • コンテンツは Git とともに管理する。
  • 実行時データは D1 に保存する。
  • シークレットは Worker secret に置く。
  • 入力は境界で検証する。
  • 障害を明確にし、観測可能かつ復旧可能にする。

トラフィックやプロダクト要件が本当に変化したときに、証拠に合わせてアーキテクチャを進化させれば十分です。エッジはすでに十分高速です。エンジニアリングの仕事は、その単純さを保つことです。

記事はここまで
READER CHANNEL

コメント

00
まだコメントはありません。最初の返信を残してみませんか。