やってみた 2026年10月10日

Flutter Webのカウンターアプリを動かしたら、既定ビルドはgstaticに届かないと真っ白だった

XECIN DartFlutterWebフロントエンドCDN

GitHubのトレンドで flutter/flutter を見かけました。スターは179,285、今日増えた分は39と、上位の新顔に比べると控えめです。それでも手が止まったのは、2日前(2026-10-08)に stable の 3.47.7 が出ていたからでした。

私はインフラ寄りの立場で案件に入ることが多く、フロントエンドの技術選定で気になるのは、見た目よりも「本番で何に依存するか」です。Flutter Web は画面を canvas に描く方式だと聞いていて、その描画エンジンをどこから持ってくるのかを、一度自分の手で確かめておきたいと考えていました。社内向けの業務画面は、外のインターネットに自由に出られない環境で動くことも珍しくありません。

なので今回は、雛形のカウンターアプリを作って、ビルドして、配って、ネットワークを眺める。それだけです。

試した環境

  • ホスト: Windows 11 + Docker Desktop(エンジン 27.3.1、linux/amd64)
  • 検証用コンテナ: debian:bookworm-slim(Debian 12.14、x86_64)。メモリ4GB・CPU2つ・プロセス数512の上限つきで、終わったら破棄しました(起動から破棄まで6分51秒)
  • Flutter: 3.47.7(channel stable、framework abaf9c5237)、Dart 3.13.5、DevTools 2.60.0
  • 表示の確認: ホストの Chrome を DevTools 経由で操作したものと、コンテナ内の Chromium 154.0.8037.92(headless)

浅いcloneのつもりが、初回起動で1.4GBになった

SDK の入れ方はいくつかありますが、今回は GitHub のリポジトリをそのまま使いました。容量を抑えたかったので --depth 1 の浅い clone です。ここは4.5秒、237MBで終わりました。

想定外だったのはその後です。flutter を初めて叩くと Dart SDK のダウンロードとツール自体のビルドが走るのは知っていましたが、思ったより終わらないのでプロセスを覗くと、ツールが git fetch --tags を実行していました。

# 浅い clone(ここまでは軽い)
$ git clone --depth 1 -b stable https://github.com/flutter/flutter.git
clone sec=4.5
$ git log -1 --format='HEAD %H %cI'
HEAD abaf9c523780a608bd46686fd5e53740a07077f8 2026-10-08T16:51:21+00:00
$ git describe --tags
3.47.7
$ du -sh /work/flutter
237M

# 初回起動(解析データの送信は最初に止める)
$ flutter --disable-analytics
Downloading Linux x64 Dart SDK from Flutter engine deb287481e3ce9468f3434937ced4240a70539ca...
Building flutter tool...
Resolving dependencies...
Got dependencies.
Analytics reporting disabled.
first_run rc=0 sec=67.1

# 初回起動の最中に ps で見えたもの
/usr/bin/git fetch --tags
/usr/lib/git-core/git index-pack --stdin --fix-thin --keep=fetch-pack 3861 on bfe2bbc5fdff --pack_header=2,1132057

# 終わったあと
shallow=true tags=1120 commits=1
457M    .git
688M    bin/cache
1.4G    /work/flutter

履歴は浅いまま(commits=1)なのに、タグが1,120本取り込まれ、.git だけで457MBになっています。パックの中身は1,155,600オブジェクトでした。Dart SDK 本体は227MBで、こちらは約7秒で落ちてきています。結局 SDK のディレクトリは1.4GBで、浅い clone で節約したつもりの容量はほぼ帳消しでした。

なぜツールがタグを取りに行くのかまでは確認していません。手元の git describe --tags は clone 直後から 3.47.7 を返していたので、少なくとも「タグが1本も無いから」ではなさそうです。

運用を考えると、ここは CI のキャッシュ設計に効いてきます。毎回まっさらな環境で浅い clone から始める構成だと、この約1分と113万オブジェクトの取得が毎回乗るわけです。SDK のディレクトリごとキャッシュするのか、配布アーカイブから入れるのかを先に決めておくことが重要だと考えています。なお、配布アーカイブで入れた場合に同じことが起きるかは、今回は試していません。

アプリ自体は拍子抜けするほど素直

肝心の Flutter のほうは、何も詰まりませんでした。

$ flutter create --platforms web counter
create rc=0 sec=5.1
$ flutter test
00:00 +1: All tests passed!
test rc=0 sec=5.5
$ flutter build web
Compiling lib/main.dart for the Web...                             22.2s
✓ Built build/web
build rc=0 sec=24.9

# build/web の中身(大きい順)
files=39 bytes=41189481
7284602  ./canvaskit/canvaskit.wasm
5428806  ./canvaskit/chromium/canvaskit.wasm
5216217  ./canvaskit/skwasm_heavy.wasm
3729129  ./canvaskit/webparagraph/canvaskit.wasm
3593715  ./canvaskit/skwasm.wasm
3581677  ./canvaskit/wimp.wasm
1895343  ./main.dart.js

雛形の生成が5.1秒、同梱のウィジェットテスト1件が5.5秒、Web 向けのビルドが24.9秒です。アイコンフォントは使っていない分が削られ、MaterialIcons は1,645,184バイトから7,800バイト(99.5%減)になったとビルドが報告していました。ほかに「Wasm dry run succeeded」と、--wasm でのビルドを勧めるメッセージも出ています(Wasm 版は今回試していません)。

気になったのは出力の大きさです。build/web は39ファイル・約41.2MBで、そのうち canvaskit/ が37,863,700バイトを占めます。描画エンジン(CanvasKit / Skwasm)の wasm が何種類も入っているんですよね。アプリ本体の main.dart.js は1,895,343バイトでした。

これをコンテナ内の静的サーバ(python3 -m http.server)で配り、ホストの Chrome で開いたのが次の画面です。

Flutterの雛形カウンターアプリをChromeで開いた初期画面。上部に紫の帯でFlutter Demo Home Page、中央にYou have pushed the button this many times:と数字の0、右下に丸いプラスボタンが表示されている

おなじみのカウンターアプリです。右下の「+」を3回押すと、数字が3になりました。ボタンにはツールチップで「Increment」と出ます。

同じ画面で中央の数字が3に増え、右下のプラスボタンの上にIncrementというツールチップが表示されている

41MBを配っているのに、描画エンジンは外から来ていた

ここで DevTools のネットワークを見て、少し考え込みました。ページが読み込んだのは10リクエストで、そのうち3つが外部宛てだったからです。

# 既定の flutter build web(10リクエスト中3件が外部)
GET http://127.0.0.1:34506/ [200]
GET http://127.0.0.1:34506/flutter_bootstrap.js [200]
GET https://www.gstatic.com/flutter-canvaskit/deb287481e3ce9468f3434937ced4240a70539ca/chromium/canvaskit.wasm [200]
GET https://www.gstatic.com/flutter-canvaskit/deb287481e3ce9468f3434937ced4240a70539ca/chromium/canvaskit.js [200]
GET http://127.0.0.1:34506/main.dart.js [200]
(この間に FontManifest.json / favicon.png / アイコンフォント2つ。いずれも 127.0.0.1)
GET https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Me4GZLCzYlKw.woff2 [200]

# flutter build web --no-web-resources-cdn で作り直したときの差分
> ./assets/fonts/fallback/Roboto-Regular.ttf     (ファイルが1つ増える)
> "useLocalCanvasKit":true};                     (flutter_bootstrap.js の設定)

# 同じ Chrome で開き直すと、10リクエストすべてが 127.0.0.1 宛て
GET http://127.0.0.1:34506/canvaskit/chromium/canvaskit.wasm [200]
GET http://127.0.0.1:34506/canvaskit/chromium/canvaskit.js [200]
GET http://127.0.0.1:34506/assets/fonts/fallback/Roboto-Regular.ttf [200]

flutter_bootstrap.js の中には https://www.gstatic.com/flutter-canvaskit という URL がそのまま入っていて、エンジンのリビジョン(deb287481e…)付きのパスから CanvasKit を取りにいきます。つまり build/web に37MB超の canvaskit/ を同梱していても、既定の構成の Chrome はそれを使わず、Google の CDN から chromium 向けの wasm を持ってきていました。フォントの Roboto も fonts.gstatic.com からです。

同じ URL を curl で叩くと、canvaskit.wasm は brotli 圧縮で1,555,790バイト、Cache-Control: public, max-age=31536000(1年)で返ってきました。リビジョンがパスに入っているので、1年キャッシュでも更新時に困らない作りです。インターネット向けのサイトなら、これは無料で使える出来のいいマネージド CDN だと思います。

--no-web-resources-cdn を付けると、ビルドの差分は上のとおり2点だけで、外部へのリクエストはなくなりました。canvaskit/chromium/canvaskit.wasm は2つのビルドでバイト単位まで同じファイルです。

gstatic に届かないと、画面は真っ白になる

では、その CDN に届かない環境ではどうなるのか。閉域網を本当に用意するのは大げさなので、コンテナ内に Chromium を入れ、/etc/hosts で www.gstatic.com と fonts.gstatic.com を 127.0.0.1 に向けて名前解決を潰しました。同じ Chromium・同じ引数で、既定ビルドと --no-web-resources-cdn 版をそれぞれ開いてスクリーンショットを撮っています。

$ chromium --version
Chromium 154.0.8037.92 built on Debian GNU/Linux 12 (bookworm)

# 4回とも同じ引数(15秒ぶんの仮想時間を待ってから撮る)
$ timeout 60 chromium --headless=new --no-sandbox --disable-dev-shm-usage --hide-scrollbars \
    --window-size=800,600 --virtual-time-budget=15000 --screenshot=/work/<name>.png <url>
c1-cdn-online rc=0 png=12694
c2-local rc=0 png=12694

$ echo "127.0.0.1 www.gstatic.com fonts.gstatic.com" >> /etc/hosts
c3-cdn-blocked rc=0 png=2727
c4-local-blocked rc=0 png=12694

$ cmp c1-cdn-online.png c4-local-blocked.png && echo "c1==c4 identical bytes"
c1==c4 identical bytes
ビルドgstatic結果(PNG のサイズ)
既定届く描画される(12,694バイト)
—no-web-resources-cdn届く描画される(12,694バイト)
既定名前解決を潰す真っ白(2,727バイト)
—no-web-resources-cdn名前解決を潰す描画される(届く場合とバイト単位で一致)

左が gstatic に届く状態の既定ビルド、右が名前解決を潰した状態の同じビルドです。

左右に並べた2枚のスクリーンショット。左はカウンターアプリが通常どおり描画され、右は同じビルドをgstaticの名前解決を潰した状態で開いたもので、何も表示されず真っ白になっている

サーバ側のログを見ると、真っ白だった回も main.dart.js まではきちんと取りに来ています(9種類のパスがどちらの回も1回ずつ)。アプリ本体は届いているのに、描画エンジンだけが無い状態です。画面には何も出ません。コンソールのエラーまでは今回取っていないので、利用者からは「何も表示されない」としか見えないはずだ、というところまでが観測できた範囲です。

正直なところ、この確かめ方はかなり粗いです。名前解決を潰しただけなので、プロキシ経由でしか外に出られない社内網や、CSP で外部の読み込みを止めているケースは試していません。自分の環境に入れる前に、その環境の条件で同じ4通りを撮り直すのが確実だと思います。

自前で配ると、量と圧縮は自分の仕事になる

--no-web-resources-cdn にすれば外部への依存は消えます。ただ、今度は配る量の問題が自分に返ってきます。今回の静的サーバは圧縮をしないので、Chrome の Resource Timing で見た転送量の合計は7,608,270バイトでした。既定ビルドのほうは1,984,398バイトですが、こちらは gstatic の canvaskit 2件がクロスオリジンのため0として数えられています。

そこで、配るファイルを gzip と brotli で圧縮したときのサイズをコンテナ内で測りました。

ファイル非圧縮gzip -9brotli -q 11gstatic の配信
chromium/canvaskit.wasm5,428,8062,063,2891,540,2461,555,790(br)
chromium/canvaskit.js86,22627,26524,14624,206(br)
Roboto171,676(TTF)90,71574,84563,464(woff2)
main.dart.js1,895,270557,560437,045(自前で配る)
flutter_bootstrap.js13,1195,2144,489(自前で配る)

単位はすべてバイトです。brotli の最高圧縮をかければ、canvaskit.wasm は gstatic が返すものとほぼ同じ大きさになります。逆に言うと、サーバ側で圧縮を設定し忘れると、wasm だけで3.5倍の量を毎回配ることになります。

コストも比べておきます。転送の単価は契約によってまちまちなので、ここでは転送量で比べます。初回訪問1回あたり、この5ファイルを brotli で配る前提だと、既定ビルドで自分のサーバが配るのは main.dart.js と flutter_bootstrap.js の441,534バイト、--no-web-resources-cdn 版は5ファイル全部で2,080,771バイトで、約4.7倍です。初回訪問1万回なら約4.4GB対約20.8GB、圧縮しないまま出してしまうと7,608,270バイト×1万回で約76GBになります。

もう1点、キャッシュの設計も自分の仕事になります。gstatic のパスにはエンジンのリビジョンが入っていましたが、自前版のパスは canvaskit/chromium/canvaskit.wasm で、リビジョンが入りません。Flutter を上げたときに古い wasm を掴ませないよう、長いキャッシュをかけるならパスかクエリで区別する工夫が要る、と考えています。ちなみに flutter_bootstrap.js には Flutter's service worker is deprecated and will be removed in a future Flutter release. というコメントも入っていて、オフライン対応を Flutter 標準の Service Worker に任せる設計も、今から選ぶものではなさそうです。

DOMにも、アクセシビリティツリーにも本文がない

最後に、運用でもう1つ効きそうな点です。初期表示の状態で、ページの DOM にもアクセシビリティツリーにも、画面に見えている文言がありませんでした。

# 初期表示(document.body.innerText は空文字、"pushed the button" は DOM のどこにも無い)
RootWebArea "Flutter Demo"
  button "Enable accessibility"      (1×1px の要素が x=-1, y=-1 に置かれている)

# "Enable accessibility" を押したあと(flt-semantics が8個できる)
heading "Flutter Demo Home Page" level="2"
StaticText "You have pushed the button this many times:"
StaticText "0"
button "Increment"

# Increment を3回押したあと
StaticText "3"
button "Increment Increment" focusable focused

画面外に置かれた1×1pxの「Enable accessibility」ボタンを押して初めて、見出し・本文・ボタンがツリーに現れます。3回押したあとのボタンの名前が「Increment Increment」と重複しているのは、ツールチップが出ている状態でフォーカスが当たっていたからだと思いますが、そこまでは切り分けていません。

運用を考えると、ここは外形監視の作りに効きます。「画面に特定の文言が出ているか」を DOM から拾って死活を見る、というよくある監視は、そのままでは成り立ちません。さきほどの真っ白な画面と正常な画面も、DOM の文言では区別できないことになります。

試してみての所感

Flutter そのものは拍子抜けするほど素直でした。雛形からテスト、Web ビルドまで1分かからず、詰まったのは SDK の初回起動のほうだけです。

そのうえで、私の結論はシンプルです。インターネットに出られる一般向けのサイトなら、既定の gstatic 構成に乗るのは合理的だと考えています。1年キャッシュ付きの CDN をタダで借りられるわけですから。ただし「Google の CDN に届かないと画面が真っ白になる」という依存は、リスク一覧に1行書いておくべきです。

社内向けや閉域網で使うなら、最初から --no-web-resources-cdn を前提にして、配信側の brotli とキャッシュの設計をセットで用意します。ここは好みが分かれるところですが、私は監視とキャッシュの設計まで決めてから選定を通したい派です。

今思えば、41MBの build/web を見た時点で「全部ここから配るのだろう」と思い込んでいました。中身と実際に飛んでいるリクエストは別物で、ネットワークのタブを1回開くだけで分かることでした。Wasm 版(--wasm)とアクセシビリティの扱いは、もう少し様子を見てから試すつもりです。