Cloudflare Worker + momento でリージョンと戦う
Cloudflare Worker + momento でリージョンと戦う
お詫び
今回の記事のプログラム
LT資料
Cloudflare Meetup Tokyo vol.3 でLTした内容です。
後半に出る Postman Monitorも見れるように用意してます。
Postman Monitor
はじめに
Cloudflare Worker の最大の特徴であるフリーリージョン。今回はこれにフォーカスして書きます。
フリーリージョンとは?
CloudflareはVPCやリージョンがなく、世界中のデータセンターで同じサービスが動いている。
特に
Cloudflare Worker に入ってくるリクエストは 一番近いデータセンターで処理されるため、どこからでも同じ速度でアクセスできるのは魅力です。
とはいえ
全部、Cloudflare Workerにするわけにもいかないケースもあると思います。そうなると考慮が必要になるのが、オリジンサーバーのリージョン。
Cloudflare Worker を通じて、オリジンへのアクセスがあるとどうしてもリージョンを意識しなくてはならず、Cloudflare のメリットを享受できません。
各リージョンにオリジンをおけばいいんじゃないの?
実際はその方が良いんですが、オリジンをワールドワイドに配置するのはとにかくコストがかかります。
特にAWSをよく使うのですが、
システム構成1セット増やすだけでお金かかるのは避けたいです。 特にワールドワイドであれば、結構な数配置することになり、 どれだけお金かかってもよくて、サービスを最速にするのであれば、 各国に配置して、MultiRegion で、データ管理すれば良いと思います。
ただこれはやはりハードルが高いです。
そこまで気にすることなの?海外でしょ?
国内向けのWebサービスだとそれほど必要ないのですが、海外を視野に入れた場合に考慮は必要だと思います。
特に私が関わるサービスだとメインターゲットは国内市場ですが、海外からのユーザーの数もそこそこあるため、体験をなるべく良くしていきたいです。
ただ、マルチリージョンにするほどの予算はないため、コストとの戦いになります。
CDN でキャッシュしたら?
それも考えたんですが、CMSを使うような単純な読み物系サイトであれば静的にファイルを配置するなど考えられますが、Write が伴う場合は難しいです。
アプリだと Writer > Reader になりやすい(Readはフロントでキャッシュしていたりもする)ので
で、結局どうしたいの?
ここから本題です。一つのアイデアですが、下記の方式である程度なんとかならないかなと思っています。
- Cloudflare Worker と オリジンの間にMomento Cache を 全リージョンにおく
 - Cloudflare Worker でリクエスト元の Country 情報を見て、一番近い Momento Cache にアクセス
 - Getリクエスト はCashe Aside パターンでキャッシュを取得して、レスポンスにして返す
 - 更新系のリクエストはWrite Through パターンでキャッシュに書き込む。この時オリジンとの同期が取れるまでキャッシュがdirty になる
 - 更新されたキャッシュを非同期で、オリジンに送り、永続化する
 - ユーザーはリージョン感をまたがない
 
処理の流れ
取得
更新
更新(オリジンへ書き込み)
処理概要
リージョンチェック
Cloudflare のRequest Header にCloudflare のcf情報が追加されており、どこのデータセンターにリクエストが到達したのかなど情報が入っています。
cf.countryフィールドを使用して、各国のデータセンターを判別します。
cfの中身
 "cf": {
        "clientTcpRtt": 7,
        "longitude": "139.68990",
        "latitude": "35.68930",
        "tlsCipher": "ECDHE-RSA-AES128-GCM-SHA256",
        "continent": "AS",
        "asn": 16509,
        "country": "JP",
        "tlsClientAuth": {
          "certIssuerDNLegacy": "",
          "certIssuerSKI": "",
          "certSubjectDNRFC2253": "",
          "certSubjectDNLegacy": "",
          "certFingerprintSHA256": "",
          "certNotBefore": "",
          "certSKI": "",
          "certSerial": "",
          "certIssuerDN": "",
          "certVerified": "NONE",
          "certNotAfter": "",
          "certSubjectDN": "",
          "certPresented": "0",
          "certRevoked": "0",
          "certIssuerSerial": "",
          "certIssuerDNRFC2253": "",
          "certFingerprintSHA1": ""
        },
        "tlsExportedAuthenticator": {
          "clientFinished": "",
          "clientHandshake": "",
          "serverHandshake": "",
          "serverFinished": ""
        },
        "tlsVersion": "TLSv1.2",
        "colo": "NRT",
        "timezone": "Asia/Tokyo",
        "city": "Tokyo",
        "verifiedBotCategory": "",
        "edgeRequestKeepAliveStatus": 1,
        "requestPriority": "",
        "httpProtocol": "HTTP/1.1",
        "region": "Tokyo",
        "regionCode": "13",
        "asOrganization": "Amazon.com",
        "postalCode": "151-0053"
      }
- Country Code / cfには 2 DIGIT ISO CODE が格納されています
 
リージョンをチェックすることで、Momento Cache の配置されているリージョンを選択します。
Honoでは
c.req.raw.cf に入っています。@yusukebe さんに教えていただきました。ありがとうございます。
app.get("/", async (c) => {
    const continent = c.req.raw.cf.continent; // cf はreq.raw.cfに入っている
    const country = c.req.raw.cf.country;
    const city = c.req.raw.cf.city;
    return new Response(
        JSON.stringify({
            version: "0.0.1",
            country: country,
            city: city,
            continent: continent,
        }),
    );
});
Momento Casheで Cache Storeを準備する
Why Momento?
Momento に関してはこちらを参照してください。
特徴
- 安い。従量課金+5GB無料。使った分だけ課金のため、キャッシュをいくら作っても料金は変わりません。今回の用途でいくら増やしても良いのがありがたいです。またリクエストが少ない場合もありがたいです。
 - 管理不要。サーバーレスなため、ElasticCacheのような面倒なインスタンス管理は不要です。
 - 速い。
 - 簡単。各言語のSDKが充実してます。特にPub/SubのTopics とWebhookの組み合わせがすごい簡単で、楽です。
 - Redisに似ている。でも互換じゃないので注意
 
Worker 使っているんだからCacheAPIで良いんじゃないの?
今回の実装例では問題ないですが、Cache StoreへのアクセスでWorker を通す必要があるので、originや、リクエスト元側で直接キャッシュを見たいケースを考慮すると、Cloudflare の外にキャッシュがある方が連携がしやすいと感じています。
Cacheの作成とDictionaryの作成
Momento Big Fun さんの記事が大変参考になりますので、そちらを参照してください。
要点
- キャッシュは各リージョンに作成する
 - キャッシュ名はTopics を利用する時に便利なのでそれぞれ分けておく
 

CacheStore データ構造
リクエストが投げられるようなキャッシュ構造にしておく必要があります。
キー
キャッシュキーは POSTなどでリクエストBodyが大きいことも考慮して、UUIDv5 で生成します。
import {v5 as uuidv5} from 'uuid'
const params = {
    url: "/api/v1/request",
    method: "GET",
    headers: {
        "Content-Type": "application/json",
        "momento-signature": "test",
    },
    body: {
        test: "aaa"
    },
};
const uuid = uuidv5(JSON.stringify(params),uuidv5.URL);
構造
{
    "key": "e4e5fa46-1873-bc80-ea56-d67489c32227",
    "value":{
          "endpoint": "/api/v1/request",
          "query": "?query1=1&query2=2",
          "method": "GET", 
          "request":{
              "header": {
                  "accept": "*/*",
                  "accept-encoding": "gzip",
                  "content-length": "167",
                  "host": "momento-cache-worker.future-techno-developers.workers.dev",
                  "Authorization Bearer": "secret",
              },
              "body":""
          },
          "response":{
            "header": {
                "Content-Length":40,
                "Content-Type":"text/plain;charset=UTF-8"
            },
            "body":{
                "version":"0.0.1",
                "continent_raw":"AS"
            }
          }
        }
    }
}
Queueの作成
Queue の作成は Momento Cache のSorted Sets を利用します。
{
  "value": "e4e5fa46-1873-bc80-ea56-d67489c32227" //CasheStore のKey
  "score": 100 //同時に書き込む時は同じ数値にする。 追加するたびに +1 する . 
}
要点
- valueはCasheStore のKeyにする
 - 既読管理にScoreを利用する
 - Score は同時に書き込むケースなどで同じScoreにする。追加するたびに+1
 - 既読管理を処理したScoreは覚えておく。
 
Momento Topics で Publishして Worker を動かす
Momento Topics は Subscriber を Momento Topics と Webhookと二つの方法があります。
今回は Webhookを使用します。
Cloudflare Worker で Momento Topics 考慮すること
1. Cloudflare Worker は 1リクエスト後に処理が終了し、常駐しません。
Momento Topics のSubscriber は常駐している必要があるため、適していません。Webhookで、Momento からリクエストを送ることで
処理をする方が適しています。今回Origin に手をいれる洗濯はしないため、Webhookを使います。
2. Publish するMomento Cacheのキャッシュを起点にSubscribeされる
これはどういうことかというと、そのキャッシュが配置されているリージョンから通知が来ます。
WebHookも該当のリージョンからAPIリクエストを送信するため、今回のケースではリージョンを一致させて処理をする必要があります。
処理概要
測定する
測定にPostman Monitor を使う
設定

- US(East)Static IP とUS(West)Static IP は外しています。これはリクエスト元のcityが同じだったため変わらないためです。
 
測定した結果
22日の21時に1stリクエスト その後オンキャッシュ状態で動作しています。

1st リクエスト以外は全てキャッシュからリクエストされています。
とはいえ。200ms 以上のリクエストがほとんどです。
考察
- リージョンの差異がな苦なった
 - とはいえ200~300ms ぐらいのレスポンス
 - どうせならキャッシュからの単純Getで100msは切りたい
 - Tokyoリージョンに対してPostman Collectionからのリクエストは60ms ぐらいになる時もあったので、頻度や測定方法にも影響ありそう
 - 測定方法もこれであっているのか評価した方が良い。性能劣化はわかるが厳密な調査ができているかは不明
 - Momentoは 別記事で書いてますが、 50ms 程度で取得できるので遅くない
 
100ms を切るには?
- キャッシュのGetのみであれば、Momento から直接取得する
 - Postはある程度しょうがないところもある。
 - Cloudflare WorkerのプランをEnterpriseにする(これはしたくない)
 - 別のEdge Functionも評価してみる
 
最後に
Cloudflare Worker と Momento を使えば 爆速な道が見えるかと思いましたが、
想像よりもCloudflare Worker に足を引っ張られています。Planだけの問題だったらこういうもんだと諦めるしかないのですが、
もうちょっと考察していきたいです。
おまけ
キャッシュチェックをMomentoのダッシュボードで行うのは面倒なので、CLIツールを作りました。
こんな感じのツールが増えるともっとMomentoが身近になるんじゃないかなと思います。
Discussion