実験レポート: examples/blog を TinyGo で Cloudflare Workers に載せる #
TODO.md の「Run examples/blog on Cloudflare Workers with TinyGo」の spike。
マージするかは未定なので、2 つの方式をそれぞれ別ブランチで試した。
| ブランチ | 方式 |
|---|---|
spike/tinygo-workers | 案 A: ビルドタグでエントリポイントを分ける(同じモジュール) |
spike/tinygo-workers-module | 案 B: Workers 用のエントリポイントを別モジュールにする(案 A の上に積んである) |
結論 #
- TinyGo 0.43.0 + syumai/workers-go v0.36.0 で、
examples/blogはwrangler dev上で動いた。記事一覧、記事、ドキュメント、favicon、404 がすべて期待どおりに返る。 pkg/*とgo:embedは TinyGo でそのまま動く。- ただし「
main.goだけをビルドタグで分ける」だけでは足りない。次の 2 点で、エントリポイント以外にも手を入れる必要があった。- TinyGo には
os.OpenRootがない。-debug用のrepo.goも TinyGo のビルドから外す必要がある。 - **TinyGo の
net/http.ServeMuxは Go 1.22 より前の実装のまま**で、GET /...(メソッド指定)、{$}、{name...}に対応していない。全ページが 404 になったため、ルーティングを手書きにした(両案に共通)。
- TinyGo には
- 推奨は **案 B(モジュール分割)**。ルートの
go.modを依存なしのまま保てて、共有部分をtinygo testでテストできる。実際、ServeMux の差異はtinygo testで 10 件のテスト失敗として検出できた。案 A では、この種の差異をテストで捕まえる手段がない。 - 別モジュールは兄弟の
examples/blog-tinyではなく、入れ子のexamples/blog/workersにするのがよい。理由は後述。
環境 #
| バージョン | |
|---|---|
| Go | 1.27.1(ホスト) |
| TinyGo | 0.43.0(Docker イメージ tinygo/tinygo:0.43.0。中の Go は 1.27.2、LLVM 22.1.4) |
| workers-go | v0.36.0。v0.35.0 で github.com/syumai/workers から github.com/syumai/workers-go に改名された(旧パスは deprecated な転送モジュール) |
| wrangler | 4.147.0(npx wrangler@4)。実験時はローカルの wrangler dev だけで確認した。デプロイは後日(「デプロイ」の節) |
TinyGo はホストに入れず、リポジトリをマウントして Docker で動かすシム(tinygo という名前のシェルスクリプト)を PATH に置いて使った。wrangler.jsonc のビルドコマンドも、このシム経由で動くことを確認した。
確認したこと #
pkg/* と go:embed #
tinygo test ./pkg/...は 4 パッケージとも成功した。go:embedは、examples/blogの記事・favicon も、ルートパッケージのsandbox.Docs(TODO.mdとdocs/)も、TinyGo の wasm でそのまま読めた(/docs/TODOなどが表示される)。
そのままではビルドできない: os.OpenRoot #
examples/blog/repo.go:37:15: undefined: os.OpenRoot-debug でファイルを読むときの os.Root が TinyGo にはない。Workers には関係のない機能なので、Workers 用のビルドから外す。
TinyGo の ServeMux は古い #
TinyGo の net/http は標準ライブラリの複製に手を入れたもので、ServeMux は Go 1.22 より前の実装に、{x} だけを扱う独自のマッチを足したものだった。同じパターンを登録したときの結果:
| パターン | リクエスト | Go | TinyGo |
|---|---|---|---|
GET /get/{x} | GET /get/1 | 200 | 404 |
/wild/{x} | GET /wild/2 | 200 | 200 |
/exact/{$} | GET /exact/ | 200 | 404 |
POST /post | POST /post | 200 | 404 |
GET / と /exact/{$} を両方登録 | — | 登録時に panic(衝突) | panic しない |
examples/blog は GET /{$}、GET /posts/{slug}、GET /docs/{path...} を使っていたので、全ページが 404 になった。
対応として、ServeMux をやめて server.route で手書きで振り分けるようにした。パスの値は r.SetPathValue で入れるので、各ハンドラは変えずに済む。ServeMux がしていた次の挙動も合わせて実装し、テストを足した。
- GET と HEAD 以外は 404 ページ(以前も
/のパターンが拾って 404 になっていた) /docs→/docs/へのリダイレクト.、..、連続するスラッシュを含むパスを、正規化したパスへリダイレクト(307)
標準 Go のビルドでは ServeMux のまま、TinyGo のビルドだけ手書きにする案もあるが、ルーティングが二重になるので採らなかった。
workers-go の実行モデル #
workers-assets-gen が生成する worker.mjs は、fetch のたびに new Go() と new WebAssembly.Instance(...) を作り、go.run で **main を実行する**。コンパイル済みのモジュールは使い回されるが、Go のヒープはインスタンスごとなので、リクエストのたびにまっさらな状態から始まる。
Cloudflare Workers 自体は isolate を複数のリクエストで使い回し、JS ならモジュールスコープの変数がリクエストをまたいで残る。毎回やり直しになるのはプラットフォームの制約ではなく、workers-go の設計による。
- 環境変数(
cloudflare.Getenv)はmainの中から読める。-base-urlの代わりに、wrangler.jsoncのvars.BASE_URLから読むようにした。 sync.Onceやグローバル変数でキャッシュしても、次のリクエストには残らない。
ローカルの wrangler dev での応答時間は、TinyGo で 3〜10ms、標準 Go で 5〜8ms ほどだった。
リクエストごとに全記事をパースしている理由 #
どのページへのリクエストでも、全記事と全ドキュメントをパースしている。原因は次の 2 つの組み合わせ。
1. **コードの作り(主因)**: main は起動時に全記事と全ドキュメントを読み込み、パースしてメモリに持つ。examples/blog は長く動き続けるサーバーを前提にしているので、起動時に一度だけ払うこのコストは問題にならなかった。main_workers.go も同じ起動手順を流用している。
2. **workers-go の実行モデル**: 上のとおり main がリクエストごとに走るので、「起動時に一度だけ」が「毎回」になる。
go:embed は関係ない。埋め込んだファイルは wasm のデータセグメントに入っていて、インスタンス化のときにメモリへコピーされるだけで、パースはされない。
ページごとに本当に必要なものは次のとおりで、記事ページやドキュメントのページなら 1 本だけで済む。
| ページ | 必要なもの |
|---|---|
/posts/{slug} | その記事 1 本 |
/docs/{path} | そのドキュメント 1 本 |
/(一覧) | 全記事のタイトル・日付・説明 |
/docs/(一覧) | 全ドキュメントのタイトル |
一覧ページは、タイトルや説明を front matter がなければ本文の最初の見出しや段落から取るので、作り方を変えても全記事のパースが要る。
今のコストは小さい。ネイティブのベンチマークで、全記事(3 本)と全ドキュメント(4 本)の読み込みは 1 回あたり約 32µs、131 KB、718 allocs だった。wasm では数倍遅いとしても 1ms に届かない。ただし記事の数に比例して増える。
必要になったときの対策の候補:
1. **ルートに応じて遅延ロードする**: 記事ページでは、ファイル名の一覧(パース不要)から該当する 1 本だけを読んでパースする。一覧ページは全件パースのまま。
2. **ビルド時にメタデータを作る**: go generate でタイトル・日付・説明の索引を作って埋め込めば、一覧ページもパースが要らなくなる。
3. **インスタンスを使い回す worker.mjs を自前で書く**: 効果は一番大きいが、workers-go の生成物から外れるのでおすすめしない。
バイナリサイズと Workers のサイズ制限 #
| ビルド | 生のサイズ | gzip -9 |
|---|---|---|
TinyGo(-target wasm -no-debug) | 2,092,466 B(2.0 MiB) | 741,246 B |
標準 Go(GOOS=js GOARCH=wasm) | 8,560,688 B(8.2 MiB) | 2,327,300 B |
標準 Go(-ldflags='-s -w') | 8,324,183 B | 2,269,595 B |
2026-09-04 の changelog で、Workers のサイズ制限は「非圧縮で 64 MiB(全プラン共通)、圧縮後のサイズは無制限」になった(以前は圧縮後で Free 3 MB、Paid 10 MB)。標準 Go のビルドも十分に収まるので、**サイズ制限はもう TinyGo を選ぶ決め手にならない**。標準 Go の wasm も wrangler dev で同じように動いた。
なお、この wasm がブラウザに送られることはない。wasm は Cloudflare のエッジ(サーバー側)で動き、ブラウザが受け取るのは SSR された HTML だけ(一覧 / は 1,464 B、記事 /posts/hello は 1,682 B、最大の /docs/sketch/ja/blog でも 18,005 B)。wasm がアップロードされるのは wrangler deploy のときだけ。
サイズが効くのはサーバー側のコストのほう:
- コールドスタート: エッジが Worker を読み込むときに wasm をコンパイルする。ここは wasm が大きいほど遅くなるはずなので、標準 Go(8.2 MiB)より TinyGo(2.0 MiB)が有利。サイズ制限がなくなっても、TinyGo を選ぶ理由はここに残る。
- リクエストごと: コンパイル済みのモジュールは使い回されるが、インスタンスの生成、Go ランタイムの初期化、
main(全記事のパース)は毎回走る(「workers-go の実行モデル」を参照)。
どちらも本番での計測はしていない。
このため、ビルドタグは tinygo ではなく js && wasm にした。TinyGo の -target wasm は GOOS=js GOARCH=wasm になるので、どちらのコンパイラでも同じファイルが選ばれる。
テスト #
- 標準 Go の
go testは、どちらの案でも今までどおり通る。 GOOS=js GOARCH=wasm go test(node で実行)は、workers-go を import したパッケージでは失敗する。workers-go のinitが Workers の JS グローバルを参照して panic するため。tinygo test -target wasmは、TinyGo の Docker イメージに node がないため試していない。- native の
tinygo testでは、httptest.NewServerに接続できず(connection refused)、Closeで止まった。この現象が出るのは-watchの SSE テスト(TestLiveReloadEvents)だけで、TinyGo では skip するようにした(案 B)。
案 A: ビルドタグ(spike/tinygo-workers) #
examples/blog/
main.go //go:build !(js && wasm) フラグ、-debug、-watch
repo.go //go:build !(js && wasm) os.OpenRoot を使う
repo_test.go //go:build !(js && wasm)
main_workers.go //go:build js && wasm workers.Serve
content.go 記事と favicon の go:embed(両方のエントリポイントで共有)
wrangler.jsonc
...その他は変更なし(server.go のルーティングだけ手書きに)- 差分は小さい(12 ファイル、+198/-26。README を含む)。ディレクトリ構成も変わらない。
- 理想の「
main.goだけを分岐」に近いが、repo.goとrepo_test.goにもタグが要る(repo.goの中身をmain.goに寄せれば、タグを付けるのはmain.goとテストだけにできる)。 - ルートの
go.modにgithub.com/syumai/workers-goが入り、go.sumもできる。go mod tidyはすべてのビルドタグを見るので、この依存は消せない。標準 Go の native ビルドには workers-go はリンクされない(go list -depsで確認)。ただし「依存は標準ライブラリのみ」というリポジトリの方針から見ると、モジュールの依存グラフには載ってしまう。 - Workers 用のビルドに対して動くテストがない。native の
tinygo testはmain.goがos.OpenRootを使うのでビルドできず、js/wasm のgo testは workers-go のinitで落ちる。ServeMux のような TinyGo 固有の差異は、wrangler devで手で確かめるまで分からない。
案 B: モジュール分割(spike/tinygo-workers-module) #
go.mod 依存なしのまま
examples/blog/
main.go, repo.go 標準の HTTP サーバー(タグなし)
posts/
embed.go package posts: //go:embed *.md
*.md
internal/site/ サーバー、ビュー、記事とドキュメントの読み込み、-watch
content.go favicon の go:embed、EmbeddedSources
workers/ 別モジュール
go.mod require go-html-sandbox v0.0.0 + replace ../../..、workers-go
main.go workers.Serve
wrangler.jsonc- ルートの
go.modは依存なしのまま。workers-go に依存するのはexamples/blog/workersだけ。 go test ./...をルートで実行しても、入れ子のモジュールは対象にならない。Workers 用はcd examples/blog/workersで別に扱う。internal/siteはos.OpenRootを使わないので、**native のtinygo test ./examples/blog/internal/siteが通る**。試しにルーティングを ServeMux に戻すと、go testは通り、tinygo testは 10 件失敗した。TinyGo 固有の差異を CI で捕まえられる。internalの import 制限はパスで判定されるので、別モジュールのexamples/blog/workersからでもexamples/blog/internal/siteを import できた。- 記事はディレクトリをそのままにして、
posts/embed.goでパッケージにした。go:embedは親ディレクトリを参照できないので、別モジュール(や別パッケージ)から記事を使うにはこの形が要る。-debugは今までどおりexamples/blog/postsを読み、embed.goは*.mdの glob に掛からないので問題ない。 - 代償は差分の大きさ(20 ファイル、+295/-105。ほとんどはファイルの移動と識別子の export)。
Post、Doc、LoadPosts、LoadDocs、NewServer、Server、Server.EnableLiveReload、Server.Watch、Source{FS, Paths}、DocSources、EmbeddedSourcesを export した。
replace ではなく go.work を使う場合 #
examples/blog/workers/go.mod の require + replace ../../.. の代わりに、ルートに go.work(use . と use ./examples/blog/workers)を置く形も試した。
go.workがあれば、workers/go.modからルートモジュールのrequireを消しても、go buildとtinygo buildの両方が通る。ルートでgo test ./.../go vet ./...を実行しても、workersのパッケージは対象にならない(go list ./...で確認)。requireを残してreplaceだけを消すと、go.workがあってもv0.0.0を取りに行って失敗した。go.workがないとき(GOWORK=off、または clone した直後)はビルドできない。.gitignoreがgo.workを無視しているので、コミットしない運用とは合わない。go mod tidyはgo.workを見ないので、workersで実行すると proxy から公開済みのルートモジュールを取ってきて、internal/siteがないと言って失敗する。
このため replace を残すのがよい。replace があれば、モジュール単体で go mod tidy もビルドも完結する。エディタ(gopls)で両方のモジュールをまとめて開きたいときに、各自が手元で go.work を作る(ignore されたまま)のは、replace と併用しても問題ない。
examples/blog-tiny(兄弟)ではなく examples/blog/workers(入れ子)にする理由 #
- 兄弟の
examples/blog-tinyからはexamples/blog/internal/...を import できない。共有部分をexamples/internal/...に移すか、internalでない公開パッケージにする必要がある。入れ子にすれば、ブログに関係するものをexamples/blogの下にまとめたままinternalを使える。 -watchや-debugなどのフラグは Workers 側には要らない。この「フラグなしの別エントリポイント」という性格は、どちらに置いても同じ。- 名前を
tinyにしなかったのは、上で書いたとおり標準 Go でもビルドできて、TinyGo 専用ではないから。中身を表すのは「Workers で動かす」こと。
比較 #
| 案 A: ビルドタグ | 案 B: 別モジュール | |
|---|---|---|
ルートの go.mod | workers-go が入る | 依存なしのまま |
| 変更の大きさ | 小(タグ 3 ファイルと content.go) | 中(共有部分を internal/site に移動、export) |
| TinyGo でのテスト | できない | tinygo test ./examples/blog/internal/site |
| ディレクトリ構成 | 変わらない | internal/site、posts/embed.go、workers/ が増える |
go test ./... | 今までどおり | 今までどおり(workers/ は対象外) |
| ビルドの切り替え | GOOS/GOARCH(-target wasm) | ディレクトリ(cd examples/blog/workers) |
両案に共通の変更: ルーティングの手書き化(とリダイレクトの再実装)、BASE_URL を wrangler.jsonc から読むこと。
デプロイ #
2026-10-14 に、TinyGo のビルドを https://blog.podhmo.workers.dev(Worker 名 blog)へデプロイした。それまでは同じ名前で標準 Go のビルドが動いていた。
- TinyGo は、上のシムを
examples/blog/workers/bin/tinygoとしてリポジトリに入れたものを使った。PATH="$PWD/bin:$PATH" npx wrangler deploy --env=""で、wrangler.jsoncのビルドコマンドがそのまま通る。 - アップロードは 2117.89 KiB(gzip 後 757.41 KiB)、wrangler が表示した Worker Startup Time は 8 ms。
/、記事、/docs/以下、.md、favicon は 200、存在しないパスは 404 を返し、og:urlはBASE_URLから作られていた。HEADは 200 で本文は空になった(Workers 側で本文が捨てられる)。
2026-10-15 に、blog は MoonBit 版に置き換えた(調査メモの「ブログ全体の比較とデプロイ」の節)。
未確認・残課題 #
- 本番での CPU 時間。
- コールドスタートの時間を、TinyGo と標準 Go のビルドで比べること。
- 記事が増えたときのリクエストごとのコスト(「リクエストごとに全記事をパースしている理由」の対策の候補を参照)。
tinygo test -target wasm(node が必要)での実行。