Redis
 Computer >> コンピューター >  >> プログラミング >> Redis

Cloudflare WorkersとUpstash Redisで作る、Webサイト独自の待合室(Waiting Room)

この記事では、Cloudflare WorkersとUpstash Redisを組み合わせて、Webサイト向けの待合室(Waiting Room)ページを実装する方法を解説します。

なぜ待合室が必要なのか?

Webサイトへの訪問者数が増えるのは本来喜ばしいことですが、必ずしもそうとは限りません。突発的な大量トラフィックはアプリケーションを容易に圧倒し、最悪の場合、サービス全体が停止してしまうこともあります。待合室は、トラフィック急増時にアクセスを制御し、リソースを保護するための有効なソリューションです。

Cloudflareには公式の「Waiting Room」機能がありますが、残念ながらBusinessプランおよびEnterpriseプラン限定の提供です。ご安心ください。この記事では、Cloudflare WorkersとUpstash Redisを使えば、どのような規模・種類のWebサイトでも待合室を構築できることをお見せします。

仕組みはどうなっている?

待合室の動作は、次の2つのパラメータで制御します。

  • 最大セッション継続時間(Max Session Duration): 訪問者がサイト上でアイドル状態のままいられる時間
  • 最大同時収容人数(Max Website Capacity): サイトが同時に受け入れられる訪問者数

訪問者がサイトにアクセスすると、まず一意のキー(セッションキーのようなもの)を生成し、Redisに保存します。このキーには有効期限として最大セッション継続時間を設定します。訪問者をサイトに入れる前に、Redisのdbsize(現在のキー総数)を確認し、その値が最大同時収容人数を超えている場合は、訪問者を待合室へ誘導します。

待合室は静的なHTMLページですが、30秒ごとに自動更新されます。つまり30秒に1回、空きが出ていれば訪問者はサイトへ入れる仕組みです。

また、生成した一意のキーはCookieとして訪問者のリクエストに付与されます。同じ訪問者からのリクエストには常に同じキーが含まれるため、キャパシティが満杯の状態でも「すでにサイト内にセッションを持つ訪問者かどうか」を判定できます。つまり、自分のキーがRedisのキースペースに存在する限り、たとえ満員でもサイトへの入場が許可されます。逆に、最大セッション継続時間を超えてアイドル状態が続くとキーはRedisから削除され、その後リクエストを送ったときにキャパシティが満杯であれば、待合室へ転送されるという流れです。

このロジックはCloudflare Workers上に実装し、RedisストアとしてはUpstashを使用します。ここからは、この技術選定の背景について説明します。

なぜCloudflare Workersなのか?

待合室の実装は、Webサイトへのすべてのリクエストを横取り(インターセプト)することになります。そのため、パフォーマンスオーバーヘッドは最小限である必要があります。Cloudflare WorkersはCloudflareのエッジインフラを活用するため、世界中どこでも最低レベルのレイテンシを実現できます。さらにAWS Lambdaと異なりコールドスタート問題がなく、サーバーレス技術なのでスケーラビリティも心配ありません。

なぜUpstash Redisなのか?

新しい訪問者を受け入れる前に、現在のサイトの利用状況(サイズ)を毎回確認する必要があります。しかしCloudflare Workersはステートレスなので、この情報は外部に保持しなければなりません。低レイテンシという点でRedisは最適な選択肢です。ただし、一般的なRedisサービスはTCPベースの接続を要求しており、Cloudflare Workersはこれをサポートしていません。そこで登場するのがUpstashです。UpstashはREST APIを内蔵した唯一のRedisサービスであり、グローバルレプリケーションのおかげで世界中どこでも低レイテンシを実現します。

ステップバイステップでの実装

以下、プロジェクトを段階的に実装していきます。クローンしてすぐに自分のサイトへ待合室をセットアップしたい方は、ソースコードのreadmeの手順に従ってください。

1. プロジェクトのセットアップ

wranglerを使ってプロジェクトを作成します。


wrangler generate waiting-room

続いて依存関係をインストールします。


npm install cookie upstash@redis

2. wrangler.tomlの更新

typeを更新します。

type = "webpack"

CloudflareアカウントIDを設定します。アカウントIDの確認方法はこちらを参照してください。

account_id = "REPLACE_HERE"

以下の変数を追加します。

[vars]
UPSTASH_REDIS_REST_TOKEN = "REPLACE_HERE"
UPSTASH_REDIS_REST_URL = "REPLACE_HERE"
TOTAL_ACTIVE_USERS = 10
SESSION_DURATION_SECONDS = 30

UpstashコンソールからGlobalデータベースを作成してください。RESTトークンとURLをコンソールからコピー&ペーストするだけです。なお、Redisデータベースは初期状態で空であり、このアプリケーション専用として使用する必要があります。

TOTAL_ACTIVE_USERSとSESSION_DURATION_SECONDSは、自身の要件に応じて適切に設定してください。

3. index.js

index.jsはCloudflare Workersの実装ファイルです。すべてのロジックをこの中に記述します。以下のコードをコピー&ペーストしてください。

import { parse } from "cookie";
import { Redis } from "@upstash/redis/cloudflare";

const redis = Redis.fromEnv();

addEventListener("fetch", (event) => {
  event.respondWith(
    handleRequest(event.request).catch(
      (err) => new Response(err.stack, { status: 500 })
    )
  );
});

const COOKIE_NAME_ID = "__waiting_room_id";
const COOKIE_NAME_TIME = "__waiting_room_last_update_time";

const init = {
  headers: {
    Authorization: "Bearer " + UPSTASH_REDIS_REST_TOKEN,
  },
};

async function handleRequest(request) {
  const { pathname } = new URL(request.url);
  if (!pathname.startsWith("/favicon")) {
    const cookie = parse(request.headers.get("Cookie") || "");
    let userId;

    if (cookie[COOKIE_NAME_ID] != null) {
      userId = cookie[COOKIE_NAME_ID];
    } else {
      userId = makeid(8);
    }

    const size = await redis.dbsize();
    console.log("current capacity:" + size);
    // there is enough capacity
    if (size < TOTAL_ACTIVE_USERS) {
      return getDefaultResponse(request, cookie, userId);
    } else {
      // site capacity is full
      const user = await redis.get(userId);
      if (user === "1") {
        // the user has already active session
        return getDefaultResponse(request, cookie, userId);
      } else {
        // capacity is full so the user is forwarded to waiting room
        return getWaitingRoomResponse(userId);
      }
    }
  } else {
    return fetch(request);
  }
}

async function getDefaultResponse(request, cookie, userId) {
  // uncomment below to test the function with a static html content
  // const newResponse = new Response(default_html)
  // newResponse.headers.append('content-type', 'text/html;charset=UTF-8')

  const response = await fetch(request);
  const newResponse = new Response(response.body, response);

  const now = Date.now();
  let lastUpdate = cookie[COOKIE_NAME_TIME];
  if (!lastUpdate) lastUpdate = 0;
  const diff = now - lastUpdate;
  const updateInterval = (SESSION_DURATION_SECONDS * 1000) / 2;
  if (diff > updateInterval) {
    await redis.setex(userId, SESSION_DURATION_SECONDS, 1);
    newResponse.headers.append(
      "Set-Cookie",
      `${COOKIE_NAME_TIME}=${now}; path=/`
    );
  }

  newResponse.headers.append(
    "Set-Cookie",
    `${COOKIE_NAME_ID}=${userId}; path=/`
  );
  return newResponse;
}

async function getWaitingRoomResponse(userId) {
  const newResponse = new Response(waiting_room_html);
  newResponse.headers.set("content-type", "text/html;charset=UTF-8");
  return newResponse;
}

function makeid(length) {
  let result = "";
  const characters =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
  const charactersLength = characters.length;
  for (let i = 0; i < length; i++) {
    result += characters.charAt(Math.floor(Math.random() * charactersLength));
  }
  return result;
}

const waiting_room_html = `
<title>Waiting Room</title>
<meta http-equiv='refresh' content='30' />

<style>*{box-sizing:border-box;margin:0;padding:0}body{line-height:1.4;font-size:1rem;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif;padding:2rem;display:grid;place-items:center;min-height:100vh}.container{width:100%;max-width:800px}p{margin-top:.5rem}</style>

<div class='container'>
  <h1>
    <div>You are now in line.</div>
    <div>Thanks for your patience.</div>
  </h1>
  <p>We are experiencing a high volume of traffic. Please sit tight and we will let you in soon. </p>
  <p><b>This page will automatically refresh, please do not close your browser.</b></p>
</div>
`;

const default_html = `
<title>Waiting Room Demo</title>

<style>*{box-sizing:border-box;margin:0;padding:0}body{line-height:1.4;font-size:1rem;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif;padding:2rem;display:grid;place-items:center;min-height:100vh}.container{width:100%;max-width:800px}p{margin-top:.5rem}</style>

<div class="container">
  <h1>
    <div>Waiting Room Demo</div>
  </h1>
    <p>
              Visit this site from a different browser, you will be forwarded to the waiting room when the capacity is full.
    </p>
  <p>  Check <a href={"https://github.com/upstash/waiting-room"} style={{"color": "blue"}}>this project </a> to set up a waiting room for your website.</p>
</div>
`;

上記のコードでは、waiting_room_html変数を編集できます。これは待合室ページの静的HTMLです。日本語のメッセージに書き換えても問題ありません。

handleRequestメソッドは、サイトの空き状況に応じてgetDefaultResponseまたはgetWaitingRoomResponseのいずれかを返します。

4. ローカルでの実行

待合室をローカルでテストするには、キャパシティを1、セッション継続時間を30秒に設定しておくと分かりやすいでしょう。そのうえで、以下のコマンドを実行します。


wrangler dev

Chromeで https://127.0.0.1:8787/ を開くと、次のように表示されます。

Cloudflare WorkersとUpstash Redisで作る、Webサイト独自の待合室(Waiting Room)

続いて、同じURLをSafari(またはChromeのシークレットモード)で開くと、次のように表示されます。

Cloudflare WorkersとUpstash Redisで作る、Webサイト独自の待合室(Waiting Room)

30秒以上待つと、待合室のページが自動更新され、サイトへ入場できるはずです。

ローカル環境では実際のWebサイトへの転送が行われないため、Cloudflareの404ページが表示されますが、これは正常な動作です。

5. 公開(デプロイ)

以下のコマンドでCloudflare Workersの関数をデプロイします。


wrangler publish

すると、https://waiting-room.upsdev.workers.dev/ のようなURLが発行されます。

次に、このWorkerを自分のWebサイトにルーティングしましょう。まず、ドメインのネームサーバーがCloudflareを指している必要があります。詳細はこちらを確認してください。その後、CF Workersダッシュボードでドメインをルートとして追加し、Workers関数を選択します。

Cloudflare WorkersとUpstash Redisで作る、Webサイト独自の待合室(Waiting Room)

まとめ

Cloudflare WorkersとUpstash Redisのおかげで、アプリケーションのコードには一切手を加えることなく、待合室を構築できました。エッジ関数とUpstashを組み合わせることで、ここまで強力なソリューションが実現できる好例だと言えるでしょう。

今後改善できる点もあります。

  • 推定待ち時間の表示: 平均待ち時間を計算して表示できるようにする
  • 公平で順序のあるキュー: 現状では空きが出た際に待機中の訪問者がランダムに入場します。キューを保持し、順番通りに入場できるように改善できる

ただし、これらの改善はいずれもより多くの状態管理と追加のリモート呼び出しを必要とするため、今回は省略しました。もしユースケース上どうしても必要な場合は、Cloudflareチームがエンタープライズ向けソリューションで行った取り組みを参考にするとよいでしょう。

ソースコードもぜひチェックしてみてください。

感想やフィードバックがあれば、TwitterDiscordでお知らせください。

  1. Cloudflare WorkersとUpstash Redisで作る、Webサイト独自の待合室(Waiting Room)

    この記事では、Cloudflare WorkersとUpstash Redisを組み合わせて、Webサイト向けの待合室(Waiting Room)ページを実装する方法を解説します。 なぜ待合室が必要なのか? Webサイトへの訪問者数が増えるのは本来喜ばしいことですが、必ずしもそうとは限りません。突発的な大量トラフィックはアプリケーションを容易に圧倒し、最悪の場合、サービス全体が停止してしまうこともあります。待合室は、トラフィック急増時にアクセスを制御し、リソースを保護するための有効なソリューションです。 Cloudflareには公式の「Waiting Room」機能がありますが、残念ながらBu

  2. Cloudflare WorkersとRedisで実現するエッジコンピューティング活用術

    エッジコンピューティングは、近年もっとも注目されている技術のひとつです。CDNがファイルをユーザーの近くに配置できるようにしたのと同じように、エッジコンピューティングはアプリケーションそのものをユーザーの近くで実行できるようにします。これにより、開発者はグローバルに分散され、高いパフォーマンスを発揮するアプリケーションを構築できるようになります。 Cloudflare Workersとステートレス性の課題 現在この分野をリードしている製品がCloudflare Workersです。コールドスタートのないサーバーレス実行環境を提供し、Cloudflareのグローバルネットワークを活かすことで、ア