何を今さらって感じではあるが、改めてそれを実感した出来事があったのでメモ。
メモリ制限
別の記事でも同じようなトピックに触れたばかりではあるけど。
https://developers.cloudflare.com/workers/platform/limits/#memory
Cloudflare Workersのメモリ制限は、
- Workerは、デプロイされた各CDNロケーションにおいて、1インスタンスのみ起動する
- その1インスタンスが、それぞれメモリ上限128MBの制限下に置かれる
- どこかのリクエストでメモリ上限を超えると、後続でエラーになるリクエストが出てくる
Worker exceeded memory limit.
エラーで503ステータスが返る
- その1インスタンスは、生存している限り、グローバル変数を使ってリクエスト間データ共有もできる
- 予告なくインスタンスは消滅する可能性がある
というわけで、リクエストごとではない。
グローバルスコープ
グローバル変数でリクエスト間のデータ共有ができるということは、
- もしグローバルにメモリリークしてるハンドラがあった場合
- そのインスタンスが生存してる限り、リクエストごとにメモリが消費されていき
- ある時点で上限を突破してしまい、他のリクエストを巻き添えにエラーを返すようになる
というシナリオがありうるってこと。
リクエスト数が少ない場合、インスタンスはすぐ消滅するので、問題が表面化するのはある程度アクセスが増えて、インスタンスの生存期間が長くなってから。
const state = [];
export default {
async fetch() {
// 適当に文字列を錬成するだけ
const s = generateLargeString();
state.push(s);
const bytes = state.map((s) => s.length).reduce((a, c) => a + c, 0);
return new Response(bytes / 1024 / 1024 + "MB");
},
};
たとえばこのように、変数state
をグローバルにおいておき、リクエストごとに巨大文字列を生成しては保存してみる。
文字列のサイズにもよるけど、10MBくらい盛っておけば、ブラウザでリロードしまくってるだけでメモリ上限エラーがすぐ拝める。
ローカルスコープだけでも
グローバルスコープなんか使いませんよねってのはその通り。
ただこの問題は、特にグローバルスコープに頼らずとも、あっさりと再現できる。
export default {
async fetch() {
const s = generateLargeString();
// 3秒以内にリクエストがきたら詰まる
await new Promise((r) => setTimeout(r, 3000));
const bytes = s.length;
return new Response(bytes / 1024 / 1024 + "MB");
},
};
というようにするだけ。
setTimeout()
も実際には使うことはないけど、単に他のリソースをfetch()
してる待ち時間だったり、コールドなKV/R2からget()
してる待ち時間だったり、リアルワールドでも普通にかかりうる時間かと。
ネットワークI/O時間は課金対象にこそならないものの、今までどおりパフォーマンスとしては問題になるし、こういうメモリ上限で困るみたいな話にもつながる。
メモリ上限はあなたのそばに
具体的な数値はわからないけども、多少なりともメモリヘビーな処理(今回の自分の件では20MBくらい使ってた)と少しの待ち時間が必要な場合、リクエスト数が増えてくるとメモリ上限を突破しちゃうこともある、という話でした。
ワークアラウンドも特にはなく、不要なデータはメモリに載せないことと、あとはさっさとGCされることを祈るしかない。
基本的というか理想的なWorkerの動作としては、リクエスト間にGCが走るなりなんなりするので、スケールするよってことなんであろう。
雑に試した感じだと、想定よりあっさりメモリ上限に引っかかることもあれば、150MBくらい使ってもレスポンス返ってくることもあり、よくわからない。
というか、1KB/reqかかる処理があったら、並列では128 * 1024以上のリクエスト数は処理できないことになる? そもそも128MBってのも、誰に対してのかよくわからん。V8?
調べてないけど、ヘビーユースなSSRとかそれこそRSCとか、メモリも時間も結構使う気がするけど、大丈夫なんだろうか。(そもそも動かん話はさておき)