実験レポート: 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 になったため、ルーティングを手書きにした(両案に共通)。
  • 推奨は **案 B(モジュール分割)**。ルートの go.mod を依存なしのまま保てて、共有部分を tinygo test でテストできる。実際、ServeMux の差異は tinygo test で 10 件のテスト失敗として検出できた。案 A では、この種の差異をテストで捕まえる手段がない。
  • 別モジュールは兄弟の examples/blog-tiny ではなく、入れ子の examples/blog/workers にするのがよい。理由は後述。

環境 #

バージョン
Go1.27.1(ホスト)
TinyGo0.43.0(Docker イメージ tinygo/tinygo:0.43.0。中の Go は 1.27.2、LLVM 22.1.4)
workers-gov0.36.0。v0.35.0 で github.com/syumai/workers から github.com/syumai/workers-go に改名された(旧パスは deprecated な転送モジュール)
wrangler4.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} だけを扱う独自のマッチを足したものだった。同じパターンを登録したときの結果:

パターンリクエストGoTinyGo
GET /get/{x}GET /get/1200404
/wild/{x}GET /wild/2200200
/exact/{$}GET /exact/200404
POST /postPOST /post200404
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 B2,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.modworkers-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 が必要)での実行。