💸

我々は富豪プログラミングをしていた。Cloudflare Workersで実装はどう変わるか

に公開1

Cloudflareへ移るまで、我々が富豪プログラミングをしているとは気づきませんでした。

EC2で動かしていたNestJSをHono + Cloudflare Workersへ移しました。RDSはそのまま使い、ElastiCacheの役割はCloudflare側へ移しています。

大きなSDKを入れる。レスポンスを全部メモリへ載せる。DBのSELECTを順番に待つ。Cronで全件を完走する。SSEをつなぎっぱなしにする。ElastiCacheの細かいキーを何度も読む。

どれも珍しい実装ではありません。少なくとも私のAWS構成では普通に動き、わざわざ直す理由もありませんでした。サーバーとクラスターの月額に、まとめて収まっていたからです。

Cloudflareへ移すと、それぞれにバンドルサイズ、128 MBのメモリ、ネットワーク往復、CPU時間、Durable ObjectsのDuration、KVのオペレーションという値札がつきました。月額に溶けていた実装の重さが、急に見えるようになったのです。

無駄遣いをしているつもりはありませんでした。数えなくても困らない世界にいただけです。我々は富豪プログラミングをしていたのです。

富豪プログラミングからの転換

Cloudflareへの移行で、数え方が変わった5つの実装を順に振り返ります。

1. パッケージは動作環境と責務で選ぶ

公式SDKがあるなら入れる。普通の判断です。認証、リトライ、型、複数オペレーションがまとめて入り、使わない機能があってもEC2の請求書はほぼ変わりません。npm installは設計判断というより作業手順でした。

WorkersはNode.jsプロセスではなく、V8 isolate上でWeb標準APIを中心に動きます。nodejs_compatを有効にしてもNode.jsそのものにはならず、importできるだけのstubもあります。インストールやビルドで安心せず、実際に使うコードパスをWorkersで通してから、容量と責務を比べます。

バンドルサイズを実測して選ぶ

Workersには圧縮後のWorkerサイズ上限があります。2026年8月時点ではFreeプランが3 MB、Paidプランが10 MBです。フロントエンドのバンドルのように利用者が毎回ダウンロードするわけではありませんが、デプロイする成果物全体の予算になります。

実際にIDトークン検証だけを置いた最小Workerを測ると、gzip後のサイズは次のようになりました。

実装 gzip
firebase-admin 14.2.0 251.30 KiB
@hono/firebase-auth 1.4.2 + Hono 33.65 KiB
jose 6.2.3 10.98 KiB

AWS Secrets Managerの1オペレーションでも、@aws-sdk/client-secrets-managerは70.64 KiB、aws4fetchは8.31 KiBでした。

いずれも上限には収まります。なので「firebase-adminは重いから使ってはいけない」という結果ではありません。認証1つ、AWS API 1つのために、残りのアプリケーションへどれだけの予算を残すかという話です。

そこで、Firebaseのユーザー管理まで必要ならAdmin SDK、IDトークンの署名とclaim検証だけならjoseというように、パッケージ名ではなくバックエンドが負う責務から選ぶようになりました。AWSも、数個のオペレーションだけならSigV4付きfetch()を使い、SDKのリトライやプロトコル追従を自分たちで所有できる範囲に絞りました。

小さければ正義、ではない

SDKを外せば、リトライや仕様追従を自分で持つことになります。小ささではなく、Workersで動くか、必要な責務か、保守できるかの順に選びます。

2. メモリは余った場所ではなく、通過点にする

メモリが余っているなら、キャッシュや配列を置く。これも普通の判断です。足りなくなればインスタンスを大きくする。棚を増やせば、片づけなくても部屋は広く見えます。

128 MBをisolate内のリクエストで共有する

Workersのメモリ上限はFree、Paidともにisolateあたり128 MBです。同じisolateが複数リクエストを扱うこともあり、モジュールスコープは単一リクエストの専有領域ではありません。

そこで、消えてもよい共有物、リクエスト固有の状態、リクエストを越えて残す状態を分けました。現在のユーザーをグローバルへ置くことも、通常のWorkerでDBコネクションを使い回すこともしません。

全部読んでから返すのをやめる

大きなファイルを一度arrayBuffer()へ入れると、入力と出力を同時に持つことがあります。流せるものはストリームで流す。全部読まなくてよいなら、全部読まない。メモリを「置き場所」ではなく「通過点」と考えるようになりました。

3. 実行時間は長さではなく形を見る

EC2では、処理が最後まで終わるかを見ていました。Workersでは、CPUを使った時間と外部を待った時間を分けて考えます。

CPU時間と経過時間を分ける

WorkersのCPU時間には、fetch()やDBクエリを待っているネットワーク時間は含まれません。そのため「DBを待ったら即CPU上限」という理解は正しくありません。

しかし、利用者はCPU時間ではなくレスポンスを待ちます。請求されない待ち時間でも、ユーザーの時計は止まりません。

DBが近いから直列SELECTで済んでいた

const user = await findUser(userId);
const status = await findStatus(userId);
const notifications = await findNotifications(userId);

EC2とRDSが同じリージョンにあったときは十分速く見えました。しかしWorkerとDBが離れると、3回のawaitは律儀に3往復します。Cloudflareが遅くしたのではなく、近いDBが直列処理を隠してくれていたのです。

依存しないクエリは並列にし、同じ集合を読むならSQLをまとめます。

const [user, status, notifications] = await Promise.all([
  findUser(userId),
  findStatus(userId),
  findNotifications(userId),
]);

そのうえでPlacementを使い、WorkerをDBの近くへ寄せます。先に往復回数を減らし、そのあと距離を縮めます。

全件処理を1回で完走しない

EC2のCronでは、対象を全部取得し、N件の外部APIを1プロセスで呼んでいました。Workersでは1回の仕事に境界があります。そこでCronはIDをQueueへ送り、コンシューマーが最大10件ずつ処理する形へ変えました。

仕事の総量は減りません。ただし途中で落ちても、全件ではなく最大10件のバッチだけをやり直せます。Queueはまれに重複配信されるため、処理自体は重複に耐えられるようにします。英雄的なCronをやめ、失敗を小さくしました。

レスポンス後の仕事をプロセスへ放置しない

レスポンス後の処理も同じです。EC2でvoid notify()としていた処理は、プロセスが残って偶然完了することがあります。「返事はした。あとは裏でよろしく」が通じていました。Workersでは短いベストエフォート処理をwaitUntil()へ、再試行や配信保証が必要な仕事をQueueへ渡します。waitUntil()はバックグラウンドジョブの仕組みではありません。

4. コネクションは「張れるか」より「誰が起き続けるか」

通知ならSSEをつなぎっぱなしにする。DBにはconnection poolを持つ。長く生きるNode.jsプロセスでは、どちらも自然な実装でした。

SSEからWebSocket Hibernationへ

EC2では、通知がなくてもSSEをつないでおけました。サーバーはすでに起きていて、静かなコネクションが1本増えても請求書は同じに見えます。

Workersではコネクションをユーザー単位でまとめるため、Durable Objectを使いました。しかし通知はまれなのに、SSEがある限り休止できません。1時間に1回の通知のため、部屋の電気を1時間つけている状態です。

そこでWebSocket Hibernationへ移し、コネクションを残したままDurable Objectを休止できるようにしました。WebSocketが常に正解なのではありません。見るべきなのは、無言のあいだ誰が起き続けるかです。

DB connection poolも自分で持たない

Node.jsサーバーでは、connection poolをグローバルに置いていました。Workersではクライアントを呼び出しごとに作り、RDSへのconnection poolはHyperdriveに任せます。poolを捨てたのではなく、持つ場所を変えました。

5. ElastiCacheの「読み放題、書き放題」を分解する

ElastiCacheはもう起動している。なら30回ぐらいGETしてもよい。これも普通の判断でした。クラスターが耐えている間は、1回のGETも100回のGETも同じ月額に見えます。請求書には「今月は無駄なGETをしました」とは書かれません。

KVとCache APIを共有範囲で分ける

最初はRedisのキーを、そのままKVのキーへ移しました。きれいな移植です。料金表を見るまでは。

KVでは読み書きがキー単位で数えられ、存在しないキーを読んでも1回です。月額に溶けていたアクセスが従量課金の数字になると、30回読む必要があったのかを考えるようになりました。

現在は共有範囲で分けています。

要件 置き場所
消えても再生成でき、同じ拠点で短時間使えればよい Cache API
複数拠点から読みたい。反映遅延を許容できる Workers KV
同じIDへ処理を集約し、強整合なストレージが必要 Durable Objects
永続データの正本 DB

一覧で使うID集合は1つのCache APIキーへまとめ、拠点を越えて共有したいものだけKVへ残しました。KVをけちるのではなく、ElastiCacheが隠してくれていた無駄を従量課金へ移植しないためです。

貧乏プログラミングになったわけではない

富豪プログラミングは、悪口ではありません。数えなくても困らないほど、プラットフォームに余裕があったということです。問題は、そのときの習慣を別のプラットフォームへそのまま持ち込むことでした。

Cloudflareもかなり富豪です。connection poolはHyperdriveへ、短命なキャッシュはCache APIへ、再試行する仕事はQueueへ渡せます。ただし、その富を1つのNode.jsプロセスで抱えず、得意な場所から借ります。

公式SDKがあるから入れる。メモリがあるから置く。DBが近いから順番に待つ。サーバーが起きているからSSEをつなぐ。ElastiCacheがあるから30回読む。どれも、やっていました。たぶん、多くの人がやっています。

Cloudflareへの移行で変わったのは、「動くか」から「誰が、どこで、いつまで持つのか」へ、実装を見る目でした。不必要な仕事は、しない方がよい。ずいぶん当たり前の結論ですが、環境を変えるまで気づきませんでした。

それではまた。

Discussion

いしはるいしはる

仮に世界中の富豪アプリがCloudflareに移行したら、世界の電力消費量はどれだけ減るのだろうか...

1