ある朝、4 つ並べて運用している AI 技術ブログのうち、Claude Lab だけが急にデプロイ失敗で止まりました。ログを見ると Script startup exceeded CPU time limit ではなく、見慣れない Your Worker exceeded the size limit of 62 MiB という一文が並んでいます。前日の push までは普通にデプロイされていたので、原因は前日に追加した 4 本の MDX のどこかにあるはずでした。
私は 2014年から個人開発でアプリを公開し続けて12年、累計5,000万ダウンロードを超える壁紙アプリと癒し系アプリを運用しています。アーティストとしての国際芸術賞 17冠の活動と並行して、2026 年からは Dolice Labs として 4 サイトの AI 技術ブログを Cloudflare Workers + Next.js(OpenNext)で並行運用しており、合計記事数はちょうどこの日 4,500 本を超えたところでした。「気がついたら壁にぶつかっていた」のは、まさに記事数が静かに積み上がっていたためです。
宮大工をしていた両祖父からは「丁寧に作るというのは、後から触る人のために線を引いておくことだ」と教わった気がしています。62 MiB 上限の壁は、まさに自分が後から触る自分への線引きが足りていなかった証拠でした。今回は、その壁を直接踏みながら、メタデータと本文を分離する Content Split Architecture に組み替えた具体的な手順を残しておきます。
62 MiB 上限を踏んだ朝に最初にやった切り分け
Cloudflare Workers の 62 MiB はバンドル後(Brotli 圧縮後)のサイズ制限です。OpenNext を通した Next.js は Server Components や middleware を含めて一つの Worker bundle に詰め込まれるため、コードだけでなく bundled 扱いになっている静的データも全部この上限に乗ります。
最初にやったのは、デプロイ前のローカルビルドで実際のサイズを見ることでした。
cd /tmp/repos/claudelab.net
npm run build
ls -lh .open-next/worker.js
# -rw-r--r-- 1 user staff 18M May 3 09:14 worker.js
18 MB は Brotli 前のサイズですが、ここに 4,500 本の MDX を JSON 化したものがほぼ全部入っていることを du -sh で確認しました。src/generated/articles.json 単体で 18 MB を超えていました。
ls -lh src/generated/articles.json
# -rw-r--r-- 1 user staff 18M May 3 09:14 articles.json
ここでようやく、これまで articles.json 一つにメタデータも HTML 本文も全部詰め込んでいたことが致命傷だと気づきました。1 本あたり 4 KB 程度でも、4,500 本で 18 MB です。さらに 1 日 4 本ペースで増えていきますから、放っておけば 3 ヶ月で 25 MB を超え、半年後には 30 MB を超えます。Brotli 圧縮率が良くても、JSON 内の HTML は重複部分が少なく、圧縮後サイズの伸びはほぼ線形でした。
articles.json を太らせていた設計の限界
そもそも初期設計では、ビルド時に generate-content.mjs が MDX を全部読み込み、メタデータとレンダリング済み HTML を articles.json 一つに集約していました。理由は単純で、Server Components から import articles from "@/generated/articles.json" だけで全記事にアクセスできるからです。
// 旧 generate-content.mjs(要約)
const articles = [];
for ( const file of allMdxFiles) {
const { data , content } = matter (fs. readFileSync (file));
const html = await mdxToHtml (content);
articles. push ({ ... data, slug, html });
}
fs. writeFileSync ( "src/generated/articles.json" , JSON . stringify (articles));
この設計は 100 本のときは便利でした。500 本でも気にならないサイズでした。1,000 本を超えた頃から「ローカルビルドが遅いな」と感じるようになり、2,000 本で「fs.readFileSync の起動コストが見えるな」と思い、3,500 本で「IDE のオートインポートが重いな」と感じ、4,500 本で 62 MiB を踏みました。後から触る自分への線引きが、気づかないうちに細っていたのです。
ここで線を引き直すべきは、メタデータと本文を物理的に別ファイルに分ける という一点でした。
メタデータと本文を分離するという選択
Content Split Architecture という呼び名は、CMS 業界では一般的なパターンです。Cloudflare Workers + OpenNext の文脈に落とすと、設計判断は次の 3 点に集約されます。
articles.json にはメタデータのみ を残す(title・slug・category・level・premium・date・description・tags・highlights)。これだけなら 1 記事 800 バイト前後で、4,500 本で 3.6 MB に収まります。
HTML 本文は public/content/articles/{locale}/{category}/{slug}.html に個別ファイル として書き出す。public/ 配下は Cloudflare Pages の static assets として配信され、Worker bundle には含まれません。
ランタイムでは ASSETS バインディング (getCloudflareContext().env.ASSETS.fetch())経由で本文を取得し、Server Component で dangerouslySetInnerHTML に流し込む。
この設計に切り替えれば、Worker bundle にはメタデータ 3.6 MB と Next.js のコードしか残らず、本文 14 MB ぶんはエッジの静的配信に逃げます。さらに、Cloudflare の static assets は per-asset で配信されるため、1 記事を開いても他の 4,499 本ぶんの HTML はネットワークを跨がない点も大きな利点でした。
generate-content.mjs に二段階出力を仕込む
実装で一番効くのは generate-content.mjs の書き直しです。1 ファイルあたり 2 つの成果物(メタデータ JSON のエントリ + 本文 HTML ファイル)を出すように変えます。
// 新 generate-content.mjs(要約)
import { promises as fs } from "node:fs" ;
import path from "node:path" ;
import matter from "gray-matter" ;
import { mdxToHtml } from "./lib/mdx.mjs" ;
const metaIndex = [];
for ( const locale of [ "ja" , "en" ]) {
const root = `content/articles/${ locale }` ;
for ( const file of await walk (root)) {
const raw = await fs. readFile (file, "utf8" );
const { data , content } = matter (raw);
const slug = path. basename (file, ".mdx" );
const category = path. basename (path. dirname (file));
const html = await mdxToHtml (content);
// (1) メタデータだけインデックスへ
metaIndex. push ({ ... data, locale, category, slug });
// (2) 本文 HTML は public/content/ に個別書き出し
const outDir = `public/content/articles/${ locale }/${ category }` ;
await fs. mkdir (outDir, { recursive: true });
await fs. writeFile ( `${ outDir }/${ slug }.html` , html, "utf8" );
}
}
await fs. writeFile (
"src/generated/articles.json" ,
JSON . stringify (metaIndex)
);
この時点で articles.json は 18 MB → 3.6 MB に縮みます。public/content/articles/{locale}/{category}/{slug}.html は 1 本 4 KB 前後で、合計 14 MB が Worker bundle 外に逃げました。
書き出し先を public/ 配下にする理由は、Cloudflare Pages / Workers Assets が public/ を自動的に static assets として配信するためです。OpenNext の場合、wrangler.toml の [assets] セクションを設定しておけば、Worker からは env.ASSETS.fetch() で取りに行けます。
ASSETS バインディング経由で本文を取得する
ランタイム側は、src/lib/content.ts に getArticleContent() を追加して呼び出し点を一本化しました。注意点として、Cloudflare Workers の Server Component から自ホスト名へ fetch() するのは無限ループ化のリスクがあるため、必ず env.ASSETS.fetch() を使う ことをお勧めします。
// src/lib/content.ts
import { getCloudflareContext } from "@opennextjs/cloudflare" ;
export async function getArticleContent (
locale : string ,
category : string ,
slug : string
) : Promise < string | null > {
const { env } = getCloudflareContext ();
const url = `https://placeholder/content/articles/${ locale }/${ category }/${ slug }.html` ;
const res = await env. ASSETS . fetch (url);
if ( ! res.ok) return null ;
return await res. text ();
}
https://placeholder の部分は ASSETS バインディングではホスト名が見られないため何を入れても動きますが、可読性のために自サイト風の文字列を入れておくとあとで読みやすくなります。Server Component 側は次のように呼び出します。
const html = await getArticleContent (locale, category, slug);
if ( ! html) notFound ();
return < article dangerouslySetInnerHTML = { { __html: html } } />;
ここで getCloudflareContext() が同期 / 非同期どちらで返るかは OpenNext のバージョン依存で変わるため、リリースノートを必ず確認することをお勧めします。私の環境(@opennextjs/cloudflare 1.x 系)では同期で { env, ctx } が返りました。
prebuild フックで Cloudflare CI に自動実行させる
ローカルでは npm run build 一発で動きますが、Cloudflare CI 側でも generate-content.mjs を必ず実行させる必要があります。私は package.json の prebuild フックに差し込むのが一番事故が少ないと感じました。
{
"scripts" : {
"prebuild" : "node generate-content.mjs" ,
"build" : "next build && open-next build"
}
}
これだけで、Cloudflare の CI が npm run build をトリガーするたびに prebuild が先に走り、src/generated/articles.json と public/content/articles/.../*.html の両方が再生成されます。src/generated/ も public/content/ も .gitignore に入れて、ビルド時生成物として扱うのが推奨です。git に大量の HTML を載せると、git pull のたびに数十 MB のデルタが乗って体感速度が落ちます。
切り替え後 1 ヶ月で観測した数値
切り替えは 4 サイトに順次展開し、Claude Lab から始めて 1 週間ずつずらして他の 3 サイトに広げました。1 ヶ月運用してみての観測値は次の通りです。
Worker bundle サイズ: 18 MB → 2.4 MB(圧縮前)、Brotli 後 4.8 MB → 0.9 MB。62 MiB 上限まで 60 倍以上の余裕ができました
npm run build の所要時間: ローカルで 78 秒 → 62 秒。HTML 書き出しが増えた分、JSON シリアライズの巨大化分が消えて差し引きで 20% 短縮
Cloudflare のデプロイ成功率: 直近 30 日で 100%。それまでは月 1 〜 2 回 62 MiB 上限で再試行が必要でした
記事ページの TTFB(東京エッジ・キャッシュヒット時): 平均 38 ms → 41 ms。ASSETS への 1 ホップが入る分わずかに増えましたが、体感差はゼロでした
public/content/ 配下の HTML ファイル数は 9,000 個(日英 2 言語 × 4,500 本)まで増えましたが、Cloudflare のオブジェクトストレージは数百万ファイルまでスケールするため、当面は気にしなくて良い水準だと判断しています。
self-fetch と getCloudflareContext の境界で起きやすい事故
実装中に何度かハマったので、後から触る人のために線を引き直す意味で書き残します。
1 つ目の落とし穴は、Worker から fetch("https://自サイト/content/...") を実行してしまうことです。これは自分自身を呼び出すループになり、Cloudflare の subrequest 上限(同一 Worker への再帰呼び出し制限)を踏むか、CPU time limit でハードに止まります。必ず env.ASSETS.fetch() を使うのが正解です。
2 つ目は、getCloudflareContext() を cache: "force-cache" 系の Next.js キャッシュ境界の外で呼ぶと、Cannot access Cloudflare context outside of a request エラーが出ることです。page.tsx のトップレベル export const dynamic = "force-static" と組み合わせると、初回ビルド時に Cloudflare コンテキストが無くて空文字列が入ったままビルドが進む現象が起きました。記事ページは dynamic = "force-dynamic" か revalidate = 60 の ISR に倒すのが安全と感じています。
3 つ目は、public/content/ の HTML を git に commit してしまうケースです。私は最初これをやって、articles.json を .gitignore に入れたのに public/content/ を入れ忘れ、リポジトリサイズが急増して git clone が遅くなりました。両方を .gitignore に入れて、ビルド時生成物として扱うことを強くお勧めします。
同じ設計判断を他の 3 サイトに展開した手順
4 サイト並行運用ではコピペ展開が現実解ですが、サイトごとに細かい差分があるため、私は次の順序で展開しました。
最初に Claude Lab で 2 週間運用し、上記 3 つの落とし穴を全部踏み終える
generate-content.mjs と src/lib/content.ts を Gemini Lab に持っていき、カテゴリ名と OG 画像パスだけ書き換えて動作確認
Gemini Lab で 1 週間運用したあと、Antigravity Lab と Rork Lab に同日適用
4 サイト全てで .gitignore に public/content/ と src/generated/ が入っていることを確認
generate-content.mjs は 4 サイトでほぼ共通の実装にしましたが、mdxToHtml だけは各サイトの remark プラグイン構成が違うため別ファイルにしてあります。SKILL.md の Step 6 に「件数一致確認」を入れているのもこの設計に依存しており、find content/articles/ja -name "*.mdx" | wc -l と find content/articles/en -name "*.mdx" | wc -l が一致していないと、英語版が無い記事が言語切替で 404 になります。Content Split 化したあとも、この件数チェックは欠かさず通すようにしています。
5,000 から 10,000 本に向けて見えている次の課題
メタデータ JSON も 5,000 本で約 4 MB です。これが 10,000 本で 8 MB、20,000 本で 16 MB と線形に伸びていきます。22,000 本あたりで Worker bundle がまた苦しくなる計算なので、その手前で次の手を打つ必要があります。
私が今のところ有力だと考えている選択肢は 2 つです。
メタデータ JSON もカテゴリ単位 / 月次単位で分割し、必要な範囲だけ動的にロードする 。articles-by-category/claude-code.json のような分割で、/articles/claude-code のページは該当 JSON だけを読みます。記事詳細ページからの関連記事抽出も、同カテゴリ JSON だけ読めば十分です。
D1(Cloudflare の SQLite)にメタデータを移し、Worker bundle からは完全に外す 。SQL クエリベースになるぶん検索体験は向上しますが、ビルドパイプラインの複雑度は上がります。
10,000 本到達は半年〜1 年先の見込みなので、それまでに 1 番から始めて、必要に応じて 2 番に移ろうと考えています。
ここまでの設計判断はすべて、後から触る自分への線引きでもあります。両祖父が宮大工として残した建物が今もまっすぐ立っているのは、当時の彼らが自分の死後に触る誰かのために線を引いていたからだと聞きました。Worker bundle 62 MiB 上限を踏みながら組み直したアーキテクチャが、半年後の自分にとっての「ありがたい線」になっていることを願っています。お読みいただきありがとうございました。