マルチテナントPaaS・ホスティングのCloudflare WorkersでKV Readを減らす

すべてのアクセスでKVを読む構成から、キャッシュ可能な判断を再利用する構成へ。東京リージョンを含む日本リージョンのクラウドホスティング基盤で、Cloudflare Workerのルーティングを最適化した記録です。

PaaSやマルチテナント型のWebホスティングでは、同じ入口に大量のホスト名とアクセスが集まります。
Workerを軽く書くだけでは不十分で、KV Read、静的ファイル取得、キャッシュの反映遅延、存在しないホストへのアクセスまで、ひとつの経路として設計する必要がありました。

KamuiDashの公開入口では、Cloudflare WorkerがHostからテナントアプリを解決します。解決したルートに応じて、動的アプリまたは静的配信基盤へリクエストを振り分けます。基本構成はCloudflareを入り口としたマルチテナントPaaSの設計で紹介しました。

今回は、その入口で増え続けるKV Readをどう減らしたかに絞ります。結論から言うと、最終的な狙いは「1アクセスごとにKVを読む」状態をやめ、同じCloudflareデータセンター内では、短時間ルーティング結果を共有することでした。

最適化後のリクエスト経路REQUEST PATH
01リクエストHost + Path
02静的本文キャッシュHITなら後段のReadは0
03Route CacheHost単位で短時間共有
04Routing KVMISS時だけ参照
STATIC静的配信HTML / CSS / Assets
DYNAMICTunnel + App動的本文はキャッシュしない
最も安い判定を前に置き、静的本文、ルーティング情報、KV、配信先の順に進みます。

最初の問題は「正しいが、毎回読む」こと

初期のルーティングは単純でした。独自ドメインならドメイン対応をKVから読み、そこから得た内部アプリ名でもう一度KVを読みます。プラットフォームのサブドメインでも、アプリの実行先や静的サイト情報をKVから解決していました。

この構成は分かりやすく、低トラフィックでは十分です。しかしPaaS全体のアクセスが増えると、ユーザーのアプリが何も変更されていない時間にも、同じルーティング情報を読み続けます。

最適化前の読み取りPER REQUEST
PLATFORM HOSTapp.platform.example
APP KV
少なくとも1 Read / request
CUSTOM DOMAINcustomer.example
DOMAIN KVAPP KV
最大2 Read / request
同じHostへ連続アクセスされても、Workerの呼び出しごとに同じKV操作を実行していました。

1. プラットフォーム所有のHostはドメイン対応KVを読まない

最初の削減は、KVを使わなくても分かることをKVへ聞かないことでした。プラットフォームのapexとワイルドカード配下は、すでに内部アプリ名として扱えるHostです。独自ドメインだけが、公開Hostから内部Hostへの変換を必要とします。

Host解決の判断ツリーDOMAIN LOOKUP
受信Hostは誰のものか正規化したhostnameだけで判定
APEXplatform.examplelanding.platform.exampleへDOMAIN KV: 0
SUBDOMAIN*.platform.exampleそのまま内部HostへDOMAIN KV: 0
CUSTOMcustomer.example対応する内部Hostを取得DOMAIN KV: 1
所有ドメインかどうかはHost名で確定できるため、プラットフォーム配下では最初のKV Readを完全に省略できます。

2. 動的・静的のルーティングKVを統合する

次に、動的アプリと静的サイトで分かれていた対応表を、一つのルーティングKVへ統合しました。Workerはアプリ名を一度引けば、配信種別と必要な情報を同時に取得できます。

KVスキーマの統合ONE ROUTING RECORD
BEFORE
動的ルーティングKVapp → 動的アプリの配信情報
静的ルーティングKVapp → 静的サイトの配信情報
AFTER
統合ルーティングKV配信種別、状態、配信に必要な値を
一つのレコードへ
Workerのフォールバックを残さず、コントロールプレーンも同時に新スキーマへ切り替えました。

移行時には、旧KVから新KVへレコードをコピーし、件数と重複を確認してからWorkerを切り替えました。新旧の読み取りフォールバックは残していません。移行途中の分岐が長く残ると、どのKVが正なのか分からなくなるためです。

3. 静的本文キャッシュをKVより前へ移す

静的サイトの同じURLが何度も読まれるなら、Hostの解決より先に本文を返せます。そこで、公開URLをキーにした静的コンテンツキャッシュを、すべてのルーティング処理より前へ移しました。

処理順序の変更EARLY RETURN
BEFORE
  1. Host解決
  2. ルーティングKV
  3. 静的か判定
  4. 本文キャッシュ
  5. 静的ファイル取得
AFTER
  1. 本文キャッシュ
  2. Host解決
  3. ルーティングKV
  4. 静的ファイル取得
静的キャッシュHITでは、KVにも静的配信の後段処理にも到達しません。Workerは実行されますが、処理は入口付近で終了します。

共有キャッシュには他の応答が入る可能性があるため、Worker自身が保存した応答だけに内部マーカーを付けています。HIT時にはマーカーを確認し、利用者へ返す前に削除します。後段から同名ヘッダーが渡ってきても削除し、内部状態が公開レスポンスへ漏れないようにしています。

4. Route Cacheでルーティング結果そのものを共有する

静的本文以外にも、Hostから最終的なルーティングレコードまでをまとめてCache APIへ保存しました。動的アプリでは本文をキャッシュせず、経路だけをキャッシュします。

Cache keyは正規化したHostから生成し、プラットフォームが管理する範囲へ閉じています。具体的な内部パスと識別子は公開していません。

Route Cache MISSとHITSHORT CACHE WINDOW
FIRST REQUEST
WorkerRoute Cache MISSKV ReadCache store配信先
NEXT REQUEST
WorkerRoute Cache HITKV Read 0配信先
動的アプリではRoute Cache HIT後もoriginへ進みます。利用者ごとに変わる本文を共有しないためです。

保存するのは、状態と配信先情報を検証した正常なレコードだけです。必要な値が欠けている場合は、暗黙の既定値へフォールバックさせず、エラーとして安全側に閉じます。内部のフィールド名と識別子は省略します。

5. 存在しないHostも短時間だけキャッシュする

正常なルートだけをキャッシュすると、存在しないHostへのアクセスは毎回KVを読みます。Cloudflare KVでは、存在しないキーを読んだ操作も課金対象です。

そこで、存在しないことが確定した結果だけを短時間negative cacheするようにしました。処理中の状態、壊れたレコード、上流エラーはキャッシュしません。

404のnegative cacheNOT FOUND IS A RESULT
1回目未知のHostKVで存在しないことを確認MISS
短時間404結果を保存不在結果だけを保存Route Cache
2回目以降同じ404を返すKVへ到達しない0 Reads
不在結果も再利用し、同じ検索を短時間に繰り返さないようにします。異常なトラフィックはキャッシュとは別の防御層で制御します。

副作用は、新規アプリや新規独自ドメインがKVへ登録される前にアクセスされると、登録後もしばらく404が残ることです。Route CacheとKV内蔵キャッシュの保持時間をそろえ、既存の反映幅を大きく変えずに、課金対象のKV操作だけを減らしています。

TTLは短ければよいわけではない

TTLはコストだけで決めず、デプロイ後に新しいルートとコンテンツが反映されるまでの許容時間から決めます。KV内蔵キャッシュ、Route Cache、静的本文キャッシュを独立に伸ばすのではなく、重なったときの反映遅延を一つのfreshness budgetとして管理しています。具体的な値は公開していません。

Cache APIとWorkers Cachingを混同しない

今回利用したのは、Workerコード内から操作するCache APIです。CloudflareにはWorker実行前に応答を返すWorkers Cachingもありますが、両者は別の機能です。

二つのキャッシュ位置EXECUTION & BILLING
CACHE API
RequestWorker runsCache HIT
  • Worker request課金あり
  • CPU実行あり
  • KV・後段ReadはHIT時0
WORKERS CACHING
RequestCache HITWorker skipped
  • Worker request課金あり
  • CPU実行なし
  • KV・後段処理は0
Workers CachingのHITもWorkerリクエスト数には課金されます。削減できるのは主にCPU時間と後段処理です。

現在のWorkerは静的・動的共通のゲートウェイです。動的応答はprivateなのでWorkers Cachingへ保存されませんが、キャッシュ確認の段階は追加されます。Workerコードが軽い現在は、まずRoute CacheでKV Readを減らす方を優先しました。

キャッシュは入力検証とセットで実装する

キャッシュは処理を高速化しますが、間違った値も高速に配布できます。そのため、保存前と読取後の両方でレコードを検証しています。

devから段階的に適用する

ルーターはすべてのテナントに影響するため、ローカル、dev、本番の順に確認しました。Terraformのplanでは、毎回Workerだけがin-place updateされることを確認し、KV、静的配信、DNS、Tunnelに変更が混ざっていない状態で適用しています。

ロールアウトと検証SAFE CHANGE
  1. 01ローカルテスト正常、404、pending、不正レコード、cache error
  2. 02dev plan0 add / 1 change / 0 destroy
  3. 03dev実URL動的、静的、未知Hostを確認
  4. 04本番 planWorkerだけの更新を再確認
  5. 05本番実URLplatform、custom、static、404
state内のWorker本文とローカルファイルのSHA-256も比較し、適用内容が完全一致することを確認しました。
期待するTerraform plan
# cloudflare_workers_script.router will be updated in-place

Plan: 0 to add, 1 to change, 0 to destroy.

ローカルでは21ケースを実行し、正常なplatform hostとcustom domain、静的サイト、positive cache、negative cache、pending、不正レコード、KV missを確認しました。本番では動的アプリ、独自ドメイン、静的サイトの2回目HIT、未知Hostの連続404を実URLで確認しています。

残っている最適化

ルーティングKVについては、主要な削減を終えました。次に効くのは、実測に基づく以下の対策です。

まとめ

今回の最適化で重要だったのは、キャッシュ機能を一つ追加することではなく、安い判断から順に並べることでした。

  1. 01
    分かっていることはKVへ聞かないプラットフォーム所有Hostは文字列だけで判定します。
  2. 02
    同じ意味のレコードを統合する一つのReadで動的・静的を決めます。
  3. 03
    本文キャッシュを最初に見る静的HITではルーティング処理そのものを省略します。
  4. 04
    正しいルートと404を短時間共有する同じ判断をリクエストごとに繰り返さない構造へ変えます。
  5. 05
    反映遅延をコストとして扱うTTLを伸ばす前に、許容できる更新時間とpurgeを設計します。

マルチテナントの入口では、小さなReadが全テナントの全アクセスに掛け算されます。Worker、KV、Cache API、静的配信を別々に見るのではなく、一つのリクエストがどこで止まるかを設計することで、構成を複雑にしすぎずにReadを減らせました。

参考資料