「Honoで静的生成」は、まだ入口だった
このサイトは以前から、Hono JSXでページを組み立て、ビルド時にHTMLへ変換する構成でした。ブラウザへ巨大なJavaScriptを送り、実行後に画面を作るSPAではありません。最初のレスポンスに本文、meta、hreflang、JSON-LDまで入っているため、この時点でも十分に高速です。
しかし、静的HTMLを作っただけでは配信経路まで最短になったとは限りません。
- 静的ファイルも毎回Hono Workerを通っていないか
- 複数ドメインを一つのWorkerで振り分けていないか
- HTMLとハッシュ付きCSSへ同じキャッシュ設定を当てていないか
- メール送信の完了をAPIレスポンスが待っていないか
- 複数のD1書き込みを逐次実行していないか
今回はこの境界を見直し、Honoを「すべてを通す入口」から「動的処理だけを担当する薄い制御面」へ変えました。
最終構成
build-site.ts
-> public(全ページとアセットを静的生成)
-> prepare-site-deployments.ts
-> dist/sites/main -> Assets + Hono Worker
-> dist/sites/nihongo-pocket -> Assets only
-> dist/sites/kage -> Assets only
-> dist/sites/madobeya -> Assets only
-> dist/sites/sokudoku -> Assets only
main Worker
/, /api/*, /healthz, /assets/ffmpeg-core/* -> Honoを先に実行
その他のHTML/CSS/JS/画像 -> Assetsが直接応答
POST /api/contact
-> Rate Limiting -> Turnstile -> D1保存 -> Queue -> Email
-> 5回失敗 -> DLQ
POST /api/transcript-jobs
-> Rate Limiting -> D1時間上限 -> D1.batch -> Queue
-> 5回失敗 -> DLQ
重要なのは、静的生成と動的APIを同じプロジェクトで管理しながら、実行経路は分けていることです。
1. 通常ページではWorkerを起動しない
メインサイトのAssets設定では、Honoを先に動かすURLを配列で限定しています。
{
"assets": {
"directory": "./dist/sites/main",
"binding": "ASSETS",
"html_handling": "drop-trailing-slash",
"run_worker_first": [
"/",
"/api/*",
"/healthz",
"/assets/ffmpeg-core/*"
]
}
}
/ja、/en、ブログ、CSS、画像などはAssetsが先に見つけ、そのまま返します。Honoが必要なのは、言語判定を行う /、API、ヘルスチェック、R2から取得する特殊なランタイムだけです。
Hono自体は軽量ですが、処理が不要なリクエストでisolateを起動しないほうが、実行経路、CPU時間、障害面のすべてを小さくできます。
2. サブドメインをAssets-only Workerへ分割する
以前は、一つのWorkerがhostnameを見て複数サイトを振り分ける余地がありました。現在は各プロダクトサイトを独立したAssets deploymentとして配信しています。
muscleindustry.work -> main Worker + Assets
nihongo-pocket.muscleindustry.work -> Assets only
kage.muscleindustry.work -> Assets only
madobeya.muscleindustry.work -> Assets only
sokudoku.muscleindustry.work -> Assets only
ビルド元は一つですが、デプロイ前にホストごとのディレクトリへ必要なHTMLとアセットだけをコピーします。各 wrangler.<site>.jsonc はCustom Domainと自分の配布ディレクトリだけを持ちます。
この分割には速度以外の効果もあります。
- 別ホスト用HTMLを誤って公開しない
- hostname分岐とrewriteを実行時から消せる
- 一つの設定ミスが全サイトへ波及しにくい
- サイトごとにCSPを最小化できる
- 静的サイト側にはD1、R2、秘密値をbindingしなくてよい
静的サイトが増えるほど「一つの万能Worker」より「生成は共通、配信境界は独立」のほうが管理しやすくなります。
3. キャッシュはファイルの性質で分ける
このサイトでは次のポリシーを使っています。
| 対象 | Cache-Control |
|---|---|
| HTML | public, max-age=0, s-maxage=86400, stale-while-revalidate=604800 |
| ハッシュ付きCSS/JS | public, max-age=31536000, immutable |
その他の /assets/* |
public, max-age=0, must-revalidate |
| robots、sitemap、RSS、app-ads | public, max-age=3600 |
| API | no-store |
HTMLはブラウザに固定せず、Cloudflareのエッジでは24時間再利用します。更新確認中も最長7日間は古い正常レスポンスを返せるため、オリジン処理を増やさず可用性を保てます。
一方、ファイル名に内容ハッシュが入ったCSSとJavaScriptは1年間immutableです。内容が変わればURLも変わるため、再検証は不要です。画像などハッシュを持たないアセットには同じ設定を使いません。
_headers の継承に注意する
移行時に実際に見つかった落とし穴が、Cloudflareの _headers は複数の一致ルールをマージすることです。
/*、/assets/*、/assets/site-HASH.css がすべて一致すると、同名の Cache-Control がカンマ区切りで結合されることがあります。具体的なルールで親の値を外してから設定し直します。
/*
Cache-Control: public, max-age=0, s-maxage=86400, stale-while-revalidate=604800
/assets/*
! Cache-Control
Cache-Control: public, max-age=0, must-revalidate
/assets/site-7482c240a6.css
! Cache-Control
Cache-Control: public, max-age=31536000, immutable
ハッシュ付きファイルはビルドごとに変わるため、prepare-site-deployments.ts が生成物を走査し、完全一致ルールを自動生成します。配信後は curl -D - でも実際のレスポンスヘッダーを確認します。
4. 問い合わせ受付とメール送信を分離する
問い合わせAPIで遅くなりやすいのは、外部メール処理をリクエスト内で待つことです。現在は次の順番にしました。
- JSONサイズとZod schemaを検証
- Cloudflare Rate Limiting bindingで短時間のburstを制限
- Turnstileのactionとhostnameを検証
- D1へ問い合わせを保存
- Queueへ通知ジョブを投入
- APIは
202 Acceptedを返す - Queue consumerがEmail bindingで送信
D1への保存完了を受付の境界にするため、メール側が一時的に遅くても入力内容は失いません。Queueはat-least-once deliveryなので、問い合わせIDからMessage-IDを固定し、D1の email_status が sent なら再送しない設計にしています。
consumerは最大5回再試行し、それでも失敗したメッセージは専用DLQへ移します。Queue投入自体に失敗してもD1へ retry_pending として残り、日次Cronが回収します。
これは単なる速度改善ではなく、ユーザーへ早く応答しながら配送の信頼性を上げる変更です。
5. レート制限は短時間と永続上限を分ける
Rate Limiting bindingは、問い合わせやAI APIへのburstをWorkerの手前に近い場所で止める用途に向いています。ただし、場所や時間をまたぐ厳密な利用枠まで一つの短期カウンターへ任せません。
動画文字起こしでは二段階にしています。
- Rate Limiting binding:60秒単位の連打を制限
- D1:ユーザーごとの1時間単位ジョブ上限を永続的に確認
以前のKVベースのカウンターはWorker bindingから外しました。低コストな短期制御は専用binding、監査可能な利用枠はD1という役割分担です。
6. 関連するD1書き込みは batch() へまとめる
動画、アセット、文字起こしジョブを別々にINSERTすると、途中で失敗したときに孤児レコードが残ります。現在は関連するstatementを作り、D1.batch() でまとめて実行します。
await db.batch([
insertVideoStatement,
insertAssetStatement,
insertTranscriptJobStatement,
]);
ネットワーク往復を減らし、関連レコードの整合性も保ちやすくなります。大量のsegment更新も同じ考え方でまとめています。
7. 型、workerdテスト、観測を構成の一部にする
高速な構成でも、Dashboardとコードのbindingがずれれば本番で壊れます。設定のsource of truthを wrangler.jsonc に寄せ、wrangler types で CloudflareBindings を生成します。
テストは二層です。
- Node上のVitest:コンテンツ、レンダリング、API契約、Queue consumer
- Cloudflare Workers Vitest integration:workerd上のfetch、Assets、D1
本番Workerでは構造化ログ、全リクエストのログ、5%のtrace、X-Request-ID を有効化しました。ただし、IP、生の問い合わせ本文、tokenはログへ出しません。
デプロイは wrangler deploy --strict を使い、意図しないDashboard driftがあれば止めます。CIは品質ゲート、D1 migration、R2ランタイム、Queue/DLQ、静的4サイト、メインWorker、5ドメインのsmoke testという順番です。
計測結果
移行前後で見たのはLighthouseの点数だけではありません。Workerを通るURL、レスポンスヘッダー、ホスト分離、APIのキャッシュ禁止、QueueとD1の実接続まで確認しました。
24回のLighthouse計測では、最小Performance 97、SEO 100、最大LCP 2.33秒、TBT 0ms、CLS 0を確認しました。速度の数値を維持しながら、通常ページのWorker実行範囲と障害範囲を小さくできたことが今回の主要な成果です。
この構成が向くサイト
この構成は、すべてのWebアプリに対する万能解ではありません。リクエストごとに認証済みHTMLを変えるアプリや、リアルタイム処理が中心のサービスでは別の設計が必要です。
相性がよいのは次のようなサイトです。
- 大部分が公開コンテンツで、ビルド時に生成できる
- SEOと初期HTMLが重要
- 問い合わせや一部のAI機能だけが動的
- 複数のLPやプロダクトサイトを同じリポジトリで管理する
- Cloudflare上で配信、API、D1、R2、Queueを完結させたい
静的にできるものはビルド時に確定し、Assetsへ直接返させる。Honoは動的な境界だけを明確に担当する。今回の「さらに爆速化」は、細かな最適化を積むより、不要な実行経路そのものを消す作業でした。