調査レポート: MoonBit 版のブログの文章を Wasm の外に出す #
TODO.md の「MoonBit 版のブログで、記事や文書を足しても Wasm が太らないようにする」の調査と実装。
今の MoonBit 版は、記事、TODO.md、docs/**/*.md を Wasm に埋め込んでいるので、書くたびに Wasm が大きくなる。
wrangler deploy と make dev だけで済む今の手軽さを保ったまま、文章を Wasm の外に出す方法を調べ、/tmp の小さな spike で確かめ、moonbit/examples/blog に実装した。
以下は、調査したときの記録に、実装と結果を足したもの。
結論 #
- **Workers の Static Assets を使う。**
wrangler.jsoncにassetsを書くだけで、同じwrangler deployで文章がアップロードされ、wrangler devでもそのまま動く。R2 や KV のような別の作成やアップロードの手順は要らない。アセットへのリクエストと保存に料金はかからない。 - **Total Upload(Worker の大きさ)にはアセットが入らない。** spike では 11 ファイルのアセットを置いても、Total Upload は Worker の JS の 1.02 KiB だけだった。
- **
run_worker_first: trueにすれば、今のルーティングは何も変わらない。** すべてのリクエストが今までどおり Worker に届き、Worker は binding(env.ASSETS.fetch())でだけ文章を読む。/posts/hello.mdのようなパスに、アセットがそのまま返ることはない。 - **最初の一歩は「Worker の起動後の最初のリクエストで、全部の文章を取って Wasm に渡す」でよい。** Wasm の中は今と同じく同期のまま、埋め込みの代わりに受け取ったファイルから
Siteを作る。目録はファイル名の一覧だけなので、シェルで作れる。リクエストごとに必要なファイルだけを取る形は、文章がずっと増えてから考える。 - 埋め込みをやめると、Wasm は約 249 KB から 100 KB 強になる見込み(埋め込んだ文章が約 134 KB)。記事を足しても Wasm は変わらない。
今の大きさ #
2026-10-15 の main(記事 5 本)で測った。
| もの | 大きさ |
|---|---|
Wasm(moon build --target wasm-gc --release) | 249,111 B |
記事(go/examples/blog/posts/*.md)の合計 | 12,929 B |
TODO.md と docs/**/*.md の合計 | 120,932 B |
生成した posts/embedded.mbt / repodocs/embedded.mbt / assets/embedded.mbt | 14,480 B / 136,193 B / 3,300 B |
| wrangler の Total Upload | 244.97 KiB(gzip 後 80.58 KiB) |
太る原因は、記事よりも文書(特に docs/sketch/ja/ のレポート)のほうが大きい。
候補 #
| 方法 | 手軽さ | Wasm の大きさ | 備考 |
|---|---|---|---|
| 今のまま埋め込む | 最も手軽 | 文章に比例して増える | 64 MiB の上限まではまだ遠い |
| Static Assets | wrangler.jsonc に数行。デプロイの手順は同じ | 文章に関係なく一定 | 第一候補 |
| R2 | バケットの作成と、文章のアップロードの手順が別に要る | 一定 | デプロイと文章の更新が別々になる。wrangler dev では別のローカルのバケットになる |
| KV | R2 と同じく、別に書き込む手順が要る | 一定 | 結果整合なので、デプロイ直後に古い文章が見えうる |
| 文章を圧縮して埋め込む | 手軽 | 増え方が緩やかになるだけ | 展開のコードが要る。DecompressionStream は JS の側にしかない |
| ビルド時にすべてのページを HTML にする | Static Assets と同じ | Wasm がリクエスト時に要らなくなる | Wasm で動かすというこのブログの目的から外れる |
Static Assets について確かめたこと #
ドキュメント #
Static Assets、binding、料金と制限、Workers の制限 を読んだ。
assetsの設定はdirectory(置き場所)、binding(Worker から読むための名前。例はASSETS)、run_worker_first。- 既定(
run_worker_first: false)では、パスがアセットに一致すると Worker を通さずにそのまま返す。trueにするとすべてのリクエストが先に Worker に届く。パターンの配列で一部のパスだけにもできる。 env.ASSETS.fetch()は Request、URL、文字列を受け取り、URL のホスト名は無視してパスだけで探す。- 料金: 「Requests to static assets are free and unlimited」「There is no additional cost for storing Assets」。Worker を通るリクエストは今までどおり Workers の料金で数える。無料プランの上限を超えると、
run_worker_firstに一致するリクエストは 429 になる(今も全リクエストが Worker を通るので、条件は変わらない)。 - 制限: アセットのファイル数は Free で 20,000、Paid で 100,000。1 ファイルは 25 MiB まで。Worker 本体は非圧縮で 64 MiB まで。起動(グローバルスコープの評価)は 1 秒まで。
- アップロードしないファイルは、アセットのディレクトリの
.assetsignore(.gitignoreと同じ書き方)で指定できる。
spike #
/tmp に、記事 5 本と docs/sketch/ja/*.md 6 本を content/ に置き、次の設定で確かめた(wrangler 4.148.0)。
{
"name": "assets-spike",
"main": "./worker.mjs",
"compatibility_date": "2026-10-01",
"assets": { "directory": "./content", "binding": "ASSETS", "run_worker_first": true }
}/raw/<path> で env.ASSETS.fetch(new URL(path, "https://assets.local")) の結果を返し、/many で記事 5 本を Promise.all でまとめて取る Worker を書いた。
| 確かめたこと | 結果 |
|---|---|
wrangler deploy --dry-run | Read 11 files from the assets directory、Total Upload は 1.02 KiB / gzip 0.50 KiB。アセットは含まれない |
/posts/2026-10-14-hello.md を直接取る | Worker の応答が返った(run_worker_first: true が効いている) |
binding で .md を取る | 200、Content-Type: text/markdown; charset=utf-8、本文はファイルのまま |
| 拡張子なしのパス、存在しないファイル | どちらも 404。html_handling は .html だけが対象で、.md が別名で見つかることはない |
wrangler dev に 20 回の往復の中央値 | 何も取らない / が 1.1 ms、23 KB の文書を 1 つ取ると 1.5 ms、記事 5 本を並行して取ると 1.8 ms |
ローカルの workerd での数字なので、本番の速さはデプロイしてから測る必要がある。
設計の案 #
文章の置き場所 #
moonbit/cloudflare/build/(当時は moonbit/workers/build/)は、今も Wasm をコピーする、git で無視している置き場所になっている。ビルドのコマンドで、ここに content/ を作って文章をコピーする。
content/posts/*.md(go/examples/blog/postsから)content/docs/TODO.mdとcontent/docs/docs/**/*.md(リポジトリから。パスは今のrepodocsと同じ)content/files.txt: 上のファイルのパスの一覧(目録)
Static Assets には一覧を返す API がないので、目録は自分で作る。find と sort で足りる。site.css と favicon.svg は小さく、記事とともに増えるものでもないので、埋め込んだままでよい。
Go 版は今までどおり go:embed で埋め込む。変わるのは MoonBit 版だけ。
Wasm への渡し方 #
今の serve は同期で、worker.mjs からパスを受け取ってその場で応答を作る。env.ASSETS.fetch() は非同期なので、Wasm から直接は呼べない。案は 3 つある。
案 1、**最初のリクエストで全部を渡す(推奨)**: Worker の起動後の最初のリクエストで、worker.mjs が files.txt とすべてのファイルを並行して取り、ファイル名と本文を順に Wasm に渡す(今の path_push と同じコード単位の受け渡しに、file_name と file_body のような export を足す)。Wasm はそれで Site を作り、以降は今と同じく同期で応答する。
- 今の
Site::embeddedは、埋め込んだファイルから最初のserveでSiteを作っているので、その入力を差し替えるだけで済む。記事のリンクを解決するのに全部の文書の一覧が要る、という今の作りもそのまま使える。 - 代償は、Worker の起動後の最初のリクエストだけが、全部の文章を取る分だけ遅くなること。今の量(約 134 KB)なら、spike の数字から数 ms と見込む。isolate のメモリにも全文が載るが、これは今と同じ。
案 2、**JS がパスから要るファイルを決めて先に取る**: リクエストごとに 1 ファイルだけ取ればよいが、ルーティングの知識が worker.mjs に漏れる。一覧ページやリンクの解決には、結局全部の一覧と各記事の title / description が要る。
案 3、**Wasm が要るファイルを返し、JS が取って呼び直す**: ルーティングは Wasm に残るが、ABI が「応答」か「このファイルが要る」の 2 通りを返すようになる。一覧ページのために、title と description をビルド時に目録に入れておく必要もある(Go 版と同じ規則で作るには、tinyfrontmatter と tinymarkdown を使う native のコマンドが要る)。
案 1 は ABI に入力の export を足すだけで、Wasm の中のルーティングもレンダリングもそのまま使える。案 3 は、文章が数 MB になって最初のリクエストの遅れが気になってきたら考える。そのときは TODO.md の Future work の「Workers 用アダプターを最適な形に磨く」と一緒に設計する。
変わらないこと、変わること #
make devとmake deployの使い方は変わらない。wrangler devのwatch_dirには、すでに記事とdocs/が入っている。make testは、テスト用の文章をSiteに直接渡すので、ネットワークも Assets も要らない。実装では、埋め込みをテストとexperiments/に残した。- 比較(
make compare)は今までどおり、Go 版と一致することを確かめる物差しになる。
実装 #
2026-10-22 に、moonbit/examples/blog/ に案 1 を実装した。
置き場所とビルド #
| 場所 | 変更 |
|---|---|
examples/blog/scripts/content.sh | 記事と文書を examples/blog/cloudflare/build/content/ にコピーし、files.txt に一覧を書く。ファイルの選び方は list-files.sh に切り出し、embed.sh と共有する |
examples/blog/cloudflare/wrangler.jsonc | assets(directory: ./build/content、binding: ASSETS、run_worker_first: true)を足した |
examples/blog/cloudflare/worker.mjs | 初回リクエストで files.txt と全部のファイルを ASSETS から並行して取り、app.load(files) で Wasm に渡す。失敗したら 500 を返し、次のリクエストでやり直す |
examples/blog/worker/worker.mbt | file(n) を export。name\tcontent を受け取り、posts/ と docs/ の下だけを受け付ける。serve は受け取ったファイルから最初のリクエストで Site を作る |
pkg/http/wasm/adapter.mjs | load(files) を足した。file(n) を 1 ファイルずつ呼び、Wasm が返したエラーは Error にする |
pkg/http/wasm/wire.mbt | read_file、write_error、FileError を足した |
examples/blog/site/ | Site::embedded をやめ、load_posts と load_docs は (名前, 本文) の組を受け取る。記事と文書の埋め込みはテストだけの依存にした |
experiments/adapter-abi/content/ | spike が使っていた Site::embedded の代わり。site(base_url~) を返す |
Makefile | embed を embed-posts、embed-repodocs、embed-assets に分けた。build は embed-assets と content だけを実行し、test と check は今までどおり全部の embed を実行する |
build/content/ は git で無視している(files.txt も含む)。記事を足したときに再生成が要るのは build だけで、テストは埋め込みを使うので embed が走る。
Wasm への渡し方 #
worker.mjs は files.txt を取ってから、一覧の各ファイルを Promise.all で取る。それを app.load() に渡すと、adapter.mjs が 1 ファイルずつ file(n) を呼ぶ。Site は serve の最初の呼び出しで作る。BASE_URL はリクエストの env から来るので、Site の組み立てをリクエストの処理まで遅らせている。
Site の組み立てに失敗したときは、その内容を 500 の本文にして返す。worker.mjs 側も ASSETS の取得に失敗したら 500 を返し、loading を捨てて次のリクエストでやり直す。isolate が生きているあいだ、内容は取り直さない(Static Assets はデプロイのときしか変わらない)。
サイズ #
2026-10-22、投稿 10 本(バンドルの 3 ファイルを含めて 12 ファイル)と文書 21(files.txt を足して 34 ファイル、約 397 KB)での比較。変更前は 2a3cdf1 の worktree で測った。
| もの | 変更前 | 変更後 |
|---|---|---|
Wasm(moon build --target wasm-gc --release) | 649,223 B | 175,001 B |
app.wasm(wasm-opt -Oz の後) | 615,994 B | 141,966 B |
| wrangler の Total Upload | 605.23 KiB(gzip 195.43 KiB) | 144.55 KiB(gzip 63.56 KiB) |
調査のときに「249 KB から 100 KB 強になる見込み」と書いたが、実際は 175 KB になった。文書が調査時より増えた(このレポート自身も入っている)のに、Wasm は記事と文書の量に比例しなくなった。wrangler deploy --dry-run は Read 41 files from the assets directory と言うが、これは 34 ファイルと 7 ディレクトリを数えたもので、Total Upload には入らない。
時間 #
Node.js 24 で、worker.mjs と同じ順序(load してから call)を実行して測った。内容は上の 34 ファイルのうち、files.txt を除く 33 ファイル。
| 処理 | 時間 |
|---|---|
app.load(files)(33 ファイル、約 396 KB) | 7.66 ms |
最初の app.call(/)(Site の組み立てを含む) | 8.88 ms |
2 回目の app.call(/) | 0.26 ms |
一番大きい文書の app.call | 0.90 ms |
wrangler dev の往復時間(curl を 40 回送り、最初の 10 回を除いた 30 回の中央値)は、同じ --persist-to を指定した変更前と変更後で次になった。
| 条件 | 変更前 | 変更後 |
|---|---|---|
| 起動後の最初のリクエスト(3 回の範囲) | 20〜23 ms | 62〜73 ms |
温まったあとの /(3 回の中央値の範囲) | 5.8〜5.9 ms | 6.1〜6.6 ms |
最初のリクエストだけ 40〜50 ms ほど遅い。内容の取得と Site の組み立ての分で、ローカルの workerd の数字なので、本番では未確認。温まったあとは 0.5 ms ほどで、リクエストごとの差はほぼない。
確かめたこと #
make test: MoonBit のテスト 93 件(ほかに JS ターゲットの 2 件)と、Node での統合テスト 10 件が通る。- Go 版のブログ(
-debug -base-url https://blog.podhmo.workers.dev)と 62 パスを比べ、0 of 62 paths differ。記事のページと.md、文書と.md、一覧、/favicon.svg、/site.css、存在しないパスを含む。 wrangler devを動かしたまま記事を 1 本足すと、20 秒ほどで新しい記事が 200 で返り、app.wasmの SHA-256 は変わらなかった。消すとfiles.txtから外れて 404 に戻った(ファイルはbuild/content/に残る。make cleanで消える)。- 記事の front matter に知らないキーを書くと、最初のリクエストが 500 になり、本文にそのキー名が出る(内容の誤りがリクエスト時に分かる)。
つまずいたこと #
**wrangler dev のビルドが重なる。** watch_dir のファイルを続けて変えると、前の make build が終わる前に次が始まる。最初の content.sh は出力を rm -rf してからコピーしていたので、そのあいだのリクエストが内容を見つけられなかった。embed.sh も $out.tmp という固定の名前を使っていたので、2 つのビルドが同じ一時ファイルを奪い合い、mv: cannot stat '...embedded.mbt.tmp' で失敗した。さらに、その途中で 0 バイトの app.wasm が置かれ、workerd が CompileError を返したことがある(開発中だけ)。
直し方:
- 一時ファイルの名前に
$$(プロセスの ID)を足した(embed.sh、embed-assets.sh)。 content.shはファイルを出力の隣で作ってからmvで置き換え、files.txtを最後に書くようにした。ビルドの途中でも、壊れたファイルは見えない(新旧のファイルが混ざることはある)。content.shは、消えた記事のファイルを消さずにfiles.txtから外すようにした。重なったビルドが互いのファイルを消す競合を避けるため。配信されないので、残っても害はない。
**計測の落とし穴。** 最初に wrangler dev の往復時間を比べたとき、変更後が 14 ms、変更前が 5.5 ms で、温まったあとも 8 ms も遅いように見えた。しかし、ブログのコードではなく wrangler dev の置き場所の問題だった。
- 変更前の worktree は
/tmp(tmpfs)にあり、変更後はリポジトリ(ext4)にあった。 wrangler devはローカルの状態を.wrangler/stateに持ち、リクエストごとにそこへ書く。tmpfs と ext4 でその費用が変わる。ASSETSを外して Wasm を呼ばずに 500 を返す経路でも 14 ms だったので、アプリの処理時間ではないと分かった。- 両方に
--persist-toで同じ tmpfs の場所を指定すると、6 ms 前後にそろった。
同じコードを /tmp に置いても速くなり、リポジトリの中の、中身が return new Response("hi") だけの Worker でも遅かった。ローカルの往復時間を比べるときは、状態の置き場所もそろえる必要がある。
**デプロイで古い JS と新しい Wasm が混ざった。** マージ直後に本番へデプロイしたところ、https://blog.podhmo.workers.dev/ が 500 で no content loaded: the host must call file for each asset を返した。git 管理外の build/ と _build/ はブランチを切り替えても残るので、あるブランチで作った app.wasm と content/ が残ったまま、別のブランチの worker.mjs でデプロイすると、この組み合わせになりうる。古い worker.mjs は file を呼ばないが、新しい Wasm は呼ばれる前提で Site を作るため、内容が空のまま最初のリクエストを迎える。
main を取り込んでからデプロイし直すと直り、Go 版との 62 パスの比較も一致した。CI のデプロイはクリーンな checkout なので起きない。ローカルでも同じことが起きないよう、make deploy は make clean に依存させ、毎回作り直してからデプロイするようにした(README の「Deploying」にも書いた)。
残る懸念 #
文章を Wasm から出したことで、記事を足しても Wasm は太らなくなった。代わりに、注意して見る場所が「Wasm のサイズ」から「isolate ごとの最初のリクエスト」に移った。
- 新しい isolate の最初のリクエストは、
files.txtと 33 ファイルの取得、fileの呼び出し、Siteの組み立てを行う。ローカルでは 40〜50 ms かかる。 - これはリクエストごとではなく isolate ごとに起きる。同じ isolate に複数の利用者が当たれば 1 回で、別の isolate ならそれぞれ 1 回。isolate は一定時間で破棄されるので、時間を空けたアクセスや別の地域からのアクセスでは、また起こりやすい。
- そのため、記事や文書が増えて取得が重くなると、影響は「Wasm が大きい」ではなく「新しい isolate に当たったリクエストが遅い」「subrequest や CPU 時間の上限に近づく」という形で出る。
- 対策の候補は、内容を 1 つの JSON にまとめて取得を 1 回にする、ページ単位で遅延ロードする、
Siteの組み立てを軽くする、など。今の量(33 ファイル、約 396 KB)ではどれも要らない。
未確認 #
- 本番での、Worker の起動後の最初のリクエストの遅れ。ローカルでは 40 ms ほど増えた。
build/content/を直接書き換えたときに、wrangler devが isolate を作り直さずに反映するか。posts/やdocs/を変えたときはwatch_dirがビルドを起こすので反映される。- アセットのファイル名に日本語などが入ったときの扱い(今の文章のファイル名は ASCII だけ)。
- 本番の Workers で、
ASSETSからの 34 ファイルの取得がどのくらいかかるか。無料プランの CPU 時間の上限(10 ms)に対して、Siteの組み立てがどこまで食うかも本番では測っていない。 - ~~
ASSETSへの 34 回の取得が subrequest の上限(無料プランで 50)に数えられるか。数えられるなら、記事と文書が増えると最初のリクエストが失敗しうる。~~ 数えられた。下の「追記: subrequest の上限」を参照。
追記: subrequest の上限(#92) #
問い #
ASSETS バインディングへの fetch は subrequest の上限に数えられるか。数えられるなら、記事と文書が増えたときにどうするか。
答え #
数えられる。記事と文書をビルド時に 1 つのファイル content.bin にまとめ、Worker は最初のリクエストでそれを 1 回だけ取得するようにした。取得の回数は記事の数によらず 1 回になる。
どう確かめたか #
**本番で失敗した。** #89 をマージしたあとに make -C moonbit deploy したところ、https://blog.podhmo.workers.dev/ が次を返した。
Too many subrequests by single Worker invocation. To configure this limit, refer to https://developers.cloudflare.com/workers/wrangler/configuration/#limitsそのときの worker.mjs は、files.txt と、そこに並ぶファイルを 1 つずつ ASSETS から取得していた。各コミットでの内容のファイル数(list-files.sh で数えた)と、最初のリクエストでの取得回数(files.txt を含む)は次のとおり。
| コミット | ファイル数 | 取得回数 |
|---|---|---|
| ea2a55c (#87) | 42 | 43 |
| 79c2958 (#88) | 45 | 46 |
| 46a1c67 (#89) | 53 | 54 |
50 を超えたのは #89 からで、失敗が始まった時期と合う。ほかに subrequest を出すコードはないので、ASSETS への取得が数えられていたことになる。
**Cloudflare のドキュメント**(2026-10-27 に読んだ)。
- Workers の Limits: subrequest は "any request a Worker makes using the Fetch API or to Cloudflare services like R2, KV, or D1"。1 回の呼び出しあたり Free で 50、Paid で 10,000(最大 1000 万)。Static Assets のファイルは 1 つ 25 MiB まで、Worker のバージョンあたり Free で 20,000 ファイルまで。
- Wrangler の設定の
limits:subrequestsは "defaults to 50 for free accounts"、"The free account maximum is 50"。また "Limits are only enforced when deployed to Cloudflare's network, not in local development"。 - Static Assets のバインディング と Billing and limitations:
ASSETSへのfetchが subrequest に数えられるかどうかは書かれていない。Service bindings には "Each request to a Worker via a Service binding counts toward your subrequest limit" とある。ASSETSもFetcherのバインディングなので、同じ扱いと考えるのが自然で、本番の失敗とも合う。
**手元での確認。** wrangler dev は上限を課さない(上のドキュメントのとおり)ので、上限で失敗するかどうかは手元では再現できない。代わりに、一時的に env.ASSETS.fetch を数えるコードを worker.mjs に足して make dev PORT=8798 で動かし、リクエストごとの取得回数を出した(確認のあとで外した)。
- 新しい isolate の最初のリクエスト(
/): 1 回(content.bin) - 続く
/posts/、/slops/、/posts/why-ai-novel-is-bored、/posts/why-ai-novel-is-bored/article1.md、/docs/: 0 回 /preview.wasm: 1 回(これまでどおりASSETSへそのまま渡す)
content.bin を worker.mjs の unpack で分けると、53 ファイルの名前が前の files.txt と同じ順で一致し、中身も元のファイルとバイト単位で一致した。Go 版と make compare で比べると、トップページと各一覧からリンクされた 44 パスと article1.md が一致した(/preview.wasm は比較ツールがバイナリを UTF-8 として読めずに両側でエラーになるので、cmp で元のファイルと同じことを確かめた)。
選ばなかった案 #
- **
limits.subrequestsを上げる。** Free の最大が 50 なので上げられない。Paid にしても、記事が増えるたびに上限へ近づく形は変わらない。 - **ページごとに必要なファイルだけ取得する。** トップページや一覧はすべての記事のタイトルと日付を要るので、最初のリクエストで結局すべてを読むことになる。
- **Wasm に埋め込み直す。** 記事を足すたびに Wasm が太るのを避けるために外へ出したので、戻す理由がない。
- **JSON にまとめる。** 今の記事や文書はどれも UTF-8 のテキストなので、JSON でも運べる。ただ、Wasm に渡すのはもともとファイルごとの
Uint8Arrayなので、<size> <name>\nのヘッダーと中身のバイト列を並べる形のほうが、文字列への変換と戻しを挟まずに切り出すだけで済む。束はバイナリーのファイルを扱わないので、バイナリーのためにこの形を選んだわけではない。
残ること #
content.binは 1 ファイルなので、25 MiB の上限に近づいたら分ける必要がある。今は約 630 KB。- ~~本番で直ったかは、デプロイしてから確かめる。~~ 修正を
make -C moonbit deployしたあと、https://blog.podhmo.workers.devのトップページ、/posts/、/slops/、/posts/why-ai-novel-is-bored、/posts/why-ai-novel-is-bored/article1.md(text/markdown)、/docs/、/preview.wasm(application/wasm)が 200 を返し、存在しないパスは 404 を返した。新しい isolate の最初のリクエストを狙って打ち分けたわけではないが、修正前はトップページが subrequest の上限のエラーを返していた。